This commit is contained in:
Petr Houška 2026-11-05 21:42:43 +08:00 • committed by GitHub
commit b431628905
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 189 additions and 7 deletions

View file

@ -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) {
@ -1541,10 +1628,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);
});
}

View file

@ -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<void>((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();
}
});
});