A reference for terminal control sequences whose examples run live in libghostty-vt compiled to WebAssembly. https://control-codes.page
  • JavaScript 65.6%
  • Nix 14.6%
  • CSS 7.7%
  • Python 4.9%
  • Rust 4%
  • Other 3.2%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Jeffrey C. Ollie 26fec17716
All checks were successful
site / test (push) Successful in 2m59s
site / xterm (push) Successful in 10m17s
site / publish (push) Successful in 3m53s
Name the kitty keyboard page's CSI = u Set Flags (Kitty Keyboard Protocol)
Now that the page is headed "Kitty Keyboard Protocol" with all four of its
codes, the name on `CSI = u` labels that sequence's own diagram, beside
Query, Report, Push and Pop Flags, so it says what the sequence does. It
keeps the protocol's name in parentheses, since it is also the page's entry
in the sidebar and the section index, where Set Flags alone would not say
whose flags. The table by final byte follows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DRwJbTU9K6SEu66NAKSoxd
2026-10-10 18:31:27 -05:00
.forgejo/workflows Add an index of control sequences by final byte 2026-10-04 18:21:25 -05:00
emulators Move DECAUPSS to the device control strings 2026-10-04 18:04:46 -05:00
LICENSES Add a terminal control sequence reference with live examples 2026-10-01 21:16:59 -05:00
tools Add an index of control sequences by final byte 2026-10-04 18:21:25 -05:00
website Name the kitty keyboard page's CSI = u Set Flags (Kitty Keyboard Protocol) 2026-10-10 18:31:27 -05:00
.gitignore Run the cases in alacritty_terminal, and derive its support rows 2026-10-03 20:25:11 -05:00
build.zig Draw DECCOLM's decision tree with Mermaid 2026-10-04 17:55:47 -05:00
build.zig.zon Add a terminal control sequence reference with live examples 2026-10-01 21:16:59 -05:00
build.zig.zon.nix Add a terminal control sequence reference with live examples 2026-10-01 21:16:59 -05:00
COVERAGE.md Move DECAUPSS to the device control strings 2026-10-04 18:04:46 -05:00
flake.lock Update libghostty-vt to upstream main (0f171f6) 2026-10-08 09:26:20 -05:00
flake.nix Draw DECCOLM's decision tree with Mermaid 2026-10-04 17:55:47 -05:00
ghostty-vt-wasm.nix Add a terminal control sequence reference with live examples 2026-10-01 21:16:59 -05:00
mermaid.nix Draw DECCOLM's decision tree with Mermaid 2026-10-04 17:55:47 -05:00
README.md Head a page that covers several codes with all of them 2026-10-10 18:15:14 -05:00
REUSE.toml Run the cases in alacritty_terminal, and derive its support rows 2026-10-03 20:25:11 -05:00
website.nix Draw DECCOLM's decision tree with Mermaid 2026-10-04 17:55:47 -05:00

control-codes

A small reference for terminal control sequences. Each page documents one sequence and gives validation cases: some input, and the screen it should leave behind. The cases run live. When a page is opened, the browser loads libghostty-vt, the terminal emulation core of Ghostty, compiled to WebAssembly. It feeds each case into a real terminal and draws the result next to the expected screen. You can step through the input one line at a time.

The site is built with Zine. Its Forgejo workflow publishes it from main to https://control-codes.page/.

Where it lives

git clone https://git.jcollie.dev/jeff/control-codes.git

On Radicle, the peer-to-peer forge, the repository is rad:z2dBmdsQqFL24uX7hpoHgxe9N2QdU. A Radicle repository can only be found by its ID, so this is how to seed or clone it:

rad clone rad:z2dBmdsQqFL24uX7hpoHgxe9N2QdU

Cloning also seeds the repository, which helps keep it available on the network.

What is covered

