mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 08:03:37 +00:00
fix(omp): track native WSL status titles
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)}`
|
||||
}
|
||||
Reference in New Issue
Block a user