From 06ed871679a39019d924ac8a0b80b7902a2db398 Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 11 Aug 2026 17:12:01 +0800 Subject: [PATCH] fix(tui): prevent split Alt+Enter from interrupting (#7899) * fix(tui): prevent split Alt+Enter from interrupting Increase the escape-sequence timeout over SSH and allow high-latency terminals to override it with PI_TUI_ESC_TIMEOUT. Fixes #7876 * docs(coding-agent): shorten escape timeout description --------- Co-authored-by: Mario Zechner --- .../docs/environment-variables.md | 1 + packages/tui/src/terminal.ts | 21 +++++++++++- packages/tui/test/stdin-buffer.test.ts | 30 +++++++++++++++++ packages/tui/test/terminal.test.ts | 32 +++++++++++++++++-- 4 files changed, 81 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/docs/environment-variables.md b/packages/coding-agent/docs/environment-variables.md index 413bd259b..1bebe10f3 100644 --- a/packages/coding-agent/docs/environment-variables.md +++ b/packages/coding-agent/docs/environment-variables.md @@ -87,6 +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 | | `VISUAL`, `EDITOR` | External editor fallback when `externalEditor` is unset | | `HTTP_PROXY`, `HTTPS_PROXY` | Proxy outbound HTTP requests | diff --git a/packages/tui/src/terminal.ts b/packages/tui/src/terminal.ts index 68f8b1a35..ade941a3a 100644 --- a/packages/tui/src/terminal.ts +++ b/packages/tui/src/terminal.ts @@ -101,6 +101,25 @@ export interface Terminal { setProgress(active: boolean): void; } +const DEFAULT_ESCAPE_TIMEOUT_MS = 10; +const DEFAULT_SSH_ESCAPE_TIMEOUT_MS = 100; + +/** + * Resolve how long to wait for the rest of an escape sequence before + * dispatching a lone ESC as the Escape key. Legacy Alt+key input is ESC plus + * another byte, so high-latency transports need a longer reassembly window. + */ +export function resolveEscapeTimeoutMs(env: NodeJS.ProcessEnv = process.env): number { + const configured = Number(env.PI_TUI_ESC_TIMEOUT); + if (Number.isFinite(configured) && configured > 0) { + return configured; + } + if (env.SSH_CONNECTION || env.SSH_TTY) { + return DEFAULT_SSH_ESCAPE_TIMEOUT_MS; + } + return DEFAULT_ESCAPE_TIMEOUT_MS; +} + /** * Real terminal using process.stdin/stdout */ @@ -183,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(); + this.stdinBuffer = new StdinBuffer({ timeout: 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 7c4997f00..a06f1ec2b 100644 --- a/packages/tui/test/stdin-buffer.test.ts +++ b/packages/tui/test/stdin-buffer.test.ts @@ -7,6 +7,7 @@ import assert from "node:assert"; import { beforeEach, describe, it } from "node:test"; +import { matchesKey } from "../src/keys.ts"; import { StdinBuffer } from "../src/stdin-buffer.ts"; describe("StdinBuffer", () => { @@ -134,6 +135,35 @@ describe("StdinBuffer", () => { assert.deepStrictEqual(emittedSequences, ["\x1b[<35"]); }); + + it("should flush a lone ESC as Escape when CR arrives after the timeout", async () => { + // Legacy-mode Alt+Enter is ESC + CR; when the terminal/transport splits + // the bytes further apart than the timeout, ESC is flushed alone and the + // host sees Escape (interrupt) instead of Alt+Enter. This locks in the + // behavior so the configurable timeout in ProcessTerminal stays honest. + processInput("\x1b"); + await wait(20); // buffer timeout is 10ms in beforeEach + processInput("\r"); + + assert.deepStrictEqual(emittedSequences, ["\x1b", "\r"]); + assert.equal(matchesKey(emittedSequences[0] ?? "", "escape"), true); + }); + + it("should merge ESC + CR split across chunks within a larger timeout", async () => { + buffer = new StdinBuffer({ timeout: 100 }); + emittedSequences = []; + buffer.on("data", (sequence) => { + emittedSequences.push(sequence); + }); + + processInput("\x1b"); + await wait(20); // > 10ms old default, < 100ms configured timeout + processInput("\r"); + + assert.deepStrictEqual(emittedSequences, ["\x1b\r"]); + assert.equal(matchesKey(emittedSequences[0] ?? "", "alt+enter"), true); + }); + it("keeps fragmented mouse sequences buffered across delayed chunks by default", async () => { const delayedBuffer = new StdinBuffer(); const delayedSequences: string[] = []; diff --git a/packages/tui/test/terminal.test.ts b/packages/tui/test/terminal.test.ts index 06c751dbc..88d9ea747 100644 --- a/packages/tui/test/terminal.test.ts +++ b/packages/tui/test/terminal.test.ts @@ -1,7 +1,35 @@ import assert from "node:assert"; import { describe, it, mock } from "node:test"; import { setKittyProtocolActive } from "../src/keys.ts"; -import { normalizeAppleTerminalInput, normalizeNativeShiftEnterInput, ProcessTerminal } from "../src/terminal.ts"; +import { + normalizeAppleTerminalInput, + normalizeNativeShiftEnterInput, + ProcessTerminal, + resolveEscapeTimeoutMs, +} from "../src/terminal.ts"; + +describe("resolveEscapeTimeoutMs", () => { + it("uses PI_TUI_ESC_TIMEOUT when configured", () => { + assert.equal(resolveEscapeTimeoutMs({ PI_TUI_ESC_TIMEOUT: "80" }), 80); + assert.equal(resolveEscapeTimeoutMs({ PI_TUI_ESC_TIMEOUT: "80", SSH_TTY: "/dev/pts/1" }), 80); + }); + + it("ignores invalid PI_TUI_ESC_TIMEOUT values", () => { + assert.equal(resolveEscapeTimeoutMs({ PI_TUI_ESC_TIMEOUT: "abc" }), 10); + assert.equal(resolveEscapeTimeoutMs({ PI_TUI_ESC_TIMEOUT: "0" }), 10); + assert.equal(resolveEscapeTimeoutMs({ PI_TUI_ESC_TIMEOUT: "-5" }), 10); + assert.equal(resolveEscapeTimeoutMs({ PI_TUI_ESC_TIMEOUT: "" }), 10); + }); + + it("defaults to 100ms over SSH", () => { + assert.equal(resolveEscapeTimeoutMs({ SSH_CONNECTION: "10.0.0.1 22" }), 100); + assert.equal(resolveEscapeTimeoutMs({ SSH_TTY: "/dev/pts/1" }), 100); + }); + + it("defaults to 10ms otherwise", () => { + assert.equal(resolveEscapeTimeoutMs({}), 10); + }); +}); describe("normalizeNativeShiftEnterInput", () => { it("rewrites Return to CSI-u Shift+Enter when native Shift detection is enabled and Shift is pressed", () => { @@ -195,7 +223,7 @@ describe("ProcessTerminal Kitty keyboard protocol negotiation", () => { const harness = setupNegotiation(); try { harness.send("\x1b["); - mock.timers.tick(10); + mock.timers.tick(resolveEscapeTimeoutMs()); assert.equal(harness.getInput(), undefined);