241 pages in nine groups:

  • C0 controls: BEL, BS, CAN, CR, DC1/DC3, ENQ, ESC, FF, HT, LF, NUL, SO/SI, SUB, VT
  • Escape sequences and C1 controls: CSI, DCS, DECALN, DECAUPSS/DECRQUPSS, DECBI/DECFI, DECDWL/DECDHLT/DECDHLB/DECSWL, DECKPAM/DECKPNM, DECSC/DECRC, HTS, LS2/LS3, NEL, RI, RIS, SOS/PM/APC, SPA/EPA, SS2/SS3, ST
  • Control sequences: CBT, CHA, CHT, CNL, CPL, CSI ? u/CSI = u/CSI > u/CSI < u, CTC, CUB, CUD, CUF, CUP, CUU, DA, DCH, DECCARA, DECCRA, DECDSR, DECELR/DECSLE/DECRQLP/DECEFR/DECLKD, DECERA, DECFRA, DECIC/DECDC, DECRQCRA, DECRQM, DECRQPSR, DECRQTSR/DECTSR/DECRSTS, DECRST, DECSASD/DECSSDT, DECSCA/DECSED/DECSEL, DECSCL, DECSCPP, DECSCUSR, DECSET, DECSLRM, DECSNLS, DECSR/DECSRC, DECSTBM, DECSTR, DECTST, DL, DSR, ECH, ED, EL, HPA, HPB, HPR, HVP, ICH, IL, MC, NP/PP/PPA/PPR/PPB, REP, RM, SD, SGR, SIMD, SL/SR, SM, SU, TBC, VPA, VPB, VPR, XTMODKEYS/XTQMODKEYS, XTRESTORE, XTSAVE, XTVERSION, XTWINOPS
  • ANSI modes: BDSM, CRM, DCSM, EBM, ERM, FEAM, FETM, GATM, GRCM, HEM, IRM, KAM, LNM, MATM, PUM, SATM, SRM, SRTM, TSM, TTM, VEM, ZDM
  • Private modes: ?1004, ?12, ?2004, ?2026, ?46, ?47/?1047/?1048/?1049, ?9/?1000 to ?1003/?1005 to ?1007/?1015/?1016, DECARM, DECAWM, DECCKM, DECCOLM, DECHCCM/DECVCCM/DECPCCM, DECKBUM, DECNRCM, DECOM, DECPEX/DECPFF, DECSCLM, DECSCNM, DECSDM, DECTCEM
  • Device control strings: DECDLD, DECRQSS/DECRPSS, DECUDK, ReGIS, Sixel, XTGETTCAP, XTGETXRES, XTSETTCAP
  • Operating system commands: OSC 0, OSC 1, OSC 10, OSC 104, OSC 105, OSC 106, OSC 11, OSC 110, OSC 111, OSC 112, OSC 113, OSC 114, OSC 115, OSC 116, OSC 117, OSC 118, OSC 119, OSC 12, OSC 13, OSC 133, OSC 1337, OSC 14, OSC 15, OSC 16, OSC 17, OSC 176, OSC 18, OSC 19, OSC 2, OSC 21, OSC 21337, OSC 22, OSC 3, OSC 30, OSC 30001, OSC 3008, OSC 30101, OSC 31, OSC 32, OSC 34, OSC 4, OSC 440, OSC 46, OSC 5, OSC 50, OSC 51, OSC 52, OSC 5522, OSC 555, OSC 6, OSC 60, OSC 61, OSC 62, OSC 633, OSC 66, OSC 666, OSC 7, OSC 701, OSC 702, OSC 710, OSC 711, OSC 712, OSC 713, OSC 72, OSC 720, OSC 721, OSC 776, OSC 777, OSC 7770, OSC 7777, OSC 8, OSC 9, OSC 9 ; 1, OSC 9 ; 10, OSC 9 ; 11, OSC 9 ; 12, OSC 9 ; 2, OSC 9 ; 3, OSC 9 ; 4, OSC 9 ; 5, OSC 9 ; 6, OSC 9 ; 7, OSC 9 ; 8, OSC 9 ; 9, OSC 99, OSC 9999, OSC I, OSC L, OSC P, OSC R, OSC l
  • Parsing: Controls Inside Sequences, Parameters, UTF-8 and C1 Controls
  • Proposals: OSC 88, the Terminal Resume Protocol, and OSC 7501, the Program Status Protocol. A proposal is summarized as its specification is written, with a link to the version read, and has no support table and no cases, since few terminals implement one yet.

The other 80 ECMA-48 functions, the ones for data communications, printing, forms and bidirectional text, are listed by purpose on one page, "The Rest of ECMA-48", whose cases show that libghostty-vt ignores them.

There are 850 cases in all. 274 are marked as known differences: libghostty-vt is expected to fail them, and each page explains why.

