fix(omp): track native WSL status titles

This commit is contained in:
OrcaWin
2026-08-14 13:55:43 -07:00
committed by OrcaWin
parent 9bb8836bb6
commit ebabd9f625
8 changed files with 167 additions and 1 deletions
+20
View File
@@ -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()
+20
View File
@@ -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<PaneStatusClearListener>()
private statusDropListeners = new Set<StatusDropListener>()
private statusChangeListeners = new Set<StatusChangeListener>()
private providerSessionChangeListeners = new Set<ProviderSessionChangeListener>()
// 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. */
+2
View File
@@ -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
@@ -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)')
})
})
@@ -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}`)
+29
View File
@@ -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')
+7 -1
View File
@@ -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)
+44
View File
@@ -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)}`
}