Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 160 additions & 0 deletions ModernTests/CodexTitleStatusAdaptorTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
//
// CodexTitleStatusAdaptorTests.swift
// ModernTests
//
// Verifies the foreground-ancestry gate matches Codex regardless of
// install method (brew, npm/npx, etc.) and refuses to claim sessions
// where no codex process is in the foreground.
//

import XCTest
@testable import iTerm2SharedARC

final class CodexTitleStatusAdaptorTests: XCTestCase {

private func newStatus() -> iTermSessionTabStatus {
return iTermSessionTabStatus(sessionID: "test")
}

// Spinner glyph captured from real Codex sessions; treated as opaque here.
private let spinnerTitle = "⠙ project"
private let idleTitle = "project"

// MARK: - Foreground match

func testBrewInstall_codexPath_matches() {
let status = newStatus()
let changed = CodexTitleStatusAdaptor.apply(
title: spinnerTitle,
ancestorJobNames: ["/opt/homebrew/bin/codex", "-zsh"],
tabStatus: status)
XCTAssertTrue(changed)
XCTAssertEqual(status.statusText, "Working")
XCTAssertTrue(status.hasIndicator)
}

func testNpmInstall_nodeWrapperAndRustChild_matches() {
// Empirically observed npm process tree:
// 1. Rust binary at .../codex-<platform>/.../bin/codex (deepest, has TTY)
// 2. node ./node_modules/.bin/codex
// 3. shell
// iTermProcessInfo lists deepest first, lowercased.
let status = newStatus()
let changed = CodexTitleStatusAdaptor.apply(
title: spinnerTitle,
ancestorJobNames: [
"/private/tmp/codex-npm-test/node_modules/@openai/codex-darwin-arm64/vendor/aarch64-apple-darwin/bin/codex",
"node",
"-zsh",
],
tabStatus: status)
XCTAssertTrue(changed)
XCTAssertEqual(status.statusText, "Working")
}

func testPlainCodexInPath_matches() {
let status = newStatus()
let changed = CodexTitleStatusAdaptor.apply(
title: spinnerTitle,
ancestorJobNames: ["codex", "-zsh"],
tabStatus: status)
XCTAssertTrue(changed)
XCTAssertEqual(status.statusText, "Working")
}

func testNoCodexInAncestors_noChange() {
let status = newStatus()
let changed = CodexTitleStatusAdaptor.apply(
title: spinnerTitle,
ancestorJobNames: ["node", "-zsh"],
tabStatus: status)
XCTAssertFalse(changed)
XCTAssertNil(status.statusText)
XCTAssertFalse(status.hasIndicator)
}

func testNilAncestors_noChange() {
let status = newStatus()
let changed = CodexTitleStatusAdaptor.apply(
title: spinnerTitle,
ancestorJobNames: nil,
tabStatus: status)
XCTAssertFalse(changed)
}

func testCodexAsSubstring_doesNotMatch() {
// Only the last path component counts; "codex-ish" must not match.
let status = newStatus()
let changed = CodexTitleStatusAdaptor.apply(
title: spinnerTitle,
ancestorJobNames: ["/usr/local/bin/codex-ish", "node", "-zsh"],
tabStatus: status)
XCTAssertFalse(changed)
}

// MARK: - Title-driven state transitions

func testCodexForeground_idleTitle_setsIdle() {
let status = newStatus()
CodexTitleStatusAdaptor.apply(
title: idleTitle,
ancestorJobNames: ["codex", "-zsh"],
tabStatus: status)
XCTAssertEqual(status.statusText, "Idle")
XCTAssertTrue(status.hasIndicator)
}

func testWorkingThenCodexExits_clearsState() {
let status = newStatus()
CodexTitleStatusAdaptor.apply(
title: spinnerTitle,
ancestorJobNames: ["codex", "-zsh"],
tabStatus: status)
XCTAssertEqual(status.statusText, "Working")

// Codex left the foreground; shim should clear what it owned.
CodexTitleStatusAdaptor.apply(
title: idleTitle,
ancestorJobNames: ["-zsh"],
tabStatus: status)
XCTAssertNil(status.statusText)
XCTAssertFalse(status.hasIndicator)
}

func testWorkingThenIdle_titleDrivenTransition() {
let status = newStatus()
CodexTitleStatusAdaptor.apply(
title: spinnerTitle,
ancestorJobNames: ["codex", "-zsh"],
tabStatus: status)
XCTAssertEqual(status.statusText, "Working")

// Codex still in foreground but title lost its spinner prefix.
CodexTitleStatusAdaptor.apply(
title: idleTitle,
ancestorJobNames: ["codex", "-zsh"],
tabStatus: status)
XCTAssertEqual(status.statusText, "Idle")
}

// MARK: - Coexistence with real OSC 21337 emitters

func testRealOSCEmitter_wins_overSynthesizedState() {
// A real OSC 21337 emitter wrote a status before codex started.
// The shim must not stomp on it.
let status = newStatus()
let update = VT100TabStatusUpdate()
update.indicatorPresence = .set
update.indicator = iTermSRGBColor(r: 1, g: 0, b: 0)
update.statusPresence = .set
update.status = "RealStatus"
XCTAssertTrue(status.apply(update))

let changed = CodexTitleStatusAdaptor.apply(
title: spinnerTitle,
ancestorJobNames: ["codex", "-zsh"],
tabStatus: status)
XCTAssertFalse(changed)
XCTAssertEqual(status.statusText, "RealStatus")
}
}
67 changes: 67 additions & 0 deletions ModernTests/CodexTitleStatusDecoderTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
//
// CodexTitleStatusDecoderTests.swift
// ModernTests
//

