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