diff --git a/packages/coding-agent/docs/environment-variables.md b/packages/coding-agent/docs/environment-variables.md index 1bebe10f3..29c324f63 100644 --- a/packages/coding-agent/docs/environment-variables.md +++ b/packages/coding-agent/docs/environment-variables.md @@ -87,7 +87,7 @@ These variables are read by Pi itself: | `PI_CACHE_RETENTION` | Set to `long` for extended provider prompt caching where supported | | `PI_SHARE_VIEWER_URL` | Override the base URL used by `/share` | | `PI_HARDWARE_CURSOR` | Set to `1` to show the hardware cursor; see [Terminal setup](terminal-setup.md) | -| `PI_TUI_ESC_TIMEOUT` | Escape-sequence timeout in milliseconds; defaults to `100` over SSH and `10` otherwise. Increase if Alt-key input is misread as Escape | +| `PI_TUI_ESC_TIMEOUT` | How long to wait after a lone ESC before treating it as Escape, in milliseconds; defaults to `100` over SSH and `10` otherwise. Increase if Alt-key input is misread as Escape | | `VISUAL`, `EDITOR` | External editor fallback when `externalEditor` is unset | | `HTTP_PROXY`, `HTTPS_PROXY` | Proxy outbound HTTP requests | diff --git a/packages/tui/src/stdin-buffer.ts b/packages/tui/src/stdin-buffer.ts index b6c483fe9..cc10c61a5 100644 --- a/packages/tui/src/stdin-buffer.ts +++ b/packages/tui/src/stdin-buffer.ts @@ -20,7 +20,8 @@ import { EventEmitter } from "events"; const ESC = "\x1b"; -const LONE_ESCAPE_TIMEOUT_MS = 10; +const DEFAULT_SEQUENCE_TIMEOUT_MS = 50; +const DEFAULT_ESCAPE_TIMEOUT_MS = 10; const BRACKETED_PASTE_START = "\x1b[200~"; const BRACKETED_PASTE_END = "\x1b[201~"; @@ -257,10 +258,15 @@ function extractCompleteSequences(buffer: string): { sequences: string[]; remain export type StdinBufferOptions = { /** - * Maximum time to wait for sequence completion (default: 50ms). - * A lone Escape uses at most 10ms to preserve keyboard responsiveness. + * Maximum time to wait for an incomplete sequence such as CSI or mouse + * (default: 50ms). */ timeout?: number; + /** + * Maximum time to wait after a lone ESC before treating it as Escape + * (default: 10ms). Increase for high-latency Alt+key input (SSH). + */ + escapeTimeout?: number; }; export type StdinBufferEventMap = { @@ -276,13 +282,15 @@ export class StdinBuffer extends EventEmitter { private buffer: string = ""; private timeout: ReturnType | null = null; private readonly timeoutMs: number; + private readonly escapeTimeoutMs: number; private pasteMode: boolean = false; private pasteBuffer: string = ""; private pendingKittyPrintableCodepoint: number | undefined; constructor(options: StdinBufferOptions = {}) { super(); - this.timeoutMs = options.timeout ?? 50; + this.timeoutMs = options.timeout ?? DEFAULT_SEQUENCE_TIMEOUT_MS; + this.escapeTimeoutMs = options.escapeTimeout ?? DEFAULT_ESCAPE_TIMEOUT_MS; } public process(data: string | Buffer): void { @@ -377,7 +385,7 @@ export class StdinBuffer extends EventEmitter { } if (this.buffer.length > 0) { - const timeoutMs = this.buffer === ESC ? Math.min(this.timeoutMs, LONE_ESCAPE_TIMEOUT_MS) : this.timeoutMs; + const timeoutMs = this.buffer === ESC ? this.escapeTimeoutMs : this.timeoutMs; this.timeout = setTimeout(() => { const flushed = this.flush(); diff --git a/packages/tui/src/terminal.ts b/packages/tui/src/terminal.ts index ade941a3a..08d8cbbcf 100644 --- a/packages/tui/src/terminal.ts +++ b/packages/tui/src/terminal.ts @@ -202,7 +202,7 @@ 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: resolveEscapeTimeoutMs() }); + this.stdinBuffer = new StdinBuffer({ escapeTimeout: resolveEscapeTimeoutMs() }); // Forward individual sequences to the input handler this.stdinBuffer.on("data", (sequence) => { diff --git a/packages/tui/test/stdin-buffer.test.ts b/packages/tui/test/stdin-buffer.test.ts index 10d2cc3ec..4ed8a0913 100644 --- a/packages/tui/test/stdin-buffer.test.ts +++ b/packages/tui/test/stdin-buffer.test.ts @@ -149,6 +149,21 @@ describe("StdinBuffer", () => { }); it("should merge ESC + CR split across chunks within a larger timeout", async () => { + buffer = new StdinBuffer({ escapeTimeout: 100 }); + emittedSequences = []; + buffer.on("data", (sequence) => { + emittedSequences.push(sequence); + }); + + processInput("\x1b"); + await wait(20); // > 10ms default escapeTimeout, < 100ms configured escapeTimeout + processInput("\r"); + + assert.deepStrictEqual(emittedSequences, ["\x1b\r"]); + assert.equal(matchesKey(emittedSequences[0] ?? "", "alt+enter"), true); + }); + + it("does not apply the sequence timeout to a lone ESC", async () => { buffer = new StdinBuffer({ timeout: 100 }); emittedSequences = []; buffer.on("data", (sequence) => { @@ -156,11 +171,11 @@ describe("StdinBuffer", () => { }); processInput("\x1b"); - await wait(20); // > 10ms old default, < 100ms configured timeout + await wait(20); processInput("\r"); - assert.deepStrictEqual(emittedSequences, ["\x1b\r"]); - assert.equal(matchesKey(emittedSequences[0] ?? "", "alt+enter"), true); + assert.deepStrictEqual(emittedSequences, ["\x1b", "\r"]); + assert.equal(matchesKey(emittedSequences[0] ?? "", "escape"), true); }); it("keeps fragmented mouse sequences buffered across delayed chunks by default", async () => { diff --git a/packages/tui/test/terminal.test.ts b/packages/tui/test/terminal.test.ts index 88d9ea747..80eadd626 100644 --- a/packages/tui/test/terminal.test.ts +++ b/packages/tui/test/terminal.test.ts @@ -223,7 +223,7 @@ describe("ProcessTerminal Kitty keyboard protocol negotiation", () => { const harness = setupNegotiation(); try { harness.send("\x1b["); - mock.timers.tick(resolveEscapeTimeoutMs()); + mock.timers.tick(50); // StdinBuffer sequence timeout, not the lone-ESC timeout assert.equal(harness.getInput(), undefined);