From 8c38de049509b7fcdb0440fa3c4d6856eb89ba16 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 3 Feb 2026 00:07:35 +0100 Subject: [PATCH] fix(tui): drain stdin on exit to avoid Kitty release leak Drain stdin for up to 1s after disabling Kitty protocol so in-flight key release events are consumed before the shell regains control. Fixes #1204 --- packages/coding-agent/CHANGELOG.md | 2 +- .../src/modes/interactive/interactive-mode.ts | 2 +- packages/tui/CHANGELOG.md | 4 +- packages/tui/src/terminal.ts | 52 +++++++++++++------ packages/tui/test/virtual-terminal.ts | 4 +- 5 files changed, 42 insertions(+), 22 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 59be26f3..38d729b8 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,7 +4,7 @@ ### Fixed -- Fixed Kitty key release events leaking to parent shell over slow SSH connections ([#1204](https://github.com/badlogic/pi-mono/issues/1204)) +- Fixed Kitty key release events leaking to parent shell over slow SSH connections by draining stdin for up to 1s on exit ([#1204](https://github.com/badlogic/pi-mono/issues/1204)) ## [0.51.1] - 2026-02-02 diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 3f852185..0074cf4b 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -2554,7 +2554,7 @@ export class InteractiveMode { // Drain any in-flight Kitty key release events before stopping. // This prevents escape sequences from leaking to the parent shell over slow SSH. - await this.ui.terminal.prepareForExit(); + await this.ui.terminal.drainInput(1000); this.stop(); process.exit(0); diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index b94819a8..07d0ae5a 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -4,11 +4,11 @@ ### Added -- Added `Terminal.prepareForExit()` method to drain Kitty key release events before exit +- Added `Terminal.drainInput()` to drain stdin before exit (prevents Kitty key release events leaking over slow SSH) ### Fixed -- Fixed Kitty key release events leaking to parent shell over slow SSH connections ([#1204](https://github.com/badlogic/pi-mono/issues/1204)) +- Fixed Kitty key release events leaking to parent shell over slow SSH connections by draining stdin for up to 1s ([#1204](https://github.com/badlogic/pi-mono/issues/1204)) ## [0.51.1] - 2026-02-02 diff --git a/packages/tui/src/terminal.ts b/packages/tui/src/terminal.ts index 985afb08..9d6846ef 100644 --- a/packages/tui/src/terminal.ts +++ b/packages/tui/src/terminal.ts @@ -13,12 +13,12 @@ export interface Terminal { stop(): void; /** - * Prepare for process exit by disabling Kitty protocol and draining stdin. - * Call this before stop() when exiting to prevent Kitty key release events - * from leaking to the parent shell over slow SSH connections. - * @param drainMs - How long to drain stdin (default: 50ms) + * Drain stdin before exiting to prevent Kitty key release events from + * leaking to the parent shell over slow SSH connections. + * @param maxMs - Maximum time to drain (default: 1000ms) + * @param idleMs - Exit early if no input arrives within this time (default: 50ms) */ - prepareForExit(drainMs?: number): Promise; + drainInput(maxMs?: number, idleMs?: number): Promise; // Write output to terminal write(data: string): void; @@ -158,25 +158,45 @@ export class ProcessTerminal implements Terminal { process.stdout.write("\x1b[?u"); } - async prepareForExit(drainMs = 50): Promise { - if (!this._kittyProtocolActive) return; + async drainInput(maxMs = 1000, idleMs = 50): Promise { + if (this._kittyProtocolActive) { + // Disable Kitty keyboard protocol first so any late key releases + // do not generate new Kitty escape sequences. + process.stdout.write("\x1b[ setTimeout(resolve, drainMs)); + let lastDataTime = Date.now(); + const onData = () => { + lastDataTime = Date.now(); + }; + + process.stdin.on("data", onData); + const endTime = Date.now() + maxMs; + + try { + while (true) { + const now = Date.now(); + const timeLeft = endTime - now; + if (timeLeft <= 0) break; + if (now - lastDataTime >= idleMs) break; + await new Promise((resolve) => setTimeout(resolve, Math.min(idleMs, timeLeft))); + } + } finally { + process.stdin.removeListener("data", onData); + this.inputHandler = previousHandler; + } } stop(): void { // Disable bracketed paste mode process.stdout.write("\x1b[?2004l"); - // Disable Kitty keyboard protocol if not already done by prepareForExit() + // Disable Kitty keyboard protocol if not already done by drainInput() if (this._kittyProtocolActive) { process.stdout.write("\x1b[ { - // No-op for virtual terminal - no Kitty protocol to drain + async drainInput(_maxMs?: number, _idleMs?: number): Promise { + // No-op for virtual terminal - no stdin to drain } stop(): void {