Case What libghostty-vt does What the sources expect
BS-5 BS is a one-column CUB, so it moves past the left margin it stays at the left margin (VT510 manual, esctest2)
CUB-4 CSI D stops at column 1, ignoring the left margin, unless reverse wraparound is on (cursorLeft) it stops at the left margin (DEC STD 070, esctest2)
DCS-6 BEL inside a device control string is ignored, as DEC STD 070 recommends for a control there it is ignored as a terminator but still rings the bell (xterm, CASE_BELL)
CAN-5 CAN does not cancel a device control string: leaving a DCS always unhooks it (Parser.zig), so a request still completes CAN cancels the string in progress (VT510 manual, DEC STD 070)
SL-1 to SL-7 SL and SR (CSI Ps SP @, CSI Ps SP A) are ignored the scrolling region moves left or right by Ps columns (ECMA-48, xterm)
SIMD-1 to SIMD-4 CSI Ps ^ is ignored xterm takes it as SD and scrolls down Ps lines
ALT-15, ALT-16 DECRQM answers each of 47, 1047, 1048 and 1049 from its own flag 47, 1047 and 1049 report whether the alternate screen is showing, and 1048 whether a cursor is saved (xterm)
ALT-17, ALT-18 mode 1046 is unknown: DECRQM answers 0, and resetting it does not stop the switch to the alternate screen xterm reports the mode backwards, and resetting it forbids the switch
MOUSE-3 to MOUSE-5, MOUSE-7 DECRQM answers each mouse mode from its own flag, so a replaced mode is still reported as set one event mode and one encoding at a time, and resetting any event mode turns tracking off (xterm)
MOUSE-6 mode 1001, highlight tracking, is unknown xterm has it
MOUSE-10 alternate scroll, 1007, is on to begin with off, unless the alternateScroll resource is set (xterm)
BLINK-4 DECSTR leaves mode 12 set DECSTR stops the cursor blinking (xterm)
SYNC-1 mode 2026 is implemented, and DECRQM answers 1 or 2 a default xterm does not recognize it and answers 0
OSC4-8 DECSTR is not implemented, so the palette survives it DECSTR resets the palette (xterm)
OSC10-4 an empty color in OSC 10 ; ; blue is passed over, so blue sets the foreground the empty one is skipped and blue sets the background (xterm)
OSC12-3, and the OSC 13 to 19 and 113 to 119 pages OSC 13 to OSC 19 and their resets are ignored xterm answers for the mouse pointer's, the Tektronix window's and the highlight colors, and resets them
OSC104-5 to OSC104-7 OSC 104 skips an entry that is not a number and goes on, resets the whole palette when no number is left, and has no special colors xterm stops at the first entry that is not a number, and OSC 104 alone tries the special colors too, which it cannot reset
OSC2-8 an empty title clears the title a default xterm sets the title xterm
OSC50-1, OSC60-1, OSC61-1, OSC62-1 xterm's font and allowed-feature queries are not answered xterm answers them
OSC50-3 Konsole's OSC 50 ; CursorShape=1 is ignored xterm takes it for a font name, cannot load it, and rings the bell
OSC5-1, OSC5-2, OSC105-1 to OSC105-3 OSC 5 and OSC 105, xterm's special colors, and palette numbers past 255 are ignored xterm answers for its special colors (xterm)
OSC7-2 the working directory is kept, and given to the host a default xterm ignores OSC 7
OSC8-2 the text becomes a hyperlink a default xterm ignores OSC 8
OSC133-2, OSC133-3 cells are marked as prompt or input a default xterm ignores OSC 133
PROGRESS-2, PROGRESS-3, CONEMU9-1, CONEMU12-1 ConEmu's progress reports go to the host, OSC 9 ; 9 sets the working directory, and OSC 9 ; 12 marks a prompt a default xterm ignores OSC 9
ITERM2-1 OSC 1337 ; CurrentDir sets the working directory a default xterm ignores OSC 1337
OSC22-5, OSC22-6 a CSS name sets that shape, and an unknown name is ignored xterm knows only X cursor names, and gives the I-beam for any other
OSC21-1 to OSC21-3 kitty's color protocol is implemented on the same colors as OSC 4 and OSC 10 a default xterm ignores OSC 21
OSC72-1, OSC5522-1, OSC5522-2 OSC 72 ; t=q is answered, and OSC 5522 requests are answered with EPERM or ENOSYS a default xterm ignores them
OSC-l-1, OSC-l-2 the Sun and dtterm form, OSC l title ST, is ignored it sets the window title (xterm)
XTWINOPS-2 CSI 11 t is not answered CSI 1 t, not iconified (xterm)
XTWINOPS-4, XTWINOPS-5 there is no title stack, so CSI 23 t does not bring the title back the title pushed with CSI 22 t is restored (xterm)
XTVERSION-1, XTVERSION-2 the answer is libghostty's own name, and is given for any parameter DCS > | XTerm(412) ST, and only for 0 (xterm)
DECRQCRA-7 with only Pi and Pp, the rectangle's bounds are left out and are the whole screen xterm reads the two parameters as the top and left, so CSI 7 ; 1 * y sums nothing and answers 0000
DECRQSS-3, DECRQSS-4 SGR attributes are reported in numerical order, and double underline as 4:2 xterm's fixed order, and 21
DECRQSS-6 DECSLRM is reported only once left and right margins are enabled always (xterm)
DECRQSS-8 to DECRQSS-11 DECSCA, DECSCL, DECSLPP, DECSCPP, DECSNLS and DECSACE are not reported xterm, a VT420, reports them, as DEC STD 070 lists
XTGETTCAP-4, XTGETTCAP-6 a key's answer is a fixed string from Ghostty's terminfo, as if DECCKM were set what the key sends now, CSI A until DECCKM is set (xterm)
XTGETTCAP-8 to XTGETTCAP-12 termcap names and TN are not answered, an unknown name gets no answer, each name gets its own DCS … ST, and names come back in upper case xterm answers termcap names and TN=xterm, answers DCS 0 + r ST for an unknown name, puts several names in one answer, and writes each name back as it was sent
XTSETTCAP-2, XTSETTCAP-3 DCS + p is ignored an empty name, or one it cannot find, rings the bell (xterm)
XTGETXRES-1 to XTGETXRES-3 DCS + Q is ignored xterm answers with its resources' values
XTMODKEYS-1 to XTMODKEYS-6 only modifyOtherKeys 2 is kept, and XTQMODKEYS and its DECRQSS form are not answered xterm keeps every modifier resource, answers for them, and changes what keys send (xterm)
XTRESTORE-5 to XTRESTORE-7 a mode never saved is restored to its default, and RIS returns every saved state to the default xterm's saved states start out zeroed and survive RIS, so restoring an unsaved mode resets it (xterm)
KITTY-1 to KITTY-4 the kitty keyboard protocol is implemented, so CSI ? u is answered a default xterm ignores CSI ? u, CSI = u, CSI > u and CSI < u
SGR-1 bold red keeps color 1 bold text in one of the eight basic colors takes the bright one, under boldColors (xterm)
DECELR-1, DECELR-3 the letter after DECELR, DECSLE or DECEFR is printed a default xterm, built without the locator, ignores the rest of the sequence up to the next final byte, swallowing the letter
OSC104-2 OSC 104 ; 1 ; 3 resets colors 1 and 3 xterm reads past the end of the list into what an earlier OSC left in its buffer, and resets color 2 too
OSC-P-1 to OSC-P-3, OSC-R-1, OSC-R-2 the Linux console's OSC P and OSC R are ordinary OSCs, ignored xterm's brokenLinuxOSC abandons them at their length, rings the bell, and prints what follows
SGR-4 the underline styles 4:2 to 4:5 are drawn a default xterm takes subparameters only for 38 and 48, and skips the rest
CTL-5 to CTL-7 C0 controls are left out of a title and a DEL is kept, and an ESC that cuts an OSC short carries it out controls and DEL become ? but for LF and tab, a NUL ends the title, and the cut-short OSC is thrown away (xterm)
PRM-2, PRM-3 a sequence with more than 24 parameters is ignored xterm keeps 30, and runs the digits of any more into the 30th
U8-3 to U8-5, CSI-5, NEL-4 every invalid piece of UTF-8 is U+FFFD a surrogate is one U+FFFD, and a stray byte before printable text is read as Latin-1 or as a C1 control, which is ignored (xterm)
CHA-5 in origin mode, moves the cursor down by the top margin as well (cursor_col in stream_terminal.zig passes the absolute row back to setCursorPos) the line does not change (esctest2)
HPR-4, VPR-4 the same, through cursor_col_relative and cursor_row_relative the cursor moves only along its own axis (esctest2)
DECRQM-6 an ANSI mode it does not have, such as GATM, is reported as not recognized: modes.zig reports 0 for any mode outside its table GATM is reported as permanently reset (DEC STD 070, VT510 manual, xterm, esctest2)
the ANSI mode pages for GATM, CRM, SRTM, VEM, HEM, PUM, FEAM, FETM, MATM, TTM, SATM, TSM and EBM (the first two cases of each) the same: every ANSI mode but KAM, IRM, SRM and LNM is reported as not recognized, 0 xterm reports these as permanently reset, 4, as the VT510 manual lists them, and CRM as reset, 2
DECSCUSR-5 CSI 0 SP q gives the host's default cursor, which is not blinking unless the host says so 0 is a blinking block (VT510 manual, xterm)
DECSTR-2 to DECSTR-7 DECSTR is not implemented, so none of the soft reset happens insert mode, origin mode, rendition, margins, cursor visibility and the saved cursor are reset (DEC STD 070, VT510 manual, esctest2)
the VT420 column and rectangle pages (DECIC, DECBI, DECCRA, DECERA, DECFRA, DECCARA) and DECSCA-10 DECIC, DECDC, DECBI, DECFI, DECCRA, DECERA, DECFRA, DECSERA, DECCARA, DECRARA and DECSACE are ignored: libghostty-vt identifies itself as a VT220 and has no handlers for them xterm, a VT420 by default (DFT_DECID), implements them all
DECDWL-1 to DECDWL-3 line size (ESC # 3 to ESC # 6) is ignored, so a double-width line still holds every column text wraps and the cursor is clamped at half the width (xterm, DEC STD 070)
DECDSR-1 to DECDSR-8 DEC's private status requests (CSI ? Ps n) get no answer: only 5, 6 and libghostty-vt's own are in device_status.zig xterm, as a VT420, answers them all
DECRQPSR-1 to DECRQPSR-5 DECRQPSR and DECRSPS (cursor information and tab stop reports) are not implemented xterm answers and restores them
DECSCL-1, DECSCL-2, DECSCL-4 DECSCL is not implemented, so the conformance level and its soft reset never change xterm limits its features to the selected level and soft-resets
DECSDM-1, DECSDM-2 there is no mode 80, so DECRQM reports it as not recognized, 0 reset, 2, and still reset after CSI ? 80 h, which a default xterm ignores without sixel graphics
DECSCPP-1, DECSCPP-2 DECSCPP is ignored, so a cursor past the new width stays put xterm moves it to column 80 at once
DECSASD-1, -2, -5, -7 after CSI 1 $ } text is no longer printed on the main display a default xterm has no status line and ignores DECSASD and DECSSDT, so text prints as usual
DECAUPSS-1 to DECAUPSS-3 DECRQUPSS and DECAUPSS are not implemented xterm answers and, after CSI & u, swallows the next character, a bug the case keeps
the DEC mode pages for DECARM, DECNRCM, DECKBUM, DECHCCM and DECPEX these modes are reported as not recognized, and there are no national character sets xterm reports them as set, reset or permanently reset, and has the national sets
CTC-1 to CTC-4 CTC (CSI W) sets and clears tab stops, and DECST8C (CSI ? 5 W) resets them a default xterm ignores CSI W, and runs DECST8C only as a VT510 or later
HPB-1 to HPB-3, VPB-1 to VPB-3 HPB (CSI j) and VPB (CSI k) move the cursor a default xterm ignores both
DECUDK-2 no answer to CSI ? 25 n xterm answers that its user-defined keys are unlocked, CSI ? 20 n
DA-5 CSI 1 c is answered like a request: the DA dispatch in stream.zig never looks at the parameter a non-zero DA identifies the sender and asks nothing (ECMA-48, DEC STD 070, xterm)
RIS-6 DECSTR (CSI ! p), the soft reset recommended instead of RIS, is not implemented: the p dispatch in stream.zig handles only DECRQM the soft reset restores normal rendition, among other things (DEC STD 070, VT510 manual)
SUB-1, SUB-2 SUB abandons a sequence but shows nothing: stream.zig only returns the parser to ground SUB shows a reversed question mark (DEC STD 070, VT510 manual, xterm)
TBC-2 CSI g with no parameter is ignored: stream.zig handles only exactly one parameter the default clears the stop at the cursor (ECMA-48, DEC STD 070, VT510 manual, esctest2)

