Why this happened
You built the app, then kept living in Ghostty anyway
Your daily workspace is Bun + tmux + Ghostty: a TypeScript CLI orchestrating a tmux grid inside Ghostty, one Claude Code session per pane. You'd been rewriting that as Rust + Tauri (towles-tool-rs) — a real app with repos, agent status, PRs, and embedded terminals on one screen.
But every time you tried to switch, you lasted a few minutes and went back. The other screens weren't the problem — the terminals were. They ran on xterm.js, and after Ghostty an also-ran terminal is the thing you notice constantly. This change closes exactly that gap: it makes the app's terminal core be Ghostty's.
The migration is one line of strategy: if you can't give up Ghostty, put Ghostty inside the app. Everything technical below serves that single goal — and the success metric isn't a benchmark, it's whether you actually stay in the app next week.
The library
What libghostty-vt is — and what it refuses to do
libghostty-vt is the first extracted piece of Ghostty's core: a
zero-dependency C/Zig library with the SIMD VT parser and the full
terminal state machine — screen model, scrollback, cursor, styles, modes,
and reflow on resize. It also ships a render-state API (dirty-tracked
row/cell iterators) so you can draw the state yourself, plus input encoders and a
selection API.
What it deliberately leaves out is the whole reason the work took a day instead of an hour:
- No renderer. It hands you cell data; drawing pixels is your job.
- No PTY. You feed it bytes and it hands back bytes to write to the shell; spawning the shell is your job.
- No font shaping. Same idea — it's the engine, not the car.
libghostty has never been released — no version tag, no artifacts. It's developed in the open on Ghostty's main branch (extraction started Sept 2025) and the C API is explicitly unstable, breaking weekly. The rule that keeps this sane: pin the commit. The Rust bindings crate does exactly that.
The shape of the change
Same PTY, a new brain, a dumb renderer
Only the middle of the pipeline changed. portable-pty still spawns the shell;
what's different is that the raw bytes now flow through a Rust-side engine that produces
frames instead of the WebView doing all the parsing.
Before · xterm.js
After · libghostty
The engine also answers terminal queries (like the "who are you?" DA1 request) by handing reply bytes back into the PTY input queue — so that works even when no WebView is watching.
The new crate
crates/tt-vt — one thread owns one terminal
The engine lives in a new Tauri-free crate. The single most load-bearing fact about it:
libghostty-vt's Terminal type is !Send — Rust
won't let it move between threads. So the natural (and only) shape is
one dedicated OS thread per terminal, which owns its engine for its whole
life. Callers talk to it over a channel.
// bytes in → frames out; the thread never shares its engine pub enum Input { Bytes(Vec<u8>), Resize{..}, Scroll(..), Select(..), Copy(..) } pub enum Event { Frame(Frame), PtyReply(Vec<u8>) }
A frame carries only the rows that changed, and within a row, cells are grouped into style runs — consecutive cells sharing the same foreground/background/flags collapse into one entry. A 200-column row becomes a handful of entries instead of 200 objects.
{ "full": false,
"changed": [ { "y": 3, "runs": [
{ "x":0, "width":6, "text":"❯ echo", "fg":5025616 },
{ "x":7, "width":16, "text":"select-me-please" } ] } ],
"cursor": { "x":0, "y":5, "shape":"block" },
"modes": { "appCursorKeys":false, "bracketedPaste":true, "altScreen":false } }
The modes on every frame are what let the frontend encode keystrokes
correctly — arrows honor DECCKM, paste honors bracketed-paste, and the
wheel scrolls history on the primary screen but sends arrow keys on the alt screen. That
state used to live invisibly inside xterm.js; now it's explicit on the wire.
The backend rewire
terminal.rs — who owns the engine handle matters
The PTY host kept its structure (a generation counter still makes sure a replaced terminal's exit can't close its successor), but two ownership details are worth understanding because they're the kind of thing that causes double-frees or hangs if you get them wrong.
The reader thread owns the engine
The thread pumping PTY output into the engine is the one that holds the engine handle and drops it at EOF — after resolving the generation-checked map entry. That guarantees the engine thread is joined exactly once, whether the shell exited normally or the PTY was replaced by a reload.
Resize goes two places
term_resize now updates both the PTY size and the engine grid
(reflow is native), and carries the renderer's cell pixel metrics. New commands round out
the surface: term_scroll, term_select, and
term_copy.
Nothing in the app parses terminal content for logic — agent status still comes
from transcript JSONL and /proc, never the byte stream. That's why this was
a rendering-only swap: the attention model that powers the agentboard never touched the
terminal.
The frontend
A canvas that paints frames and nothing more
terminal-view.tsx is now a 2D-canvas renderer over a client-side grid mirror.
It repaints only dirty rows, draws all four cursor shapes, handles wide characters, and
pulls its colors from the host element's computed Tailwind styles so it matches
light/dark. Selection is a translucent overlay driven by ranges the engine sends back.
term-protocol.ts holds the frame types plus the
DOM-key → escape-sequence encoder — the table that turns a
KeyboardEvent into the bytes a shell expects.
A canvas can't receive text input. The fix — the same one xterm.js uses — is a
hidden <textarea> that holds focus and catches keydown, IME
composition, and paste. Without it, dead keys and non-Latin input (the classic
canvas-terminal trap) would break.
Selection & the payoff
Selection that knows what a word is
Double-click selection uses Ghostty's own word-boundary rules, computed in the engine; the frontend just sends cell coordinates and paints the highlight ranges that come back. One subtlety worth knowing:
A selection change forces a full-frame render. Selections don't reliably mark rows dirty, so a full frame is the guarantee that highlights — and de-selection — repaint everywhere instead of leaving stale highlight behind.
The numbers that make it viable
At those numbers the terminal-state layer simply isn't a cost. And per Mitchell's throughput post, most of Ghostty's speed work lands in libghostty-vt ABI-compatibly — so the app inherits future gains by upgrading the pin.
The honest ledger
What got better, what it cost
Gains
- State lives in Rust. Scrollback survives WebView reloads and can be serialized — xterm.js lost it on every reload.
-
Ghostty-grade emulation. Native reflow on resize; alt-screen TUIs like
toprender correctly first try. - Renderer is swappable. The frontend is just a frame consumer now.
Costs
- You wrote a renderer and a key encoder — a few hundred lines xterm.js gave for free.
- Zig 0.15.x joined the toolchain (the bindings build libghostty from source).
- Alpha API. Upgrades are now deliberate, pinned events.
App restart still kills shells and agents — only the rail's session definitions
survive; the app is the sole host, no daemon. The near-term win is
reattach-on-WebView-reload (the engine already holds the state; today
term_start replaces instead of reattaching). Also deferred: mouse-event
reporting to TUIs, grapheme clusters, a scrollbar.
Prove it
Comprehension check
Ten questions on the reasoning above — not trivia, the why. You need 8 of 10 to pass. Pick an answer for each, then check.