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 <badlogicgames@gmail.com>
This commit is contained in:
Arthur 2026-08-11 17:12:01 +08:00 • committed by GitHub
parent 230029078d
commit 06ed871679
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 81 additions and 3 deletions

View file

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

View file

@ -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) => {

View file

@ -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[] = [];

View file

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