libghostty-vt Migration — Comprehension Report

Comprehension Report · code change

The terminal brain moved
from the WebView into Rust

A full read-through of the change that swapped xterm.js for libghostty (Ghostty's own terminal core) inside the Tauri app — with the reasoning behind each decision, and a quiz at the end you have to pass.

branch feat/libghostty-vt-terminals PR #69 3 commits · +930 / −130 built with Fable

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.

Intuition

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:

Caveat

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

portable-pty spawns shell, raw bytes
↓ base64 over Tauri event
xterm.js in WebView parse · state · scrollback · render — everything

After · libghostty

portable-pty spawns shell, raw bytes
↓ bytes
tt-vt engine thread libghostty: parse · state · scrollback
↓ dirty-row frames (JSON)
canvas renderer paint only

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 } }
Intuition

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.

Intuition

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.

Intuition

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:

Intuition

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

418 MB/s
VT parse throughput — 20 MB of styled output in 48 ms (release build).
190 µs
Full render pass over a 200×50 grid — every one of 10,000 cells, worst case.

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 top render 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.
Still open

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.