diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 364030d779a..cf087197949 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -21,7 +21,7 @@ import type { WorkspaceSessionState } from '../../shared/types' import { AGENT_STATUS_STALE_AFTER_MS } from '../../shared/agent-status-types' -import { MAX_OSC_TITLE_CHARS } from '../../shared/agent-detection' +import { detectAgentStatusFromTitle, MAX_OSC_TITLE_CHARS } from '../../shared/agent-detection' import { addWorktree, assertWorktreeCleanForRemoval, @@ -11272,6 +11272,122 @@ describe('OrcaRuntimeService', () => { expect(pty?.lastAgentStatus).toBe('idle') }) + it('normalizes rotating Grok working-frame OSC titles to one stable stored title', async () => { + const runtime = new OrcaRuntimeService(store) + syncSinglePty(runtime) + + runtime.onPtyData('pty-1', '\x1b]0;⠋ - Waiting for response… - grok\x07', 100) + const pty = ( + runtime as unknown as { + ptysById: Map + } + ).ptysById.get('pty-1') + expect(pty?.lastOscTitle).toBe('⠋ Grok') + expect(pty?.lastAgentStatus).toBe('working') + + // A different rotating frame must store an identical title — title + // equality is what stops per-frame session-tab and mobile-snapshot touch. + runtime.onPtyData('pty-1', '\x1b]0;⠴ - Thinking - grok\x07', 101) + expect(pty?.lastOscTitle).toBe('⠋ Grok') + expect(pty?.lastAgentStatus).toBe('working') + }) + + it('does not republish mobile session tabs for same-status Grok title frames', async () => { + const spawn = vi.fn().mockResolvedValue({ id: 'laptop-created-pty' }) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + const events: RuntimeMobileSessionTabsResult[] = [] + const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot)) + + await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + tabId: 'laptop-tab', + leafId: HEADLESS_LEAF_ID + }) + events.length = 0 + + runtime.onPtyData('laptop-created-pty', '\x1b]0;⠋ - Waiting for response… - grok\x07', 100) + runtime.onPtyData('laptop-created-pty', '\x1b]0;⠴ - Thinking - grok\x07', 101) + runtime.onPtyData('laptop-created-pty', '\x1b]0;⠙ - Responding - grok\x07', 102) + + expect(events).toHaveLength(1) + expect(events[0]?.tabs[0]).toEqual( + expect.objectContaining({ + type: 'terminal', + title: '⠋ Grok', + agentStatus: expect.objectContaining({ state: 'working' }) + }) + ) + + unsubscribe() + }) + + it('stores normalized Pi idle OSC titles that still classify as idle', async () => { + const runtime = new OrcaRuntimeService(store) + syncSinglePty(runtime) + + runtime.onPtyData('pty-1', '\x1b]0;π - my-project\x07', 100) + const pty = ( + runtime as unknown as { + ptysById: Map + } + ).ptysById.get('pty-1') + expect(pty?.lastOscTitle).toBe('Pi') + expect(pty?.lastAgentStatus).toBe('idle') + // Why: worktree.ps / mobile re-detect from stored lastOscTitle, not the + // raw OSC frame. Bare "Pi" must still classify as idle after normalize. + expect(detectAgentStatusFromTitle(pty?.lastOscTitle ?? '')).toBe('idle') + }) + + it('normalizes hydration-seeded Grok and Pi titles the same as live OSC frames', async () => { + const runtime = new OrcaRuntimeService(store) + syncSinglePty(runtime) + + ;( + runtime as unknown as { + applySeededAgentStatus: (ptyId: string, title: string) => void + } + ).applySeededAgentStatus('pty-1', '⠴ - Thinking - grok') + const pty = ( + runtime as unknown as { + ptysById: Map + } + ).ptysById.get('pty-1') + expect(pty?.lastOscTitle).toBe('⠋ Grok') + // Seed writes leaf status only; re-detect from the stored title must still + // report working so later live frames compare equal and do not thrash. + expect(detectAgentStatusFromTitle(pty?.lastOscTitle ?? '')).toBe('working') + + ;( + runtime as unknown as { + applySeededAgentStatus: (ptyId: string, title: string) => void + } + ).applySeededAgentStatus('pty-1', 'π - my-project') + expect(pty?.lastOscTitle).toBe('Pi') + expect(detectAgentStatusFromTitle(pty?.lastOscTitle ?? '')).toBe('idle') + }) + + it('stores other-agent OSC titles that merely end in grok unchanged', async () => { + const runtime = new OrcaRuntimeService(store) + syncSinglePty(runtime) + + runtime.onPtyData('pty-1', '\x1b]0;⠋ wire up grok\x07', 100) + const pty = ( + runtime as unknown as { + ptysById: Map + } + ).ptysById.get('pty-1') + expect(pty?.lastOscTitle).toBe('⠋ wire up grok') + + // Claude/Codex braille + task ending " - grok" is not a Grok frame shape. + runtime.onPtyData('pty-1', '\x1b]0;⠋ fix the flaky suite - grok\x07', 101) + expect(pty?.lastOscTitle).toBe('⠋ fix the flaky suite - grok') + }) + it('seeds newly synced leaves from PTY pending ANSI state', async () => { const runtime = new OrcaRuntimeService(store) runtime.registerPty('pty-1', TEST_WORKTREE_ID) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 1bef543ab66..6bf085ff8f6 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -5,7 +5,8 @@ import { extractLastOscTitle, detectAgentStatusFromTitle, isClaudeManagementTitle, - isShellProcess + isShellProcess, + normalizeTerminalTitle } from '../../shared/agent-detection' import { extractOscTitleScanTail } from '../../shared/osc-title-scan-tail' import { extractLastOsc7Uri, extractOscScanTail } from '../daemon/osc7-uri-extraction' @@ -5336,8 +5337,15 @@ export class OrcaRuntimeService { // strips the escape sequences. Agent CLIs (Claude Code, Gemini, etc.) // announce status via OSC 0/1/2 title sequences — this is the same // detection path the renderer uses for notifications and sidebar badges. - const oscTitle = this.extractLastOscTitleForPty(ptyId, data) - const agentStatus = oscTitle ? detectAgentStatusFromTitle(oscTitle) : null + const rawOscTitle = this.extractLastOscTitleForPty(ptyId, data) + // Why: collapse high-churn agent titles (Grok/Pi spinner frames, Gemini + // per-keystroke updates) once at the observation boundary so lastOscTitle — + // and the mobile session-tab titles/snapshots derived from it — stays + // stable instead of changing every animation frame. Status is detected + // from the raw title (mirroring the renderer's tracker) so working/idle + // transitions are unaffected by normalization. + const oscTitle = rawOscTitle === null ? null : normalizeTerminalTitle(rawOscTitle) + const agentStatus = rawOscTitle ? detectAgentStatusFromTitle(rawOscTitle) : null const pty = this.getOrCreatePtyWorktreeRecord(ptyId) let shouldTouchPtyBackedSessionTabs = false @@ -5975,18 +5983,22 @@ export class OrcaRuntimeService { return } const status = detectAgentStatusFromTitle(title) + // Why: live observations store normalized titles, so seeds must match — + // otherwise the first live frame after hydration compares unequal and + // touches session tabs once for no visible change. + const seededTitle = normalizeTerminalTitle(title) const pty = this.ptysById.get(ptyId) if (pty) { const observedAt = this.nextTitleObservationSequence() - pty.lastOscTitle = title + pty.lastOscTitle = seededTitle pty.lastOscTitleAt = observedAt - this.setPtyManagementTitleFromObservedTitle(pty, title, observedAt) + this.setPtyManagementTitleFromObservedTitle(pty, seededTitle, observedAt) } for (const leaf of this.getLeavesForPty(ptyId)) { // Why: seed lastOscTitle even when the seeded title doesn't classify // as an agent state, so worktree.ps recomputes status from the live // title rather than treating the leaf as agentless. - leaf.lastOscTitle = title + leaf.lastOscTitle = seededTitle leaf.lastOscTitleAt = this.nextTitleObservationSequence() if (status !== null) { leaf.lastAgentStatus = status diff --git a/src/renderer/src/lib/agent-status.test.ts b/src/renderer/src/lib/agent-status.test.ts index 9f2a7847817..820741031d7 100644 --- a/src/renderer/src/lib/agent-status.test.ts +++ b/src/renderer/src/lib/agent-status.test.ts @@ -411,12 +411,16 @@ describe('normalizeTerminalTitle', () => { // Names "grok" mid-title but ends with another agent → not a Grok frame. expect(normalizeTerminalTitle('⠋ debugging grok - claude')).toBe('⠋ debugging grok - claude') // Claude/Codex task text ending in "grok" must not collapse — only the - // Grok Build identity suffix " - grok" is the rotating-frame signal. + // Grok frame shape "spinner - phrase - grok" is the rotating-frame signal. expect(normalizeTerminalTitle('⠋ wire up grok')).toBe('⠋ wire up grok') expect(normalizeTerminalTitle('⠋ Codex is thinking about grok')).toBe( '⠋ Codex is thinking about grok' ) expect(normalizeTerminalTitle('⠋ support for Grok')).toBe('⠋ support for Grok') + // Trailing " - grok" alone is not enough without the post-spinner delimiter. + expect(normalizeTerminalTitle('⠋ fix the flaky suite - grok')).toBe( + '⠋ fix the flaky suite - grok' + ) }) it('collapses Pi spinner and idle titles to stable labels', () => { diff --git a/src/shared/agent-detection.test.ts b/src/shared/agent-detection.test.ts index a1bee91aef3..28c18b4d16e 100644 --- a/src/shared/agent-detection.test.ts +++ b/src/shared/agent-detection.test.ts @@ -5,7 +5,8 @@ import { extractAllOscTitles, extractLastOscTitle, getAgentLabel, - MAX_OSC_TITLE_CHARS + MAX_OSC_TITLE_CHARS, + normalizeTerminalTitle } from './agent-detection' import { hasCompatibleAgentTitleIdentity, @@ -88,15 +89,27 @@ describe('Pi-compatible title detection', () => { it.each([ ['\u280b OMP', 'OMP', 'working'], ['OMP ready', 'OMP', 'idle'], + ['OMP', 'OMP', 'idle'], ['OMP - action required', 'OMP', 'permission'], ['\u280b Pi', 'Pi', 'working'], ['Pi ready', 'Pi', 'idle'], + // Why: normalizeTerminalTitle collapses idle π frames to bare "Pi"; re-detection + // from stored lastOscTitle must still classify idle, not neutral. + ['Pi', 'Pi', 'idle'], ['Pi - action required', 'Pi', 'permission'] ] as const)('classifies synthesized %s', (title, expectedLabel, expectedStatus) => { expect(getAgentLabel(title)).toBe(expectedLabel) expect(detectAgentStatusFromTitle(title)).toBe(expectedStatus) }) + it('re-detects status after display-title normalization for Pi idle frames', () => { + expect(normalizeTerminalTitle('π - my-project')).toBe('Pi') + expect(detectAgentStatusFromTitle(normalizeTerminalTitle('π - my-project'))).toBe('idle') + expect(detectAgentStatusFromTitle(normalizeTerminalTitle('\u280b π - my-project'))).toBe( + 'working' + ) + }) + it.each([ ['\u280b Pi', 'omp', '\u280b OMP'], ['Pi ready', 'omp', 'OMP ready'], diff --git a/src/shared/pi-compatible-synthetic-title.ts b/src/shared/pi-compatible-synthetic-title.ts index e54117255d5..3060ce5779c 100644 --- a/src/shared/pi-compatible-synthetic-title.ts +++ b/src/shared/pi-compatible-synthetic-title.ts @@ -3,7 +3,6 @@ export type PiCompatibleSyntheticAgentStatus = 'working' | 'permission' | 'idle' const PI_COMPATIBLE_SYNTHETIC_TITLE_RE = /^\s*(?:[\u2800-\u28ff]\s+)?(pi|omp)(?:\s+-\s+action required|\s+(?:ready|idle|done))?\s*$/i -const PI_COMPATIBLE_IDLE_RE = /(? { expect(isGrokRotatingWorkingTitle('⠋ ~/grok-scratch/ready')).toBe(false) // path fragment, not a trailing token expect(isGrokRotatingWorkingTitle('⠋ grokking the plan')).toBe(false) // "grok" not a whole trailing token expect(isGrokRotatingWorkingTitle('⠋ Codex')).toBe(false) - // Task text ending in "grok" is not the Grok Build identity suffix " - grok". + // Task text ending in "grok" is not the Grok frame shape "spinner - phrase - grok". expect(isGrokRotatingWorkingTitle('⠋ wire up grok')).toBe(false) expect(isGrokRotatingWorkingTitle('⠋ Codex is thinking about grok')).toBe(false) expect(isGrokRotatingWorkingTitle('⠋ support for Grok')).toBe(false) + // Why: Claude/Codex braille + task can end with " - grok" without the + // post-spinner delimiter that marks a real Grok Build frame. + expect(isGrokRotatingWorkingTitle('⠋ fix the flaky suite - grok')).toBe(false) + expect(isGrokRotatingWorkingTitle('⠋ review grok integration - claude')).toBe(false) }) }) diff --git a/src/shared/terminal-title-agent-type.ts b/src/shared/terminal-title-agent-type.ts index b0c0a82a544..e40098ee46e 100644 --- a/src/shared/terminal-title-agent-type.ts +++ b/src/shared/terminal-title-agent-type.ts @@ -51,22 +51,22 @@ export function isPiTerminalTitle(title: string): boolean { return isLegacyPiCompatibleTitle(title) && !containsBrailleSpinner(title) } -// Why: Grok Build's working OSC titles interpolate a rotating status/tool phrase -// between the spinner and its name — "⠋ - Waiting for response… - grok", -// "⠴ - Thinking - grok", "⠦ - Sleep 2s… - grok" — so every frame is a distinct -// title that flips the tab and sidebar labels. Require the identity suffix -// " - grok" (not arbitrary task text ending in "grok") so Claude/Codex titles -// like "⠋ wire up grok" stay intact. Spinner marks working; no-spinner session -// titles ("Fix the auth bug - grok") pass through. The bare "spinner + grok" -// branch keeps our own collapsed label idempotent under re-normalization. -const GROK_FRAME_IDENTITY_SUFFIX_RE = / - grok\s*$/i +// Why: Grok Build's working OSC titles use a fixed frame shape — +// "spinner - - grok" — so every frame is a distinct title +// that flips tab and sidebar labels. Require BOTH the post-spinner " - " +// delimiter and the trailing identity " - grok" so Claude/Codex task text +// like "⠋ fix the flaky suite - grok" or "⠋ wire up grok" is not mislabeled. +// Spinner marks working; no-spinner session titles ("Fix the auth bug - grok") +// pass through. The bare "spinner + grok" branch keeps our own collapsed +// label idempotent under re-normalization. +const GROK_ROTATING_FRAME_RE = /^[\u2800-\u28FF]+\s+-\s+[\s\S]+?\s-\s+grok\s*$/i const GROK_COLLAPSED_WORKING_TITLE_RE = /^[\u2800-\u28FF]+\s+grok\s*$/i export function isGrokRotatingWorkingTitle(title: string): boolean { if (!containsBrailleSpinner(title)) { return false } - return GROK_FRAME_IDENTITY_SUFFIX_RE.test(title) || GROK_COLLAPSED_WORKING_TITLE_RE.test(title) + return GROK_ROTATING_FRAME_RE.test(title) || GROK_COLLAPSED_WORKING_TITLE_RE.test(title) } export function isPiAgentTitle(title: string): boolean {