From fd1e7c2bc356d773589320051853f8e058ca636e Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 8 Jun 2026 08:21:19 -0700 Subject: [PATCH] fix(tui): install the process.on('exit') terminal-mode backstop (#42165) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #19194's fix added process.exit(0) to die()/dieWithCode() with a comment relying on a process.on('exit') handler in entry.tsx that resets terminal modes — but that handler was never installed. So /quit, Ctrl+C, Ctrl+D and every process.exit() path left DEC mouse tracking (?1000/1002/1003/1006) armed in the parent shell. The terminal then kept emitting mouse reports into stdin — read as keystrokes by the shell or a freshly relaunched TUI — surfacing as ...;...M garbage in the input box. Install the missing handler. 'exit' fires once on real termination and runs synchronous code only; resetTerminalModes() writes via writeSync, so the disable sequence lands before the process is gone. Fixes #28419 --- ui-tui/src/__tests__/terminalModes.test.ts | 22 ++++++++++++++++++++++ ui-tui/src/entry.tsx | 16 ++++++++++++++++ 2 files changed, 38 insertions(+) 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.