Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .gitattributes
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
* text=auto eol=lf
install/install.sh linguist-detectable=false
install/install.ps1 linguist-detectable=false
install/install.ps1 linguist-detectable=false

# Third-party bundles dropped in unchanged. `vendored` rather than
# `detectable=false` because that is what they are, and it also collapses the
# minified bundle in diffs.
crates/tui-test/assets/xtermjs/xterm-headless.js linguist-vendored=true
crates/tui-test/assets/xtermjs/addon-unicode11.js linguist-vendored=true
40 changes: 40 additions & 0 deletions .github/scripts/vendor-xtermjs.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
# Re-vendor the xterm.js bundles in crates/tui-test/assets/xtermjs.
#
# vendor-xtermjs.sh re-fetch the versions pinned.json already names
# vendor-xtermjs.sh --latest bump pinned.json to the latest releases first
#
# The bundles are checked into the repository so that building tui-test needs
# neither Node nor a network. This script is how they are updated.
set -euo pipefail

assets="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/crates/tui-test/assets/xtermjs"
pinned="$assets/pinned.json"

if [ "${1:-}" = "--latest" ]; then
headless="$(npm view @xterm/headless version)"
unicode11="$(npm view @xterm/addon-unicode11 version)"
jq -n --arg h "$headless" --arg u "$unicode11" \
'{"@xterm/headless": $h, "@xterm/addon-unicode11": $u}' > "$pinned"
else
headless="$(jq -r '."@xterm/headless"' "$pinned")"
unicode11="$(jq -r '."@xterm/addon-unicode11"' "$pinned")"
fi

work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
(
cd "$work"
npm pack "@xterm/headless@$headless" "@xterm/addon-unicode11@$unicode11" >/dev/null 2>&1
tar xzOf "xterm-headless-$headless.tgz" package/lib-headless/xterm-headless.js \
> "$assets/xterm-headless.js"
tar xzOf "xterm-addon-unicode11-$unicode11.tgz" package/lib/addon-unicode11.js \
> "$assets/addon-unicode11.js"
)

echo "Vendored @xterm/headless@$headless and @xterm/addon-unicode11@$unicode11."
echo
echo "Next:"
echo " cargo test -p tui-test-rs --features xtermjs"
echo " re-measure the emoji width table in $assets/README.md"
echo " re-check each package's LICENSE, which carry separate notices"
130 changes: 130 additions & 0 deletions .github/workflows/xtermjs-update.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
name: Update xterm.js

# A vendored bundle has nothing watching it: no lockfile, no dependency bot,
# and no build that fails when it falls behind. This checks weekly and opens a
# pull request when either package has a newer release.
on:
schedule:
- cron: "0 6 * * 1"
workflow_dispatch:

permissions:
contents: write
pull-requests: write

jobs:
update:
name: Check npm for newer releases
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0

- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: "22"

- name: Re-vendor at the latest releases
id: vendor
shell: bash
run: |
set -euo pipefail
was_headless="$(jq -r '."@xterm/headless"' crates/tui-test/assets/xtermjs/pinned.json)"
was_unicode11="$(jq -r '."@xterm/addon-unicode11"' crates/tui-test/assets/xtermjs/pinned.json)"

./.github/scripts/vendor-xtermjs.sh --latest

headless="$(jq -r '."@xterm/headless"' crates/tui-test/assets/xtermjs/pinned.json)"
unicode11="$(jq -r '."@xterm/addon-unicode11"' crates/tui-test/assets/xtermjs/pinned.json)"

{
echo "was_headless=$was_headless"
echo "was_unicode11=$was_unicode11"
echo "headless=$headless"
echo "unicode11=$unicode11"
} >> "$GITHUB_OUTPUT"

# Scoped to what the script writes, not the whole directory: a
# README or LICENSE edit sitting in the tree is not a new release.
# A package can also be republished at the same version, so the
# bundles are what decide this rather than pinned.json alone.
assets=crates/tui-test/assets/xtermjs
vendored=("$assets/pinned.json" "$assets/xterm-headless.js" "$assets/addon-unicode11.js")
if git diff --quiet -- "${vendored[@]}"; then
echo "changed=false" >> "$GITHUB_OUTPUT"
echo "Already on the latest of both."
else
echo "changed=true" >> "$GITHUB_OUTPUT"
git --no-pager diff --stat -- "${vendored[@]}"
fi

- name: Install Rust
if: steps.vendor.outputs.changed == 'true'
run: |
rustup toolchain install stable --profile minimal
rustup default stable

# Nothing else checks this. CI runs the workspace with default features,
# so the xterm.js conformance cases never run there, and a pull request
# opened with GITHUB_TOKEN does not start a workflow of its own. The shim
# reads several things xterm.js does not expose publicly, so a new
# release can move one, and this is where that shows up.
- name: Conformance
if: steps.vendor.outputs.changed == 'true'
id: conformance
continue-on-error: true
run: cargo test -p tui-test-rs --features xtermjs

