fix(tui): install the process.on('exit') terminal-mode backstop (#42165)

#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
This commit is contained in:
Teknium 2026-06-08 08:21:19 -07:00 committed by GitHub
parent 7230fcb7f2
commit fd1e7c2bc3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 38 additions and 0 deletions

View file

@ -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)
}
})
})

View file

@ -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.