diff --git a/packages/app/e2e/terminal-protocol-query.spec.ts b/packages/app/e2e/terminal-protocol-query.spec.ts new file mode 100644 index 000000000..6122a5c34 --- /dev/null +++ b/packages/app/e2e/terminal-protocol-query.spec.ts @@ -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); + } + }); +}); diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts index b0c5485e1..efba45128 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts @@ -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 }); diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts index 158baefeb..227f81c1b 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts @@ -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(); diff --git a/packages/server/src/terminal/terminal.posix.test.ts b/packages/server/src/terminal/terminal.posix.test.ts index 32f16d451..8e7eaf710 100644 --- a/packages/server/src/terminal/terminal.posix.test.ts +++ b/packages/server/src/terminal/terminal.posix.test.ts @@ -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): boolean { return getLines(state).some(isDaOkLine); } @@ -208,6 +240,10 @@ function hasDsrOkLine(state: ReturnType): boolean { return getLines(state).some(isDsrOkLine); } +function hasOsc11OkLine(state: ReturnType): boolean { + return getLines(state).some(isOsc11OkLine); +} + function lastNonEmptyLineIsPrompt(state: ReturnType): 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", () => { diff --git a/packages/server/src/terminal/terminal.ts b/packages/server/src/terminal/terminal.ts index 446182678..da021fd6a 100644 --- a/packages/server/src/terminal/terminal.ts +++ b/packages/server/src/terminal/terminal.ts @@ -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([ + [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 { + if (data.trim() !== "?") { + return false; + } + ptyProcess.write(`\x1b]${code};${response}\x1b\\`); + return true; + }); + } if (titleMode === "auto") { titleChangeSubscription = terminal.onTitleChange((nextTitle) => {