mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
Merge 20c8f7c05e into 55a5c69145
This commit is contained in:
commit
b431628905
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) {
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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