fix(terminal): disarm stale TUI modes when a pane confirms return to shell (#9608)

A TUI killed hard (SIGKILL, OOM, crash) never restores the modes it armed. When
its parent shell survives, the emulator keeps mouse tracking, focus reporting
and Kitty keyboard flags on: every pointer move over the pane lands as typed SGR
motion reports at the prompt, and the doomed process burns CPU parsing the
motion firehose while it lives.

Orca's existing mode cleanups all hang off dead-PTY paths (hibernation kill,
daemon reattach), so an agent dying under a live shell crossed none of them.

Fire POST_REPLAY_REATTACH_RESET at the pane-foreground-agent tracker's confirmed
return-to-shell transition, next to the sibling stale-title cleanup. That
transition is gated on a real foreground-process read rather than the bare
OSC 133;D, because a full-screen agent's nested command shells leak their own D
onto the main PTY. The write goes through the replay guard so xterm's auto
replies cannot leak to the shell as input.

The reused constant already excludes ?2004l, so the bracketed-paste protection
the live shell re-arms at its prompt is preserved.
This commit is contained in:
David Anderson 2026-07-27 15:41:34 -07:00 committed by GitHub
parent 17fc40eae6
commit c0734f039d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 88 additions and 0 deletions

View File

@ -5905,6 +5905,88 @@ describe('connectPanePty', () => {
expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('alt-enter')
})
it('disarms stale TUI modes in the emulator after a confirmed return to shell', async () => {
vi.useFakeTimers()
const { connectPanePty } = await import('./pty-connection')
vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue('bash')
const dataCallbackRef: { current: ((data: string) => void) | null } = { current: null }
const ptyId = 'pty-stale-mode-disarm'
const transport = createMockTransport(ptyId)
transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
dataCallbackRef.current = callbacks.onData ?? null
return { id: ptyId }
})
transportFactoryQueue.push(transport)
const paneKey = makePaneKey('tab-1', LEAF_1)
const pane = createPane(1)
const { writes } = captureCallbackTerminalWrites(pane)
connectPanePty(
pane as never,
createManager(1) as never,
createDeps({ isVisibleRef: { current: false } }) as never
)
await vi.advanceTimersByTimeAsync(20)
await flushAsyncTicks()
mockStoreState.agentLaunchConfigByPaneKey[paneKey] = {
launchConfig: { agentArgs: '', agentEnv: {} },
identity: { agentType: 'droid' }
}
// A SIGKILLed agent emits no mode teardown; only the shell's next prompt
// mark arrives. The bare 133;D must not disarm yet — the foreground read
// has not confirmed the agent is gone.
dataCallbackRef.current?.('\x1b]133;D;0\x07')
expect(writes.some((w) => w.includes(POST_REPLAY_REATTACH_RESET))).toBe(false)
await vi.advanceTimersByTimeAsync(350)
await flushAsyncTicks()
const disarm = writes.find((w) => w.includes(POST_REPLAY_REATTACH_RESET))
expect(disarm).toBeDefined()
// The live shell re-arms bracketed paste at its prompt before the disarm
// fires; the reset must not strip it.
expect(disarm).not.toContain('\x1b[?2004l')
})
it('keeps armed modes while the agent still owns the foreground after a leaked 133;D', async () => {
vi.useFakeTimers()
const { connectPanePty } = await import('./pty-connection')
vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue('droid')
const dataCallbackRef: { current: ((data: string) => void) | null } = { current: null }
const ptyId = 'pty-stale-mode-live-agent'
const transport = createMockTransport(ptyId)
transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
dataCallbackRef.current = callbacks.onData ?? null
return { id: ptyId }
})
transportFactoryQueue.push(transport)
const paneKey = makePaneKey('tab-1', LEAF_1)
const pane = createPane(1)
const { writes } = captureCallbackTerminalWrites(pane)
connectPanePty(
pane as never,
createManager(1) as never,
createDeps({ isVisibleRef: { current: false } }) as never
)
await vi.advanceTimersByTimeAsync(20)
await flushAsyncTicks()
mockStoreState.agentLaunchConfigByPaneKey[paneKey] = {
launchConfig: { agentArgs: '', agentEnv: {} },
identity: { agentType: 'droid' }
}
// A full-screen agent's nested command shell leaks a 133;D onto the main
// PTY; the confirming read republishes the live agent, so its armed
// mouse/kitty modes must survive untouched.
dataCallbackRef.current?.('\x1b]133;D;0\x07')
await vi.advanceTimersByTimeAsync(350 + 1200 + 6000)
await flushAsyncTicks()
expect(writes.some((w) => w.includes(POST_REPLAY_REATTACH_RESET))).toBe(false)
})
it('retires stale routing after unavailable command-finish reads without asserting shell', async () => {
vi.useFakeTimers()
const { connectPanePty } = await import('./pty-connection')

View File

@ -2010,6 +2010,12 @@ export function connectPanePty(
hasKnownAgentIdentity: paneHasKnownAgentIdentity,
onConfirmedShellForeground: (reason) => {
clearStaleAgentTabTitleOnConfirmedShell()
// Why: a hard-killed agent leaves mouse/focus/kitty modes armed, and the
// surviving shell then receives pointer moves as typed SGR reports; the
// replay guard keeps xterm's auto-replies from leaking to the shell.
replayIntoTerminal(pane, deps.replayingPanesRef, POST_REPLAY_REATTACH_RESET, {
shouldRefreshViewportSynchronously: shouldRefreshForegroundSynchronously
})
if (reason === 'visible-pty') {
useAppStore.getState().clearAgentLaunchConfig(cacheKey)
return