Repository navigation
feat(terminal): add xterm.js as a selectable backend - #83
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
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. |
based on our offline sync, I think we want to include all backends in the bindings. |
`assets/xterm` reads as the X11 terminal rather than the JavaScript one, so it becomes `assets/xtermjs`, matching the backend and the feature. The bundles keep the filenames npm publishes, since those are what the tarballs contain. A vendored bundle has nothing watching it: no lockfile, no dependency bot, and no build that fails when it falls behind, so it can sit years out of date without anyone noticing. A weekly job now compares both packages against npm and opens a pull request when either has a newer release. It runs conformance itself rather than leaving it to the pull request, because nothing else would: CI runs the workspace with default features, so the xterm.js cases never run there, and a pull request opened with `GITHUB_TOKEN` starts no workflow of its own. The shim reaches for several things xterm.js does not expose publicly, which is exactly what a release can move, so an update that breaks one should say so rather than look clean. The suite cannot see the emoji width table, which is the reason the unicode11 addon is pinned where it is, so the pull request asks for that by hand. The pinned versions were written down in the workflow and again in prose, which a bot would have to rewrite in both. They now live in `pinned.json` alone, so nothing can claim a version the bytes are not. Also recorded are the two addons deliberately not used yet, since the reasons are easy to lose: unicode-graphemes is still experimental and disagrees with alacritty on two of the measured widths, and the clipboard addon implements an `OSC 52` that no backend supports today. Adding the latter now would make xterm.js alone answer a query the others ignore, which is the kind of divergence conformance exists to prevent. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Ayman Bagabas <ayman.bagabas@gmail.com>
Rust 1.98 turns a constant chunk size into a `chunks_exact` lint, which `-D warnings` makes an error, so this is a build failure rather than a suggestion. `as_chunks` says the same thing and hands back arrays, so the length is known at the type level instead of being a runtime invariant the indexing below relies on. Stable since 1.88, comfortably under the 1.90 this workspace asks for, and the remainder is discarded either way. The raster walk is untouched by the branch this lands on and fails the same way on main; it is here because the clippy step compiles both and cannot go green while either stands. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Ayman Bagabas <ayman.bagabas@gmail.com>
They are 235 KB against the 65 KB of JavaScript the repository has of its own, so counting them would make a Rust project read as mostly JavaScript and rank it above Python. `linguist-vendored` rather than the `linguist-detectable=false` the install scripts use: those are ours and merely uninteresting, these are somebody else's, and the vendored mark also collapses a minified bundle in diffs instead of asking a reviewer to scroll past it. `shim.js` is left alone. It is our code and counting it is correct. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Ayman Bagabas <ayman.bagabas@gmail.com>
cpendery (cpendery)
left a comment
There was a problem hiding this comment.
left some comments on some fixes, overall work looks really good.
nit: can we make sure the ci is actually running the xtermjs conformance tests? cargo test --workspace uses the default features so it'll skip the xterm tests even if clippy compiles them
The dynamic colors are a single list and the OSC code says only where in it to start, so `OSC 10;a;b` sets the foreground and then the background. Every parameter was being applied to the color the code named, so the last one won and `OSC 10;#010203;#040506` left the foreground `#040506` and the background untouched. Queries had the same shape: `OSC 10;?;?` answered `OSC 10` twice with the foreground rather than answering the foreground and then the background. The list continues past the cursor into pointer, Tektronix, and highlight colors this terminal has no notion of. Those positions are stepped over rather than folded onto the last slot, so a long sequence sets what it names and nothing else. Both cases 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 already reads the list this way. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Ayman Bagabas <ayman.bagabas@gmail.com>
Main added the rio backend against the same extension points this branch adds xterm.js to, so every conflict is the two backends landing in one list. Both are kept everywhere: the `Backend` enum and its `as_str`, `build`, `build_with_bells`, and `FromStr` arms, the CLI `BackendArg`, the napi enum, and the TypeScript and Python backend literals. `Backend::ALL` keeps this branch's slice form. Main spelled it as four cfg-gated fixed-size arrays, one per ghostty/rio combination, which a third optional backend would have doubled to eight; the slice takes one line per backend instead. `expected()` stays derived from it rather than repeating the same combinations a second time. Cargo.lock is regenerated rather than hand-merged, and the README conflicts take main's reorganized prose with xterm.js folded in. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Ayman Bagabas <ayman.bagabas@gmail.com>
Fetch the bundles rather than committing them (microsoft#83). `build.rs` pulls the versions `pinned.json` names from npm into `OUT_DIR`, and prefers a copy sitting next to it when there is one, which is the case in a published crate: `cargo package` carries the bundles, so consuming a release needs neither Node nor a network. Only building from a checkout does. Keep `SGR 59` apart from a real white underline. xterm.js stores the reset as an explicit RGB #ffffff, indistinguishable through the public getters from `58;2;255;255;255`, so reading the cell could only guess; the previous guess dropped genuine white underlines. Correct it where the parser sets it, while the two are still distinguishable. Report JS exceptions instead of returning each call's default in their place. A throw left the grid frozen and every later read answered from it as if it were fact. The first fault is recorded and reported by the next operation, since the reader thread has nobody to return one to. Also: drop the weekly update workflow for a release-time check that the pinned versions are the latest published ones; drop the `Backend::ALL` comment; shorten the vendoring notes and move the deferred-addons section to microsoft#175; add conformance cases for the underline reset and for the all-default cell fast path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Ayman Bagabas <ayman.bagabas@gmail.com>
Reverts the build-time npm fetch. A clone builds without a network again, and there is no build script: `include_str!` reads the committed bundles directly, and `Cargo.toml` no longer needs an explicit `include` list to get them into the published crate, because git tracks them again. `.github/scripts/vendor-xtermjs.sh` stays as the way to update them, and grows `--latest` to bump `pinned.json` to the newest releases before fetching. `.github/workflows/vendored.yml` comes back with it: it re-fetches the pinned versions whenever these files change and fails if what is committed is not byte for byte what npm publishes, which is what makes a vendored blob checkable rather than trusted. The release check that the pinned versions are the latest published ones stays. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Ayman Bagabas <ayman.bagabas@gmail.com>
Re-vendoring is a manual step, so nothing in CI should be reaching for the tarballs. `.github/scripts/vendor-xtermjs.sh` is the only thing that rewrites these files. The release check stays: it compares version strings and never touches the bundles, which is what keeps a release from shipping on a stale pin. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Ayman Bagabas <ayman.bagabas@gmail.com>
A vendored bundle has nothing watching it, and the release-time version check answered that at the worst moment: it turned someone else's publish into a release blocker, on a tag that was otherwise ready. `.github/workflows/xtermjs-update.yml` runs the vendoring script weekly, opens a pull request when either package has a newer release, and runs the xterm.js conformance suite against the new bundles first, since nothing else does — CI builds with default features, so those cases never run there. It decides there is something to do from the vendored files themselves rather than from `pinned.json` alone, so a package republished at the same version is still caught, and scopes that to the three files the script writes so an unrelated README edit cannot open a spurious pull request. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Ayman Bagabas <ayman.bagabas@gmail.com>
A sanity check on what is about to be published: the release `verify` job re-fetches the versions `pinned.json` names and fails if the committed bundles are not byte for byte what npm publishes. This is provenance rather than freshness, which is what makes it safe to block a release on. It can only fail on something under our control — an edited blob, or a pin bumped without re-vendoring — so an upstream release cannot turn a ready tag into a failed one. Falling behind is the weekly job's business, and it opens a pull request instead. Both failure modes were exercised against the real packages, and the message names which file, which version it claims, and the script that restores it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Ayman Bagabas <ayman.bagabas@gmail.com>
This reverts commit bcb3c65. The check re-asserted at release time something already settled when the files were written, for a mismatch that cannot reach the artifact: `pinned.json` is metadata, and the committed bytes are what compile in. A pin disagreeing with them is a wrong note, not a wrong build. The failure modes it named are covered elsewhere or not real. A hand-edited bundle is a diff against a minified file in code review. A pin bumped without re-vendoring is what the script and the weekly job exist to prevent, and the weekly job corrects it on its next run. Against that it put a network dependency and a Node toolchain on the release path, and could block a ready tag on a metadata inconsistency — the same objection that removed the freshness gate, only rarer. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Ayman Bagabas <ayman.bagabas@gmail.com>
tui-test can already run a session on alacritty, ghostty, or rio. This adds the emulator behind VS Code's terminal as a fourth, 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.They are committed rather than fetched, so a clone builds without a network and there is no build script.
.github/scripts/vendor-xtermjs.sh --latestbumpspinned.jsonto the newest releases and re-fetches in one command, and.github/workflows/xtermjs-update.ymlruns that same script weekly and opens a pull request when either package has a newer release.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
Five things the
Emulatorcontract asks for are missing from, or wrong 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 inSGR 5958;2;255;255;255once written; the shim clears it where the parser sets it, while the two are still apartA 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, so each backend adds one line rather than doubling the number of cfg-gated definitions, 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 and rio equally, but it does mean these 53 cases are green locally rather than on a runner. Worth a follow-up.assets/xtermjs/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.
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, reproducible with.github/scripts/vendor-xtermjs.sh.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.Review round two
Ten threads from cpendery (@cpendery), all addressed and resolved. Two of them landed the other way from the review; both are called out below with the reasoning, and I am happy to reopen either.
Two were real bugs, both reproduced before being touched:
SGR 59swallowed a genuine white underline. xterm.js stores the reset as an explicit RGB #ffffff, and every public getter then reports exactly what a real58;2;255;255;255reports, so reading the cell could only guess — and the old heuristic guessed "reset", meaning no cell could ever have a white underline. The shim now clears it where the parser sets it, while the two are still distinguishable. Verified the new conformance case fails on xterm.js with the old heuristic restored.processandresizediscarded theirResultentirely, so a throw froze the grid and every later read answered from it as fact. Faults are now recorded and reported by the next operation, through a newEmulator::faultchecked inEngine::with_session— before the operation runs, since a wrong answer off a stale grid is worse than a failure. This is not hypothetical: the shim reads several private xterm.js fields, so a bundle upgrade moving one is exactly how it fires.Two went against the review, so the reasoning is here rather than buried in a thread:
Vendoring. I tried de-vendoring in 0fc1fbb and reverted it in 8ff376c: a build script put npm and a network on the path of anyone building from a clone, which is a heavier cost than keeping 264 KB out of git. The bundles stay committed, and
.github/scripts/vendor-xtermjs.sh --latestbumps and re-fetches in one command.How a stale pin is noticed. A weekly pull request rather than a release-time check. A release gate fires on someone else's schedule — an upstream publish turns a ready tag into a failed release — and a version-string comparison cannot see the failure that actually matters: the shim reads several private xterm.js fields, so a release moving one is the real risk. The weekly job runs the conformance suite against the new bundles before opening the PR, which nothing else does, since CI builds with default features. It opens one only on a real new release, decided from the vendored bytes rather than
pinned.jsonalone.I also tried and reverted a release-time provenance check (bcb3c65 → 50b5d7e): it re-asserted something already settled when the files were written, for a mismatch that could not reach the artifact, and put a network dependency on the release path. Nothing xterm.js-specific runs at release time now.
The rest:
isAttributeDefault()fast path where one call stands in for nineteen getters — that shortcut previously had a comment claiming it was verified with nothing checking it.assets/xtermjs/README.mdfrom 114 lines to 74, and moved the deferred-addons section to xterm.js backend: revisit addon-unicode-graphemes and addon-clipboard (OSC 52) #175.Backend::ALLcomment.raster.rsquestion (recent clippy lints, isolated in 9a68c1c) and the OSC 10-12 one (fixed earlier in 8f6520b, with two conformance cases).Every new conformance case is a case, not a snapshot of current behaviour: each was verified to fail on xterm.js without its fix and pass on alacritty, ghostty, and rio.
Testing
53 conformance cases against the xterm.js backend, plus the full workspace suite (514 tests),
cargo clippy --workspace --all-targets --all-features -- -D warnings, the no-default-features clippy pass, fmt, and the Python and Node binding suites.cargo publish --dry-runpackages 94 files and compiles from the tarball.Also verified that a clone builds with no network and no Node:
cargo build -p tui-test-rs --features xtermjssucceeds with npm offPATH, which is the property the vendored bundles exist to preserve.Driven end to end through a real PTY as well: title, cursor, colour assertions, and SVG screenshots all agree with alacritty.