import XCTest
@testable import iTerm2SharedARC

final class CodexTitleStatusDecoderTests: XCTestCase {

func testBrailleSpinnerPrefix_isWorking() {
XCTAssertTrue(CodexTitleStatusDecoder.isWorkingTitle("⠙ iTerm2"))
XCTAssertTrue(CodexTitleStatusDecoder.isWorkingTitle("⠹ iTerm2"))
XCTAssertTrue(CodexTitleStatusDecoder.isWorkingTitle("⠇ iTerm2"))
XCTAssertTrue(CodexTitleStatusDecoder.isWorkingTitle("⠿ Some Project"))
}

func testPlainTitle_isNotWorking() {
XCTAssertFalse(CodexTitleStatusDecoder.isWorkingTitle("iTerm2"))
XCTAssertFalse(CodexTitleStatusDecoder.isWorkingTitle("Some Project"))
}

func testEmptyTitle_isNotWorking() {
XCTAssertFalse(CodexTitleStatusDecoder.isWorkingTitle(""))
}

func testBrailleWithoutSpace_isNotWorking() {
// A braille glyph at position 0 but no following space - not Codex's format.
XCTAssertFalse(CodexTitleStatusDecoder.isWorkingTitle("⠙iTerm2"))
XCTAssertFalse(CodexTitleStatusDecoder.isWorkingTitle("⠙"))
}

func testBrailleNotAtStart_isNotWorking() {
// Braille not at position 0 doesn't count - prevents matching titles that
// legitimately contain a braille character later.
XCTAssertFalse(CodexTitleStatusDecoder.isWorkingTitle(" ⠙ iTerm2"))
XCTAssertFalse(CodexTitleStatusDecoder.isWorkingTitle("foo ⠙ bar"))
}

func testOtherUnicodeSpinners_areNotWorking() {
// Only U+2800..U+28FF Braille Patterns count. Other spinner glyphs from
// different libraries (e.g. unicode block elements, geometric shapes) don't.
XCTAssertFalse(CodexTitleStatusDecoder.isWorkingTitle("◐ Working"))
XCTAssertFalse(CodexTitleStatusDecoder.isWorkingTitle("◓ Working"))
XCTAssertFalse(CodexTitleStatusDecoder.isWorkingTitle("| Working"))
XCTAssertFalse(CodexTitleStatusDecoder.isWorkingTitle("/ Working"))
}

func testJustOutsideBrailleBlock_isNotWorking() {
// Glyphs adjacent to the Braille Patterns block must not match: that pins
// the boundary so a future widening (e.g. accidentally including arrows
// or geometric shapes) shows up as a test failure rather than a quiet
// overreach.
let beforeBlock = Unicode.Scalar(0x27FF)!
let afterBlock = Unicode.Scalar(0x2900)!
XCTAssertFalse(CodexTitleStatusDecoder.isWorkingTitle("\(beforeBlock) project"))
XCTAssertFalse(CodexTitleStatusDecoder.isWorkingTitle("\(afterBlock) project"))
}

func testWordsAloneAreNotWorking() {
// The second-opinion's main worry: bare 'Working' / 'Thinking' / 'Ready' must not match.
XCTAssertFalse(CodexTitleStatusDecoder.isWorkingTitle("Working"))
XCTAssertFalse(CodexTitleStatusDecoder.isWorkingTitle("Thinking"))
XCTAssertFalse(CodexTitleStatusDecoder.isWorkingTitle("Ready"))
XCTAssertFalse(CodexTitleStatusDecoder.isWorkingTitle("Working on PR #123"))
}
}
108 changes: 108 additions & 0 deletions ModernTests/EventTriggerEvaluatorTitleChangedTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
//
// EventTriggerEvaluatorTitleChangedTests.swift
// iTerm2
//
// Covers the Title Changed event trigger and the always-on built-in Codex
// title-status trigger seeded by EventTriggerEvaluator.
//

import XCTest
@testable import iTerm2SharedARC

