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 4a5066405a2..9d30c648bc8 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -122,9 +122,15 @@ vi.mock('@/lib/agent-status', async (importOriginal) => { ...actual, isGeminiTerminalTitle: vi.fn(() => false), isClaudeAgent: vi.fn(() => false), - detectAgentStatusFromTitle: vi.fn((title: string) => - /Claude (working|done)/.test(title) ? (/working/.test(title) ? 'working' : 'idle') : null - ) + detectAgentStatusFromTitle: vi.fn((title: string) => { + if (/Claude (working|done)/.test(title)) { + return /working/.test(title) ? 'working' : 'idle' + } + if (/Codex( working)?/.test(title)) { + return /working/.test(title) ? 'working' : 'idle' + } + return null + }) } }) @@ -572,6 +578,229 @@ describe('connectPanePty', () => { onDataHandler = handler return { dispose: vi.fn() } }) as typeof pane.terminal.onData) + const manager = createManager(1) + manager.getActivePane.mockReturnValue({ id: 1 }) + 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, manager 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, 'Terminal') + expect(deps.updateTabTitle).toHaveBeenCalledWith('tab-1', 'Terminal') + 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('infers exact Ctrl+C terminal input when keydown capture misses the press', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + vi.useFakeTimers() + vi.setSystemTime(1_100) + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState.agentStatusByPaneKey[paneKey] = { + state: 'working', + prompt: 'stop from real terminal byte', + updatedAt: 1_000, + stateStartedAt: 900, + agentType: 'codex', + paneKey, + terminalTitle: 'Codex working', + stateHistory: [] + } + const pane = createPane(1) + 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) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + if (!onDataHandler) { + throw new Error('expected onData handler to be registered') + } + ;(onDataHandler as unknown as (data: string) => void)('\x03') + await flushAsyncTicks() + vi.advanceTimersByTime(500) + await flushAsyncTicks() + + expect(window.api.agentStatus.inferInterrupt).toHaveBeenCalledWith({ + paneKey, + baselineUpdatedAt: 1_000, + baselineStateStartedAt: 900, + baselinePrompt: 'stop from real terminal byte', + baselineAgentType: 'codex', + intent: 'ctrl-c' + }) + }) + + it('infers captured Ctrl+C even when xterm emits an enhanced keyboard sequence', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + vi.useFakeTimers() + vi.setSystemTime(1_100) + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState.agentStatusByPaneKey[paneKey] = { + state: 'working', + prompt: 'stop enhanced keyboard input', + 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) + + connectPanePty(pane as never, createManager(1) as never, createDeps() 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)('\x1b[99;5u') + await flushAsyncTicks() + vi.advanceTimersByTime(500) + await flushAsyncTicks() + + expect(transport.sendInputAccepted).toHaveBeenCalledWith('\x1b[99;5u') + expect(window.api.agentStatus.inferInterrupt).toHaveBeenCalledWith({ + paneKey, + baselineUpdatedAt: 1_000, + baselineStateStartedAt: 900, + baselinePrompt: 'stop enhanced keyboard input', + baselineAgentType: 'codex', + intent: 'ctrl-c' + }) + }) + + it('drops stale working status instead of inferring done when the title is already non-agent', async () => { + const { connectPanePty } = await import('./pty-connection') + 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 after process 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) + + connectPanePty(pane as never, createManager(1) as never, createDeps() 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)('\x1b[99;5u') + await flushAsyncTicks() + mockStoreState.runtimePaneTitlesByTabId = { + 'tab-1': { + 1: 'Terminal 1' + } + } + vi.advanceTimersByTime(500) + await flushAsyncTicks() + + expect(window.api.agentStatus.inferInterrupt).not.toHaveBeenCalled() + expect(mockStoreState.dropAgentStatus).toHaveBeenCalledWith(paneKey) + }) + + it('drops inferred interrupted status after the pane settles on a non-agent title', async () => { + const { connectPanePty } = await import('./pty-connection') + 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 and leave shell', + updatedAt: 1_000, + stateStartedAt: 900, + agentType: 'codex', + paneKey, + terminalTitle: 'Codex working', + stateHistory: [] + } + vi.mocked(window.api.agentStatus.inferInterrupt).mockImplementation(async () => { + mockStoreState.agentStatusByPaneKey[paneKey] = { + state: 'done', + prompt: 'stop and leave shell', + interrupted: true, + updatedAt: 1_100, + stateStartedAt: 1_100, + agentType: 'codex', + paneKey, + terminalTitle: 'Terminal 1' + } + return true + }) + 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 = { @@ -589,24 +818,54 @@ describe('connectPanePty', () => { throw new Error('expected onData handler to be registered') } terminalTarget.dispatch(keyEvent({ key: 'c', ctrlKey: true })) - ;(onDataHandler as unknown as (data: string) => void)('\x03') + ;(onDataHandler as unknown as (data: string) => void)('\x1b[99;5u') await flushAsyncTicks() vi.advanceTimersByTime(500) await flushAsyncTicks() + expect(window.api.agentStatus.inferInterrupt).toHaveBeenCalled() + expect(mockStoreState.dropAgentStatus).not.toHaveBeenCalled() - 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') + vi.advanceTimersByTime(750) + await flushAsyncTicks() + + expect(mockStoreState.dropAgentStatus).toHaveBeenCalledWith(paneKey) + }) + + it('does not infer exact interrupt input when the transport cannot acknowledge writes', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + delete transport.sendInputAccepted + transportFactoryQueue.push(transport) + vi.useFakeTimers() + vi.setSystemTime(1_100) + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState.agentStatusByPaneKey[paneKey] = { + state: 'working', + prompt: 'ssh style write', + updatedAt: 1_000, + stateStartedAt: 900, + agentType: 'codex', + paneKey, + terminalTitle: 'Codex working', + stateHistory: [] + } + const pane = createPane(1) + 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) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + if (!onDataHandler) { + throw new Error('expected onData handler to be registered') + } + ;(onDataHandler as unknown as (data: string) => void)('\x03') + vi.advanceTimersByTime(500) + await flushAsyncTicks() + + expect(transport.sendInput).toHaveBeenCalledWith('\x03') + expect(window.api.agentStatus.inferInterrupt).not.toHaveBeenCalled() }) it('removes agent status and pane title on PTY exit after inferred interrupt', async () => { diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index fe6d7389533..5023a69e8bf 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -1,7 +1,11 @@ /* oxlint-disable max-lines */ import type { PaneManager, ManagedPane } from '@/lib/pane-manager/pane-manager' import type { IDisposable } from '@xterm/xterm' -import { isGeminiTerminalTitle, isClaudeAgent } from '@/lib/agent-status' +import { + detectAgentStatusFromTitle, + isGeminiTerminalTitle, + isClaudeAgent +} from '@/lib/agent-status' import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph' import { useAppStore } from '@/store' import type { PtyConnectResult } from './pty-transport' @@ -24,7 +28,6 @@ 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' @@ -44,6 +47,7 @@ const PTY_CONNECT_DIAG_LIMIT = 200 const AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS = 250 const AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS = 1000 const AGENT_TASK_COMPLETE_NOTIFICATION_DETAIL_MAX_AGE_MS = 10_000 +const INFERRED_INTERRUPT_NEUTRAL_TITLE_DROP_MS = 750 function isAgentTaskCompleteNotificationEnabled(): boolean { const notifications = useAppStore.getState().settings?.notifications @@ -174,6 +178,7 @@ export function connectPanePty( let agentTaskCompleteNotificationGraceTimer: ReturnType | null = null let agentTaskCompleteNotificationMaxTimer: ReturnType | null = null let agentTaskCompleteStatusUnsubscribe: (() => void) | null = null + let inferredInterruptNeutralTitleDropTimer: ReturnType | null = null let terminalBellNotificationTimer: ReturnType | null = null let pendingTerminalBellNotification = false // Why: passphrase-gate waits register a teardown here so dispose() can @@ -195,6 +200,43 @@ 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 neutralTerminalTitle = (): string => { + const state = useAppStore.getState() + const tab = (state.tabsByWorktree[deps.worktreeId] ?? []).find( + (entry) => entry.id === deps.tabId + ) + return tab?.defaultTitle?.trim() || 'Terminal' + } + const inferredInterruptTargetStillLooksAgent = (): boolean => { + const state = useAppStore.getState() + const currentTitle = state.runtimePaneTitlesByTabId?.[deps.tabId]?.[pane.id] + const tabTitle = (state.tabsByWorktree[deps.worktreeId] ?? []).find( + (entry) => entry.id === deps.tabId + )?.title + const statusTitle = state.agentStatusByPaneKey[cacheKey]?.terminalTitle + const title = currentTitle ?? tabTitle ?? statusTitle + return title ? detectAgentStatusFromTitle(title) !== null : true + } + const clearInferredInterruptNeutralTitleDropTimer = (): void => { + if (inferredInterruptNeutralTitleDropTimer !== null) { + clearTimeout(inferredInterruptNeutralTitleDropTimer) + inferredInterruptNeutralTitleDropTimer = null + } + } + const scheduleInferredInterruptNeutralTitleDrop = (): void => { + clearInferredInterruptNeutralTitleDropTimer() + inferredInterruptNeutralTitleDropTimer = setTimeout(() => { + inferredInterruptNeutralTitleDropTimer = null + const entry = useAppStore.getState().agentStatusByPaneKey[cacheKey] + if ( + entry?.state === 'done' && + entry.interrupted === true && + !inferredInterruptTargetStillLooksAgent() + ) { + useAppStore.getState().dropAgentStatus(cacheKey) + } + }, INFERRED_INTERRUPT_NEUTRAL_TITLE_DROP_MS) + } const clearInferredInterruptWorkingTitle = (): void => { const state = useAppStore.getState() const currentTitle = state.runtimePaneTitlesByTabId?.[deps.tabId]?.[pane.id] @@ -203,20 +245,30 @@ export function connectPanePty( if (!title) { return } + const neutralTitle = neutralTerminalTitle() // 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)) + // their OSC title stuck on a working spinner. Replace only this fallback + // title signal with a neutral terminal label so the existing process tracker + // can still decide whether an agent TUI is truly alive. + deps.setRuntimePaneTitle(deps.tabId, pane.id, neutralTitle) + if (manager.getActivePane()?.id === pane.id) { + deps.updateTabTitle(deps.tabId, neutralTitle) + } } const interruptInference = createAgentInterruptInference({ paneKey: cacheKey, getStatusEntry: () => useAppStore.getState().agentStatusByPaneKey[cacheKey], inferInterrupt: (request) => { + if (!inferredInterruptTargetStillLooksAgent()) { + useAppStore.getState().dropAgentStatus(cacheKey) + return false + } return window.api.agentStatus .inferInterrupt(request) .then((applied) => { if (applied) { clearInferredInterruptWorkingTitle() + scheduleInferredInterruptNeutralTitleDrop() } return applied }) @@ -265,6 +317,15 @@ export function connectPanePty( (intent === 'plain-escape' && data === '\x1b') || (intent === 'ctrl-c' && data === '\x03') ) } + const inferIntentFromExactTerminalInput = (data: string): AgentInterruptInputIntent | null => { + if (data === '\x03') { + return 'ctrl-c' + } + if (data === '\x1b') { + return 'plain-escape' + } + return null + } const observeSentTerminalInputIntent = ( data: string, intent = pendingTerminalInputIntent @@ -705,13 +766,18 @@ export function connectPanePty( deps.clearTerminalTabUnread(deps.tabId) deps.clearWorktreeUnread(deps.worktreeId) const intent = pendingTerminalInputIntent - if (intent && transport.sendInputAccepted) { + // Why: real xterm can deliver the terminal byte even when our DOM keydown + // listener missed the press. Exact Ctrl+C/Escape bytes are still safe to + // infer for local/remote acknowledged writes; SSH fire-and-forget remains + // excluded because those transports do not expose sendInputAccepted. + const acknowledgedIntent = intent ?? inferIntentFromExactTerminalInput(data) + if (acknowledgedIntent && transport.sendInputAccepted) { clearPendingTerminalInputIntent() const writePromise = transport .sendInputAccepted(data) .then((accepted) => { if (accepted) { - observeSentTerminalInputIntent(data, intent) + interruptInference.observeInputIntent(acknowledgedIntent) } }) .catch((err) => { @@ -1584,6 +1650,7 @@ export function connectPanePty( clearPendingTerminalInputIntent() pendingTerminalInputWrite = null interruptInference.dispose() + clearInferredInterruptNeutralTitleDropTimer() // Why: actively resolve any in-flight passphrase-gate waits so their // zustand subscribers + async IIFEs don't hang for the rest of the // session when the pane is torn down before SSH state changes.