Fix interrupt cleanup for exited agent TUIs

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
brennanb2025
2026-05-18 21:52:11 -07:00
co-authored by Orca
parent 49b1dc2d57
commit 894a4e5070
2 changed files with 350 additions and 24 deletions
@@ -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 () => {
@@ -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<typeof setTimeout> | null = null
let agentTaskCompleteNotificationMaxTimer: ReturnType<typeof setTimeout> | null = null
let agentTaskCompleteStatusUnsubscribe: (() => void) | null = null
let inferredInterruptNeutralTitleDropTimer: ReturnType<typeof setTimeout> | null = null
let terminalBellNotificationTimer: ReturnType<typeof setTimeout> | 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.