diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index c47c0bcc0..b654906cd 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -17,6 +17,7 @@ ### Fixed - Fixed overlay compositing dropping `CURSOR_MARKER` when an overlay ends directly left of the cursor, which lost the hardware cursor position +- Fixed fragmented terminal color replies leaking hex values into the prompt and triggering the external editor via their BEL terminator (related reports: [#10256](https://github.com/earendil-works/pi/issues/10256), [#10657](https://github.com/earendil-works/pi/issues/10657)) ## [1.1.0] - 2026-10-07 diff --git a/packages/tui/src/stdin-buffer.ts b/packages/tui/src/stdin-buffer.ts index cc10c61a5..ed1b3ea52 100644 --- a/packages/tui/src/stdin-buffer.ts +++ b/packages/tui/src/stdin-buffer.ts @@ -24,6 +24,13 @@ const DEFAULT_SEQUENCE_TIMEOUT_MS = 50; const DEFAULT_ESCAPE_TIMEOUT_MS = 10; const BRACKETED_PASTE_START = "\x1b[200~"; const BRACKETED_PASTE_END = "\x1b[201~"; +// Color and program-status OSC replies, XTVersion, and Kitty graphics replies. +// Until their header is complete, the input may be batched legacy Alt keystrokes. +const TERMINAL_REPLY_HEADER_PATTERN = /^\x1b(?:\](?:1[01];|4;\d{1,3};|7501;\?)|P>\||_G(?:[a-z]=\d+,)*[a-z]=\d+;)/; + +function isControlString(data: string): boolean { + return data.startsWith(`${ESC}]`) || data.startsWith(`${ESC}P`) || data.startsWith(`${ESC}_`); +} /** * Check if a string is a complete escape sequence or needs more data @@ -200,9 +207,24 @@ function extractCompleteSequences(buffer: string): { sequences: string[]; remain // Try to extract a sequence starting at this position if (remaining.startsWith(ESC)) { + const terminalReply = isControlString(remaining) && TERMINAL_REPLY_HEADER_PATTERN.test(remaining); // Find the end of this escape sequence let seqEnd = 1; while (seqEnd <= remaining.length) { + if (terminalReply && seqEnd > 2) { + const char = remaining[seqEnd - 1]; + if (char === "\x18" || char === "\x1a") { + // CAN and SUB cancel the string, not subsequent keyboard input. + pos += seqEnd; + break; + } + if (char === ESC && seqEnd < remaining.length && remaining[seqEnd] !== "\\") { + // A new escape sequence aborts an unterminated reply. Keep a + // trailing ESC buffered until we can distinguish it from ST. + pos += seqEnd - 1; + break; + } + } const candidate = remaining.slice(0, seqEnd); const status = isCompleteSequence(candidate); @@ -258,8 +280,10 @@ function extractCompleteSequences(buffer: string): { sequences: string[]; remain export type StdinBufferOptions = { /** - * Maximum time to wait for an incomplete sequence such as CSI or mouse - * (default: 50ms). + * Maximum time to wait for an incomplete keyboard or mouse sequence + * (default: 50ms). Strings with a recognized terminal reply header wait for + * their terminator instead, so delayed replies cannot become keypresses. + * Ambiguous legacy Alt-key input still uses this timeout. */ timeout?: number; /** @@ -385,12 +409,24 @@ export class StdinBuffer extends EventEmitter { } if (this.buffer.length > 0) { + // Only a recognizable reply header establishes a delimited string. + // Flushing it would turn a delayed tail into text and an OSC's BEL + // terminator into Ctrl+G (the external editor shortcut). Other input, + // including Alt+] followed by text, must retain bounded recovery. + if (TERMINAL_REPLY_HEADER_PATTERN.test(this.buffer)) return; const timeoutMs = this.buffer === ESC ? this.escapeTimeoutMs : this.timeoutMs; this.timeout = setTimeout(() => { const flushed = this.flush(); for (const sequence of flushed) { - this.emitDataSequence(sequence); + if (sequence.length > 2 && isControlString(sequence)) { + // Recover the Alt key and reparse its tail so batched text, + // Enter, and control keys are not swallowed as one event. + this.emitDataSequence(sequence.slice(0, 2)); + this.process(sequence.slice(2)); + } else { + this.emitDataSequence(sequence); + } } }, timeoutMs); } diff --git a/packages/tui/test/stdin-buffer.test.ts b/packages/tui/test/stdin-buffer.test.ts index 4ed8a0913..d3e1bb406 100644 --- a/packages/tui/test/stdin-buffer.test.ts +++ b/packages/tui/test/stdin-buffer.test.ts @@ -6,7 +6,7 @@ */ import assert from "node:assert"; -import { beforeEach, describe, it } from "node:test"; +import { afterEach, beforeEach, describe, it } from "node:test"; import { matchesKey } from "../src/keys.ts"; import { StdinBuffer } from "../src/stdin-buffer.ts"; @@ -24,6 +24,8 @@ describe("StdinBuffer", () => { }); }); + afterEach(() => buffer.destroy()); + // Helper to process data through the buffer function processInput(data: string | Buffer): void { buffer.process(data); @@ -192,6 +194,133 @@ describe("StdinBuffer", () => { }); }); + describe("Terminal Reply Strings", () => { + const replies = [ + { header: "\x1b]4;1;", reply: "\x1b]4;1;rgb:eaea/6c6c/6d6d\x07" }, // Ghostty's Ayu Light palette color 1. + { header: "\x1b]10;", reply: "\x1b]10;rgb:ffff/ffff/ffff\x1b\\" }, + { header: "\x1b]11;", reply: "\x1b]11;rgb:0000/0000/0000\x07" }, + { header: "\x1b]4;1;", reply: "\x1b]4;1;rgb:6464/6464/4d4d\x07" }, // Another reported color-reply tail. + { header: "\x1bP>|", reply: "\x1bP>|Ghostty 1.3.1\x1b\\" }, + { header: "\x1b_Gi=1;", reply: "\x1b_Gi=1;OK\x1b\\" }, + { header: "\x1b]7501;?", reply: "\x1b]7501;?\x1b\\" }, + ]; + + for (const { header, reply } of replies) { + it(`keeps delayed ${JSON.stringify(reply)} intact at every split after its reply header`, (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + for (let split = header.length; split < reply.length; split++) { + const delayedBuffer = new StdinBuffer(); + const sequences: string[] = []; + delayedBuffer.on("data", (sequence) => sequences.push(sequence)); + try { + delayedBuffer.process(reply.slice(0, split)); + t.mock.timers.tick(5000); + delayedBuffer.process(`${reply.slice(split)}x`); + assert.equal( + sequences.some((sequence) => matchesKey(sequence, "ctrl+g")), + false, + ); + assert.deepStrictEqual(sequences, [reply, "x"]); + } finally { + delayedBuffer.destroy(); + } + } + }); + } + + it("keeps an OSC reply buffered across multiple expired sequence timeouts", (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + processInput("\x1b]4;1;rgb:eaea/6c"); + t.mock.timers.tick(100); + assert.deepStrictEqual(emittedSequences, []); + processInput("6c/6d6d"); + t.mock.timers.tick(100); + assert.deepStrictEqual(emittedSequences, []); + processInput("\x07"); + assert.deepStrictEqual(emittedSequences, [replies[0].reply]); + }); + + it("still times out bare string introducers as Alt keys", (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + for (const prefix of ["\x1b]", "\x1bP", "\x1b_"]) { + processInput(prefix); + t.mock.timers.tick(10); + } + assert.deepStrictEqual(emittedSequences, ["\x1b]", "\x1bP", "\x1b_"]); + }); + + for (const ambiguous of [ + "\x1b]a", + "\x1b]1", + "\x1b]10", + "\x1b]0;title", + "\x1b]4;", + "\x1b]4;a;", + "\x1bPa", + "\x1bP>", + "\x1b_a", + "\x1b_G", + "\x1b_Gi=1", + ]) { + it(`recovers batched legacy Alt input ${JSON.stringify(ambiguous)} within the sequence timeout`, (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + const legacyBuffer = new StdinBuffer(); + const sequences: string[] = []; + legacyBuffer.on("data", (sequence) => sequences.push(sequence)); + try { + legacyBuffer.process(ambiguous); + t.mock.timers.tick(20); + legacyBuffer.process("x\r\x03\x04"); + t.mock.timers.tick(49); + assert.deepStrictEqual(sequences, []); + t.mock.timers.tick(1); + assert.deepStrictEqual(sequences, [ + ambiguous.slice(0, 2), + ...ambiguous.slice(2), + "x", + "\r", + "\x03", + "\x04", + ]); + assert.strictEqual(legacyBuffer.getBuffer(), ""); + + legacyBuffer.process("y\r\x03\x04"); + assert.deepStrictEqual(sequences.slice(-4), ["y", "\r", "\x03", "\x04"]); + } finally { + legacyBuffer.destroy(); + } + }); + } + + it("recognizes a reply header assembled within the ambiguous-input timeout", (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + processInput("\x1b]1"); + t.mock.timers.tick(5); + processInput("0;"); + t.mock.timers.tick(5000); + assert.deepStrictEqual(emittedSequences, []); + processInput("rgb:ffff/ffff/ffff\x1b\\"); + assert.deepStrictEqual(emittedSequences, [replies[1].reply]); + }); + + it("recovers from an unterminated reply when a new escape sequence arrives", (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + processInput("\x1b]4;1;rgb:eaea/6c"); + t.mock.timers.tick(100); + processInput("\x1b"); + t.mock.timers.tick(100); + processInput("[Ahello"); + assert.deepStrictEqual(emittedSequences, ["\x1b[A", "h", "e", "l", "l", "o"]); + }); + + it("discards a canceled reply without swallowing subsequent input", () => { + for (const cancel of ["\x18", "\x1a"]) { + processInput(`\x1b]11;rgb:0000${cancel}x`); + } + assert.deepStrictEqual(emittedSequences, ["x", "x"]); + }); + }); + describe("Mixed Content", () => { it("should handle characters followed by escape sequence", () => { processInput("abc\x1b[A"); diff --git a/packages/tui/test/terminal-colors.test.ts b/packages/tui/test/terminal-colors.test.ts index 5977ac881..40e8f9d3e 100644 --- a/packages/tui/test/terminal-colors.test.ts +++ b/packages/tui/test/terminal-colors.test.ts @@ -8,6 +8,7 @@ import { type TUI, TuiMainScreen, } from "../src/index.ts"; +import { StdinBuffer } from "../src/stdin-buffer.ts"; import { parseOscColorResponse } from "../src/terminal-colors.ts"; class TestTerminal implements Terminal { @@ -159,6 +160,29 @@ describe("TUI.queryTerminalColors", () => { } }); + it("consumes delayed Ghostty color fragments without typing their tail or opening the editor", async (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + const { terminal, tui, component } = setup(); + const buffer = new StdinBuffer(); + buffer.on("data", (data) => terminal.sendInput(data)); + try { + const query = tui.queryTerminalColors({ timeoutMs: 1000 }); + // Captured from Ghostty 1.3.1 with Ayu Light, split at the reported tail. + buffer.process("\x1b]4;1;rgb:eaea/6c"); + t.mock.timers.tick(100); + buffer.process(`6c/6d6d\x07${DA1}`); + assert.deepStrictEqual(await query, { foreground: undefined, background: undefined, palette: undefined }); + assert.deepStrictEqual(component.inputs, []); + + // A real Ctrl+G must still reach the editor after the reply. + buffer.process("\x07"); + assert.deepStrictEqual(component.inputs, ["\x07"]); + } finally { + buffer.destroy(); + tui.stop(); + } + }); + it("resolves on DA1 with the replies that arrived, in query order", async () => { const { terminal, tui } = setup(); try {