diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts b/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts index f806b994e02..4b0af2cd617 100644 --- a/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts +++ b/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts @@ -43,6 +43,18 @@ vi.mock('./use-notification-dispatch', () => ({ dispatchTerminalNotification })) +// Why: command-status semantics are covered in parked-terminal-command-status.test.ts; +// these tests only prove the watcher wires bytes/facts into the policy. +const commandStatusPolicy = { + onCommandFinished: vi.fn(), + onCommandCodeWorking: vi.fn(), + onCommandCodeDone: vi.fn(), + dispose: vi.fn() +} +vi.mock('./parked-terminal-command-status', () => ({ + createParkedTerminalCommandStatusPolicy: vi.fn(() => commandStatusPolicy) +})) + vi.mock('@/lib/terminal-theme', () => ({ getSystemPrefersDark: () => true })) @@ -110,6 +122,10 @@ describe('startParkedTerminalByteWatcher', () => { vi.resetModules() vi.useFakeTimers() dispatchTerminalNotification.mockClear() + commandStatusPolicy.onCommandFinished.mockClear() + commandStatusPolicy.onCommandCodeWorking.mockClear() + commandStatusPolicy.onCommandCodeDone.mockClear() + commandStatusPolicy.dispose.mockClear() onData = null mockStoreState = createMockStoreState() ;(globalThis as { window: typeof window }).window = { @@ -372,6 +388,26 @@ describe('startParkedTerminalByteWatcher', () => { dispose() }) + it('scans OSC 133;D bytes into the parked command policy and disposes it with the watcher', async () => { + const { dispose } = await startWatcher() + + emit('\x1b]133;D;0\x07') + expect(commandStatusPolicy.onCommandFinished).toHaveBeenCalledWith(0) + + dispose() + expect(commandStatusPolicy.dispose).toHaveBeenCalledTimes(1) + }) + + it('feeds Command Code output through the parked byte detector', async () => { + const { dispose } = await startWatcher() + + emit('# Command Code v0.27.2') + emit('⌘ Parsing...') + + expect(commandStatusPolicy.onCommandCodeWorking).toHaveBeenCalledTimes(1) + dispose() + }) + it('fires completion when seeded with a working title and the agent goes idle while parked', async () => { // Why: the pane was working at park time; the watcher's fresh tracker // must be seeded or this working→idle transition can never fire. @@ -575,6 +611,32 @@ describe('startParkedTerminalByteWatcher', () => { dispose() }) + it('routes command lifecycle facts into the parked command policy', async () => { + enableMainAuthority() + const { dispose } = await startWatcher() + + await dispatchFacts([ + { kind: 'command-finished', exitCode: 0 }, + { kind: 'command-code-working', prompt: 'Fix the spinner' }, + { kind: 'command-code-done', prompt: 'Fix the spinner' } + ]) + + expect(commandStatusPolicy.onCommandFinished).toHaveBeenCalledWith(0) + expect(commandStatusPolicy.onCommandCodeWorking).toHaveBeenCalledWith('Fix the spinner') + expect(commandStatusPolicy.onCommandCodeDone).toHaveBeenCalledWith('Fix the spinner') + dispose() + }) + + it('ignores replayed command lifecycle facts (no-attention-replay rule)', async () => { + enableMainAuthority() + const { dispose } = await startWatcher() + + await dispatchFacts([{ kind: 'command-finished', exitCode: 0 }], { seq: 5, replay: true }) + + expect(commandStatusPolicy.onCommandFinished).not.toHaveBeenCalled() + dispose() + }) + it('does not request a title snapshot for an ordinary parked watcher', async () => { enableMainAuthority() const getSideEffectSnapshot = vi.fn() diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts b/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts index 1748cb8290f..8b9475f5ce4 100644 --- a/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts +++ b/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts @@ -16,6 +16,9 @@ import { isAgentTaskCompleteOsNotificationEnabledFromState, isAgentTaskCompleteTrackingEnabledFromState } from './agent-task-complete-policy' +import { createCommandCodeOutputStatusDetector } from '../../../../shared/command-code-output-status' +import { createOsc133CommandFinishedScanner } from '../../../../shared/terminal-osc133-command-finished' +import { createParkedTerminalCommandStatusPolicy } from './parked-terminal-command-status' import { startParkedTerminalMode2031Responder } from './parked-terminal-mode2031-responder' import { subscribeToPtyData } from './pty-data-sidecar-subscriptions' import { createPtyOutputProcessor } from './pty-transport' @@ -187,6 +190,16 @@ export function startParkedTerminalByteWatcher( } } + // Why: command-lifecycle signals drive store-level policy only (git nudge, SSH same-turn + // status drop, Command Code seed/settle); the pane-coupled parts stay with the mounted pane. + const commandStatusPolicy = createParkedTerminalCommandStatusPolicy({ + ptyId, + worktreeId, + tabId, + paneId, + paneKey + }) + // Why: with the authority switch on, the fact consumer is the single policy consumer — registering byte parsers too would double-fire bells. const mainSideEffectAuthority = isMainTerminalSideEffectAuthorityForPty({ settings: useAppStore.getState().settings, @@ -214,12 +227,26 @@ export function startParkedTerminalByteWatcher( const observeTerminalGitHubPRLink = mainSideEffectAuthority ? null : createTerminalGitHubPRLinkDetector() + // Why (byte-parser mode only): mode parity — main's tracker emits these as facts; the byte + // path scans the same shared parsers the mounted kill-switch-off pane uses. + const commandFinishedScanner = mainSideEffectAuthority + ? null + : createOsc133CommandFinishedScanner(commandStatusPolicy.onCommandFinished) + const commandCodeOutputStatusDetector = mainSideEffectAuthority + ? null + : createCommandCodeOutputStatusDetector({ + onWorking: commandStatusPolicy.onCommandCodeWorking, + onDone: commandStatusPolicy.onCommandCodeDone + }) const unregisterFactConsumer = mainSideEffectAuthority ? registerTerminalSideEffectFactConsumer({ ptyId, // Why: ordinary park already has a pane-owned title; the flag below requests a snapshot only when no pane did. callbacks: { ...sideEffectCallbacks, + onCommandFinished: commandStatusPolicy.onCommandFinished, + onCommandCodeWorking: commandStatusPolicy.onCommandCodeWorking, + onCommandCodeDone: commandStatusPolicy.onCommandCodeDone, onPrLink: (link) => useAppStore.getState().observeTerminalGitHubPullRequestLink(worktreeId, link), // Why (gate mode only): the 2031 subscribe arrives as a fact, but the reply stays here — query authority stays with the view/watcher (invariant 6). @@ -247,6 +274,8 @@ export function startParkedTerminalByteWatcher( : subscribeToPtyData(ptyId, (data) => { // Why: empty pane callbacks — no xterm to deliver bytes to, the watcher wants only the parser side effects. processor.processData(data, {}) + commandFinishedScanner?.scan(data) + commandCodeOutputStatusDetector?.observe(data) if (observeTerminalGitHubPRLink) { for (const link of observeTerminalGitHubPRLink(data)) { useAppStore.getState().observeTerminalGitHubPullRequestLink(worktreeId, link) @@ -266,6 +295,8 @@ export function startParkedTerminalByteWatcher( unregisterFactConsumer?.() // Why: clears tracker/timer/detector state so the watcher can't fire after the revealed pane's live parsers take over. processor?.clearAccumulatedState() + commandFinishedScanner?.reset() + commandStatusPolicy.dispose() clearBellNotificationTimer() clearAgentTaskCompleteTimer() pendingBellNotification = false diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-command-status.test.ts b/src/renderer/src/components/terminal-pane/parked-terminal-command-status.test.ts new file mode 100644 index 00000000000..39c80cbf954 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/parked-terminal-command-status.test.ts @@ -0,0 +1,236 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' + +const PTY_ID_LOCAL = 'pty-1' +const PTY_ID_SSH = 'ssh:target-1@@pty-9' +const TAB_ID = 'tab-1' +const WORKTREE_ID = 'repo-1::/tmp/wt-1' +const PANE_KEY = `${TAB_ID}:11111111-1111-4111-8111-111111111111` +const PANE_ID = 1 +// Mirrors COMMAND_CODE_OUTPUT_DONE_SETTLE_MS. +const DONE_SETTLE_MS = 1500 + +const ROUTING = { connectionId: null } + +type MockStoreState = { + agentStatusByPaneKey: Record + runtimePaneTitlesByTabId: Record> + setAgentStatus: ReturnType + dropAgentStatus: ReturnType + clearAgentLaunchConfig: ReturnType +} + +let mockStoreState: MockStoreState +const dispatchTerminalCommandFinishedEvent = vi.fn() +const resolveLiveAgentStatusConnectionRouting = vi.fn() +const getConnectionIdFromState = vi.fn() + +vi.mock('@/store', () => ({ + useAppStore: { getState: () => mockStoreState } +})) +vi.mock('@/hooks/terminal-command-finished-event', () => ({ + dispatchTerminalCommandFinishedEvent +})) +vi.mock('@/lib/agent-status-connection-ownership', () => ({ + resolveLiveAgentStatusConnectionRouting +})) +vi.mock('@/lib/connection-owner-resolution', () => ({ + getConnectionIdFromState +})) + +function makeStatusEntry(overrides: Partial = {}): AgentStatusEntry { + return { + state: 'working', + prompt: 'build the feature', + agentType: 'claude', + updatedAt: 1000, + stateStartedAt: 1000, + ...overrides + } as AgentStatusEntry +} + +async function createPolicy(ptyId: string) { + const { createParkedTerminalCommandStatusPolicy } = + await import('./parked-terminal-command-status') + return createParkedTerminalCommandStatusPolicy({ + ptyId, + worktreeId: WORKTREE_ID, + tabId: TAB_ID, + paneId: PANE_ID, + paneKey: PANE_KEY + }) +} + +describe('createParkedTerminalCommandStatusPolicy', () => { + beforeEach(() => { + vi.resetModules() + vi.useFakeTimers() + dispatchTerminalCommandFinishedEvent.mockClear() + resolveLiveAgentStatusConnectionRouting.mockReset().mockReturnValue(ROUTING) + getConnectionIdFromState.mockReset().mockReturnValue(null) + mockStoreState = { + agentStatusByPaneKey: {}, + runtimePaneTitlesByTabId: { [TAB_ID]: { [PANE_ID]: '✳ Build feature' } }, + setAgentStatus: vi.fn(), + dropAgentStatus: vi.fn(), + clearAgentLaunchConfig: vi.fn() + } + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('seeds a Command Code working row with the current pane title', async () => { + const policy = await createPolicy(PTY_ID_LOCAL) + + policy.onCommandCodeWorking('Fix the spinner') + + expect(mockStoreState.setAgentStatus).toHaveBeenCalledWith( + PANE_KEY, + { state: 'working', prompt: 'Fix the spinner', agentType: 'command-code' }, + '✳ Build feature', + undefined, + ROUTING + ) + policy.dispose() + }) + + it('writes nothing when connection routing does not resolve', async () => { + resolveLiveAgentStatusConnectionRouting.mockReturnValue(undefined) + const policy = await createPolicy(PTY_ID_LOCAL) + + policy.onCommandCodeWorking('Fix the spinner') + + expect(mockStoreState.setAgentStatus).not.toHaveBeenCalled() + policy.dispose() + }) + + it('leaves a settled done row alone when working repeats the same prompt', async () => { + mockStoreState.agentStatusByPaneKey[PANE_KEY] = makeStatusEntry({ + state: 'done', + prompt: 'Fix the spinner', + agentType: 'command-code' + }) + const policy = await createPolicy(PTY_ID_LOCAL) + + policy.onCommandCodeWorking('Fix the spinner') + policy.onCommandCodeWorking('') + + expect(mockStoreState.setAgentStatus).not.toHaveBeenCalled() + policy.dispose() + }) + + it('settles a still-working Command Code row to done after the settle window', async () => { + mockStoreState.agentStatusByPaneKey[PANE_KEY] = makeStatusEntry({ + state: 'working', + prompt: 'Fix the spinner', + agentType: 'command-code' + }) + const policy = await createPolicy(PTY_ID_LOCAL) + + policy.onCommandCodeDone('Fix the spinner') + expect(mockStoreState.setAgentStatus).not.toHaveBeenCalled() + vi.advanceTimersByTime(DONE_SETTLE_MS) + + expect(mockStoreState.setAgentStatus).toHaveBeenCalledWith( + PANE_KEY, + { state: 'done', prompt: 'Fix the spinner', agentType: 'command-code' }, + '✳ Build feature', + undefined, + ROUTING + ) + policy.dispose() + }) + + it('abandons the done settle when another agent owns the row by then', async () => { + mockStoreState.agentStatusByPaneKey[PANE_KEY] = makeStatusEntry({ agentType: 'claude' }) + const policy = await createPolicy(PTY_ID_LOCAL) + + policy.onCommandCodeDone('Fix the spinner') + vi.advanceTimersByTime(DONE_SETTLE_MS) + + expect(mockStoreState.setAgentStatus).not.toHaveBeenCalled() + policy.dispose() + }) + + it('cancels a pending done settle when a working repaint arrives', async () => { + mockStoreState.agentStatusByPaneKey[PANE_KEY] = makeStatusEntry({ + state: 'working', + prompt: 'Fix the spinner', + agentType: 'command-code' + }) + const policy = await createPolicy(PTY_ID_LOCAL) + + policy.onCommandCodeDone('Fix the spinner') + policy.onCommandCodeWorking('Fix the spinner') + vi.advanceTimersByTime(DONE_SETTLE_MS * 2) + + const states = mockStoreState.setAgentStatus.mock.calls.map(([, payload]) => payload.state) + expect(states).toEqual(['working']) + policy.dispose() + }) + + it('dispose cancels a pending done settle', async () => { + mockStoreState.agentStatusByPaneKey[PANE_KEY] = makeStatusEntry({ + state: 'working', + prompt: 'Fix the spinner', + agentType: 'command-code' + }) + const policy = await createPolicy(PTY_ID_LOCAL) + + policy.onCommandCodeDone('Fix the spinner') + policy.dispose() + vi.advanceTimersByTime(DONE_SETTLE_MS * 2) + + expect(mockStoreState.setAgentStatus).not.toHaveBeenCalled() + }) + + it('nudges git UI on command finished for every PTY class', async () => { + const local = await createPolicy(PTY_ID_LOCAL) + local.onCommandFinished(0) + local.dispose() + const ssh = await createPolicy(PTY_ID_SSH) + ssh.onCommandFinished(0) + ssh.dispose() + + expect(dispatchTerminalCommandFinishedEvent).toHaveBeenCalledTimes(2) + expect(dispatchTerminalCommandFinishedEvent).toHaveBeenCalledWith(WORKTREE_ID) + }) + + it('drops a same-turn status row on command finished for SSH PTYs only', async () => { + mockStoreState.agentStatusByPaneKey[PANE_KEY] = makeStatusEntry() + const local = await createPolicy(PTY_ID_LOCAL) + local.onCommandFinished(0) + // Why: local drops need the mounted pane's foreground process-confirm ladder + // (leaked nested-shell 133;D protection), so the watcher must not drop them. + expect(mockStoreState.dropAgentStatus).not.toHaveBeenCalled() + local.dispose() + + const ssh = await createPolicy(PTY_ID_SSH) + ssh.onCommandFinished(0) + expect(mockStoreState.dropAgentStatus).toHaveBeenCalledWith(PANE_KEY) + ssh.dispose() + }) + + it('clears the launch registry on SSH command finished when no status row exists', async () => { + const ssh = await createPolicy(PTY_ID_SSH) + + ssh.onCommandFinished(0) + + expect(mockStoreState.clearAgentLaunchConfig).toHaveBeenCalledWith(PANE_KEY) + expect(mockStoreState.dropAgentStatus).not.toHaveBeenCalled() + ssh.dispose() + }) + + it('does nothing after dispose', async () => { + const ssh = await createPolicy(PTY_ID_SSH) + ssh.dispose() + + ssh.onCommandFinished(0) + ssh.onCommandCodeWorking('Fix the spinner') + + expect(dispatchTerminalCommandFinishedEvent).not.toHaveBeenCalled() + expect(mockStoreState.setAgentStatus).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-command-status.ts b/src/renderer/src/components/terminal-pane/parked-terminal-command-status.ts new file mode 100644 index 00000000000..a670377f97b --- /dev/null +++ b/src/renderer/src/components/terminal-pane/parked-terminal-command-status.ts @@ -0,0 +1,180 @@ +/** + * Parked-pane command-lifecycle status policy. + * Why: parking unmounts TerminalPane, so OSC 133;D and Command Code scrape signals went dark. + * This ports the store-level subset of pty-connection's handlers; the pane-coupled parts + * (foreground process-confirm ladder, key-intent interrupt inference) stay with the mounted pane. + */ +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' +import { dispatchTerminalCommandFinishedEvent } from '@/hooks/terminal-command-finished-event' +import { resolveLiveAgentStatusConnectionRouting } from '@/lib/agent-status-connection-ownership' +import { getConnectionIdFromState } from '@/lib/connection-owner-resolution' +import { useAppStore } from '@/store' + +// Mirrors pty-connection.ts COMMAND_CODE_OUTPUT_DONE_SETTLE_MS — the settle window must be +// identical whether the pane is mounted or parked, or park/reveal changes completion timing. +const COMMAND_CODE_OUTPUT_DONE_SETTLE_MS = 1500 + +export type ParkedTerminalCommandStatusPolicy = { + onCommandFinished: (bestEffortExitCode: number | null) => void + onCommandCodeWorking: (prompt: string) => void + onCommandCodeDone: (prompt: string) => void + dispose: () => void +} + +export function createParkedTerminalCommandStatusPolicy(options: { + ptyId: string + worktreeId: string + tabId: string + /** PaneManager pane id whose runtime-title slot the watcher writes; read for status title pairing. */ + paneId: number + paneKey: string +}): ParkedTerminalCommandStatusPolicy { + const { ptyId, worktreeId, tabId, paneId, paneKey } = options + let disposed = false + let commandCodeOutputDoneTimer: ReturnType | null = null + + const clearCommandCodeOutputDoneTimer = (): void => { + if (commandCodeOutputDoneTimer !== null) { + clearTimeout(commandCodeOutputDoneTimer) + commandCodeOutputDoneTimer = null + } + } + + const resolveRouting = (): ReturnType => { + if (disposed) { + return undefined + } + const state = useAppStore.getState() + return resolveLiveAgentStatusConnectionRouting({ + state, + paneKey, + ptyId, + expectedConnectionId: getConnectionIdFromState(state, worktreeId) + }) + } + + // Port of pty-connection's dropCommandFinishedStatusIfSameTurn, minus the interrupt-inference + // option: parked panes receive no key events, so inference never has evidence here. + const dropCommandFinishedStatusIfSameTurn = (entry: AgentStatusEntry | undefined): void => { + const state = useAppStore.getState() + if (!entry) { + // Why: an Orca-started agent can exit before its first hook status; clear the launch + // registry on command exit like the mounted path does. + state.clearAgentLaunchConfig(paneKey) + return + } + const current = state.agentStatusByPaneKey[paneKey] + if (!current) { + state.clearAgentLaunchConfig(paneKey) + return + } + const unchanged = + current.state === entry.state && + current.prompt === entry.prompt && + current.updatedAt === entry.updatedAt && + current.stateStartedAt === entry.stateStartedAt && + current.agentType === entry.agentType + if (!unchanged) { + return + } + state.dropAgentStatus(paneKey) + } + + return { + onCommandFinished: (): void => { + if (disposed) { + return + } + // Why: the finished command may have moved HEAD or the index (an agent running + // `git checkout` in a parked worktree); nudge git UI now instead of waiting for a poll. + dispatchTerminalCommandFinishedEvent(worktreeId) + // Why: drop the same-turn status row only for SSH PTYs — exact parity with the mounted + // path, whose foreground tracker refuses SSH ids and drops un-probed. Local PTYs need + // pty-connection's process-confirm ladder to tell a leaked nested-shell 133;D from a + // real agent exit, so their drop stays with the mounted pane. + if (parseAppSshPtyId(ptyId) === null) { + return + } + dropCommandFinishedStatusIfSameTurn(useAppStore.getState().agentStatusByPaneKey[paneKey]) + }, + + // Port of pty-connection's seedCommandCodeOutputWorkingStatus (store-level only). + onCommandCodeWorking: (prompt: string): void => { + clearCommandCodeOutputDoneTimer() + const routing = resolveRouting() + if (!routing) { + return + } + const currentState = useAppStore.getState() + const currentEntry = currentState.agentStatusByPaneKey[paneKey] + const currentTitle = currentState.runtimePaneTitlesByTabId?.[tabId]?.[paneId] + const normalizedPrompt = prompt.trim() + if ( + currentEntry?.agentType === 'command-code' && + currentEntry.state === 'done' && + (!normalizedPrompt || normalizedPrompt === currentEntry.prompt.trim()) + ) { + return + } + currentState.setAgentStatus( + paneKey, + { + state: 'working', + prompt: + normalizedPrompt || (currentEntry?.state === 'working' ? currentEntry.prompt : ''), + agentType: 'command-code' + }, + currentTitle, + undefined, + routing + ) + }, + + // Port of pty-connection's scheduleCommandCodeOutputDoneStatus: Command Code keeps rendering + // the composer while tools run, so only complete the row if no active repaint arrives. + onCommandCodeDone: (prompt: string): void => { + clearCommandCodeOutputDoneTimer() + const normalizedPrompt = prompt.trim() + if (!normalizedPrompt) { + return + } + commandCodeOutputDoneTimer = setTimeout(() => { + commandCodeOutputDoneTimer = null + if (disposed) { + return + } + const routing = resolveRouting() + if (!routing) { + return + } + const currentState = useAppStore.getState() + const currentEntry = currentState.agentStatusByPaneKey[paneKey] + if (currentEntry?.agentType !== 'command-code' || currentEntry.state !== 'working') { + return + } + const currentPrompt = currentEntry.prompt.trim() + if (currentPrompt && currentPrompt !== normalizedPrompt) { + return + } + const currentTitle = currentState.runtimePaneTitlesByTabId?.[tabId]?.[paneId] + currentState.setAgentStatus( + paneKey, + { + state: 'done', + prompt: currentPrompt || normalizedPrompt, + agentType: 'command-code' + }, + currentTitle, + undefined, + routing + ) + }, COMMAND_CODE_OUTPUT_DONE_SETTLE_MS) + }, + + dispose: (): void => { + disposed = true + clearCommandCodeOutputDoneTimer() + } + } +}