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: "DSR-?6", bytes: "\x1b[?6n" },
|
||||||
{ name: "DECRQM", bytes: "\x1b[1$p" },
|
{ name: "DECRQM", bytes: "\x1b[1$p" },
|
||||||
{ 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 }) => {
|
])("does not emit a PTY input reply for $name", async ({ bytes }) => {
|
||||||
await page.viewport(900, 600);
|
await page.viewport(900, 600);
|
||||||
const mounted = createTerminalHost({ width: 720, height: 360 });
|
const mounted = createTerminalHost({ width: 720, height: 360 });
|
||||||
|
|||||||
@@ -301,6 +301,9 @@ export class TerminalEmulatorRuntime {
|
|||||||
{ prefix: "?", intermediates: "$", final: "p" },
|
{ prefix: "?", intermediates: "$", final: "p" },
|
||||||
() => true,
|
() => true,
|
||||||
);
|
);
|
||||||
|
for (const code of [10, 11, 12]) {
|
||||||
|
terminal.parser.registerOscHandler(code, (data) => data.trim() === "?");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
registerProtocolQuerySuppression();
|
registerProtocolQuerySuppression();
|
||||||
|
|
||||||
|
|||||||
@@ -176,6 +176,26 @@ process.stdin.on("data", (chunk) => {
|
|||||||
process.stdout.write(query);
|
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 {
|
function writeDaHelper(prefix: string): string {
|
||||||
const dir = mkdtempSync(join(tmpdir(), prefix));
|
const dir = mkdtempSync(join(tmpdir(), prefix));
|
||||||
temporaryDirs.push(dir);
|
temporaryDirs.push(dir);
|
||||||
@@ -192,6 +212,14 @@ function writeDsrHelper(prefix: string): string {
|
|||||||
return path;
|
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 {
|
function isDaOkLine(line: string): boolean {
|
||||||
return line.startsWith("DA_OK:");
|
return line.startsWith("DA_OK:");
|
||||||
}
|
}
|
||||||
@@ -200,6 +228,10 @@ function isDsrOkLine(line: string): boolean {
|
|||||||
return line.startsWith("DSR_OK:");
|
return line.startsWith("DSR_OK:");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isOsc11OkLine(line: string): boolean {
|
||||||
|
return line.startsWith("OSC11_OK:");
|
||||||
|
}
|
||||||
|
|
||||||
function hasDaOkLine(state: ReturnType<TerminalSession["getState"]>): boolean {
|
function hasDaOkLine(state: ReturnType<TerminalSession["getState"]>): boolean {
|
||||||
return getLines(state).some(isDaOkLine);
|
return getLines(state).some(isDaOkLine);
|
||||||
}
|
}
|
||||||
@@ -208,6 +240,10 @@ function hasDsrOkLine(state: ReturnType<TerminalSession["getState"]>): boolean {
|
|||||||
return getLines(state).some(isDsrOkLine);
|
return getLines(state).some(isDsrOkLine);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasOsc11OkLine(state: ReturnType<TerminalSession["getState"]>): boolean {
|
||||||
|
return getLines(state).some(isOsc11OkLine);
|
||||||
|
}
|
||||||
|
|
||||||
function lastNonEmptyLineIsPrompt(state: ReturnType<TerminalSession["getState"]>): boolean {
|
function lastNonEmptyLineIsPrompt(state: ReturnType<TerminalSession["getState"]>): boolean {
|
||||||
const last =
|
const last =
|
||||||
getLines(state)
|
getLines(state)
|
||||||
@@ -982,6 +1018,25 @@ describe.skipIf(isPlatform("win32"))("terminal POSIX-only", () => {
|
|||||||
const ack = getLines(session.getState()).find(isDsrOkLine) ?? "";
|
const ack = getLines(session.getState()).find(isDsrOkLine) ?? "";
|
||||||
expect(ack).toBe("DSR_OK:status");
|
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", () => {
|
describe("stream snapshots", () => {
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ let nodePtySpawnHelperChecked = false;
|
|||||||
const TERMINAL_TITLE_DEBOUNCE_MS = 150;
|
const TERMINAL_TITLE_DEBOUNCE_MS = 150;
|
||||||
const TERMINAL_EXIT_OUTPUT_LINE_LIMIT = 12;
|
const TERMINAL_EXIT_OUTPUT_LINE_LIMIT = 12;
|
||||||
const TERMINAL_EXIT_OUTPUT_CHAR_LIMIT = 16000;
|
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 {
|
export interface TerminalExitInfo {
|
||||||
exitCode: number | null;
|
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`);
|
ptyProcess.write(`\x1b[?${buffer.cursorY + 1};${buffer.cursorX + 1}R`);
|
||||||
return true;
|
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") {
|
if (titleMode === "auto") {
|
||||||
titleChangeSubscription = terminal.onTitleChange((nextTitle) => {
|
titleChangeSubscription = terminal.onTitleChange((nextTitle) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user