feat(terminal): add xterm.js as a selectable backend - #83
feat(terminal): add xterm.js as a selectable backend#83Ayman Bagabas (aymanbagabas) wants to merge 3 commits into
Conversation
cpendery (cpendery)
left a comment
There was a problem hiding this comment.
Overall looks good, I think we can put alacritty as the default & include the other backends as features. For the client libraries, I think it makes sense to include xtermjs & alacritty by default
| @@ -0,0 +1,40 @@ | |||
| # Vendored xterm.js assets | |||
There was a problem hiding this comment.
I'm wondering if we can just use the shim + esbuild & pinned versions for xtermjs's npm module. I'd like to avoid vendoring where we can
There was a problem hiding this comment.
Agreed on the instinct, and I dug into what it would actually buy us here. Two things make this case narrower than it looks:
There's nothing to bundle. @xterm/headless publishes lib-headless/xterm-headless.js already bundled and minified by its own webpack build. What's checked in is that file, copied out of the tarball unchanged — so esbuild would be re-bundling an artifact that's already a bundle. shim.js is our code and is already separate.
The versions are already pinned, just pinned in git rather than in a lockfile. Same two packages, same exact bytes:
xterm-headless.js @xterm/headless@6.0.0 byte-for-byte
addon-unicode11.js @xterm/addon-unicode11@0.9.0 byte-for-byte
The real question is only whether the bytes live in the repo or get fetched during the build, and fetching costs more than it first appears: cargo build --features xtermjs would need network and a Node toolchain, which breaks offline and air-gapped builds and makes publishing to crates.io awkward. That's a heavier build requirement than Ghostty's Zig, which we already call out in the README as the reason Windows ARM64 artifacts aren't published. It'd also mean the one backend that currently needs no external toolchain would start needing two.
So I've gone at the underlying worry instead — that a vendored blob can silently stop matching what it claims to be. There's now a CI job that re-fetches both packages at their pinned versions and fails if what's checked in isn't byte-identical, and it only runs on commits that touch assets/xterm/, so it costs nothing elsewhere. The SHA-256s and the exact commands to reproduce the files are in the README next to them. A hash committed alongside the bytes it describes proves nothing on its own; re-fetching from npm is what actually closes that loop.
Worth noting we already vendor 6.6 MB of fonts under the same crates/tui-test/assets/ with adjacent licenses. xterm.js is 264 KB, about 4% of that, and follows the same shape.
Happy to switch to a build-time fetch if you'd still prefer it — just wanted the toolchain cost on the table first, since it lands on everyone building the crate.
tui-test can already run a session on alacritty or ghostty, and this adds the emulator behind VS Code's terminal as a third. A suite that passes on one and fails on another is telling you something real about the program under test, and xterm.js is what a large share of users are actually looking at. `@xterm/headless` and the unicode11 addon are vendored and evaluated into a QuickJS context per session, so the backend adds no dependency on Node or on anything installed on the machine. The grid crosses that boundary packed rather than cell by cell: reading an 80x30 screen a getter at a time is 2,400 calls with ten property reads each, so the shim flattens a row span into one string and one integer array and this side decodes it. Four things the contract asks for are not in the headless bundle's public surface, and the shim supplies them rather than the Rust side pretending they are absent: the window title, cursor visibility and shape, and the `OSC` colour sequences. Colour answers are pushed onto the same queue the terminal's own replies use, so they keep the order they were asked in. A query's terminator has to be echoed and an OSC handler is not told which one ended the sequence, so the incoming bytes are scanned for it, keyed by OSC code so a title arriving between two queries cannot misroute a reply. The backend passes the conformance suite, which is what makes swapping emulators safe, with one declared exception: xterm.js records a cell's underline colour only when that cell also has an underline style. Nothing renders differently, and declaring it where the backend opts in keeps the exception visible instead of turning it into a quietly failing case. `Backend::ALL` becomes a slice so that each optional backend adds one line rather than doubling the length-carrying definitions the next one has to spell out, and the list of names in a parse error is derived from it instead of being repeated in prose that can drift. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Ayman Bagabas <ayman.bagabas@gmail.com>
7b1c56f to
495d248
Compare
A colour query answered with the wrong terminator leaves a program that reads until the one it sent waiting for something that never comes. The scan that recovers the terminator waited for the `\` of an `ST`, but the parser it feeds ends an OSC on the `ESC` before it, so a read that split between the two bytes answered `BEL` to an `ST` query. Recording the terminator on the `ESC` is what keeps the two in step. The scan also recorded every sequence while only colour ones claim what it records, so a shell that retitles the window on each prompt left an entry per prompt for the life of the session. Only answered codes are recorded now. `OSC 4;1x` named no slot, but `parseInt` read the leading digit and wrote to slot 1. The first two are contract behaviour rather than quirks of this backend, so they are conformance cases: both fail on xterm.js without this change and pass on alacritty, which is what makes them worth asserting of every backend rather than fixing quietly in one. A profile can also ask for deeper scrollback than the count crossing into JS holds, where the cast wrapped a deep request round to a shallow one. The vendored licence now reproduces both packages' notices: `@xterm/headless` ships none and declares MIT in its manifest, while the addon carries its own, and the two differ. Their hashes are recorded alongside the commands that reproduce them, so the bytes can be checked rather than trusted. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Ayman Bagabas <ayman.bagabas@gmail.com>
The bundles are copied out of the published tarballs unchanged, so a fresh fetch has to reproduce them byte for byte. Checking that leaves no room for one to arrive edited or from somewhere other than the release it claims, which a hash recorded in the same commit as the bytes cannot rule out. It runs only on commits that touch the vendored files, since that is the only way they can change, and so costs nothing on the PRs that do not. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Ayman Bagabas <ayman.bagabas@gmail.com>
That's already how the crate is set up —
This PR does the additive half: The other half — dropping Ghostty from the published bindings — I've deliberately left out, and I think it deserves its own PR. It's a change to what shipped packages contain rather than an addition, it touches a backend this PR doesn't otherwise go near, and it has a release-notes consequence for anyone already passing It's also worth doing on its own merits, and the argument is stronger than just build times: Ghostty needs Zig 0.16 to build, and its upstream Zig build is the reason we don't publish Windows ARM64 artifacts at all today. xterm.js needs no external toolchain, so bindings carrying Alacritty + xterm.js could publish everywhere the CLI does. That looks like the real motivation for your suggestion, and it's a good one — I'd just rather land it where it can be reviewed as that change. Happy to open it as a follow-up right after this, or to pull it in here if you'd rather have them together. |
tui-test can already run a session on alacritty or ghostty. This adds the emulator behind VS Code's terminal as a third, so a suite can be run against what a large share of users are actually looking at. A test that passes on one emulator and fails on another is telling you something real about the program under test.
Selectable everywhere the other backends are: the CLI's
--backend, and both the Python and Node bindings.No Node at runtime
@xterm/headlessand the unicode11 addon are vendored and evaluated into a QuickJS context per session, so the backend depends on nothing installed on the machine.shim.jsis our own code; the bundles are dropped in unchanged at pinned versions.The grid crosses that boundary packed rather than a cell at a time. Reading an 80x30 screen through the per-cell getters is 2,400 calls with ten property reads each, which costs milliseconds per poll; the shim flattens a row span into one string and one integer array, so a whole screen crosses as two values and this side decodes rather than traverses.
What the shim has to supply
Four things the
Emulatorcontract asks for are not in the headless bundle's public surface. The shim supplies them rather than the Rust side pretending they are absent:onTitleChange, withwindowOptions.pushTitle/popTitleenabling theCSI 22/23 tstack the bundle implements but leaves off by defaultcoreService.isCursorHiddencoreService.decPrivateModes.cursorStyle, which is absent untilDECSCUSRsets one and so reads as a block until thenOSC 4/10/11/12set, query, and resetparser.registerOscHandler, answering out of the same reply queue the terminal's own replies use, so answers keep the order they were asked inA colour reply has to echo the terminator its query used, and an OSC handler is handed its payload but not that terminator. The shim scans the incoming bytes for it, carrying state between calls because a PTY read splits wherever it likes — including between the two bytes of an
ST— and keying what it finds by OSC code so a title arriving between two queries cannot put the wrong terminator on a reply.Verified against a real session rather than only in unit tests. The same probe run on both backends, querying the background, setting it, resetting it, and querying once more with each terminator:
Byte-identical, terminator included.
Conformance
The backend passes the conformance suite, which is what makes swapping emulators safe, with one declared exception: xterm.js records a cell's underline colour only when that cell also has an underline style, so
SGR 58on its own — or a colour that outlives anSGR 24— is not readable back off the cell.Nothing renders differently, since a cell with no underline draws no underline colour either way. What is lost is the colour surviving in the cell vocabulary. I confirmed this against the bundle rather than inferring it: the cell reports
isAttributeDefault(), and the colour is gone from the line's extended attributes while remaining in the current SGR state, so it is xterm.js's per-cell storage rather than anything this mapping does.The suite grew a
Divergencesdeclaration for it, so the exception is a visible claim a reviewer can check rather than a quietly failing case or a backend that skips conformance altogether. It is deliberately narrow: a field earns a place there only when the limitation is in the emulator itself.Also here
Backend::ALLbecomes a slice. With one optional backend the old form needed two length-carrying definitions; with two it would need four. Now each backend adds one line, and the list of names in a parse error is derived from it instead of repeated in prose that can drift out of step.Notes for review
--backend xtermjsworks out of the box. Measured cost: 229.6 KiB of embedded JavaScript, and a release binary going 9,208,800 -> 10,506,064 bytes, so about 1.30 MB once QuickJS is compiled in. Happy to put it behind an off-by-default feature instead if the size matters more than the availability.xtermjscrate feature itself is off by default, socargo build -p tui-test-rsis unaffected.cargo test --workspaceuses default features while only clippy passes--all-features. That predates this PR and applies to ghostty equally, but it does mean these 47 cases are green locally rather than on a runner. Worth a follow-up.assets/xterm/README.md.Review
Four reviewers went over this (shim correctness, Rust/FFI, architecture, supply chain). Three real divergences came back, all now fixed in the second commit and all reproduced before being touched:
STwas answered withBEL. The scan that recovers a query's terminator waited for the\of anST, but xterm.js ends an OSC on theESCbefore it, so a PTY read splitting between those two bytes answered the wrong terminator. Recording it on theESCkeeps the two in step.OSC 4;1xwrote to slot 1.parseInttook the leading digit of an index that was not a number.The first two are contract behaviour rather than quirks of this backend, so they went into the conformance suite: both fail on xterm.js without the fix and pass on alacritty, which is what makes them worth asserting of every backend. That is 49 cases per backend now.
Also fixed: a
usize->u32scrollback cast that wrapped a deep request into a shallow one, and the vendored licence, which reproduced only one of the two packages' notices.Verified clean by review, worth recording:
@xterm/headless@6.0.0and@xterm/addon-unicode11@0.9.0. Their SHA-256s and the commands that reproduce them are now inassets/xterm/README.md, so this is checkable rather than trusted.Considered and not done, happy to be overruled:
onBell, butbuild_with_bellsfalls through tobuildexactly as ghostty's does. Worth doing for both backends together rather than making them differ.Testing
49 conformance cases against the xterm.js backend, plus the full workspace suite (440 tests),
cargo clippy --workspace --all-targets --all-features -- -D warnings, the no-default-features clippy pass, fmt, the Python binding suite (87), and the Node binding suite. Also driven end to end through a real PTY: title, cursor, colour assertions, and SVG screenshots all agree with alacritty.