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:52:39 +02:00
parent 452923b54a
commit 2a95ef70db
5 changed files with 34 additions and 11 deletions

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

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

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

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