mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
fix(tui): keep late and header-less color replies out of the prompt
ConPTY answers DA1 before the terminal's color replies and can strip the OSC header or deliver only the reply tail. Keep checking input for color replies for a bounded window after the query, and drop runs of reply payload characters terminated by BEL or ST. Held text that is not a fragment is delivered in order, so typing is never lost. fixes #10256
This commit is contained in:
parent
b79f21095d
commit
20c8f7c05e
2 changed files with 189 additions and 7 deletions
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue