Something went wrong. Try again.
A reference for terminal control sequences whose examples run live in libghostty-vt compiled to WebAssembly.
Something went wrong. Try again.
22 kB · 504 lines
JavaScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
// Run one validation case in the terminal this program is running in, and// write what was checked. Started by emulators/run.mjs in a fresh terminal// for every case, sized to the case's screen and titled with a sentinel, so// that nothing one case does can leak into the next.//// node case.mjs CASE.json RESULT.json//// CASE.json is { input, expected } in the site's notation, with the// sentinel title, the window's class name, and the profile: how to fence// and read the screen in this terminal. "xterm" fences with DECRQCRA and// reads each cell with it, as below; "kitty" fences with an XTGETTCAP for a// name no case asks for, which kitty answers with the name, and reads the// whole screen with `kitten @ get-text --ansi` (see kitty-screen.mjs),// which gives every attribute and color, so its `attr` checks are complete;// "foot" fences the same way and reads the screen as plain text with its// `pipe-visible` action, bound to a key and pressed with wtype (see// foot-screen.mjs), so its `attr` checks are untested and the title comes// from sway; "urxvt" fences with an OSC 777 command to an extension the// harness loads into rxvt-unicode (urxvt/cc-harness), which writes the// screen, with each cell's rendition, the cursor and the bells, to a file.// The steps, for xterm://// 1. Confirm the screen is the case's size (XTWINOPS 18).// 2. Write the case's input.// 3. Fence: a DECRQCRA with an identifier no case uses. The terminal// answers requests in order, so when the fence's answer arrives,// everything the case sent has been processed, and whatever came back// before it is the case's reply.// 4. Read back what the expected result checks: the cursor (CPR, adjusted// for origin mode), whether it is visible (DECRQM 25), each cell's// character and attributes (DECRQCRA on that one cell, with xterm's// XTCHECKSUM extension set to report the code point, and then the code// point plus attribute bits), and the title (from X, with xdotool).//// What cannot be read back this way is "untested", never a failure: the// bell, the working directory, progress reports, the pointer shape, pending// wrap, and the colors, italic, faint and the rest of an `attr` line beyond// bold, underline, blink, inverse and invisible.//// DECRQCRA is the one thing this needs that a default xterm refuses: its// `disallowedWindowOps` names GetChecksum. run.mjs lifts that one// restriction, so the cases about DECRQCRA itself are expected to differ.
import { execFileSync, execSync } from "node:child_process";import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";import { parseCase, escapeBytes } from "../website/assets/vtcase.js";import { parseScreen, hasAttr, describe } from "./kitty-screen.mjs";import { parseFootText } from "./foot-screen.mjs";
const [casePath, resultPath] = process.argv.slice(2);const { input, expected: expectedText, sentinel, windowName, profile = "xterm" } = JSON.parse(readFileSync(casePath, "utf8"));const testCase = parseCase(input, expectedText);
const ESC = "\x1b";const decoder = new TextDecoder("latin1");
// The tty, raw: no echo, no line editing, and no output processing, so that// a case's LF reaches the terminal as LF and not as CR LF.execSync("stty raw -echo -opost -onlcr -ixon", { stdio: ["inherit", "inherit", "inherit"] });
let buffer = "";let waiters = [];process.stdin.on("data", (chunk) => { buffer += decoder.decode(chunk); for (const w of waiters.slice()) w();});
function write(data) { process.stdout.write(typeof data === "string" ? Buffer.from(data, "latin1") : Buffer.from(data));}
const bytesOf = (s) => Uint8Array.from(s, (ch) => ch.charCodeAt(0));
// Wait until the input holds a match for `re`; return what came before the// match and the match itself, and consume both. Rejects after `ms`.function readUntil(re, ms = 3000) { return new Promise((resolve, reject) => { const attempt = () => { const m = re.exec(buffer); if (!m) return false; const before = buffer.slice(0, m.index); buffer = buffer.slice(m.index + m[0].length); waiters = waiters.filter((w) => w !== check); clearTimeout(timer); resolve({ before, match: m }); return true; }; const check = () => attempt(); const timer = setTimeout(() => { waiters = waiters.filter((w) => w !== check); reject(new Error(`timed out waiting for ${re}; had ${escapeBytes(bytesOf(buffer.slice(-200)))}`)); }, ms); if (!attempt()) waiters.push(check); });}
async function query(seq, re) { write(seq); return (await readUntil(re)).match;}
// One cell's value from DECRQCRA, as the low 16 bits xterm reports.async function cellChecksum(row, col) { const m = await query(`${ESC}[1;1;${row};${col};${row};${col}*y`, /\x1bP1!~([0-9A-Fa-f]{4})\x1b\\/); return parseInt(m[1], 16);}
function title() { if (profile === "foot") return swayTitle(); try { const env = { ...process.env, LANG: "en_US.UTF-8" }; const win = execFileSync("xdotool", ["search", "--classname", windowName], { encoding: "utf8", env }).trim().split("\n")[0]; return execFileSync("xdotool", ["getwindowname", win], { encoding: "utf8", env }).replace(/\n$/, ""); } catch { return null; }}
// The title of the window whose app-id is the harness's, from sway.function swayTitle() { try { const tree = JSON.parse(execFileSync("swaymsg", ["-t", "get_tree", "--raw"], { encoding: "utf8" })); const find = (node) => (node.app_id === windowName ? node : [...(node.nodes ?? []), ...(node.floating_nodes ?? [])].map(find).find(Boolean)); const win = find(tree); return win ? (win.name ?? "") : null; } catch { return null; }}
// xterm's checksum attribute bits (screen.c, xtermCheckRect).const ATTR_BITS = { invisible: 0x8, underline: 0x10, inverse: 0x20, blink: 0x40, bold: 0x80 };
function judgeAttrs(words, bits) { let untested = false; for (const word of words) { const negated = word.startsWith("-"); const name = negated ? word.slice(1) : word; if (name in ATTR_BITS) { if ((bits & ATTR_BITS[name]) !== 0 === negated) return "fail"; } else { untested = true; } } return untested ? "untested" : "pass";}
async function run() { const { expected, steps } = testCase; const rows = expected.rows.length; const cols = expected.cols; const checks = [];
let size = null; try { size = await query(`${ESC}[18t`, /\x1b\[8;(\d+);(\d+)t/); } catch { // Not answered: carry on, trusting the size the terminal was opened at. } if (size && (+size[1] !== rows || +size[2] !== cols)) { return { status: "untested", reason: `the terminal is ${size[1]}x${size[2]}, not ${rows}x${cols}`, checks }; } if (profile === "kitty") return runFenced(rows, cols, steps, expected, checks, KITTY); if (profile === "foot") return runFenced(rows, cols, steps, expected, checks, FOOT); if (profile === "urxvt") return runFenced(rows, cols, steps, expected, checks, URXVT);
// All of it in one write, as one read: the cases are written that way // (see the UTF-8 page), and xterm decodes a stray byte by what follows it // in the same read. const all = new Uint8Array(steps.reduce((n, s) => n + s.bytes.length, 0)); steps.reduce((at, s) => (all.set(s.bytes, at), at + s.bytes.length), 0); write(all); let reply; try { write(fenceSeq()); reply = (await readUntil(fenceRe(), 3000)).before; } catch { return { status: "untested", reason: "the fence was not answered after the input: the case left the terminal inside a string, or below level 4, where DECRQCRA is not answered", checks }; }
// The cursor first, while origin mode is as the case left it. const cursorCheck = expected.checks.find((c) => c.kind === "cursor"); const origin = (await query(`${ESC}[?6$p`, /\x1b\[\?6;(\d)\$y/))[1] === "1"; if (cursorCheck) { const cpr = await query(`${ESC}[6n`, /\x1b\[(\d+);(\d+)R/); let row = +cpr[1]; let col = +cpr[2]; if (origin) { const tb = await query(`${ESC}P$qr${ESC}\\`, /\x1bP(\d)\$r(?:(\d+);(\d+))?r\x1b\\/); if (tb[2]) row += +tb[2] - 1; const lr = (await query(`${ESC}[?69$p`, /\x1b\[\?69;(\d)\$y/))[1] === "1"; if (lr) { const s = await query(`${ESC}P$qs${ESC}\\`, /\x1bP(\d)\$r(?:(\d+);(\d+))?s\x1b\\/); if (s[2]) col += +s[2] - 1; } } const want = `${cursorCheck.row},${cursorCheck.col}`; const got = `${row},${col}`; checks.push({ kind: "cursor", status: want === got ? "pass" : "fail", want, got }); } // DECRQCRA counts from the margins in origin mode; the screen is read in // absolute positions, so turn it off. (That moves the cursor, which has // already been read.) if (origin) write(`${ESC}[?6l`);
// Each cell: the code point, unmasked (XTCHECKSUM 23). A character from a // national replacement set is stored as its ASCII byte, so where that does // not match, ask again through the character set mapping (XTCHECKSUM 7). write(`${ESC}[23#y`); const cells = []; for (let r = 0; r < rows; r++) { const line = []; for (let c = 0; c < cols; c++) line.push(await cellChecksum(r + 1, c + 1)); cells.push(line); } let screenOk = true; for (let r = 0; r < rows; r++) { for (let c = 0; c < cols; c++) { const want = expected.rows[r][c]; const wantCode = want === "_" ? 0x20 : want.codePointAt(0) & 0xffff; // xterm's DECRQCRA skips characters from U+FFF0 to U+FFFF, U+FFFD // among them, and the right half of a wide character is one of those; // a blank is a space. So 0 can only be one of those two. if (cells[r][c] === 0 && (want === "\ufffd" || want === "_")) continue; const got = cells[r][c]; if (got === wantCode) continue; write(`${ESC}[7#y`); const mapped = await cellChecksum(r + 1, c + 1); write(`${ESC}[23#y`); if (mapped === wantCode) { cells[r][c] = mapped; continue; } screenOk = false; } } const shown = cells.map((line) => `|${line.map((v) => (v === 0 ? "\ufffd" : v === 0x20 ? "_" : String.fromCodePoint(v))).join("")}|`); checks.push({ kind: "screen", status: screenOk ? "pass" : "fail", want: expected.rows.map((r) => `|${r.join("")}|`), got: shown });
for (const check of expected.checks) { switch (check.kind) { case "cursor": break; case "reply": { const want = escapeBytes(check.value) || "none"; const got = escapeBytes(bytesOf(reply)) || "none"; checks.push({ kind: "reply", status: want === got ? "pass" : "fail", want, got }); break; } case "cursor-visible": { const m = await query(`${ESC}[?25$p`, /\x1b\[\?25;(\d)\$y/); const got = m[1] === "1"; checks.push({ kind: "cursor-visible", status: got === check.value ? "pass" : "fail", want: check.value, got }); break; } case "attr": { const plain = cells[check.row - 1]?.[check.col - 1]; if (plain === undefined) { checks.push({ kind: "attr", status: "fail", want: check.attrs.join(" "), got: "off screen" }); break; } write(`${ESC}[23#y`); const code = await cellChecksum(check.row, check.col); write(`${ESC}[21#y`); const withAttrs = await cellChecksum(check.row, check.col); write(`${ESC}[23#y`); const bits = (withAttrs - code) & 0xffff; const words = check.attrs.flatMap((a) => (a === "plain" ? ["-bold", "-underline", "-blink", "-inverse", "-invisible", "italic"] : [a])); const got = Object.entries(ATTR_BITS).filter(([, b]) => bits & b).map(([n]) => n).join(" ") || "none of bold, underline, blink, inverse, invisible"; checks.push({ kind: "attr", status: judgeAttrs(words, bits), want: check.attrs.join(" "), got, at: `${check.row},${check.col}` }); break; } case "title": { const got = title(); const want = check.value === "" ? sentinel : check.value; checks.push({ kind: "title", status: got === null ? "untested" : got === want ? "pass" : "fail", want: check.value, got: got === sentinel ? "" : got }); break; } default: checks.push({ kind: check.kind, status: "untested" }); } }
const statuses = checks.map((c) => c.status); const status = statuses.includes("fail") ? "fail" : statuses.includes("untested") ? "partial" : "pass"; return { status, checks };}
// kitty: fence with XTGETTCAP, whose answer names what was asked for.const TCAP_NAME = Buffer.from("cc-harness-fence").toString("hex");
function kittyText() { return execFileSync("kitten", ["@", "--to", process.env.KITTY_LISTEN_ON, "get-text", "--match", `id:${process.env.KITTY_WINDOW_ID}`, "--extent", "screen", "--ansi"], { encoding: "utf8" });}
// foot has no way to be asked for its screen but a key binding: the flake// binds `pipe-visible` to Ctrl+Shift+F9, writing the visible text to// FOOT_SCREEN, and the key is pressed here with wtype, through the// compositor's virtual keyboard.const FOOT_SCREEN = "/tmp/cc-foot-screen";async function footText() { rmSync(FOOT_SCREEN, { force: true }); execFileSync("wtype", ["-M", "ctrl", "-M", "shift", "-k", "F9", "-m", "shift", "-m", "ctrl"]); for (let waited = 0; !existsSync(FOOT_SCREEN); waited += 20) { if (waited > 3000) throw new Error("foot did not write the screen"); await new Promise((r) => setTimeout(r, 20)); } return readFileSync(FOOT_SCREEN, "utf8");}
// The fence kitty and foot answer: an XTGETTCAP for a name no case asks// for, which they answer with the name.const TCAP_FENCE = { seq: `${ESC}P+q${TCAP_NAME}${ESC}\\`, re: new RegExp(`\\x1bP[01]\\+r${TCAP_NAME}(?:=[0-9A-Fa-f]*)?\\x1b\\\\`, "i"),};
// rxvt-unicode answers no request that reads the screen, so the harness// loads an extension of its own into it (emulators/urxvt/cc-harness), which// answers OSC 777 commands: `start` counts bells from then on, and `dump`// writes the screen, the cursor and the bell count to a file. Each is// answered with an acknowledgement, which is the fence.const URXVT_DUMP = "/tmp/cc-urxvt-screen.json";const URXVT_ACK = /\x1b\]777;cc-harness;ack\x07/;function urxvtScreen(rows, cols) { const dump = JSON.parse(readFileSync(URXVT_DUMP, "utf8")); // Color 0 and 1 are the default foreground and background; the palette // starts at 2. const color = (index) => (index < 2 ? null : { index: index - 2 }); // A 24-bit color is kept in a slot past the 256 of the palette, which // the extension cannot turn back into RGB: such a cell's colors are // unreadable. const truecolor = (c) => c.fg > 257 || c.bg > 257; const grid = dump.rows.slice(0, rows).map((line) => line.slice(0, cols).map((c) => ({ text: c.t, style: { bold: !!c.b, faint: false, italic: !!c.i, blink: !!c.k, inverse: !!c.v, invisible: false, strikethrough: false, overline: false, underline: c.u ? "single" : "none", fg: truecolor(c) ? null : color(c.fg), bg: truecolor(c) ? null : color(c.bg), link: false, ...(truecolor(c) ? { unreadable: true } : {}), }, })), ); return { grid, sawTab: false, cursor: { row: dump.cursor[0] + 1, col: Math.min(dump.cursor[1], dump.ncol - 1) + 1 }, bells: dump.bells };}
// How to read the screen back in each terminal fenced this way: the fences// before and after the case's input, and whether what is read has the cells'// attributes. A reader whose screen() also gives the cursor and a count of// bells has those judged from it.const KITTY = { before: TCAP_FENCE, after: TCAP_FENCE, screen: async (rows, cols) => parseScreen(kittyText(), rows, cols), attrs: true, name: "kitty" };const FOOT = { before: TCAP_FENCE, after: TCAP_FENCE, screen: async (rows, cols) => parseFootText(await footText(), rows, cols), attrs: false, name: "foot" };const URXVT = { before: { seq: `${ESC}]777;cc-harness;start\x07`, re: URXVT_ACK }, after: { seq: `${ESC}]777;cc-harness;dump;${URXVT_DUMP}\x07`, re: URXVT_ACK }, screen: async (rows, cols) => urxvtScreen(rows, cols), attrs: true, name: "rxvt-unicode",};
async function runFenced(rows, cols, steps, expected, checks, reader) { // kitty's -T pins the title against the program, so the sentinel is set // here instead, and fenced, before the case begins. write(`${ESC}]2;${sentinel}${ESC}\\`); write(reader.before.seq); await readUntil(reader.before.re, 3000); const all = new Uint8Array(steps.reduce((n, s) => n + s.bytes.length, 0)); steps.reduce((at, s) => (all.set(s.bytes, at), at + s.bytes.length), 0); write(all); let reply; try { write(reader.after.seq); reply = (await readUntil(reader.after.re, 3000)).before; } catch { return { status: "untested", reason: "the fence was not answered after the input: the case left the terminal inside a string", checks }; }
const screen = await reader.screen(rows, cols); const cursorCheck = expected.checks.find((c) => c.kind === "cursor"); if (cursorCheck && screen.cursor) { const want = `${cursorCheck.row},${cursorCheck.col}`; const got = `${screen.cursor.row},${screen.cursor.col}`; checks.push({ kind: "cursor", status: want === got ? "pass" : "fail", want, got }); } else if (cursorCheck) { try { const cpr = await query(`${ESC}[6n`, /\x1b\[(\d+);(\d+)R/); let row = +cpr[1]; let col = +cpr[2]; const origin = (await query(`${ESC}[?6$p`, /\x1b\[\?6;(\d)\$y/))[1] === "1"; if (origin) { const tb = await query(`${ESC}P$qr${ESC}\\`, /\x1bP(\d)\$r(?:(\d+);(\d+))?r\x1b\\/); if (tb[2]) row += +tb[2] - 1; const lr = (await query(`${ESC}[?69$p`, /\x1b\[\?69;(\d)\$y/))[1] === "1"; if (lr) { const s = await query(`${ESC}P$qs${ESC}\\`, /\x1bP(\d)\$r(?:(\d+);(\d+))?s\x1b\\/); if (s[2]) col += +s[2] - 1; } } const want = `${cursorCheck.row},${cursorCheck.col}`; const got = `${row},${col}`; checks.push({ kind: "cursor", status: want === got ? "pass" : "fail", want, got }); } catch (e) { checks.push({ kind: "cursor", status: "untested", got: String(e) }); } }
const { grid, sawTab } = screen; let screenOk = true; for (let r = 0; r < rows; r++) { for (let c = 0; c < cols; c++) { const want = expected.rows[r][c]; const got = grid[r][c].text; const ok = want === "_" ? got === " " || got === "" : got === want; if (!ok) screenOk = false; } } const shown = grid.map((line) => `|${line.map((cell) => (cell.text === " " || cell.text === "" ? "_" : cell.text)).join("")}|`); // kitty and foot write a run of blanks a tab made as one tab, and how many // it stood for is lost; expanded to the default stops, a mismatch could be // that, so it is not counted as a failure. checks.push({ kind: "screen", status: screenOk ? "pass" : sawTab ? "untested" : "fail", want: expected.rows.map((r) => `|${r.join("")}|`), got: shown, ...(sawTab && !screenOk ? { reason: `${reader.name}'s text has a tab, which hides how many blanks it stood for` } : {}) });
for (const check of expected.checks) { switch (check.kind) { case "cursor": break; case "reply": { const want = escapeBytes(check.value) || "none"; const got = escapeBytes(bytesOf(reply)) || "none"; checks.push({ kind: "reply", status: want === got ? "pass" : "fail", want, got }); break; } case "cursor-visible": { try { const m = await query(`${ESC}[?25$p`, /\x1b\[\?25;(\d)\$y/); const got = m[1] === "1"; checks.push({ kind: "cursor-visible", status: got === check.value ? "pass" : "fail", want: check.value, got }); } catch { checks.push({ kind: "cursor-visible", status: "untested" }); } break; } case "attr": { if (!reader.attrs) { checks.push({ kind: "attr", status: "untested", want: check.attrs.join(" "), at: `${check.row},${check.col}` }); break; } const cell = grid[check.row - 1]?.[check.col - 1]; if (!cell) { checks.push({ kind: "attr", status: "fail", want: check.attrs.join(" "), got: "off screen" }); break; } if (cell.style.unreadable) { checks.push({ kind: "attr", status: "untested", want: check.attrs.join(" "), at: `${check.row},${check.col}` }); break; } const verdicts = check.attrs.map((w) => hasAttr(cell.style, w)); const status = verdicts.includes(false) ? "fail" : verdicts.includes(null) ? "untested" : "pass"; checks.push({ kind: "attr", status, want: check.attrs.join(" "), got: describe(cell.style), at: `${check.row},${check.col}` }); break; } case "bell": { if (screen.bells === undefined) { checks.push({ kind: "bell", status: "untested" }); break; } checks.push({ kind: "bell", status: screen.bells === check.value ? "pass" : "fail", want: String(check.value), got: String(screen.bells) }); break; } case "title": { const got = title(); const want = check.value === "" ? sentinel : check.value; checks.push({ kind: "title", status: got === null ? "untested" : got === want ? "pass" : "fail", want: check.value, got: got === sentinel ? "" : got }); break; } default: checks.push({ kind: check.kind, status: "untested" }); } } const statuses = checks.map((c) => c.status); const status = statuses.includes("fail") ? "fail" : statuses.includes("untested") ? "partial" : "pass"; return { status, checks };}
const FENCE = 31337;function fenceSeq() { return `${ESC}[${FENCE};1;1;1;1;1*y`;}function fenceRe() { return new RegExp(`\\x1bP${FENCE}!~([0-9A-Fa-f]{4})\\x1b\\\\`);}
let result;try { result = await run();} catch (e) { result = { status: "error", reason: String(e) };}writeFileSync(resultPath, JSON.stringify(result) + "\n");process.exit(0);