From 8913aea96802c37a0c95402385ab364a06c175d7 Mon Sep 17 00:00:00 2001 From: Flux Labs Date: Sun, 15 Mar 2026 17:17:58 -0500 Subject: [PATCH] fix: prevent arrow keys from inserting escape sequences as text (#493) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Arrow keys produce `^[[D`/`^[[C` instead of moving the cursor when event loop latency causes the StdinBuffer to split escape sequences. Three layered fixes: 1. Increase StdinBuffer timeout from 10ms to 50ms (matches xterm default) so split escape sequences are reassembled even under load. 2. Clean up stale readline listeners after @clack/prompts onboarding — readline.emitKeypressEvents() leaves a permanent data listener that is unnecessary for the TUI. 3. Guard in editor against CSI remnants: if a split still occurs, reject text matching navigation escape patterns ([A-F, [H, [Z, [n~) instead of inserting them as characters. Closes #493 --- packages/pi-tui/src/components/editor.ts | 11 ++++++++++- packages/pi-tui/src/terminal.ts | 5 ++++- src/cli.ts | 9 +++++++++ 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/packages/pi-tui/src/components/editor.ts b/packages/pi-tui/src/components/editor.ts index 768439289..efa0195d3 100644 --- a/packages/pi-tui/src/components/editor.ts +++ b/packages/pi-tui/src/components/editor.ts @@ -730,8 +730,17 @@ export class Editor implements Component, Focusable { return; } - // Regular characters + // Regular characters — reject partial escape sequence remnants that can + // occur when event loop latency causes the StdinBuffer to split an escape + // sequence (e.g. \x1b flushed as ESC, then "[D" arrives as text). if (data.charCodeAt(0) >= 32) { + if (data[0] === "[" && data.length >= 2 && data.length <= 8) { + const last = data[data.length - 1]!; + // CSI navigation remnants: [A-F (arrows/home/end), [H, [Z (shift-tab), [~ (func keys) + if (/^[A-FHZ]$/.test(last) || last === "~") { + return; // Drop CSI remnant (e.g. "[D", "[C", "[5~") + } + } this.insertCharacter(data); } } diff --git a/packages/pi-tui/src/terminal.ts b/packages/pi-tui/src/terminal.ts index 9f5cc17d9..52bb27ad3 100644 --- a/packages/pi-tui/src/terminal.ts +++ b/packages/pi-tui/src/terminal.ts @@ -112,7 +112,10 @@ export class ProcessTerminal implements Terminal { * to handle the case where the response arrives split across multiple events. */ private setupStdinBuffer(): void { - this.stdinBuffer = new StdinBuffer({ timeout: 10 }); + // 50ms matches xterm's default escapeCodeTimeout and gives enough headroom + // for escape sequences that arrive split across multiple stdin data events + // (e.g. \x1b arriving separately from [D due to event loop latency). + this.stdinBuffer = new StdinBuffer({ timeout: 50 }); // Kitty protocol response pattern: \x1b[?u const kittyResponsePattern = /^\x1b\[\?(\d+)u$/; diff --git a/src/cli.ts b/src/cli.ts index 0836cd9c5..fc7a3fc78 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -137,6 +137,15 @@ migratePiCredentials(authStorage) // Run onboarding wizard on first launch (no LLM provider configured) if (!isPrintMode && shouldRunOnboarding(authStorage)) { await runOnboarding(authStorage) + + // Clean up stdin state left by @clack/prompts. + // readline.emitKeypressEvents() adds a permanent data listener and + // readline.createInterface() may leave stdin paused. Remove stale + // listeners and pause stdin so the TUI can start with a clean slate. + process.stdin.removeAllListeners('data') + process.stdin.removeAllListeners('keypress') + if (process.stdin.setRawMode) process.stdin.setRawMode(false) + process.stdin.pause() } // Non-blocking update check — runs at most once per 24h, fire-and-forget