diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index ad11a36c5..27bc12141 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -176,6 +176,21 @@ const TERMINAL_COLOR_QUERY = `\x1b]10;?\x07\x1b]11;?\x07${Array.from( (_, index) => `\x1b]4;${index};?\x07`, ).join("")}\x1b[c`; const DEVICE_ATTRIBUTES_RESPONSE_PATTERN = /^\x1b\[\?[\d;]*c$/; +/** + * How long after a color query starts, completes, or times out, or after the last consumed reply, input is + * still checked for color replies. ConPTY answers DA1 itself, before the terminal's color replies arrive, + * and can strip the reply's `ESC ] Ps` header or deliver only its tail, e.g. `;0;rgb:0000/0000/0000` + BEL + * (mintty) or `a8` + ST (WezTerm). Without this window those bytes become typed text and BEL opens the + * external editor. + */ +const TERMINAL_COLOR_REPLY_WINDOW_MS = 1000; +/** How long held text waits for the BEL or ST that marks it as a header-less color reply fragment. */ +const TERMINAL_COLOR_FRAGMENT_IDLE_MS = 300; +/** Longer than any OSC 4/10/11 reply payload, e.g. `4;255;rgb:ffff/ffff/ffff`. */ +const TERMINAL_COLOR_FRAGMENT_MAX_LENGTH = 64; +/** Characters of an OSC color reply payload: the `]`, target, and `rgb:`, `rgba:`, or `#` color value. */ +const TERMINAL_COLOR_FRAGMENT_CHAR_PATTERN = /^[0-9a-f;:/#rg\]]$/i; +const STRING_TERMINATOR = "\x1b\\"; /** * Interface for components that can receive focus and display a hardware cursor. @@ -531,6 +546,11 @@ export abstract class TuiBase extends Container implements TUI { * replies belong to the oldest one. Queries stay here after a timeout to collect late replies. */ private pendingTerminalColorQueries: PendingTerminalColorQuery[] = []; + /** End of the window in which input is checked for late or header-less color replies (`Date.now()` ms). */ + private terminalColorReplyWindowEnd = 0; + /** Input that may be a header-less color reply fragment, held until its BEL or ST terminator arrives. */ + private heldColorReplyFragment = ""; + private heldColorReplyFragmentTimer: NodeJS.Timeout | undefined; private terminalColorSchemeListeners = new Set<(scheme: TerminalColorScheme) => void>(); private terminalColorSchemeNotificationsEnabled = false; /** Directory for debug/crash logs. When undefined, debug logging is disabled and crash dumps fall back to the OS temp directory. */ @@ -1013,6 +1033,7 @@ export abstract class TuiBase extends Container implements TUI { this.stopped = true; process.off("exit", this.exitRestoreHandler); this.cancelRenderTimer(); + this.clearHeldColorReplyFragment(); if (this.terminalColorSchemeNotificationsEnabled) { this.terminal.write("\x1b[?2031l"); } @@ -1085,9 +1106,15 @@ export abstract class TuiBase extends Container implements TUI { } private handleTerminalInput(data: string): void { - if (this.consumeTerminalColorResponse(data)) { + if (this.consumeTerminalColorReply(data)) { return; } + // Held text was not a color reply fragment: deliver it before the input that followed it. + this.flushHeldColorReplyFragment(); + this.dispatchTerminalInput(data); + } + + private dispatchTerminalInput(data: string): void { if (this.consumeTerminalColorSchemeReport(data)) { return; } @@ -1162,6 +1189,66 @@ export abstract class TuiBase extends Container implements TUI { } } + /** + * Consume color replies: complete replies to a pending query, and inside the reply window also late + * complete replies and header-less fragments. A fragment is a run of reply payload characters ended by + * BEL or ST. The run is held until its terminator; if anything else follows, or nothing follows in time, + * the held text is delivered as input, so typing is delayed but never lost. A BEL or ST with no held + * text is a keystroke (Ctrl+G or Alt+\) and is delivered. + */ + private consumeTerminalColorReply(data: string): boolean { + if (this.consumeTerminalColorResponse(data)) { + this.extendTerminalColorReplyWindow(); + return true; + } + if (Date.now() >= this.terminalColorReplyWindowEnd) { + return false; + } + if (parseOscColorResponse(data)) { + this.extendTerminalColorReplyWindow(); + return true; + } + if (data === "\x07" || data === STRING_TERMINATOR) { + if (!this.heldColorReplyFragment) { + return false; + } + this.clearHeldColorReplyFragment(); + this.extendTerminalColorReplyWindow(); + return true; + } + if ( + !TERMINAL_COLOR_FRAGMENT_CHAR_PATTERN.test(data) || + this.heldColorReplyFragment.length >= TERMINAL_COLOR_FRAGMENT_MAX_LENGTH + ) { + return false; + } + this.heldColorReplyFragment += data; + clearTimeout(this.heldColorReplyFragmentTimer); + this.heldColorReplyFragmentTimer = setTimeout( + () => this.flushHeldColorReplyFragment(), + TERMINAL_COLOR_FRAGMENT_IDLE_MS, + ); + return true; + } + + private extendTerminalColorReplyWindow(): void { + this.terminalColorReplyWindowEnd = Date.now() + TERMINAL_COLOR_REPLY_WINDOW_MS; + } + + private clearHeldColorReplyFragment(): void { + clearTimeout(this.heldColorReplyFragmentTimer); + this.heldColorReplyFragmentTimer = undefined; + this.heldColorReplyFragment = ""; + } + + private flushHeldColorReplyFragment(): void { + const held = this.heldColorReplyFragment; + this.clearHeldColorReplyFragment(); + for (const char of held) { + this.dispatchTerminalInput(char); + } + } + private consumeTerminalColorResponse(data: string): boolean { const query = this.pendingTerminalColorQueries[0]; if (!query) { @@ -1539,10 +1626,12 @@ export abstract class TuiBase extends Container implements TUI { }; // Resolve with the replies so far, and keep collecting late replies for `onLateReply`. query.timer = setTimeout(() => { + this.extendTerminalColorReplyWindow(); query.deliver = onLateReply; resolve(this.terminalColorQueryResult(query)); }, timeoutMs); this.pendingTerminalColorQueries.push(query); + this.extendTerminalColorReplyWindow(); this.terminal.write(TERMINAL_COLOR_QUERY); }); } diff --git a/packages/tui/test/terminal-colors.test.ts b/packages/tui/test/terminal-colors.test.ts index 40e8f9d3e..32c64943b 100644 --- a/packages/tui/test/terminal-colors.test.ts +++ b/packages/tui/test/terminal-colors.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert"; -import { describe, it } from "node:test"; +import { describe, it, type TestContext } from "node:test"; import { type Component, parseTerminalColorSchemeReport, @@ -91,8 +91,6 @@ class InputRecorder implements Component { invalidate(): void {} } -const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - describe("parseTerminalColorSchemeReport", () => { it("parses color scheme reports", () => { assert.strictEqual(parseTerminalColorSchemeReport("\x1b[?997;1n"), "dark"); @@ -201,12 +199,13 @@ describe("TUI.queryTerminalColors", () => { } }); - it("reports late replies after a timeout and consumes them until DA1", async () => { + it("reports late replies after a timeout and consumes them until DA1", async (t) => { + t.mock.timers.enable({ apis: ["setTimeout", "Date"] }); const { terminal, tui, component } = setup(); try { const late: TerminalColors[] = []; const query = tui.queryTerminalColors({ timeoutMs: 1, onLateReply: (colors) => late.push(colors) }); - await wait(5); + t.mock.timers.tick(5); assert.strictEqual((await query).background, undefined); terminal.sendInput("\x1b]11;#ffffff\x07"); @@ -214,7 +213,8 @@ describe("TUI.queryTerminalColors", () => { assert.deepStrictEqual(late, [{ foreground: undefined, background: WHITE, palette: undefined }]); assert.deepStrictEqual(component.inputs, []); - // With no query pending, color replies are ordinary input again. + // Once the reply window after the query has passed, color replies are ordinary input again. + t.mock.timers.tick(1000); terminal.sendInput("\x1b]11;#ffffff\x07"); assert.deepStrictEqual(component.inputs, ["\x1b]11;#ffffff\x07"]); } finally { @@ -222,3 +222,96 @@ describe("TUI.queryTerminalColors", () => { } }); }); + +// Color replies that arrive after the query's DA1 reply, as ConPTY answers DA1 itself (#10256). +describe("TUI.queryTerminalColors replies after DA1", () => { + function setupWithBuffer(t: TestContext) { + t.mock.timers.enable({ apis: ["setTimeout", "Date"] }); + const context = setup(); + const buffer = new StdinBuffer(); + buffer.on("data", (data) => context.terminal.sendInput(data)); + const query = context.tui.queryTerminalColors({ timeoutMs: 100 }); + return { ...context, buffer, query }; + } + + it("drops mintty's header-less OSC 4 replies without typing them or opening the editor", async (t) => { + const { tui, component, buffer, query } = setupWithBuffer(t); + try { + // Captured from mintty (Git Bash on Windows): DA1 first, then OSC 4 replies without `ESC ] 4`. + buffer.process(`\x1b[?1;0c;0;rgb:0000/0000/0000\x07;1;rgb:d4d4/2c2c/3a3a\x07`); + assert.deepStrictEqual(await query, { foreground: undefined, background: undefined, palette: undefined }); + // The same replies, split across reads. + buffer.process(";2"); + t.mock.timers.tick(20); + buffer.process(";rgb:1c1c/a8a8/0000"); + t.mock.timers.tick(20); + buffer.process("\x07"); + assert.deepStrictEqual(component.inputs, []); + + // A real Ctrl+G still reaches the editor. + buffer.process("\x07"); + assert.deepStrictEqual(component.inputs, ["\x07"]); + } finally { + buffer.destroy(); + tui.stop(); + } + }); + + it("drops ST-terminated reply tails, also when glued to another reply", async (t) => { + const { tui, component, buffer, query } = setupWithBuffer(t); + try { + // Captured from WezTerm 20240203 on Windows: only the tail of a palette reply arrives. + buffer.process(`${DA1}a8\x1b\\`); + await query; + buffer.process("\x1b[6;21;10t79\x1b\\"); + assert.deepStrictEqual(component.inputs, []); + } finally { + buffer.destroy(); + tui.stop(); + } + }); + + it("consumes complete color replies that arrive after DA1", async (t) => { + const { tui, component, buffer, query } = setupWithBuffer(t); + try { + // Captured from tmux 3.6 under Windows Terminal: DA1 first, then the color replies. + buffer.process("\x1b[?1;2;4c"); + await query; + buffer.process("\x1b]10;rgb:cccc/cccc/cccc\x07\x1b]11;rgb:0c0c/0c0c/0c0c\x07"); + t.mock.timers.tick(500); + buffer.process("\x1b]4;1;rgb:c5c5/0f0f/1f1f\x07"); + assert.deepStrictEqual(component.inputs, []); + } finally { + buffer.destroy(); + tui.stop(); + } + }); + + it("delivers text typed in the reply window in order", async (t) => { + const { tui, component, buffer, query } = setupWithBuffer(t); + try { + buffer.process(DA1); + await query; + // Text that could be a fragment waits for the next key. + buffer.process("add"); + assert.deepStrictEqual(component.inputs, []); + buffer.process("s"); + assert.deepStrictEqual(component.inputs, ["a", "d", "d", "s"]); + // Or until no terminator follows in time. + buffer.process("f"); + buffer.process("\x1b[A"); + buffer.process("0"); + assert.deepStrictEqual(component.inputs, ["a", "d", "d", "s", "f", "\x1b[A"]); + t.mock.timers.tick(300); + assert.deepStrictEqual(component.inputs, ["a", "d", "d", "s", "f", "\x1b[A", "0"]); + + // After the window, nothing is held. + t.mock.timers.tick(1000); + buffer.process("a"); + assert.deepStrictEqual(component.inputs.at(-1), "a"); + } finally { + buffer.destroy(); + tui.stop(); + } + }); +});