fix: prevent arrow keys from inserting escape sequences as text (#493)

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
This commit is contained in:
Flux Labs 2026-03-15 17:17:58 -05:00
parent 2d4a14b7ca
commit 8913aea968
3 changed files with 23 additions and 2 deletions

View file

@ -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), [<n>~ (func keys)
if (/^[A-FHZ]$/.test(last) || last === "~") {
return; // Drop CSI remnant (e.g. "[D", "[C", "[5~")
}
}
this.insertCharacter(data);
}
}

View file

@ -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[?<flags>u
const kittyResponsePattern = /^\x1b\[\?(\d+)u$/;

View file

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