From 49b1dc2d57790d40014bf1cc7ae95d0c3d7dc8a2 Mon Sep 17 00:00:00 2001 From: brennanb2025 Date: Mon, 18 May 2026 21:25:19 -0700 Subject: [PATCH] Fix visible agent status after interrupts Co-authored-by: Orca --- .../terminal-pane/pty-connection.test.ts | 126 ++++++++++++++++++ .../terminal-pane/pty-connection.ts | 30 ++++- 2 files changed, 152 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index e610640bbce..4a5066405a2 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -62,6 +62,7 @@ type StoreState = { removeDeferredSshSessionId: ReturnType consumePendingColdRestore: ReturnType consumePendingSnapshot: ReturnType + runtimePaneTitlesByTabId: Record> agentStatusByPaneKey: Record removeAgentStatus: ReturnType dropAgentStatus: ReturnType @@ -360,6 +361,7 @@ describe('connectPanePty', () => { removeDeferredSshSessionId: vi.fn(), consumePendingColdRestore: vi.fn(() => null), consumePendingSnapshot: vi.fn(() => null), + runtimePaneTitlesByTabId: {}, agentStatusByPaneKey: {}, removeAgentStatus: vi.fn(), dropAgentStatus: vi.fn() @@ -538,6 +540,130 @@ describe('connectPanePty', () => { }) }) + it('clears stale working pane title after inferred interrupt applies', async () => { + const { connectPanePty } = await import('./pty-connection') + const { resolveWorktreeStatus } = await import('@/lib/worktree-status') + vi.mocked(window.api.agentStatus.inferInterrupt).mockResolvedValue(true) + const transport = createMockTransport() + transportFactoryQueue.push(transport) + vi.useFakeTimers() + vi.setSystemTime(1_100) + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState.runtimePaneTitlesByTabId = { + 'tab-1': { + 1: 'Codex working' + } + } + mockStoreState.agentStatusByPaneKey[paneKey] = { + state: 'working', + prompt: 'stop visible spinner', + updatedAt: 1_000, + stateStartedAt: 900, + agentType: 'codex', + paneKey, + terminalTitle: 'Codex working', + stateHistory: [] + } + const terminalTarget = createKeyboardEventTarget() + const pane = createPane(1) + ;(pane.terminal as { element?: unknown }).element = terminalTarget.target + let onDataHandler: ((data: string) => void) | null = null + pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { + onDataHandler = handler + return { dispose: vi.fn() } + }) as typeof pane.terminal.onData) + const deps = createDeps({ + setRuntimePaneTitle: vi.fn((tabId: string, paneId: number, title: string) => { + mockStoreState.runtimePaneTitlesByTabId = { + ...mockStoreState.runtimePaneTitlesByTabId, + [tabId]: { + ...mockStoreState.runtimePaneTitlesByTabId[tabId], + [paneId]: title + } + } + }) + }) + + connectPanePty(pane as never, createManager(1) as never, deps as never) + if (!onDataHandler) { + throw new Error('expected onData handler to be registered') + } + terminalTarget.dispatch(keyEvent({ key: 'c', ctrlKey: true })) + ;(onDataHandler as unknown as (data: string) => void)('\x03') + await flushAsyncTicks() + vi.advanceTimersByTime(500) + await flushAsyncTicks() + + expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'Codex') + expect( + resolveWorktreeStatus({ + tabs: [{ id: 'tab-1', title: 'Codex working' }], + browserTabs: [], + ptyIdsByTabId: { 'tab-1': ['tab-pty'] }, + runtimePaneTitlesByTabId: mockStoreState.runtimePaneTitlesByTabId, + hasPermission: false, + hasLiveWorking: false, + hasLiveDone: true, + hasRetainedDone: false + }) + ).toBe('done') + }) + + it('removes agent status and pane title on PTY exit after inferred interrupt', async () => { + const { connectPanePty } = await import('./pty-connection') + vi.mocked(window.api.agentStatus.inferInterrupt).mockResolvedValue(true) + const transport = createMockTransport('tab-pty') + transportFactoryQueue.push(transport) + vi.useFakeTimers() + vi.setSystemTime(1_100) + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState.runtimePaneTitlesByTabId = { + 'tab-1': { + 1: 'Codex working' + } + } + mockStoreState.agentStatusByPaneKey[paneKey] = { + state: 'working', + prompt: 'stop then exit', + updatedAt: 1_000, + stateStartedAt: 900, + agentType: 'codex', + paneKey, + terminalTitle: 'Codex working', + stateHistory: [] + } + const terminalTarget = createKeyboardEventTarget() + const pane = createPane(1) + ;(pane.terminal as { element?: unknown }).element = terminalTarget.target + let onDataHandler: ((data: string) => void) | null = null + pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { + onDataHandler = handler + return { dispose: vi.fn() } + }) as typeof pane.terminal.onData) + const deps = createDeps() + + connectPanePty(pane as never, createManager(1) as never, deps as never) + if (!onDataHandler) { + throw new Error('expected onData handler to be registered') + } + terminalTarget.dispatch(keyEvent({ key: 'c', ctrlKey: true })) + ;(onDataHandler as unknown as (data: string) => void)('\x03') + await flushAsyncTicks() + vi.advanceTimersByTime(500) + await flushAsyncTicks() + const onPtyExit = createdTransportOptions[0]?.onPtyExit as ((ptyId: string) => void) | undefined + if (!onPtyExit) { + throw new Error('expected onPtyExit callback to be registered') + } + + onPtyExit('tab-pty') + + expect(deps.clearRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1) + expect(deps.clearTabPtyId).toHaveBeenCalledWith('tab-1', 'tab-pty') + expect(deps.setCacheTimerStartedAt).toHaveBeenCalledWith(paneKey, null) + expect(mockStoreState.removeAgentStatus).toHaveBeenCalledWith(paneKey) + }) + it('ignores repeated and modified terminal interrupt keydowns', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport() diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 88adacd6809..fe6d7389533 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -24,6 +24,7 @@ import { waitForTerminalOutputParsed, writeTerminalOutput } from '@/lib/pane-manager/pane-terminal-output-scheduler' +import { clearWorkingIndicators } from '../../../../shared/agent-detection' import { makePaneKey } from '../../../../shared/stable-pane-id' import { createTerminalCommandLifecycle } from './terminal-command-lifecycle' import { e2eConfig } from '@/lib/e2e-config' @@ -194,14 +195,35 @@ export function connectPanePty( // Use the stable layout leaf UUID, not the renderer-local numeric pane id. const cacheKey = makePaneKey(deps.tabId, pane.leafId) const pendingSpawnKey = cacheKey + const clearInferredInterruptWorkingTitle = (): void => { + const state = useAppStore.getState() + const currentTitle = state.runtimePaneTitlesByTabId?.[deps.tabId]?.[pane.id] + const statusTitle = state.agentStatusByPaneKey[cacheKey]?.terminalTitle + const title = currentTitle ?? statusTitle + if (!title) { + return + } + // Why: inferred interrupts update the explicit hook row, but many CLIs leave + // their OSC title stuck on a working spinner. Clear that pane-local title so + // the sidebar/worktree status reflects the inferred done state users see. + deps.setRuntimePaneTitle(deps.tabId, pane.id, clearWorkingIndicators(title)) + } const interruptInference = createAgentInterruptInference({ paneKey: cacheKey, getStatusEntry: () => useAppStore.getState().agentStatusByPaneKey[cacheKey], inferInterrupt: (request) => { - return window.api.agentStatus.inferInterrupt(request).catch((err) => { - console.warn('[agent-interrupt] inferInterrupt failed:', err) - return false - }) + return window.api.agentStatus + .inferInterrupt(request) + .then((applied) => { + if (applied) { + clearInferredInterruptWorkingTitle() + } + return applied + }) + .catch((err) => { + console.warn('[agent-interrupt] inferInterrupt failed:', err) + return false + }) } }) const dropCommandFinishedStatusIfUnchanged = (entry: AgentStatusEntry | undefined): void => {