From ebabd9f625f36d611274c2f9db7b9ca72b5ba3a6 Mon Sep 17 00:00:00 2001 From: OrcaWin <293788423+OrcaWin@users.noreply.github.com> Date: Tue, 11 Aug 2026 19:09:48 -0700 Subject: [PATCH] fix(omp): track native WSL status titles --- src/main/agent-hooks/server.test.ts | 20 +++++++++ src/main/agent-hooks/server.ts | 20 +++++++++ src/main/index.ts | 2 + .../startup/agent-hook-spinner-wiring.test.ts | 11 +++++ .../terminal-title-tracker-parity.test.ts | 34 ++++++++++++++ src/shared/agent-detection.test.ts | 29 ++++++++++++ src/shared/agent-title-status.ts | 8 +++- src/shared/omp-native-title-status.ts | 44 +++++++++++++++++++ 8 files changed, 167 insertions(+), 1 deletion(-) create mode 100644 src/main/startup/agent-hook-spinner-wiring.test.ts create mode 100644 src/shared/omp-native-title-status.ts diff --git a/src/main/agent-hooks/server.test.ts b/src/main/agent-hooks/server.test.ts index 23aef22a7b4..e1a482931f1 100644 --- a/src/main/agent-hooks/server.test.ts +++ b/src/main/agent-hooks/server.test.ts @@ -1988,6 +1988,26 @@ describe('AgentHookServer listener replay', () => { expect(listener).toHaveBeenNthCalledWith(4, []) }) + it('notifies status-drop subscribers only when a live row is dropped', () => { + const server = new AgentHookServer() + const listener = vi.fn() + server.subscribeStatusDrop(listener) + + server.dropStatusEntry(PANE) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + payload: { state: 'working', agentType: 'claude' } + }, + 'conn-1' + ) + server.dropStatusEntry(PANE) + + expect(listener).toHaveBeenCalledExactlyOnceWith(PANE) + }) + it('notifies pane-status-clear listener when pane teardown evicts a cached status', () => { const server = new AgentHookServer() const listener = vi.fn() diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index f819414ad4b..bd84515b8d5 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -147,6 +147,7 @@ export type AgentHookAuthorityAttestation = Readonly<{ type StatusChangeListener = (statuses: AgentHookStatusChangeEntry[]) => void type ProviderSessionChangeListener = (providerSessions: AgentHookProviderSessionIdentity[]) => void type PaneStatusClearListener = (clear: AgentStatusClearIpcPayload) => void +type StatusDropListener = (paneKey: string) => void type PaneKeyAliasPersistenceListener = (entries: LegacyPaneKeyAliasEntry[]) => void type PaneKeyAliasEntry = { stablePaneKey: string @@ -602,6 +603,7 @@ export class AgentHookServer { private onClaudeStatusLine: ((event: ClaudeStatusLineRateLimits) => void) | null = null private onPaneStatusCleared: PaneStatusClearListener | null = null private paneStatusClearListeners = new Set() + private statusDropListeners = new Set() private statusChangeListeners = new Set() private providerSessionChangeListeners = new Set() // Why: setListener is a single slot owned by the main-window fanout; the @@ -696,6 +698,23 @@ export class AgentHookServer { } } + subscribeStatusDrop(listener: StatusDropListener): () => void { + this.statusDropListeners.add(listener) + return () => { + this.statusDropListeners.delete(listener) + } + } + + private emitStatusDropped(paneKey: string): void { + for (const listener of this.statusDropListeners) { + try { + listener(paneKey) + } catch (err) { + console.error('[agent-hooks] status-drop listener threw', err) + } + } + } + private emitPaneStatusCleared(clear: AgentStatusClearIpcPayload): void { this.onPaneStatusCleared?.(clear) for (const listener of this.paneStatusClearListeners) { @@ -2266,6 +2285,7 @@ export class AgentHookServer { } this.scheduleStatusPersist() this.notifyStatusChangeListeners() + this.emitStatusDropped(deleted.paneKey) } /** Clear statuses proven to belong to one lost SSH transport. */ diff --git a/src/main/index.ts b/src/main/index.ts index 2e816ba4c89..387f070d36e 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1950,6 +1950,8 @@ registerPaneKeyTeardownListener((paneKey) => { stopSyntheticTitleSpinner(paneKey) }) +agentHookServer.subscribeStatusDrop(stopSyntheticTitleSpinner) + function sendSyntheticTitle(ptyId: string, data: string, options: { force?: boolean } = {}): void { if (!mainWindow || mainWindow.isDestroyed()) { return diff --git a/src/main/startup/agent-hook-spinner-wiring.test.ts b/src/main/startup/agent-hook-spinner-wiring.test.ts new file mode 100644 index 00000000000..cf7d3608199 --- /dev/null +++ b/src/main/startup/agent-hook-spinner-wiring.test.ts @@ -0,0 +1,11 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +describe('agent hook spinner wiring', () => { + it('stops the pane spinner when its live hook status is dropped', () => { + const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + + expect(source).toContain('agentHookServer.subscribeStatusDrop(stopSyntheticTitleSpinner)') + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-title-tracker-parity.test.ts b/src/renderer/src/components/terminal-pane/terminal-title-tracker-parity.test.ts index 6dd817ca6bf..7170fb124f7 100644 --- a/src/renderer/src/components/terminal-pane/terminal-title-tracker-parity.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-title-tracker-parity.test.ts @@ -119,6 +119,40 @@ describe('main title tracker parity with the renderer transport processor', () = expect(kinds.indexOf('became-working')).toBeLessThan(kinds.indexOf('became-idle')) }) + it('derives working and idle facts from static OMP WSL titles', () => { + const chunk = `${ESC}]0;zsh | π : cwd${BEL}response text\r\n` + `${ESC}]0;zsh | π > cwd${BEL}` + feedBoth(paths, chunk) + + expect(paths.main.events).toEqual(paths.renderer.events) + const kinds = paths.main.events.map((event) => event.kind) + expect(kinds).toContain('became-working') + expect(kinds.indexOf('became-working')).toBeLessThan(kinds.indexOf('became-idle')) + }) + + it('treats marker-shaped text in the OMP label as opaque', () => { + feedBoth(paths, `${ESC}]0;zsh | π > session | π : note | π ! note${BEL}`) + + expect(paths.main.events).toEqual(paths.renderer.events) + expect(paths.main.events.map((event) => event.kind)).not.toContain('became-working') + }) + + it('clears a stale wrapped native OMP working title in both paths', () => { + feedBoth(paths, `${ESC}]0;zsh | π : release | π > note | π ! note${BEL}`) + feedBoth(paths, 'title-free output') + vi.advanceTimersByTime(3_000) + + expect(paths.main.events).toEqual(paths.renderer.events) + expect(paths.main.events).toContainEqual({ + kind: 'title', + normalized: 'zsh | π > release | π > note | π ! note', + raw: 'zsh | π > release | π > note | π ! note' + }) + expect(paths.main.events).toContainEqual({ + kind: 'became-idle', + title: 'zsh | π > release | π > note | π ! note' + }) + }) + it('derives identical facts from BEL- and ST-terminated titles', () => { feedBoth(paths, `${ESC}]2;Codex working${ST}body bytes`) feedBoth(paths, `${ESC}]0;Codex done${BEL}`) diff --git a/src/shared/agent-detection.test.ts b/src/shared/agent-detection.test.ts index 14ee33eb7a8..7abb9e6d789 100644 --- a/src/shared/agent-detection.test.ts +++ b/src/shared/agent-detection.test.ts @@ -167,6 +167,35 @@ describe('Pi-compatible title detection', () => { expect(detectAgentStatusFromTitle(title)).toBe(expectedStatus) }) + it.each([ + ['π : my-project', 'working'], + ['π > my-project', 'idle'], + ['π ! my-project', 'permission'], + ['π: my-project', 'idle'], + ['zsh | π : wrapped-project', 'working'], + ['zsh | tmux | π > wrapped-project', 'idle'], + ['zsh | π ! wrapped-project', 'permission'], + ['π : release | windows', 'working'], + ['π > release | windows', 'idle'], + ['π ! release | windows', 'permission'], + ['zsh | π : release | windows', 'working'], + ['zsh | π > release | windows', 'idle'], + ['zsh | π ! release | windows', 'permission'] + ] as const)('classifies native OMP title %s', (title, expectedStatus) => { + expect(detectAgentStatusFromTitle(title)).toBe(expectedStatus) + }) + + it.each([ + ['π : session | π > note | π ! note', 'working'], + ['π > session | π : note | π ! note', 'idle'], + ['π ! session | π : note | π > note', 'permission'], + ['zsh | π : session | π > note | π ! note', 'working'], + ['zsh | π > session | π : note | π ! note', 'idle'], + ['zsh | π ! session | π : note | π > note', 'permission'] + ] as const)('treats the native OMP label as opaque: %s', (title, expectedStatus) => { + 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') diff --git a/src/shared/agent-title-status.ts b/src/shared/agent-title-status.ts index 5c13bb56e95..54ffea9496f 100644 --- a/src/shared/agent-title-status.ts +++ b/src/shared/agent-title-status.ts @@ -24,6 +24,7 @@ import { isPiTerminalTitle } from './agent-title-core' import type { AgentStatus } from './agent-title-core' +import { clearOmpNativeWorkingStatus, getOmpNativeTitleStatus } from './omp-native-title-status' import { isOpenCodeNativeTitle } from './opencode-terminal-title' import { getPiCompatibleSyntheticAgentStatus } from './pi-compatible-synthetic-title' import { isGrokRotatingWorkingTitle } from './terminal-title-agent-type' @@ -32,7 +33,7 @@ import { isGrokRotatingWorkingTitle } from './terminal-title-agent-type' * Strip working-status indicators so stale exit titles stop reporting working. */ export function clearWorkingIndicators(title: string): string { - let cleaned = title + let cleaned = clearOmpNativeWorkingStatus(title) ?? title cleaned = cleaned.replace(GEMINI_WORKING, '') cleaned = cleaned.replace(GEMINI_SILENT_WORKING, '') @@ -176,6 +177,11 @@ export function detectAgentStatusFromTitle(title: string): AgentStatus | null { return 'idle' } + const ompNativeStatus = getOmpNativeTitleStatus(title) + if (ompNativeStatus) { + return ompNativeStatus + } + // Why: resolve synthetic Pi/OMP permission/idle labels before the broader // Pi and braille-spinner checks below. const piCompatibleSyntheticAgentStatus = getPiCompatibleSyntheticAgentStatus(title) diff --git a/src/shared/omp-native-title-status.ts b/src/shared/omp-native-title-status.ts new file mode 100644 index 00000000000..d8c06744fdc --- /dev/null +++ b/src/shared/omp-native-title-status.ts @@ -0,0 +1,44 @@ +import type { AgentStatus } from './agent-title-core' + +const OMP_NATIVE_STATE_RE = /(?:^| \| )\s*π\s+([:>!])(?=\s|$)/u + +type OmpNativeStateMatch = { + delimiter: string + delimiterIndex: number +} + +function getOmpNativeStateMatch(title: string): OmpNativeStateMatch | null { + const match = OMP_NATIVE_STATE_RE.exec(title) + const delimiter = match?.[1] + if (delimiter === undefined || match?.index === undefined) { + return null + } + return { + delimiter, + delimiterIndex: match.index + match[0].lastIndexOf(delimiter) + } +} + +/** Resolves the native OMP marker while treating its remaining label as opaque. */ +export function getOmpNativeTitleStatus(title: string): AgentStatus | null { + const delimiter = getOmpNativeStateMatch(title)?.delimiter + if (delimiter === ':') { + return 'working' + } + if (delimiter === '!') { + return 'permission' + } + if (delimiter === '>') { + return 'idle' + } + return null +} + +/** Converts an authoritative native OMP working marker into an idle marker. */ +export function clearOmpNativeWorkingStatus(title: string): string | null { + const match = getOmpNativeStateMatch(title) + if (match?.delimiter !== ':') { + return null + } + return `${title.slice(0, match.delimiterIndex)}>${title.slice(match.delimiterIndex + 1)}` +}