final class EventTriggerEvaluatorTitleChangedTests: XCTestCase {

private var evaluator: EventTriggerEvaluator!
private var fired: [(action: String, captures: [String])]!

private let codexAction = "iTermCodexStatusTrigger"

override func setUp() {
super.setUp()
evaluator = EventTriggerEvaluator(sessionDescription: "test")
fired = []
evaluator.fireTriggerHandler = { [weak self] trigger, captures, _ in
self?.fired.append((action: trigger.action, captures: captures))
}
}

override func tearDown() {
evaluator = nil
fired = nil
super.tearDown()
}

private func loadUserTitleTrigger(titleRegex: String? = nil, disabled: Bool = false) {
var eventParams: [String: Any] = [:]
if let titleRegex {
eventParams["titleRegex"] = titleRegex
}
let dict: [String: Any] = [
"action": "AlertTrigger",
"regex": "",
"parameter": "test",
"matchType": NSNumber(value: iTermTriggerMatchType.eventTitleChanged.rawValue),
"disabled": NSNumber(value: disabled),
"eventParams": eventParams
]
evaluator.loadFromProfileArray([dict])
}

// MARK: - Built-in Codex trigger

func testBuiltinCodexTriggerFiresOnEveryTitleChangeWithNoConfig() {
evaluator.loadFromProfileArray([])
evaluator.titleChanged(to: "\u{2807} working")
XCTAssertEqual(fired.count, 1)
XCTAssertEqual(fired.first?.action, codexAction)
XCTAssertEqual(fired.first?.captures, ["\u{2807} working"])
}

func testBuiltinFiresRegardlessOfWhetherTitleLooksLikeCodex() {
evaluator.loadFromProfileArray([])
evaluator.titleChanged(to: "plain shell title")
XCTAssertEqual(fired.count, 1)
XCTAssertEqual(fired.first?.action, codexAction)
}

func testHasTitleChangedTriggerAlwaysTrue() {
evaluator.loadFromProfileArray([])
XCTAssertTrue(evaluator.hasTitleChangedTrigger)
}

func testDisabledEvaluatorSuppressesBuiltin() {
evaluator.loadFromProfileArray([])
evaluator.disabled = true
evaluator.titleChanged(to: "anything")
XCTAssertEqual(fired.count, 0)
}

// MARK: - User title-changed triggers (coexist with the built-in)

func testUserTitleTriggerFiresAlongsideBuiltinWhenRegexMatches() {
loadUserTitleTrigger(titleRegex: "error")
evaluator.titleChanged(to: "an error happened")
XCTAssertEqual(fired.count, 2)
XCTAssertTrue(fired.contains { $0.action == codexAction })
XCTAssertTrue(fired.contains { $0.action == "AlertTrigger" })
}

func testUserTitleTriggerSkippedWhenRegexDoesNotMatchButBuiltinStillFires() {
loadUserTitleTrigger(titleRegex: "error")
evaluator.titleChanged(to: "all good")
XCTAssertEqual(fired.count, 1)
XCTAssertEqual(fired.first?.action, codexAction)
}

func testUserTitleTriggerWithNoRegexFiresOnEveryTitle() {
loadUserTitleTrigger()
evaluator.titleChanged(to: "whatever")
XCTAssertEqual(fired.filter { $0.action == "AlertTrigger" }.count, 1)
}

func testDisabledUserTriggerDoesNotFireButBuiltinDoes() {
loadUserTitleTrigger(disabled: true)
evaluator.titleChanged(to: "x")
XCTAssertEqual(fired.count, 1)
XCTAssertEqual(fired.first?.action, codexAction)
}
}
4 changes: 4 additions & 0 deletions ModernTests/VT100ScreenTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1674,6 +1674,10 @@ class FakeSession: NSObject, VT100ScreenDelegate {

}

func screenApplyCodexTitleStatus(withTitle title: String) {

}

func screenStatPath(_ path: String, queue: dispatch_queue_t, completion: @escaping (Int32, UnsafePointer<stat>) -> Void) {
var s = stat()
completion(0, &s)
Expand Down
12 changes: 12 additions & 0 deletions docs/notes-3.7.txt
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,18 @@ Major New Features:
General > AI > spawnTerm. Adapted from
gnachman/iTerm2#670.

- A new spawnTerm capability, "Codex Tab
Status", shows Codex CLI working/idle
activity in the tab status. Codex does not
emit OSC 21337; it prefixes the terminal
title with a braille spinner while working.
A built-in Title Changed trigger decodes
that and drives the tab indicator dot and
subtitle like a native emitter. Off by
default; enable it in Settings > General >
AI > spawnTerm. Adapted from
gnachman/iTerm2#673.

- The Companion app can now receive push
notifications for terminal alerts. Turn on
"Send alerts to paired iPhone" in a profile's
Expand Down
Loading