Fix terminal color query replies

Answer OSC color queries from the daemon and suppress browser-side replies so response bytes do not race back into the shell prompt as user input.
This commit is contained in:
Mohamed Boudra
2026-05-26 09:32:15 +07:00
parent 48d4e3e408
commit 6e56ee9e32
5 changed files with 136 additions and 0 deletions
@@ -0,0 +1,61 @@
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { test, expect } from "./fixtures";
import { TerminalE2EHarness } from "./helpers/terminal-dsl";
import { getTerminalBufferText, waitForTerminalContent } from "./helpers/terminal-perf";
const OSC11_CAPTURE_SCRIPT = `
let captured = Buffer.alloc(0);
function finish() {
process.stdout.write("PASEO_OSC11_CAPTURE:" + JSON.stringify(captured.toString("latin1")) + "\\n");
process.exit(0);
}
process.stdout.write("\\x1b]11;?\\x07");
if (process.stdin.isTTY) {
process.stdin.setRawMode(true);
}
process.stdin.resume();
process.stdin.on("data", (chunk) => {
captured = Buffer.concat([captured, chunk]);
if (captured.includes(Buffer.from("rgb:"))) {
finish();
}
});
setTimeout(finish, 700);
`;
test.describe("Terminal protocol queries", () => {
let harness: TerminalE2EHarness;
test.beforeAll(async () => {
harness = await TerminalE2EHarness.create({ tempPrefix: "terminal-protocol-query-" });
await writeFile(path.join(harness.tempRepo.path, "osc11-capture.cjs"), OSC11_CAPTURE_SCRIPT);
});
test.afterAll(async () => {
await harness?.cleanup();
});
test("does not send browser OSC 11 color-query replies back to the PTY", async ({ page }) => {
const terminalInstance = await harness.createTerminal({ name: "osc11-query" });
try {
await harness.openTerminal(page, { terminalId: terminalInstance.id });
await harness.setupPrompt(page);
const terminal = harness.terminalSurface(page);
await terminal.pressSequentially("node osc11-capture.cjs\n", { delay: 0 });
await waitForTerminalContent(page, (text) => text.includes("PASEO_OSC11_CAPTURE:"), 10_000);
await page.waitForTimeout(500);
const text = await getTerminalBufferText(page);
expect(text).toContain("rgb:0b0b/0b0b/0b0b");
expect(text).not.toContain("rgb:ffff/ffff/ffff");
} finally {
await harness.killTerminal(terminalInstance.id);
}
});
});
@@ -319,6 +319,9 @@ describe("terminal emulator runtime in a real browser", () => {
{ name: "DSR-?6", bytes: "\x1b[?6n" },
{ name: "DECRQM", bytes: "\x1b[1$p" },
{ name: "DECRQM-?", bytes: "\x1b[?1$p" },
{ name: "OSC-10-foreground-color", bytes: "\x1b]10;?\x07" },
{ name: "OSC-11-background-color", bytes: "\x1b]11;?\x07" },
{ name: "OSC-12-cursor-color", bytes: "\x1b]12;?\x07" },
])("does not emit a PTY input reply for $name", async ({ bytes }) => {
await page.viewport(900, 600);
const mounted = createTerminalHost({ width: 720, height: 360 });
@@ -301,6 +301,9 @@ export class TerminalEmulatorRuntime {
{ prefix: "?", intermediates: "$", final: "p" },
() => true,
);
for (const code of [10, 11, 12]) {
terminal.parser.registerOscHandler(code, (data) => data.trim() === "?");
}
};
registerProtocolQuerySuppression();
@@ -176,6 +176,26 @@ process.stdin.on("data", (chunk) => {
process.stdout.write(query);
`;
const OSC11_HELPER_SCRIPT = `process.stdin.setRawMode(true);
process.stdin.resume();
let buf = "";
const timer = setTimeout(() => {
process.stdout.write("OSC11_TIMEOUT\\n");
process.exit(2);
}, 2500);
process.stdin.on("data", (chunk) => {
buf += chunk.toString("binary");
const match = buf.match(/\\x1b\\]11;rgb:[0-9a-f/]+\\x1b\\\\/);
if (!match) {
return;
}
clearTimeout(timer);
process.stdout.write("OSC11_OK:" + match[0].replace(/\\x1b/g, "ESC") + "\\n");
process.exit(0);
});
process.stdout.write("\\x1b]11;?\\x07");
`;
function writeDaHelper(prefix: string): string {
const dir = mkdtempSync(join(tmpdir(), prefix));
temporaryDirs.push(dir);
@@ -192,6 +212,14 @@ function writeDsrHelper(prefix: string): string {
return path;
}
function writeOsc11Helper(prefix: string): string {
const dir = mkdtempSync(join(tmpdir(), prefix));
temporaryDirs.push(dir);
const path = join(dir, "helper.cjs");
writeFileSync(path, OSC11_HELPER_SCRIPT);
return path;
}
function isDaOkLine(line: string): boolean {
return line.startsWith("DA_OK:");
}
@@ -200,6 +228,10 @@ function isDsrOkLine(line: string): boolean {
return line.startsWith("DSR_OK:");
}
function isOsc11OkLine(line: string): boolean {
return line.startsWith("OSC11_OK:");
}
function hasDaOkLine(state: ReturnType<TerminalSession["getState"]>): boolean {
return getLines(state).some(isDaOkLine);
}
@@ -208,6 +240,10 @@ function hasDsrOkLine(state: ReturnType<TerminalSession["getState"]>): boolean {
return getLines(state).some(isDsrOkLine);
}
function hasOsc11OkLine(state: ReturnType<TerminalSession["getState"]>): boolean {
return getLines(state).some(isOsc11OkLine);
}
function lastNonEmptyLineIsPrompt(state: ReturnType<TerminalSession["getState"]>): boolean {
const last =
getLines(state)
@@ -982,6 +1018,25 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
const ack = getLines(session.getState()).find(isDsrOkLine) ?? "";
expect(ack).toBe("DSR_OK:status");
});
it("delivers OSC 11 background-color replies to a foreground app on stdin", async () => {
const helperPath = writeOsc11Helper("terminal-osc11-helper-");
const session = trackSession(
await createTerminal({
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ " },
}),
);
await waitForLines(session, ["$"]);
session.send({ type: "input", data: `${process.execPath} ${helperPath}\r` });
await waitForState(session, hasOsc11OkLine);
const ack = getLines(session.getState()).find(isOsc11OkLine) ?? "";
expect(ack).toBe("OSC11_OK:ESC]11;rgb:0b0b/0b0b/0b0bESC\\");
});
});
describe("stream snapshots", () => {
+14
View File
@@ -17,6 +17,11 @@ let nodePtySpawnHelperChecked = false;
const TERMINAL_TITLE_DEBOUNCE_MS = 150;
const TERMINAL_EXIT_OUTPUT_LINE_LIMIT = 12;
const TERMINAL_EXIT_OUTPUT_CHAR_LIMIT = 16000;
const TERMINAL_OSC_COLOR_QUERY_RESPONSES = new Map<number, string>([
[10, "rgb:e6e6/e6e6/e6e6"],
[11, "rgb:0b0b/0b0b/0b0b"],
[12, "rgb:e6e6/e6e6/e6e6"],
]);
export interface TerminalExitInfo {
exitCode: number | null;
@@ -681,6 +686,15 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
ptyProcess.write(`\x1b[?${buffer.cursorY + 1};${buffer.cursorX + 1}R`);
return true;
});
for (const [code, response] of TERMINAL_OSC_COLOR_QUERY_RESPONSES) {
terminal.parser.registerOscHandler(code, (data) => {
if (data.trim() !== "?") {
return false;
}
ptyProcess.write(`\x1b]${code};${response}\x1b\\`);
return true;
});
}
if (titleMode === "auto") {
titleChangeSubscription = terminal.onTitleChange((nextTitle) => {