diff --git a/ui-tui/src/__tests__/terminalModes.test.ts b/ui-tui/src/__tests__/terminalModes.test.ts index 2769913481c..90d551a3dfd 100644 --- a/ui-tui/src/__tests__/terminalModes.test.ts +++ b/ui-tui/src/__tests__/terminalModes.test.ts @@ -36,4 +36,26 @@ describe('terminal mode reset', () => { expect(resetTerminalModes({ isTTY: false, write } as unknown as NodeJS.WriteStream)).toBe(false) expect(write).not.toHaveBeenCalled() }) + + // entry.tsx installs `process.on('exit', () => resetTerminalModes())` as the + // final backstop (#28419): /quit, Ctrl+C, Ctrl+D and any process.exit() path + // must disarm DEC mouse tracking so the parent shell / next TUI doesn't read + // leaked mouse reports as keystrokes. 'exit' handlers run synchronously only, + // so the reset must complete via a single synchronous write — verify that an + // exit-style invocation disables every SGR mouse mode that produced the + // reported `…;…M` garbage. + it('disarms mouse tracking from a synchronous exit-style handler', () => { + const write = vi.fn() + const stream = { isTTY: true, write } as unknown as NodeJS.WriteStream + + // Mirror entry.tsx's process.on('exit') callback. + const onExit = () => resetTerminalModes(stream) + onExit() + + expect(write).toHaveBeenCalledTimes(1) + const written = write.mock.calls[0]?.[0] as string + for (const mode of ['\x1b[?1006l', '\x1b[?1003l', '\x1b[?1002l', '\x1b[?1000l']) { + expect(written).toContain(mode) + } + }) }) diff --git a/ui-tui/src/entry.tsx b/ui-tui/src/entry.tsx index a4125981216..22fee6bccbd 100644 --- a/ui-tui/src/entry.tsx +++ b/ui-tui/src/entry.tsx @@ -23,6 +23,22 @@ if (!process.stdin.isTTY) { // terminal tab can still have mouse/focus/paste modes enabled. resetTerminalModes() +// Final backstop for terminal cleanup. setupGracefulExit() resets modes on +// signals/uncaught errors, and die()/dieWithCode() call process.exit() after +// Ink's unmount specifically so this handler can fire (see useMainApp.ts and +// #19194). But that handler was never actually installed — so /quit, Ctrl+C, +// Ctrl+D, and any process.exit() path left DEC mouse tracking (?1000/1002/ +// 1003/1006) armed in the parent shell. The terminal then keeps emitting mouse +// reports into whatever reads stdin next — the shell or a freshly relaunched +// TUI mid-init — which surface as `102;71M5;104;62M`-style garbage in the input +// box (#28419). 'exit' fires exactly once on real termination and only runs +// synchronous code; resetTerminalModes() writes via writeSync, so it completes +// before the process is gone. Idempotent and cheap, so layering it under the +// graceful-exit cleanups is safe. +process.on('exit', () => { + resetTerminalModes() +}) + // Desktop terminals benefit from a clean startup slate because the TUI usually // runs in AlternateScreen. On Termux we keep prior output intact so users can // review/copy earlier assistant replies after reopening the app.