fix(tui): apply PI_TUI_ESC_TIMEOUT only to lone ESC

Split StdinBuffer's incomplete-sequence wait from the lone-ESC wait so SSH and PI_TUI_ESC_TIMEOUT can reassemble split Alt+Enter without delaying Escape or mouse/CSI buffering.
This commit is contained in:
David Brailovsky
2026-08-11 12:53:06 +02:00
parent 452923b54a
commit 2a95ef70db
5 changed files with 34 additions and 11 deletions
@@ -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 |
+13 -5
View File
@@ -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<StdinBufferEventMap> {
private buffer: string = "";
private timeout: ReturnType<typeof setTimeout> | 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<StdinBufferEventMap> {
}
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();
+1 -1
View File
@@ -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) => {
+18 -3
View File
@@ -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 () => {
+1 -1
View File
@@ -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);