mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
fix(tui): keep delayed terminal replies out of keyboard input
Preserve terminal reply strings until their terminator while retaining bounded recovery for ambiguous legacy Alt-key input. Related: #10256, #10657. Missing OSC prefixes and fragmented DA1 replies remain separate issues.
This commit is contained in:
parent
091bcc0998
commit
edb2eb966d
4 changed files with 194 additions and 4 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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<StdinBufferEventMap> {
|
|||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue