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:
@@ -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", () => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user