A Rust port of the pty project —
persistent terminal sessions with detach/attach plus a Playwright-style TUI
testing library — using libghostty (the terminal library extracted from
the Ghostty terminal emulator) as the terminal-emulation backend in place of
@xterm/headless.
Two things live here:
- The
ptyCLI (v0) — a runnableptybinary with a per-session daemon that owns the PTY and a libghostty terminal:run/ls/peek/send/attach/kill/status. See TheptyCLI below. pty-testkit— the Rust port of pty's terminal testing harness (theSessiontype), which is also the foundation the CLI is built on and the correctness net for the port.
The long-term target is a behavior-compatible Rust implementation of the Node
pty, plus a first-class Rust API for embedding a live terminal in clients such
as Fractal. The Node implementation is the behavioral reference while the port
converges. This README does not claim that the current experiment has reached
full parity.
Compatibility means that the same user-visible operations and wire messages have
the same result. It does not require identical source code or internal design.
Rust, portable-pty, and libghostty can require a different implementation.
When that difference changes behavior, record a decision that states the Node
behavior, the Rust behavior, the reason, the client effect, and the conformance
test.
The embedding API should serve the Rust CLI and other Rust clients through one
implementation. Its terminal handle should eventually provide the capabilities
that Node's @myobie/pty/tui PtyHandle provides: attach lifecycle, input,
resize, typed cell-grid and wrapped-line reads, cursor and terminal-mode state,
scrollback access, and activity or exit events. libghostty::Terminal is not
Send, so one clear actor must own it and publish typed events or snapshots to
consumers.
Keep the current protocol as the baseline. Add a protocol feature only after a real failing use case shows that the current byte-framed messages cannot express the required behavior. Track the compatibility matrix, crate boundaries, and acceptance tests in issue #1.
cargo build # builds the `pty` binary
PTY=target/debug/pty
$PTY run -- bash --norc --noprofile # spawn a persistent session -> prints its id
$PTY ls # list sessions (--json for JSON)
$PTY peek <id> # print the current screen (ANSI)
$PTY peek --plain <id> # ... as plain text
$PTY peek -f <id> # follow output live (read-only; Ctrl-C to stop)
$PTY peek --wait "Ready" -t 10 <id> # wait until text appears
$PTY send <id> --seq "echo hi" --seq key:return # send an ordered key sequence
$PTY send <id> "literal text" # or literal text (no newline)
$PTY attach <id> # attach interactively (Ctrl+\ to detach)
$PTY status <id> # session stats as JSON
$PTY restart <id> # respawn with the same command
$PTY rename <id> "My Service" # set a display label (also a lookup key)
$PTY rm <id> # kill (if running) and remove from the registry
$PTY kill <id> # terminate the session
# Manifests (pty.toml):
$PTY up [dir] [names...] # start sessions declared in ./pty.toml
$PTY down [dir] [names...] # stop themA pty.toml looks like:
prefix = "myapp"
[sessions.web]
command = "node server.js"
cwd = ".."
[sessions.web.env]
PORT = "3000"Each session runs in a detached daemon that hosts the PTY and a libghostty
terminal; clients connect to a per-session unix socket under $PTY_ROOT
(default ~/.local/state/pty) speaking the wire protocol (protocol.rs). The
daemon replays the screen on attach and answers device queries (DA1/DSR) through
libghostty, so the session behaves like a real terminal. Set PTY_ROOT to
isolate a registry (e.g. for tests).
Nesting prevention: running pty run inside an existing pty session
(detected via PTY_SESSION) runs the command directly instead of creating a
session-inside-a-session; use pty run -d to force a background session anyway.
The original pty project ships a Session testing harness: it spawns a
process in a PTY, feeds the output into a headless xterm.js, and lets tests
take "screenshots" (plain text + ANSI) and wait for on-screen content. This
crate reimplements that harness in Rust, swapping the terminal emulator for
libghostty:
real process ──stdout──▶ PTY ──bytes──▶ libghostty Terminal ──▶ Screenshot
(bash, ls, …) (portable-pty) (VT parse + grid) { lines, text, ansi }
▲ │
└────────────── input / query replies ◀────┘
Session::spawn— spawn a command in a real PTY.screenshot()→{ lines, text, ansi }, matching the TSScreenshot(plain text via libghosttyFormat::Plain; ANSI viaFormat::Vt).wait_for_text/wait_for_absent/wait_for— poll the screen.send_keys/type_str/press("ctrl+c")— send input; named keys use the same encoding table aspty'skeys.ts.resize(rows, cols)— resize the PTY (SIGWINCH) and the emulator together.title()— the OSC-set window title libghostty tracks.- Terminal query replies (DA1, DSR, …) that libghostty generates are
captured via
on_pty_writeand flushed back to the PTY, so programs that block on a device-attributes response (e.g. fish) start promptly.
Because Terminal from libghostty is !Send, it lives on the test thread; a
reader thread only ferries raw PTY bytes over a channel, which the main thread
drains into the terminal on demand.
-
Rust (edition 2021; built with 1.97).
-
Zig 0.15.2 on
PATH. Thelibghostty-vt-sysbuild script fetches the Ghostty source and compiles the VT core withzig build, so a matching Zig toolchain must be installed. Install it with:curl -fsSL https://ziglang.org/download/0.15.2/zig-x86_64-linux-0.15.2.tar.xz | tar -xJ -C ~/.local/opt ln -sf ~/.local/opt/zig-x86_64-linux-0.15.2/zig ~/.cargo/bin/zig # ~/.cargo/bin is already on PATH for cargo
The first build clones + compiles Ghostty's VT core (~20s); it is cached thereafter.
cargo test173 tests pass:
| Test file | Ported from | Count | Backend |
|---|---|---|---|
tests/keys.rs |
tests/keys.test.ts |
21 | pure |
tests/duration.rs |
tests/duration.test.ts |
15 | pure |
tests/env_isolation.rs |
tests/env-isolation.test.ts |
5 | pure |
tests/input_parse.rs |
tests/input-parse.test.ts |
21 | pure |
tests/mouse_parse.rs |
tests/mouse-parse.test.ts |
9 | pure |
tests/protocol.rs |
tests/protocol.test.ts |
20 | pure |
tests/ptyfile.rs |
tests/ptyfile.test.ts |
16 | pure |
tests/paste.rs |
tests/send-paste.test.ts (wrapping) |
4 | pure |
tests/terminal_queries.rs (strip) |
tests/terminal-queries.test.ts |
16 | pure |
tests/terminal_queries.rs (responses) |
tests/terminal-queries.test.ts |
3 | libghostty |
tests/terminal_spawn.rs |
screenshot.test.ts / shells.test.ts |
11 | libghostty |
tests/terminal_fidelity.rs |
screen-replay-altscreen / scrollback-fidelity |
4 | libghostty |
tests/interactive_tui.rs |
interactive-editing (Playwright-style) | 3 | libghostty |
tests/parity.rs |
Node behavior parity cases | 7 | pure + libghostty |
tests/parity_fixtures.rs |
shared Node/Rust JSON fixtures | 2 | libghostty |
tests/registry_liveness.rs |
Node-compatible registry liveness | 1 | pure |
tests/cli_e2e.rs |
pty CLI lifecycle / up-down / restart / attach (Ctrl+\ detach + double-tap) / follow / nesting |
14 | libghostty |
| doctest | — | 1 | — |
interactive_tui.rs drives bash's raw-mode readline through the harness —
arrow-key cursor editing, Ctrl-A line-start jump, Ctrl-C line-discard — and
asserts on how libghostty renders the in-place redraws. This is the marquee use
case: send keystrokes, watch the screen update, assert the result.
The query-response tests prove libghostty answers device queries end-to-end:
a program emits ESC[c / ESC[6n / ESC[>c, libghostty generates the reply
(ESC[?62;22c / ESC[1;1R / ESC[>1;0;0c), and the harness flushes it back to
the PTY. (libghostty does not answer the OSC 10/11 color queries without default
colors configured, so those two TS response cases are intentionally not ported.)
The libghostty-backed tests drive real programs and assert on the emulated
screen: echo/ls/ls -la capture, ANSI color preservation, cursor
positioning (CUP), CJK wide characters, clear-screen, bash input + echo, ctrl+c
interrupt, ctrl+d, resize → SIGWINCH (stty size), OSC window-title tracking,
alternate-screen enter/restore (?1049h/?1049l), scrollback retention, text
styling (bold/underline) in the ANSI capture, and carriage-return overwrite.
Ported so far (v0 + hardening): the testing harness (Session on
libghostty), the pty CLI + per-session daemon (run/ls/peek/send/
attach/status/kill/up/down/restart/rename/rm), the wire
protocol, the session registry (sessions.ts layout), pty.toml
manifests, and the pure utility modules the harness/CLI depend on (keys,
duration, input, queries, paste).
Still on the TypeScript side (not yet ported): the TUI framework + widgets,
events log, tags, gc, remote/fabric, and the many higher-level CLI niceties
(exec, stats, tag, filters, follow mode). The pty project is ~24.6k
lines of tests across 108 files; this is a focused, faithful port of the core
that proves libghostty can back a real pty, with the ported tests as the
correctness net.
src/
bin/pty.rs the `pty` CLI: run/ls/peek/send/attach/kill/status + __daemon
daemon.rs per-session daemon: PTY + libghostty terminal, serves protocol
client.rs client ops: peek / send / status / interactive attach
protocol.rs wire packet framing (port of protocol.ts)
registry.rs session dir + <name>.sock/.pid/.json (port of sessions.ts)
ptyfile.rs pty.toml manifest parsing (port of ptyfile.ts)
session.rs Session test harness: PTY (portable-pty) + libghostty Terminal
screenshot.rs Screenshot { lines, text, ansi } capture
keys.rs named-key → bytes (port of keys.ts)
duration.rs parse/format durations (port of duration.ts)
input.rs stdin key + SGR-mouse + Kitty CSI-u parsing (port of tui/input.ts)
queries.rs terminal-query stripping (port of stripTerminalQueries)
paste.rs bracketed-paste wrapping (port of paste.ts)
examples/demo.rs live libghostty screenshot loop (cargo run --example demo)
tests/ ported test suites + CLI e2e (see table above)