Each known difference is still run on every page load. If Ghostty fixes one, the page shows "passes now" and the case checker fails, which is the signal to remove the marker.

COVERAGE.md is the checklist for what to add next. It lists all 162 control functions in ECMA-48 and the DEC private functions DEC STD 070 adds, with which sources define each, whether libghostty-vt implements it, and whether this site documents it yet.

Layout

Path What it is
website/content/ one SuperMD page per sequence, under c0/, esc/, csi/, ansi/, modes/, dcs/ and osc/, the pages on parsing under parser/, and the guide pages
website/layouts/ the SuperHTML templates
website/assets/vt.js the wasm wrapper: a Terminal with write, cursor, title, pwd, colors, screen (each cell's text, style, hyperlink and semantic mark), and the replies and bells it sends back
website/assets/vtcase.js parses the case notation, runs a case and compares the result
website/assets/cases.js the case widget on each page, and the pass tallies on the index pages
website/assets/sequence.js the byte-by-byte syntax diagram
website/assets/diagram.js draws a page's Mermaid diagrams, such as DECCOLM's decision tree, in the site's colors; Mermaid is loaded only on a page that has one
website/assets/toc.js the page's table of contents in the right-hand column, built from its headings, with each case's verdict and the part of the page in view
tools/check-cases.mjs runs every case under Node with the same code as the pages
tools/markdown.mjs writes a Markdown copy of every page, and llms.txt indexing them
tools/sitemap.mjs writes sitemap.xml, dating each page by its source's last commit
tools/favicon.sh renders favicon.ico and apple-touch-icon.png from website/assets/favicon.svg, the ^[ of the masthead
tools/order.mjs works out the order of each section's pages, by mnemonic, mode number or OSC number, and writes it into the section's index.smd; --check fails if one is out of date
tools/cases.mjs finds the validation cases in the SuperMD sources, for the case checker and the harness
emulators/ runs the cases in real terminals, in NixOS virtual machines, and in pyte, xterm.js, alacritty_terminal and VTE, in-process, and checks the reference xterm's run in CI; see Running the cases in other terminals
tools/support.mjs writes the Support table into each sequence page from website/support.json and the measured results, and the Support page (website/content/support.smd), every sequence against every terminal, below its hand-written introduction; --check fails if a page has no data or a table is out of date
tools/finals.mjs writes the table of control sequences by final byte into website/content/finals.smd, from every page's sequence and also_sequences and from the ECMA-48 functions COVERAGE.md lists without a page; --check fails if it is out of date, or if a documented function is on no page's list
tools/pages.mjs the list of pages and their frontmatter, shared by the tools above
ghostty-vt-wasm.nix builds ghostty-vt.wasm from the pinned Ghostty source
mermaid.nix takes mermaid.min.js from Mermaid's npm package, pinned, so that the site serves its own copy
website.nix builds the site

libghostty-vt reports replies and the bell through C callbacks. A callback is an index into the wasm module's function table, and a JavaScript function cannot go in that table directly, so vt.js wraps each callback in a tiny wasm module of its own. The module imports the function and exports it again with the right type. For the answers that are the host's to choose, such as Device Attributes, it gives the ones Ghostty gives. The colors a terminal starts with and its size reports are the host's choice too, and for those it follows a default xterm, as the cases do: xterm's palette, black on white, and the case's own size for CSI 18 t.

vt.js takes every struct offset and enum value from the layout description that libghostty-vt publishes about itself (ghostty_type_json). No offsets are hard-coded, so a Ghostty update that moves a field is picked up rather than misread.

For search engines and LLMs

Every page carries a rel="canonical" link and Open Graph tags, both with its absolute URL. It also links a plain Markdown copy of itself, published beside it as index.html.md, which is where llms.txt readers look for one. llms.txt at the root of the site indexes those copies. The copies come from the SuperMD sources: the validation cases become a plain input block and expected screen, and links between pages point at the other pages' copies.

sitemap.xml gives each page the date of the last commit that changed its source. A Nix build sees no git history, so the sitemap is not part of nix build .#website. The publish job adds it to a copy of the build, from a checkout with the full history, and tools/sitemap.mjs refuses to run in a shallow clone. robots.txt allows every crawler and names the sitemap.

When the sources disagree

Where DEC STD 070 and xterm disagree, a case's expectation follows xterm, bugs included, and the page names what DEC STD 070 asks for instead. That is Ghostty's documented policy: the first principle on its features page is xterm compatibility. A case is marked a known difference when libghostty-vt differs from xterm or, where xterm does not implement the function, from the documents the site cites. "xterm" means a default build with its default resources, which is a VT420: where that xterm ignores a function, because its parser drops the sequence or the feature needs a configure option, the case expects the function to be ignored, and libghostty-vt implementing it is a known difference.

Writing a case

A case is a block directive holding two plain code blocks, placed after a heading:

## CBT-3: From exactly on a tab stop

>[]($block.attrs("vt-case"))
> ```
> \e[?5W    # reset tab stops
> \e[1;9H
> X
> ```
> ```
> |________X_|
> cursor 1,10
> ```

Each line of input is one step. Escapes include \e, \xNN, \NNN and \u{NNNN}, and a # after whitespace starts a comment. The expected screen gives one |…| row per line, with _ for an empty cell. Its size sets the size of the terminal. The screen can be followed by cursor, pending-wrap, title and attr checks. Add "known-difference" to the attributes to mark a case libghostty-vt is known to fail, "checksum-report" to run it with DECRQCRA allowed, which libghostty-vt, like a default xterm, leaves off, and "no-column-resize" to run it with DECCOLM kept from changing the width, as xterm is with ColumnMode disallowed. parseCase in website/assets/vtcase.js turns these into settings for vt.terminal, and the VM harness's terminal command can name them too (see emulators/run.mjs). libghostty-vt has no setting for no-column-resize. The site's notation page has the full syntax.

A diagram is a block with the diagram attribute holding one code block of Mermaid source:

>[]($block.attrs("diagram"))
> ```
> flowchart TD
>     a{"Mode 40?"} -- set --> b["Clear the screen"]
> ```

The source is what shows without scripts, and what the page's Markdown copy carries, as a mermaid code block.

Building

Ghostty builds with Zig 0.16, and Zine 0.14 needs a Zig 0.17 development build. So the wasm is its own Nix derivation, and the site build is given its path. Mermaid, which draws the diagrams, comes in the same way. The devshell exports the wasm's path as $VT_WASM, Mermaid's as $MERMAID_JS, and the Ghostty commit as $GHOSTTY_REVISION.

$ nix develop
$ zig build serve -Dvt-wasm="$VT_WASM" -Dmermaid="$MERMAID_JS" -Dghostty-revision="$GHOSTTY_REVISION"
$ node tools/check-cases.mjs "$VT_WASM"
$ node tools/order.mjs website   # after adding a page, to place it in its section
$ node tools/support.mjs website # after changing website/support.json
$ node tools/finals.mjs website  # after changing a sequence or also_sequences
$ node tools/sitemap.mjs website zig-out/public   # after a release build
$ nix build .#website            # the site, in result/public
$ nix build .#ghostty-vt-wasm    # just the wasm

Ghostty is pinned as the flake input ghostty. Running nix flake update ghostty moves the site to a newer libghostty-vt. After an update, run the case checker first: it reports any case whose result changed.

The wasm must be served over HTTP. A page opened from file:// cannot fetch it.

Running the cases in other terminals

emulators/ runs every validation case in a real terminal emulator, inside a NixOS virtual machine, with X11 or, for a Wayland terminal, a headless sway, with no judgment involved: each check is a comparison of bytes.

  • run.mjs starts a fresh terminal for each case, sized to the case's screen and titled with a sentinel, since a reset does not clear everything a case can change (the dynamic colors, saved modes, the saved cursor).
  • case.mjs runs in that terminal in place of a shell. It writes the case's input to its own tty, waits for a fence (a DECRQCRA no case uses, which the terminal answers only once everything before it is processed), and reads the terminal's state back with sequences it answers: the cursor with CPR, each cell's character and attributes with a DECRQCRA per cell using xterm's XTCHECKSUM extension, the title from X with xdotool.
  • What cannot be read back that way is reported as untested, never as a failure: the bell, the working directory, progress reports, the pointer, and colors, italic and faint.
  • How a terminal is fenced and read is its profile. xterm is the above. kitty fences with an XTGETTCAP for a name no case asks for, which kitty answers with the name, and reads the whole screen with its remote control, kitten @ get-text --ansi, parsed by kitty-screen.mjs, which gives every attribute and color too. kitty writes back a run of blanks that a tab made as one tab, losing how many it stood for, so a screen with a tab that does not match is untested rather than failed.
  • foot fences the same way. foot has no way to be asked for its screen but a key binding, so the flake binds its pipe-visible action to Ctrl+Shift+F9, writing the visible text to a file, and case.mjs presses the key with wtype, through sway's virtual keyboard; foot-screen.mjs turns the text back into a grid, recovering soft-wrapped rows from the column count. The text has no attributes, so attr checks are untested, and tabs are lost as for kitty. The title comes from swaymsg.
  • urxvt is for rxvt-unicode, which answers no request that reads its screen. The harness loads a Perl extension of its own into it, emulators/urxvt/cc-harness, and nothing else; it answers two OSC 777 commands with an acknowledgement, which is the fence. start counts bells from then on, and dump writes the screen, cell by cell with each cell's rendition, along with the cursor and the bell count, to a file, through rxvt-unicode's own Perl API (ROW_t, ROW_r, screen_cur, on_bell). So its colors, bold, italic, blink, inverse and underline are judged, as are its bells; a 24-bit color is kept where the API cannot turn it back into RGB, so a cell with one is untested. The title comes from X. nixpkgs' rxvt-unicode 9.31 does not build with a compiler that defaults to C++20, where its own lerp is ambiguous with std::lerp, so the flake builds it as C++17, which changes nothing about how it behaves.
  • summarize.mjs prints the counts and each failure from results.json.

The checks are flake checks, Linux only:

$ nix build .#checks.x86_64-linux.xterm-default   # xterm-411a, xterm's own defaults
$ node emulators/summarize.mjs result/results.json
$ nix build .#checks.x86_64-linux.xterm           # nixpkgs' xterm
$ nix build .#checks.x86_64-linux.kitty           # nixpkgs' kitty
$ nix build .#checks.x86_64-linux.foot            # nixpkgs' foot, under sway
$ nix build .#checks.x86_64-linux.urxvt           # nixpkgs' rxvt-unicode
$ nix build .#checks.x86_64-linux.pyte            # pyte, in-process
$ nix build .#checks.x86_64-linux.xtermjs         # xterm.js headless, in-process
$ nix build .#checks.x86_64-linux.alacritty       # alacritty_terminal, in-process
$ nix build .#checks.x86_64-linux.vte             # VTE, in-process, under Xvfb

Each terminal is a package, a profile and the command that opens it, in flake.nix, run by emulators/vm.nix. kitty, foot and rxvt-unicode are given a default xterm's colors, as libghostty-vt is, so that color answers can be compared with the cases. A Wayland terminal runs under sway on wlroots' headless backend, drawing with pixman, so it needs no display or GPU; its window floats with no border and no minimum size, so that it is exactly the size of the case's screen. Weston has a headless backend too, but no virtual keyboard to press foot's key with.

Four emulators are libraries, so their checks need no VM and no terminal to read back. Each case's input goes to a fresh instance, the screen, cursor, title, replies and bells it is left with are read from it directly, and emulators/library.mjs judges that state with runCase from website/assets/vtcase.js, the same comparison the site runs on libghostty-vt. Every check is judged but the working directory, progress reports and pointer shape, which none of them has, and an exception or a panic from the emulator fails the case. A case may read its state back with DECRQM or DECRQSS; an emulator that answers none of that request's own cases does not have it, so elsewhere a case whose reply should have been nothing but its answers, and was nothing, is untested rather than failed, unless something else about it fails. library.mjs applies this to finished results, so run.mjs applies it to a terminal in a VM as well.

  • pyte, the Python terminal emulator: emulators/pyte.mjs sends every case through emulators/pyte_driver.py, one Python process.
  • xterm.js, headless: emulators/xtermjs.mjs runs @xterm/headless in the same Node process, from the npm tarball the flake fetches. Cursor visibility and underline styles are read from its internals, as its API does not expose them. Headless, its parser hands color changes and color queries to an event only the browser build's theme service handles, so OSC 4, 10, 11 and the rest do nothing, and the rows are named xterm.js headless to say that they are not about xterm.js in a browser.
  • alacritty_terminal, the crate Alacritty is built on: emulators/alacritty.mjs sends every case through a small Rust driver in emulators/alacritty/, which the flake builds from its Cargo.lock. The crate leaves to its host the colors it was not told and the size of a cell in pixels; the driver answers as a default xterm would, with xterm's palette, black on white, and 6×13 cells. It feeds the input a byte at a time, so a color query answers with the palette as it stood then, and lets through any input still held for synchronized output when the case ends. HT leaves a tab character in the blank cells it passes over, for copying; the driver reports those as the blanks they are drawn as.
  • VTE, the widget GNOME Terminal is built on: emulators/vte.mjs sends every case through emulators/vte_driver.py, which drives VTE through PyGObject. VTE is a GTK widget and needs a display, which the check gives it with Xvfb; no window is shown. The terminal sits in an offscreen window, since VTE processes its input only once realized, and is forced to the case's size with a size request. The screen is read cell by cell with get_text_range_format, as text and as HTML for the attributes, with VTE given xterm's palette. The HTML has no faint, blink, invisible or overline, shows inverse only as swapped colors, and says nothing of a blank cell, so those are untested, as are cursor visibility, the pointer and progress. As in alacritty_terminal, a tab character in a blank cell is read as a blank.

xterm-default is the reference: the xterm the cases follow, so every case should pass apart from those the harness's one change to a default xterm affects. It lets xterm answer DECRQCRA, which disallowedWindowOps refuses by default, so the DECRQCRA and OSC 61 cases differ. The harness reads the screen with DECRQCRA, so for now it can test only terminals that answer it.

CI runs xterm-default on every push, on a runner with KVM, and checks the run with emulators/verify.mjs: every case must pass, apart from the ones emulators/xterm-default.expected.json lists with the status they are expected to have and why. A case that stops failing as listed fails the check too, so the list cannot go stale. The site is published only when this passes as well, so its cases always agree with a real xterm.

Other terminals are not run in CI. A maintainer runs their checks when a terminal's version changes in nixpkgs or the cases change, and commits the results.json that comes out under emulators/results/, named for the check; what the site needs from them is built from those files.

A measured terminal's row in a page's Support table is derived from its results rather than written by hand. website/support.json names the results file under measured, and emulators/roles.json gives every case a role:

  • probe: the case shows the function working as its specification says, so a terminal that implements it passes, and one that ignores it fails.
  • follow: the case pins something a default xterm does where a correct terminal may differ: the function being ignored, xterm's quirks and bugs, answers whose content is the host's choice (identification, default colors, how an SGR is spelled), and parser edge cases. A case that a terminal ignoring the function would also pass is a follow case too.

The row is Yes when every probe on the page passes, Partly when some do and No when none do. A case the harness could judge only in part is left out along 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. The version cell says run in a VM or, for the emulators run as libraries, run as a library, and links to the results. A reply that differs from xterm's only in form counts as a pass, and the row says so: one ending its strings with ST where xterm used BEL, since either terminator is valid, and one writing a color's 16-bit components as ff00 where xterm repeats the 8 bits as ffff, since both are the same color; colors are compared by their top 8 bits. support.mjs fails if a case has no role, so a new case needs one.

$ nix build .#checks.x86_64-linux.xterm-default
$ node emulators/verify.mjs result/results.json emulators/xterm-default.expected.json

Sources

Each sequence page cites its definition in ECMA-48, DEC STD 070, the VT220 manual and the VT510 manual, where it has one, and the graphics pages cite the VT330/VT340 graphics manual. A citation gives the section and the printed page, and the ECMA-48 and DEC STD 070 citations link to that page of the scan at the Internet Archive. The site's Sources page describes the documents.

The citations are in each page's frontmatter: ecma48, ecma48_name and ecma48_page give the section, the name and the printed page; dec070, dec070_name, dec070_page and dec070_pdf give the same plus the PDF page; vt220 and vt220_name give the VT220 manual's chapter and section anchor on VT100.net and the section's title; vt340 and vt340_name do the same for the VT330/VT340 graphics manual; vt510 and vt510_name give the VT100.net page and its title. The layout builds the links. The Internet Archive numbers a scan's pages from 0, so ECMA-48's printed page N is the scan's page N + 13, and DEC STD 070's PDF page N is the scan's page N − 1.

A page that covers more than one number or mnemonic, such as rxvt-unicode's fonts, OSC 710 to 713, or DECRQCRA with its reply DECCKSR and XTCHECKSUM, lists the others in index_also, each as "OSC 711|Set Bold Font": the label, a |, and the name. tools/order.mjs sorts each into the section's order with the pages, as page|label|name, and the sidebar, the home page and the section page give it an entry of its own, leading to the page, so that every number and mnemonic can be found where it falls in the list. Such a page lists the other sequences it covers in also_sequences too, each a map, { "mnemonic": "DECCKSR", "name": "Memory Checksum Report", "syntax": "DCS Pi ! ~ D…D ST" }, written on one line; a map rather than a string with a delimiter, since a syntax can contain any byte, as DECRQLP's CSI Ps ' | does. The layout draws each one's syntax diagram under its name, below the page's own, and tools/finals.mjs lists the CSI ones by their final byte.

A page whose title names several codes, as "Set Fonts (OSC 710 to 713)" and "Save and Restore Cursor (DECSC, DECRC)" do, copies the list in the title's parentheses into codes. The page is then headed with that list and the rest of its title rather than with its first code, and the first code's own mnemonic and name move down to label its diagram, the way the others in also_sequences are labeled. A page whose title names one code, such as DECRQCRA, is headed with that code, and its reply is a diagram further down.

Credits

The CBT cases are adapted from Ghostty's VT reference (https://ghostty.org/docs/vt), which is MIT licensed. The prose, the other cases and the site design are this project's own.

License

MIT; see LICENSES/MIT.txt. The project follows REUSE.

References cited