feat: add xterm.js as a selectable terminal backend - #83
Draft
aymanbagabas wants to merge 3 commits into
Draft
Conversation
Adds a second `Emulator` implementation built on `@xterm/headless`, running on an embedded QuickJS interpreter via `rquickjs`. The bundle and its host shim are compiled into the binary, so this adds no runtime dependency on Node or on anything installed on the machine. Two things the seam needed: `Terminal.write()` is asynchronous. It queues the chunk and drains it from a `setTimeout` callback, so the grid is still empty when it returns, while `Emulator::process` has to have the grid ready. The shim collects timers into a queue and drains it to empty before handing control back, which makes writes synchronous and deterministic. `performance.now()` returns a constant so the write loop never takes its 12ms yield and one feed always consumes the whole chunk. Reading a cell is a call into JS, and an 80x30 screen is 2,400 of them with ten property reads each. The shim instead flattens a row span into one NUL-joined string and one flat integer array, so a whole screen crosses the boundary as two values. PTY output is passed as a `Uint8Array` rather than a string so xterm.js's own incremental UTF-8 decoder carries a sequence split across two reads. The backend passes the existing conformance suite unchanged except for `underline_color_outlives_the_underline`, which is narrowed here. xterm.js keeps the underline color in an extended-attribute record whose `isEmpty()` consults only the underline style and hyperlink id, so a cell with a color but no shape drops the record and reports the foreground instead. The test now pins the shape transitions, which every emulator agrees on, and the color only where it is actually drawn. The shim collapses that foreground fallback back to "unset", which is what `underline_color: None` already means, so both backends emit identical cells. xterm.js reports the `blink` attribute, which alacritty parses and discards. It is the first backend to source that part of the vocabulary. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Ayman Bagabas <ayman.bagabas@gmail.com>
Adds `Backend`, threaded from the wire protocol through `Session::open` to the single place an emulator was constructed. `--backend` is accepted on `open` and `run`, and as a `backend` option in the JS and Python bindings; `state` reports the backend in use so a client can confirm what it got. The field is optional everywhere and defaults to alacritty, so clients released before backend selection existed keep deserializing and keep getting the emulator they already had. The emulator is built before the PTY is spawned: a backend that cannot start is then a plain error rather than a live child process to clean up. Only the xtermjs path is covered by a new end-to-end test, since every other test in that file already runs a session on the default backend and each one spawns a real daemon. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Ayman Bagabas <ayman.bagabas@gmail.com>
Four independent reviews of the new backend turned up defects that a passing conformance suite did not catch, because the suite did not yet pin the contracts they broke. Each fix below has a new conformance case, so both backends are now held to it. Cell mapping: Width alone does not identify a wide-char continuation. xterm.js also reports width 0 for a genuine zero-width grapheme that had no base character to combine with — a lone combining mark, ZWSP, ZWJ, or a variation selector at the start of a row. Reading width alone discarded the grapheme and, because a continuation serializes to nothing, left the row one column short of the grid, shifting every snapshot and screenshot after it. Two bytes were enough to trigger it. A continuation is now the cell that is both zero-width and empty. SGR 59 does not clear xterm.js's underline-color record: it stores a sentinel that reads back through the public getters as RGB white, so resetting the underline color painted one. The sentinel is indistinguishable from a real `58;2;255;255;255` at that layer, so the common case wins and the shim collapses it to unset. The headless bundle ships only the Unicode 6 width tables, which measure astral emoji as one column where alacritty measures two, moving the reported column of everything after an emoji on the line. It now loads the Unicode 11 provider, which was chosen by measurement rather than by recency: of the four available width tables it is the only one that agrees with alacritty on emoji, ZWJ sequences, skin-tone modifiers, flags, and CJK alike. The vendored assets grew a README recording that comparison so the next person does not "upgrade" it to a worse match. Grid reads: `pack` built one JS array of six boxed numbers per cell, which at the 5,000-row scrollback is 2.4 million of them per call — large enough to cost tens of megabytes and small enough that QuickJS did not collect it, so `expect --full` grew the daemon by hundreds of megabytes as it polled. Rows are now read in batches, and cells whose attributes are all default take one getter call instead of nineteen. A ten-call full-scrollback read goes from +376 MB to +27 MB, alongside alacritty's +21 MB, and from 389 ms to 144 ms per call. Sizes: xterm.js clamps to a 2x1 minimum while the backend cached the size it had asked for, so the decoder chunked the packed grid by the wrong width and returned twice as many rows as the terminal had — with `size()` reporting the size that did not exist. It now reads the applied size back. At zero columns the same code divided by zero, which kills the daemon outright since requests are served on its main thread. Since alacritty panics on a zero-width resize too, in its own grid arithmetic, the size is clamped once at the seam so neither backend is asked for a grid it cannot represent and both report the same clamped result. Also aligns `--backend xterm`, which the wire accepted and the CLI rejected, and adds the missing `backend` field to the Python `State`. The TypeScript one becomes optional, since a newer client may be talking to a daemon that predates it. Two divergences are documented rather than fixed, both in README and in the conformance cases that stop short of pinning them: narrowing a session reflows on alacritty and truncates on xterm.js, and a combining mark with no base is dropped by one and given a column by the other. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Ayman Bagabas <ayman.bagabas@gmail.com>
cpendery
self-requested a review
August 4, 2026 18:48
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds
@xterm/headlessas a second, selectable terminal emulator backend, running on an embedded QuickJS interpreter viarquickjs. The bundle is compiled into the binary, so no Node.js is needed at runtime and nothing new has to be installed.shell-use open --backend xtermjs # or --backend alacritty (default) shell-use run --backend xtermjs -- htopThe point is answering "does my TUI look right in VS Code's terminal", which only xterm.js can answer. Everything else is unchanged:
backendis optional on the wire and defaults to alacritty, so clients that predate it keep the behavior they had.This builds on the
Emulatorseam from #78 — the trait, the neutral cell vocabulary, and the conformance suite were all designed for exactly this, andcell.rsalready cited xterm.js'sCM_P16/FgFlags.BLINKin its comments.Why not WASM
The obvious framing is "run xterm.js in WASM", but that means QuickJS-compiled-to-WASM run by wasmtime embedded in Rust — QuickJS-in-WASM-in-Rust, when
rquickjsgives you QuickJS-in-Rust directly. It would add a heavy wasmtime dependency to a project that ships via Homebrew/winget/npm withstrip+lto, plus a second ABI boundary to marshal the grid across on every poll. The only thing bought is sandboxing, which is theater in a process that already spawns PTYs and runs arbitrary shell commands.How it works
Two problems had to be solved:
Terminal.write()is asynchronous. It queues the chunk and drains it from asetTimeoutcallback, so the grid is still empty when it returns — butEmulator::processmust have the grid ready. The shim replaces the timer functions with a queue thatfeed()drains to empty before returning.performance.now()is frozen at 0 so xterm.js's internal "spent 12ms, yield to a new timer" check never fires and one feed always consumes the whole chunk.Reading a cell costs a JS call. An 80x30 screen is 2,400 cells with ~19 getters each. The shim flattens a row span into one NUL-joined string plus one flat int array, so a whole screen crosses the boundary as two values. Cells whose attributes are all default take one getter call instead of nineteen.
PTY output is passed as a
Uint8Array, not a string, so xterm.js's own incremental UTF-8 decoder carries a multi-byte character split across two reads.Review findings, and what they changed
Four independent reviews found defects that a green conformance suite did not catch, because the suite did not yet pin the contracts they broke. Every fix below has a new conformance case, so both backends are held to it.
CONTINUATION— a 2-byte input left the row a column short, shifting every snapshot after itSGR 59painted a white underline (xterm.js's reset sentinel reads back through the public getters as RGB#ffffff)full_rowsgrew the daemon ~38 MB per call; a singleexpect --fullreached 1.3 GBsize()reported the requested size, not the applied one — returned 48 rows for a 24-row terminalcols=0divided by zero and killed the daemonMeasured, 5,000-row scrollback at 80x24, ten
text --fullcalls:On the Unicode version
unicode11was chosen by measurement, not recency. Cursor column after each sequence:🙂X👨👩X👍🏽X🇺🇸X你XOnly v11 agrees with alacritty everywhere.
@xterm/addon-unicode-graphemes(v15) is also marked experimental by its own package description and needs anatobthe QuickJS host doesn't have. That table lives incrates/shell-use/assets/xterm/README.mdso nobody "upgrades" it into a regression.Known divergences — documented, not fixed
Both are inherent to the emulators. The conformance cases deliberately stop short of pinning them, following the suite's existing convention for legitimate divergence (see the reflow and tab-stop tests).
abcdefghijklmnopfrom 10 columns to 6 givesabcdef/ghijkl/mnopvsabcdef/klmnop— xterm.js drops what no longer fits. Worth knowing before you narrow a session whose scrollback you still need.xtermjsreportsblink(alacritty parses SGR 5 and discards it) — it's the first backend to source that part of the vocabulary.xtermjs. The visible screen is unaffected.Testing
cargo clippy --all-targetsclean,cargo fmtclean, JS bindings typecheck.bash,top, SVG screenshots, and both the JS and Python bindings.Risk flagged for CI
rquickjs-syscompiles QuickJS from C, making it the first C-compiled dependency in the tree.release.ymlinstallsmusl-toolsandgcc-aarch64-linux-gnubut only sets the linker env var;ccwill look for anaarch64-linux-musl-gccfor that target. Nothing has ever needed to compile C for that triple, so a green CI onmaindoesn't prove this works. Worth a dry run of the release workflow before merge. Release binary grows 5.81 MB → 7.04 MB (+21%).Related
Emulatorseam).Backendenum in place, rio-vt could become a third selectable option rather than a replacement.Follow-ups, not in this PR
Event::ColorRequestwhich the proxy drops; xterm.js exposes it through the publicparser.registerOscHandler. Both use identical indices (256/257/258) and neither resets on RIS. Blocked on the item below, since answering a query requires one canonical default color.render/svg.rsandassert/color.rscarry different hardcoded palettes —expect --fg "#800000"currently passes on a cell the screenshot paints#e88388.🤖 Generated with GitHub Copilot CLI