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.
12 kB · 234 lines
JavaScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
// The "Support" table on each sequence page: which terminals this site has// examined have the sequence, and how far. The data is website/support.json:// `versions`, what each terminal was checked against, and `pages`, keyed by// page ("csi/cup"), each entry a list of [terminal, level, note] with level// one of "yes", "partial" or "no" and the note in Markdown. libghostty-vt's// version is the Ghostty revision pinned in flake.lock, which the cases run// against, so a bump to Ghostty updates every table. This// writes the table into the page as a section of its own, just before// "# Validation", so that it is part of the page's text like the rest.//// A terminal that has been run in emulators/ is *measured*: support.json's// `measured` names its results file, and its row on a page is derived from// those results rather than written by hand. Each case has a role in// emulators/roles.json: "probe" if it shows the function working as// specified, "follow" if it pins what a default xterm does where terminals// may differ. The row is Yes when every probe on the page passes, Partly// when some do, No when none do. A case the harness could judge only in part// is left out with those it could not judge at all, since what it could not// read may be the whole point of the case; and a page with no probe it could judge keeps its hand-written row,// if any. Every case must have a role. A results file whose `harness` is// "library" was run in-process (pyte, xterm.js, alacritty_terminal, VTE) rather than in a VM, and its rows say// so.//// A reply that differs from xterm's only in form counts as a pass, and the// row says how. xterm ends an OSC or DCS reply with whichever of BEL and ST// ended the request; ST is the terminator the standards define, and a// terminal that always answers with it has implemented the function. And// xterm writes a color's 16-bit components by repeating its 8 bits, `80` as// `8080`; a terminal that writes `8000` reports the same color, so colors// are compared by their top 8 bits.//// It also writes the site's Support page, website/content/support.smd, from// the same rows: a table of the terminals, then one table per section with// the sequences as rows and the terminals as columns. The prose at the top of// that page is written by hand; everything from its first heading on is this// script's.//// node tools/support.mjs website write the tables// node tools/support.mjs website --check fail if a page has no data, or// its table is out of date
import { readFileSync, writeFileSync } from "node:fs";import { join } from "node:path";import { readPages } from "./pages.mjs";import { extractCases, walk } from "./cases.mjs";
const [websiteDir = "website", flag] = process.argv.slice(2);const check = flag === "--check";
// The order the rows always come in: the hardware first, then xterm, which// the cases follow, then libghostty-vt, which they run in, then the rest.const TERMINALS = ["VT220", "VT340", "VT510", "xterm", "libghostty-vt", "Konsole", "kitty", "foot", "pyte", "xterm.js headless", "alacritty_terminal", "iTerm2", "ConEmu", "rxvt-unicode", "mintty", "VTE", "VS Code", "Orca", "Linux console"];const LEVELS = { yes: "Yes", partial: "Partly", no: "No" };
const { versions, measured = {}, pages: data } = JSON.parse(readFileSync(join(websiteDir, "support.json"), "utf8"));const repo = join(websiteDir, "..");const roles = JSON.parse(readFileSync(join(repo, "emulators", "roles.json"), "utf8"));const RESULTS_URL = "https://git.jcollie.dev/jeff/control-codes/src/branch/main/";const ghostty = JSON.parse(readFileSync(join(websiteDir, "..", "flake.lock"), "utf8")).nodes.ghostty.locked.rev;versions["libghostty-vt"] = `[${ghostty.slice(0, 7)}](https://github.com/ghostty-org/ghostty/tree/${ghostty})`;for (const t of TERMINALS) if (!versions[t]) throw new Error(`support.json: no version for ${t}`);// A proposal's page summarizes it as written, with no support table.const pages = readPages(websiteDir).filter((p) => p.fields.layout === "sequence.shtml" && !p.rel.startsWith("proposals/"));const problems = [];
// Every case, by page, and its role.const contentDir = join(websiteDir, "content");const casesByPage = {};const allCases = new Set();for (const file of walk(contentDir)) { const page = file.slice(contentDir.length + 1).replace(/\.smd$/, ""); for (const c of extractCases(readFileSync(file, "utf8"))) { const key = `${page} ${c.heading.split(":")[0]}`; allCases.add(key); (casesByPage[page] ??= []).push(key); if (!["probe", "follow"].includes(roles[key])) problems.push(`${key}: no role in emulators/roles.json`); }}for (const key of Object.keys(roles)) if (!allCases.has(key)) problems.push(`${key}: a role in emulators/roles.json for a case that does not exist`);
// The measured terminals' results, by case.const runs = {};for (const [terminal, file] of Object.entries(measured)) { const run = JSON.parse(readFileSync(join(repo, file), "utf8")); const status = {}; const form = { terminator: new Set(), precision: new Set() }; for (const r of run.cases) { const key = `${r.page} ${r.id}`; status[key] = r.status; const failed = (r.checks ?? []).filter((c) => c.status === "fail"); const kinds = failed.map(formOnly); if (r.status === "fail" && failed.length && kinds.every(Boolean)) { status[key] = "pass"; for (const kind of kinds.flat()) form[kind].add(key); } } runs[terminal] = { file, version: run.version, harness: run.harness ?? "vm", status, form };}
// How a failed check differs from xterm's reply only in form, as a list of// "terminator" and "precision", or null if it differs in substance.function toST(s) { return s.replaceAll("\\x07", "\\e\\\\");}function top8(h) { return (h.length === 1 ? h + h : h.slice(0, 2)).toLowerCase();}function to8(s) { return s.replace(/rgb:([0-9a-fA-F]+)\/([0-9a-fA-F]+)\/([0-9a-fA-F]+)/g, (_, r, g, b) => `rgb:${top8(r)}/${top8(g)}/${top8(b)}`);}function formOnly(c) { if (c.kind !== "reply" || typeof c.want !== "string" || typeof c.got !== "string" || c.want === c.got) return null; if (toST(c.want) === toST(c.got)) return ["terminator"]; if (to8(c.want) === to8(c.got)) return ["precision"]; if (to8(toST(c.want)) === to8(toST(c.got))) return ["terminator", "precision"]; return null;}
function listed(keys) { const ids = keys.map((k) => k.split(" ")[1]); return ids.length <= 4 ? ids.join(", ") : `${ids.slice(0, 4).join(", ")} and ${ids.length - 4} more`;}
// A measured terminal's row for a page, or null if no probe could be judged.function derived(terminal, page) { const run = runs[terminal]; const probes = (casesByPage[page] ?? []).filter((k) => roles[k] === "probe"); const judged = probes.filter((k) => ["pass", "fail"].includes(run.status[k])); if (judged.length === 0) return null; const failing = judged.filter((k) => run.status[k] === "fail"); const n = judged.length; const of = n === 1 ? "the case that shows it working" : `the ${n} cases that show it working`; const level = failing.length === 0 ? "yes" : failing.length === n ? "no" : "partial"; let note = failing.length === 0 ? `passes ${of}` : failing.length === n ? `fails ${of}` : `fails ${listed(failing)} of ${of}`; if (judged.some((k) => run.form.terminator.has(k))) note += "; ends its replies with ST where xterm uses BEL"; if (judged.some((k) => run.form.precision.has(k))) note += "; writes colors as `ff00` where xterm writes `ffff`"; const how = run.harness === "library" ? "run as a library" : "run in a VM"; return [terminal, level, note, `${run.version}, [${how}](${RESULTS_URL}${run.file})`];}let stale = 0;const finalRows = {};
function table(key, rows) { for (const [terminal, level] of rows) { if (!TERMINALS.includes(terminal)) problems.push(`${key}: unknown terminal ${terminal}`); if (!(level in LEVELS)) problems.push(`${key}: ${terminal}: unknown level ${level}`); } const sorted = [...rows].sort((a, b) => TERMINALS.indexOf(a[0]) - TERMINALS.indexOf(b[0])); const lines = sorted.map(([terminal, level, note, version]) => `| ${terminal} | ${version ?? versions[terminal]} | ${LEVELS[level] ?? level} | ${note ?? ""} |`); return `# Support\n\n| Terminal | Version | Support | Notes |\n| --- | --- | --- | --- |\n${lines.join("\n")}\n\n`;}
for (const page of pages) { const key = page.rel.slice(0, -".smd".length); if (!data[key]) { problems.push(`${key}: no support data`); continue; } let rows = data[key]; for (const terminal of Object.keys(runs)) { const row = derived(terminal, key); if (row) rows = [...rows.filter((r) => r[0] !== terminal), row]; } const source = readFileSync(page.file, "utf8"); const withoutOld = source.replace(/^# Support\n[\s\S]*?(?=^# Validation\n)/m, ""); if (!/^# Validation\n/m.test(withoutOld)) { problems.push(`${key}: no "# Validation" heading to put the table before`); continue; } finalRows[key] = rows; const updated = withoutOld.replace(/^# Validation\n/m, `${table(key, rows)}# Validation\n`); if (updated !== source) { stale++; if (check) problems.push(`${key}: the support table is out of date; run node tools/support.mjs ${websiteDir}`); else writeFileSync(page.file, updated); }}
for (const key of Object.keys(data)) { if (!pages.some((p) => p.rel === `${key}.smd`)) problems.push(`${key}: support data for a page that does not exist`);}
// The Support page. Sections come in the order the sidebar lists them,// newest date first, and their pages in the order tools/order.mjs wrote.// Bold and italic, so that the stylesheet can color them, and they still// read apart in the page's Markdown copy.const SHORT = { yes: "**Yes**", partial: "*Partly*", no: "No" };function matrix() { const sections = readPages(websiteDir) .filter((p) => p.fields.layout === "section.shtml") .sort((a, b) => (a.fields.date < b.fields.date ? 1 : -1)); const used = TERMINALS.filter((t) => Object.values(finalRows).some((rows) => rows.some((r) => r[0] === t))); const out = []; out.push("# Terminals", "", ">[]($block.attrs(\"support-terminals\"))", "> | Terminal | Version |", "> | --- | --- |"); for (const t of used) { const run = runs[t]; const how = run && (run.harness === "library" ? "run as a library" : "run in a VM"); out.push(`> | ${t} | ${run ? `${run.version}, [${how}](${RESULTS_URL}${run.file})` : versions[t]} |`); } out.push(""); for (const section of sections) { const dir = section.rel.slice(0, -"/index.smd".length); const order = [...(/"order": \[([\s\S]*?)\]/.exec(readFileSync(section.file, "utf8"))?.[1] ?? "").matchAll(/"([^"]+)"/g)].map((m) => m[1]); const keys = order.filter((k) => finalRows[k]); if (keys.length === 0) continue; out.push(`# ${section.fields.title}`, "", ">[]($block.attrs(\"support-matrix\"))"); out.push(`> | | ${used.join(" | ")} |`, `> | --- |${used.map(() => " --- |").join("")}`); for (const key of keys) { const page = pages.find((p) => p.rel === `${key}.smd`); const label = page.fields.custom?.mode ?? page.fields.custom?.mnemonic ?? key; const cells = used.map((t) => SHORT[finalRows[key].find((r) => r[0] === t)?.[1]] ?? ""); out.push(`> | [${label}](/${key}/) | ${cells.join(" | ")} |`); } out.push(""); } return out.join("\n");}
const supportFile = join(websiteDir, "content", "support.smd");const supportSource = readFileSync(supportFile, "utf8");const heading = supportSource.search(/^# /m);const supportUpdated = (heading < 0 ? supportSource.replace(/\n*$/, "\n\n") : supportSource.slice(0, heading)) + matrix();if (supportUpdated !== supportSource) { stale++; if (check) problems.push(`support.smd: the Support page is out of date; run node tools/support.mjs ${websiteDir}`); else writeFileSync(supportFile, supportUpdated);}
for (const p of problems) console.error(p);if (problems.length) process.exit(1);console.log(check ? `${pages.length} support tables up to date` : `${stale} support tables written`);