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 ee0880d26..6011813f2 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -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') diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index fdeccfd7b..4fbc14094 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -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