This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

Respond to OSC 10/11/4, DA2, DSR, and XTVERSION terminal queries

Nathan Herald (Apr 15, 2026, 2:47 PM +0200) a5b37169 fb4e6d55

+137
+61
src/server.ts
··· 243 243 return false; 244 244 }); 245 245 246 + // ── Terminal query responses ── 247 + // Programs send queries expecting the terminal to respond on stdin. 248 + // xterm-headless doesn't answer, so the query leaks to the client's 249 + // real terminal, whose response comes back as garbage input. We 250 + // intercept common queries and respond directly to the PTY process. 251 + 252 + // OSC 10: foreground color query (less, vim) 253 + this.terminal.parser.registerOscHandler(10, (data: string) => { 254 + if (data === "?") { 255 + this.ptyProcess.write("\x1b]10;rgb:c0c0/c0c0/c0c0\x1b\\"); 256 + } 257 + return false; 258 + }); 259 + // OSC 11: background color query (less, vim) 260 + this.terminal.parser.registerOscHandler(11, (data: string) => { 261 + if (data === "?") { 262 + this.ptyProcess.write("\x1b]11;rgb:0000/0000/0000\x1b\\"); 263 + } 264 + return false; 265 + }); 266 + // OSC 4: palette color query (vim, emacs) 267 + this.terminal.parser.registerOscHandler(4, (data: string) => { 268 + if (data.includes("?")) { 269 + // Format: "N;?" — respond with color N's value 270 + const idx = parseInt(data, 10); 271 + if (!isNaN(idx)) { 272 + // Respond with xterm-256color default for this index 273 + this.ptyProcess.write(`\x1b]4;${idx};rgb:0000/0000/0000\x1b\\`); 274 + } 275 + } 276 + return false; 277 + }); 278 + // DA2: secondary device attributes (vim, tmux) 279 + this.terminal.parser.registerCsiHandler( 280 + { prefix: ">", final: "c" }, 281 + (_params) => { 282 + // Respond as xterm version 382 283 + this.ptyProcess.write("\x1b[>0;382;0c"); 284 + return false; 285 + } 286 + ); 287 + // DSR: cursor position query (CSI 6 n, vim, readline) 288 + this.terminal.parser.registerCsiHandler( 289 + { final: "n" }, 290 + (params) => { 291 + if (params.length === 1 && params[0] === 6) { 292 + const buf = this.terminal.buffer.active; 293 + this.ptyProcess.write(`\x1b[${buf.cursorY + 1};${buf.cursorX + 1}R`); 294 + } 295 + return false; 296 + } 297 + ); 298 + // XTVERSION: terminal version query (CSI > 0 q, vim) 299 + this.terminal.parser.registerCsiHandler( 300 + { prefix: ">", final: "q" }, 301 + (_params) => { 302 + this.ptyProcess.write("\x1bP>|pty(0.8)\x1b\\"); 303 + return false; 304 + } 305 + ); 306 + 246 307 // Spawn the child process in a PTY via a shell, so that shell scripts, 247 308 // symlinks, and shebangs all work reliably (like tmux/screen do). 248 309 // `exec "$@"` replaces the shell with the actual process.
+76
tests/terminal-queries.test.ts
··· 1 + import { describe, it, expect, afterAll } from "vitest"; 2 + import * as fs from "node:fs"; 3 + import * as os from "node:os"; 4 + import { Session } from "../src/testing/index.ts"; 5 + 6 + // Isolate from real session directory 7 + const testSessionDir = fs.mkdtempSync(os.tmpdir() + "/pty-queries-"); 8 + process.env.PTY_SESSION_DIR = testSessionDir; 9 + afterAll(() => { 10 + return new Promise((resolve) => { 11 + setTimeout(() => { 12 + try { fs.rmSync(testSessionDir, { recursive: true, force: true }); } catch {} 13 + resolve(undefined); 14 + }, 500); 15 + }); 16 + }); 17 + 18 + describe("terminal query responses", () => { 19 + it("responds to DA1 (ESC[c)", async () => { 20 + // Fish uses this — already tested in shells.test.ts, but verify directly 21 + const session = await Session.server("sh", ["-c", "printf '\\033[c'; cat"], { 22 + rows: 24, 23 + cols: 80, 24 + }); 25 + await session.attach(); 26 + // The response (\x1b[?62;22c) goes to the PTY's stdin, cat echoes it 27 + await session.waitForText("62;22", 3000); 28 + await session.close(); 29 + }, 15000); 30 + 31 + it("responds to OSC 11 background color query", async () => { 32 + // less sends this — the response should appear as echoed output from cat 33 + const session = await Session.server("sh", ["-c", "printf '\\033]11;?\\033\\\\'; cat"], { 34 + rows: 24, 35 + cols: 80, 36 + }); 37 + await session.attach(); 38 + // The response contains rgb:0000/0000/0000 39 + await session.waitForText("0000/0000/0000", 3000); 40 + await session.close(); 41 + }, 15000); 42 + 43 + it("responds to OSC 10 foreground color query", async () => { 44 + const session = await Session.server("sh", ["-c", "printf '\\033]10;?\\033\\\\'; cat"], { 45 + rows: 24, 46 + cols: 80, 47 + }); 48 + await session.attach(); 49 + await session.waitForText("c0c0/c0c0/c0c0", 3000); 50 + await session.close(); 51 + }, 15000); 52 + 53 + it("responds to DSR cursor position query (ESC[6n)", async () => { 54 + const session = await Session.server("sh", ["-c", "printf '\\033[6n'; cat"], { 55 + rows: 24, 56 + cols: 80, 57 + }); 58 + await session.attach(); 59 + // Response: ESC[row;colR — cursor should be at row 1 or 2 60 + await session.waitForText(";", 3000); 61 + const ss = session.screenshot(); 62 + expect(ss.text).toMatch(/\d+;\d+R/); 63 + await session.close(); 64 + }, 15000); 65 + 66 + it("responds to DA2 (ESC[>c)", async () => { 67 + const session = await Session.server("sh", ["-c", "printf '\\033[>c'; cat"], { 68 + rows: 24, 69 + cols: 80, 70 + }); 71 + await session.attach(); 72 + // Response: ESC[>0;382;0c 73 + await session.waitForText("382", 3000); 74 + await session.close(); 75 + }, 15000); 76 + });