Skip to content

feat: add xterm.js as a selectable terminal backend - #83

Draft
aymanbagabas wants to merge 3 commits into
microsoft:mainfrom
aymanbagabas:feat/xtermjs-backend
Draft

feat: add xterm.js as a selectable terminal backend#83
aymanbagabas wants to merge 3 commits into
microsoft:mainfrom
aymanbagabas:feat/xtermjs-backend

Conversation

@aymanbagabas

Copy link
Copy Markdown
Member

Summary

Adds @xterm/headless as a second, selectable terminal emulator backend, running on an embedded QuickJS interpreter via rquickjs. 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 -- htop
await su.open({ backend: "xtermjs" });    // JS
await client.open(backend="xtermjs")      # Python

The point is answering "does my TUI look right in VS Code's terminal", which only xterm.js can answer. Everything else is unchanged: backend is optional on the wire and defaults to alacritty, so clients that predate it keep the behavior they had.

This builds on the Emulator seam from #78 — the trait, the neutral cell vocabulary, and the conformance suite were all designed for exactly this, and cell.rs already cited xterm.js's CM_P16/FgFlags.BLINK in 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 rquickjs gives you QuickJS-in-Rust directly. It would add a heavy wasmtime dependency to a project that ships via Homebrew/winget/npm with strip+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 a setTimeout callback, so the grid is still empty when it returns — but Emulator::process must have the grid ready. The shim replaces the timer functions with a queue that feed() 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.

Defect Fix
Zero-width grapheme mapped to CONTINUATION — a 2-byte input left the row a column short, shifting every snapshot after it A continuation is the cell that is both zero-width and empty
SGR 59 painted a white underline (xterm.js's reset sentinel reads back through the public getters as RGB #ffffff) Collapsed to unset
Emoji measured 1 column vs alacritty's 2, moving the reported column of everything after it Load the Unicode 11 provider
full_rows grew the daemon ~38 MB per call; a single expect --full reached 1.3 GB Batched reads + default-attribute fast path
size() reported the requested size, not the applied one — returned 48 rows for a 24-row terminal Read the applied size back
cols=0 divided by zero and killed the daemon Clamp to 2x1 at the seam

Measured, 5,000-row scrollback at 80x24, ten text --full calls:

before after alacritty
daemon RSS growth +376 MB +27 MB +21 MB
latency 389 ms/call 144 ms/call 11 ms/call

On the Unicode version

unicode11 was chosen by measurement, not recency. Cursor column after each sequence:

Input alacritty v6 v11 v15 v15-graphemes
🙂X 3 2 3 3 3
👨‍👩X 5 3 5 6 3
👍🏽X 5 3 5 5 3
🇺🇸X 3 3 3 3 3
你X 3 3 3 3 3

Only v11 agrees with alacritty everywhere. @xterm/addon-unicode-graphemes (v15) is also marked experimental by its own package description and needs an atob the QuickJS host doesn't have. That table lives in crates/shell-use/assets/xterm/README.md so 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).

  • Narrowing a session reflows on alacritty and truncates on xterm.js. Resizing abcdefghijklmnop from 10 columns to 6 gives abcdef/ghijkl/mnop vs abcdef/klmnop — xterm.js drops what no longer fits. Worth knowing before you narrow a session whose scrollback you still need.
  • A combining mark with no base character is discarded by alacritty and given its own column by xterm.js.
  • Only xtermjs reports blink (alacritty parses SGR 5 and discards it) — it's the first backend to source that part of the vocabulary.
  • Reading the full scrollback is ~10x slower on xtermjs. The visible screen is unaffected.

Testing

  • 205 tests green (up from 193), cargo clippy --all-targets clean, cargo fmt clean, JS bindings typecheck.
  • The conformance suite now runs 37 cases against each backend.
  • Full suite run 4x consecutively with no failures to rule out flakes.
  • Verified end-to-end on real sessions: bash, top, SVG screenshots, and both the JS and Python bindings.

Risk flagged for CI

rquickjs-sys compiles QuickJS from C, making it the first C-compiled dependency in the tree. release.yml installs musl-tools and gcc-aarch64-linux-gnu but only sets the linker env var; cc will look for an aarch64-linux-musl-gcc for that target. Nothing has ever needed to compile C for that triple, so a green CI on main doesn'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

Follow-ups, not in this PR

  • OSC 10/11/12 and 110/111/112. Researched: neither backend replies to a color query today (verified — programs doing light/dark detection stall, then guess). alacritty emits Event::ColorRequest which the proxy drops; xterm.js exposes it through the public parser.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.
  • Unified terminal config. Each backend has its own config type; shell-use should have one that maps onto both. This also has to resolve a pre-existing bug where render/svg.rs and assert/color.rs carry different hardcoded palettes — expect --fg "#800000" currently passes on a cell the screenshot paints #e88388.

🤖 Generated with GitHub Copilot CLI

aymanbagabas and others added 3 commits August 4, 2026 12:11
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant