diff --git a/packages/server/src/terminal/terminal.test.ts b/packages/server/src/terminal/terminal.test.ts index 5d32f6cbb..af26324cf 100644 --- a/packages/server/src/terminal/terminal.test.ts +++ b/packages/server/src/terminal/terminal.test.ts @@ -999,6 +999,33 @@ process.stdin.on("data", (chunk) => { process.stdout.write("\\x1b[c"); `; +const DSR_HELPER_SCRIPT = `process.stdin.setRawMode(true); +process.stdin.resume(); +const mode = process.argv[2] || "cursor"; +const query = mode === "private-cursor" ? "\\x1b[?6n" : mode === "status" ? "\\x1b[5n" : "\\x1b[6n"; +const pattern = mode === "private-cursor" ? /\\x1b\\[\\?(\\d+);(\\d+)R/ : mode === "status" ? /\\x1b\\[0n/ : /\\x1b\\[(\\d+);(\\d+)R/; +let buf = ""; +const timer = setTimeout(() => { + process.stdout.write("DSR_TIMEOUT\\n"); + process.exit(2); +}, 2500); +process.stdin.on("data", (chunk) => { + buf += chunk.toString("binary"); + const match = buf.match(pattern); + if (!match) { + return; + } + clearTimeout(timer); + if (mode === "status") { + process.stdout.write("DSR_OK:status\\n"); + } else { + process.stdout.write("DSR_OK:" + match[1] + ":" + match[2] + "\\n"); + } + process.exit(0); +}); +process.stdout.write(query); +`; + function writeDaHelper(prefix: string): string { const dir = mkdtempSync(join(tmpdir(), prefix)); temporaryDirs.push(dir); @@ -1007,14 +1034,30 @@ function writeDaHelper(prefix: string): string { return path; } +function writeDsrHelper(prefix: string): string { + const dir = mkdtempSync(join(tmpdir(), prefix)); + temporaryDirs.push(dir); + const path = join(dir, "helper.cjs"); + writeFileSync(path, DSR_HELPER_SCRIPT); + return path; +} + function isDaOkLine(line: string): boolean { return line.startsWith("DA_OK:"); } +function isDsrOkLine(line: string): boolean { + return line.startsWith("DSR_OK:"); +} + function hasDaOkLine(state: ReturnType): boolean { return getLines(state).some(isDaOkLine); } +function hasDsrOkLine(state: ReturnType): boolean { + return getLines(state).some(isDsrOkLine); +} + function lastNonEmptyLineIsPrompt(state: ReturnType): boolean { const last = getLines(state) @@ -1059,6 +1102,63 @@ describe("terminal protocol queries", () => { await waitForState(session, hasDaOkLine); await waitForState(session, lastNonEmptyLineIsPrompt); }); + + it("delivers public DSR cursor-position replies to a foreground app on stdin", async () => { + const helperPath = writeDsrHelper("terminal-dsr-helper-"); + + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ " }, + }), + ); + await waitForLines(session, ["$"]); + + session.send({ type: "input", data: `${process.execPath} ${helperPath} cursor\r` }); + await waitForState(session, hasDsrOkLine); + + const ack = getLines(session.getState()).find(isDsrOkLine) ?? ""; + expect(ack).toMatch(/^DSR_OK:\d+:\d+$/); + }); + + it("delivers private DSR cursor-position replies to a foreground app on stdin", async () => { + const helperPath = writeDsrHelper("terminal-dsr-private-helper-"); + + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ " }, + }), + ); + await waitForLines(session, ["$"]); + + session.send({ type: "input", data: `${process.execPath} ${helperPath} private-cursor\r` }); + await waitForState(session, hasDsrOkLine); + + const ack = getLines(session.getState()).find(isDsrOkLine) ?? ""; + expect(ack).toMatch(/^DSR_OK:\d+:\d+$/); + }); + + it("delivers DSR terminal-status replies to a foreground app on stdin", async () => { + const helperPath = writeDsrHelper("terminal-dsr-status-helper-"); + + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ " }, + }), + ); + await waitForLines(session, ["$"]); + + session.send({ type: "input", data: `${process.execPath} ${helperPath} status\r` }); + await waitForState(session, hasDsrOkLine); + + const ack = getLines(session.getState()).find(isDsrOkLine) ?? ""; + expect(ack).toBe("DSR_OK:status"); + }); }); describe("stream snapshots", () => { diff --git a/packages/server/src/terminal/terminal.ts b/packages/server/src/terminal/terminal.ts index 99dd4cf74..e3906ef57 100644 --- a/packages/server/src/terminal/terminal.ts +++ b/packages/server/src/terminal/terminal.ts @@ -670,6 +670,29 @@ export async function createTerminal(options: CreateTerminalOptions): Promise { + if (params.length !== 1) { + return false; + } + if (params[0] === 5) { + ptyProcess.write("\x1b[0n"); + return true; + } + if (params[0] === 6) { + const buffer = terminal.buffer.active; + ptyProcess.write(`\x1b[${buffer.cursorY + 1};${buffer.cursorX + 1}R`); + return true; + } + return false; + }); + terminal.parser.registerCsiHandler({ prefix: "?", final: "n" }, (params) => { + if (params.length !== 1 || params[0] !== 6) { + return false; + } + const buffer = terminal.buffer.active; + ptyProcess.write(`\x1b[?${buffer.cursorY + 1};${buffer.cursorX + 1}R`); + return true; + }); let disposeTitleChangeSubscription: { dispose(): void } | null = null; if (!lockedTitle) {