- name: Open a pull request
if: steps.vendor.outputs.changed == 'true'
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HEADLESS: ${{ steps.vendor.outputs.headless }}
UNICODE11: ${{ steps.vendor.outputs.unicode11 }}
WAS_HEADLESS: ${{ steps.vendor.outputs.was_headless }}
WAS_UNICODE11: ${{ steps.vendor.outputs.was_unicode11 }}
CONFORMANCE: ${{ steps.conformance.outcome }}
run: |
set -euo pipefail
branch="xtermjs-update/$HEADLESS-$UNICODE11"
title="chore(xtermjs): update vendored bundles to $HEADLESS and $UNICODE11"

if [ "$CONFORMANCE" = "success" ]; then
verdict="The conformance suite passes against the new bundles."
else
verdict="**The conformance suite fails against the new bundles.** The shim reads several things xterm.js does not expose publicly, so a release can move one. Start at the failing case rather than at the diff."
fi

cat > /tmp/body.md <<BODY
\`@xterm/headless\` $WAS_HEADLESS → $HEADLESS
\`@xterm/addon-unicode11\` $WAS_UNICODE11 → $UNICODE11

$verdict

Two things conformance does not cover, worth checking before merging:

- **The emoji width table** in \`crates/tui-test/assets/xtermjs/README.md\`. Agreeing with alacritty on those cases is the reason the unicode11 addon is pinned where it is, and nothing asserts it.
- **Each package's \`LICENSE\`**, which are separate notices: \`@xterm/headless\` ships none and declares MIT in its manifest, the addon carries its own.

Opened by \`.github/workflows/xtermjs-update.yml\`, which runs \`.github/scripts/vendor-xtermjs.sh --latest\`. Pull requests created with \`GITHUB_TOKEN\` do not start CI, so push an empty commit to run it.
BODY

git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git checkout -b "$branch"
assets=crates/tui-test/assets/xtermjs
git add "$assets/pinned.json" "$assets/xterm-headless.js" "$assets/addon-unicode11.js"
git commit -s -m "$title" -m "$verdict"
git push -f origin "$branch"

if [ -z "$(gh pr list --head "$branch" --state open --json number -q '.[].number')" ]; then
gh pr create --head "$branch" --base main --title "$title" --body-file /tmp/body.md
else
echo "A pull request for $branch is already open; pushed the update to it."
fi

- name: Fail if conformance did not pass
if: steps.vendor.outputs.changed == 'true' && steps.conformance.outcome != 'success'
run: |
echo "The pull request was opened, but conformance failed against the new bundles."
exit 1
56 changes: 56 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ flate2 = "1.1.9"
fontdb = { version = "0.23.0", default-features = false, features = ["fs", "std"] }
gif = "0.14.0"
interprocess = "2.4.2"
rquickjs = { version = "0.12.2", features = ["parallel"] }
ghostty-vt = { package = "libghostty-vt", version = "0.2.1", git = "https://github.com/Uzaaft/libghostty-rs.git", rev = "a28e4ad00d6ba79b98b8cac651ed8976d8500903", default-features = false }
png = "0.17.16"
portable-pty = "0.9.0"
Expand Down
30 changes: 25 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -292,21 +292,22 @@ Calling `open` or `run` for a session that already has a live child reuses that

#### Terminal backends

Select a backend per session with `--backend`. The available values are `alacritty` (default), `ghostty`, and `rio`; Ghostty uses [Ghostty's Rust VT bindings](https://github.com/Uzaaft/libghostty-rs).
Select a backend per session with `--backend`. The available values are `alacritty` (default), `ghostty`, `rio`, and `xtermjs`; Ghostty uses [Ghostty's Rust VT bindings](https://github.com/Uzaaft/libghostty-rs), and `xtermjs` runs [xterm.js](https://xtermjs.org) — the emulator behind VS Code's terminal — inside an embedded QuickJS interpreter.

```sh
tui-test open --backend ghostty
tui-test run --backend rio vim file.txt
tui-test run --backend xtermjs vim file.txt
```

All backends use the same renderer, assertions, snapshot format, and conformance suite. Shell semantic-prompt tracking reads raw PTY bytes, so command boundaries, exit codes, and cwd tracking do not depend on the selected backend. Backend-specific VT behavior can differ: Ghostty preserves SGR blink, while Alacritty parses blink but cannot report it.
All backends use the same renderer, assertions, snapshot format, and conformance suite. Shell semantic-prompt tracking reads raw PTY bytes, so command boundaries, exit codes, and cwd tracking do not depend on the selected backend. Backend-specific VT behavior can differ: Ghostty preserves SGR blink, while Alacritty parses blink but cannot report it. xterm.js records a cell's underline color only when that cell also has an underline style, which changes nothing about how a cell renders.

The CLI and published Python and JavaScript packages include all backends. Windows ARM64 artifacts are not currently published because Ghostty's upstream Zig build does not support that target.

Rust users enable non-default backends through Cargo features. To enable Ghostty:

```sh
cargo add tui-test-rs --features rio
cargo add tui-test-rs --features xtermjs
```

```rust
Expand All @@ -318,7 +319,7 @@ let options = OpenOptions {
};
```

The default Rust features include only Alacritty and do not require Zig. The `ghostty` feature builds a pinned Ghostty revision and requires Zig 0.16 on `PATH`.
The default Rust features include only Alacritty and do not require Zig. The `ghostty` feature builds a pinned Ghostty revision and requires Zig 0.16 on `PATH`. The `xtermjs` feature needs no extra tooling — xterm.js is vendored and embedded, so it depends on nothing installed on the machine, at the cost of compiling QuickJS and carrying about 230 KB of JavaScript in the binary.

### Terminal control

Expand Down Expand Up @@ -518,7 +519,7 @@ The default palette is the classic VGA/xterm palette expected by `TERM=xterm-256
| | tui-test | [Tuistory](https://github.com/remorses/tuistory) | [tui-use](https://github.com/onesuper/tui-use) | [terminal-use](https://github.com/flipbit03/terminal-use) |
| --- | --- | --- | --- | --- |
| Language | Rust | TypeScript | TypeScript | Rust |
| Emulator | Alacritty, Ghostty, or Rio, per session | Ghostty (via OpenTUI) | xterm (headless) | Alacritty |
| Emulator | Alacritty, Ghostty, Rio, or xterm.js, per session | Ghostty (via OpenTUI) | xterm (headless) | Alacritty |
| Shell command tracking | ✅ command boundaries, exit codes, cwd | ❌ | ❌ | ❌ |
| Testing / snapshots | ✅ `expect` text / output / exit-code / snapshot | ✅ snapshots | ❌ | ❌ |
| Color and per-cell attributes | ✅ fg/bg, ANSI-256/hex/RGB, `cells` | ✅ style-filtered text | ❌ plain text with highlights | ✅ via PNG |
Expand All @@ -530,6 +531,25 @@ The default palette is the classic VGA/xterm palette expected by `TERM=xterm-256
| Runtime | Native | Node.js | Node.js | Native |
| Platforms | Windows, Linux, and macOS | Windows, Linux, and macOS | Windows, Linux, and macOS | Linux and macOS |

## Development

Building the workspace with default features needs only a Rust toolchain. Of the optional backends, only `ghostty` adds a build requirement:

| Feature | Requires | Why |
| ------- | -------- | --- |
| `ghostty` | Zig 0.16 on `PATH` | builds a pinned Ghostty revision from source |
| `rio` | — | ordinary Rust dependency |
| `xtermjs` | — | xterm.js is vendored in the repository |

The xterm.js bundles are committed under `crates/tui-test/assets/xtermjs` at the versions `pinned.json` names, so a clone builds without a network. To update them by hand:

```sh
.github/scripts/vendor-xtermjs.sh --latest # or omit --latest to re-fetch the pinned versions
cargo test -p tui-test-rs --features xtermjs
```

`.github/workflows/xtermjs-update.yml` runs that same script weekly and opens a pull request when either package has a newer release, so a vendored bundle does not quietly fall behind.

## Debugging

The daemon does not log by default. Start it with `--verbose` to record every byte read from or written to the PTY, along with lifecycle events, in `~/.tui-test/<session>.log`.
Expand Down
7 changes: 6 additions & 1 deletion bindings/js/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,12 @@ test = false
[dependencies]
napi = { version = "3", default-features = false, features = ["napi4", "dyn-symbols", "async"] }
napi-derive = "3"
tui-test = { workspace = true, features = ["ghostty", "rio", "recording-font-jetbrains-mono-styles"] }
tui-test = { workspace = true, features = [
"ghostty",
"rio",
"xtermjs",
"recording-font-jetbrains-mono-styles",
] }

[build-dependencies]
napi-build = "2"
4 changes: 2 additions & 2 deletions bindings/js/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,12 +64,12 @@ Module-level helpers: `sessions()`, `closeAll()`, `getRecording()`, `uniqueSessi
They reuse a live named session unless `restart: true` is passed. The
constructor also accepts `backend` and `profile` as defaults for later opens
and runs. Backend values are `"alacritty"` (default), `"ghostty"`, and
`"rio"`:
`"rio"`, and `"xtermjs"`:

```js
const terminal = new TuiTest("work", { backend: "ghostty" });
await terminal.open();
await terminal.run("vim", ["file.txt"], { backend: "rio" });
await terminal.run("vim", ["file.txt"], { backend: "xtermjs" });
```

The native package includes every supported emulator. Profiles are partial;
Expand Down
3 changes: 2 additions & 1 deletion bindings/js/native/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,8 @@ export declare class NativeSession {
export declare const enum Backend {
Alacritty = 'alacritty',
Ghostty = 'ghostty',
Rio = 'rio'
Rio = 'rio',
Xtermjs = 'xtermjs'
}

export interface BellEvent {
Expand Down
Loading
Loading