Skip to content

feat(web): render terminals with libghostty-vt - #4860

Merged
juliusmarminge merged 54 commits into
pingdotgg:mainfrom
StiensWout:agent/ghostty-web-terminal
Jul 31, 2026
Merged

feat(web): render terminals with libghostty-vt#4860
juliusmarminge merged 54 commits into
pingdotgg:mainfrom
StiensWout:agent/ghostty-web-terminal

Conversation

@StiensWout

@StiensWout StiensWout commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Ready for review and feedback.

Closes #1310.

What changed

Replaces the web terminal's xterm.js parser and renderer with a T3-owned browser adapter over the official libghostty-vt C ABI.

The implementation stays deliberately close to mobile:

  • builds web and Android from the same canonical Ghostty revision
  • uses Ghostty terminal, render-state, selection, scrollback, key and mouse encoders, paste, hyperlink, and PTY-reply APIs
  • renders Ghostty cell snapshots into Canvas 2D with style runs and dirty-row updates
  • keeps browser-only behavior (IME textarea, Canvas, clipboard, link activation, and sizing) at the platform boundary
  • shares one WASM runtime per browser tab while retaining and freeing per-terminal handles
  • leaves the existing PTY/WebSocket contracts unchanged for local, remote, relay, and tunnel connections

A 112-byte companion WASM trampoline connects Ghostty's official write_pty callback to JavaScript through Ghostty's exported function table. This preserves terminal-generated replies without depending on Ghostty's unstable Zig API. Captured-session restore temporarily detaches that callback so historical device queries cannot emit replies into the current shell.

The reproducible build script reads mobile's canonical libghostty-vt/VERSION, checks out that exact upstream revision, and vendors the WASM artifact, license, and version. The production build emits separate cacheable assets:

  • ghostty-vt.wasm: 630.9 KB raw / 187.9 KB gzip
  • ghostty-write-pty.wasm: 112 bytes

This is an independent implementation of the worthwhile idea behind #3820, avoiding the lifecycle and input limitations of the third-party ghostty-web wrapper.

Expected benefits

  • one official Ghostty terminal model across Android and web
  • stronger grapheme, Unicode, VT, mouse, selection, hyperlink, and modern keyboard-protocol behavior
  • no dependency on an unmaintained or single-maintainer web wrapper
  • a narrow adapter T3 can profile and evolve without an xterm compatibility facade
  • platform rendering remains replaceable without changing server or transport contracts

Review fixes and reliability pass

All review findings to date are fixed and their threads resolved:

  • application mouse reporting uses Ghostty's official mouse encoder; hover motion is only reported in any-event tracking (DEC 1003), and Shift plus the platform link modifier retain browser behavior
  • attaching a viewport to a session that already exited runs exit handling exactly once, matching the old xterm mount
  • key releases for presses handled locally (keybindings, copy/paste shortcuts, AltGraph text) are swallowed so Kitty report-event-types sessions never see an orphan release

A review-driven polish pass hardened the rest of the adapter:

  • wheel deltas are normalized by deltaMode with fractional accumulation, and wheel motion on the alternate screen becomes arrow keys (honoring DECCKM) so vim/less scroll without mouse tracking
  • drag selections are anchored in Ghostty screen coordinates (streaming output cannot shift the origin) and dragging past the canvas edge autoscrolls into scrollback
  • the canvas refits when devicePixelRatio changes; cell metrics wait for the bundled webfonts; the font stack falls back to locally installed Nerd Font faces for prompt glyphs and devicons
  • the cursor blink restarts from the visible phase on writes, and an unfocused surface shows a steady hollow cursor
  • the IME textarea follows the terminal cursor so candidate windows appear where the user types; Safari's keyCode 229 composition keydown is guarded
  • selection colors come from the app theme with the xterm-parity overlays and no forced foreground; the canvas gets a text cursor and a pointer over link gestures
  • ABI error paths free temporaries on every throwing path, opaque slots are zeroed before first use, and grapheme codepoints are read without an alignment assumption
  • the drawer re-applies the theme after async WASM mount, sends one PTY resize RPC per grid change, and anchors the selection-action menu from Ghostty cell geometry

Validation

  • rebased onto current main
  • focused web tests: 45 passing across the Ghostty ABI, key codes, renderer, surface behavior, and terminal drawer, including vendored-WASM regressions for DEC 1003 mode queries and Kitty release encoding under CSI > 3 u
  • web typecheck, focused lint, and formatting
  • browser QA through real PTYs for mouse SGR bytes, word/line selection actions, OSC 8 activation, scrollback, Unicode/IME, and responsive rendering
  • Electron QA for IME, split lifecycle, and Vim with no page errors
  • production web, desktop, and server builds used for integrated QA
  • reproducible Zig 0.15.2 WASM build from the mobile-pinned Ghostty revision

images

01-terminal-git-graph-nerd-fonts-dark 02-selection-actions-dark 03-selection-light-theme 04-vim-alt-screen-dark 05-htop-dark
06-ghostty-terminal-demo.webm

Feedback on the adapter boundary, checked-in artifacts, callback trampoline, and Canvas 2D as the initial renderer would be especially useful.

Implemented by GPT-5.6-sol through the Codex harness in T3 Code, with review fixes and the reliability pass by Claude Fable 5 through Claude Code.

Note

Replace xterm.js with libghostty-vt WebAssembly for web terminal rendering

  • Replaces the xterm.js terminal renderer with a Canvas 2D renderer backed by libghostty-vt compiled to WebAssembly, aligning the web terminal with the existing Android implementation.
  • Adds GhosttyRuntime to load and interact with the WASM module, GhosttyTerminalCore to manage terminal state and I/O, and renderGhosttySnapshot to paint dirty rows to a canvas with cursor and selection support.
  • Adds a build script to compile and vendor ghostty-vt.wasm and ghostty-write-pty.wasm from the pinned Ghostty revision.
  • Rewrites TerminalViewport to use GhosttyTerminalSurface for resize, selection, copy, link activation, and exit handling.
  • Risk: CSP on the desktop app now permits 'wasm-unsafe-eval' in script-src to allow WASM execution.

Macroscope summarized 0054869.


Note

High Risk
Large renderer swap with async WASM startup, new CSP for WASM, and deep input/selection/mouse behavior; PTY transport is unchanged but terminal UX regressions are the main risk.

Overview
Replaces xterm.js in the web app with a Canvas 2D stack built on vendored libghostty-vt WebAssembly, aligned with the Android terminal’s Ghostty revision and C ABI.

Adds a reproducible Zig build (build-libghostty-wasm.sh) for ghostty-vt.wasm and a tiny PTY callback trampoline, plus modules for runtime loading, terminal core, rendering, and browser input/selection/IME/links. ThreadTerminalDrawer now mounts GhosttyTerminalSurface asynchronously (theme, resize, selection, exit handling) and drops xterm dependencies and CSS.

Desktop CSP gains wasm-unsafe-eval for WASM. ChatView widens terminal ID allocation so drawer and panel terminals cannot collide on one PTY. Third-party notices document the shared Ghostty pin.

Reviewed by Cursor Bugbot for commit 0b3c1fc. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Replace xterm.js with libghostty-vt WebAssembly terminal renderer in the web app

  • Adds a complete Ghostty WASM terminal stack: runtime.ts wraps the WASM ABI, core.ts manages terminal state, renderer.ts paints frames to a canvas, and surface.ts handles input, fonts, scrollbar, and layout.
  • Refactors ThreadTerminalDrawer.tsx to use GhosttyTerminalSurface instead of xterm.js, with async creation, onResize-driven PTY sizing, and theme updates via setTheme.
  • Removes @xterm/xterm and @xterm/addon-fit dependencies; adds a build script that downloads Zig and compiles ghostty-vt.wasm and ghostty-write-pty.wasm from the same pinned Ghostty revision used by the mobile app.
  • Adds 'wasm-unsafe-eval' to the desktop Electron Content-Security-Policy to allow WASM execution.
  • Fixes terminal ID allocation in ChatView to use the union of all known terminal ID sets when creating or splitting terminals.
  • Risk: WASM execution requires 'wasm-unsafe-eval' in CSP; the custom scrollbar and canvas cursor replace all previous xterm-derived styles.

Macroscope summarized 0b3c1fc.

@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: f5e0dab7-0bb9-4e07-8cf7-c0cd346dbba3

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:XXL 1,000+ changed lines (additions + deletions). labels Jul 29, 2026
Comment thread apps/web/src/components/ThreadTerminalDrawer.tsx Outdated
Comment thread apps/web/src/terminal/ghostty/core.ts Outdated
Comment thread apps/web/src/terminal/ghostty/surface.ts Outdated
Comment thread apps/web/src/terminal/ghostty/surface.ts
Comment thread apps/web/src/terminal/ghostty/core.ts Outdated
Comment thread apps/web/src/terminal/ghostty/surface.ts
Comment thread apps/web/src/terminal/ghostty/core.ts
Comment thread apps/web/src/terminal/ghostty/surface.ts
Comment thread apps/web/src/terminal/ghostty/keyCodes.ts
Comment thread apps/web/src/terminal/ghostty/surface.ts Outdated
Comment thread apps/web/src/terminal/ghostty/surface.ts
Comment thread apps/web/src/terminal/ghostty/renderer.ts
Comment thread apps/web/src/terminal/ghostty/core.ts Outdated
Comment thread apps/web/src/terminal/ghostty/surface.ts
Comment thread apps/web/src/terminal/ghostty/surface.ts
@macroscopeapp

macroscopeapp Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

Diff is too large for automated approval analysis. A human reviewer should evaluate this PR.

You can customize Macroscope's approvability policy. Learn more.

Comment thread apps/web/src/terminal/ghostty/surface.ts
Comment thread apps/web/src/terminal/ghostty/surface.ts Outdated
Comment thread apps/web/src/terminal/ghostty/surface.ts Outdated
Comment thread apps/web/src/terminal/ghostty/surface.ts Outdated
Comment thread apps/web/src/terminal/ghostty/surface.ts Outdated
Comment thread apps/web/src/terminal/ghostty/surface.ts
Comment thread apps/web/src/terminal/ghostty/surface.ts Outdated
Comment thread apps/web/src/terminal/ghostty/surface.ts
Comment thread apps/web/src/terminal/ghostty/surface.ts
Comment thread apps/web/src/terminal/ghostty/surface.ts
Comment thread apps/web/src/components/ThreadTerminalDrawer.tsx
Comment thread apps/web/src/terminal/ghostty/surface.ts Outdated
Comment thread apps/web/src/terminal/ghostty/surface.ts
@StiensWout
StiensWout force-pushed the agent/ghostty-web-terminal branch from 9a3b61d to 92bd4ec Compare July 29, 2026 21:02
@StiensWout StiensWout changed the title [WIP] feat(web): render terminals with libghostty-vt feat(web): render terminals with libghostty-vt Jul 29, 2026
Comment thread apps/web/src/terminal/ghostty/surface.ts
…e alive

Viewport-edge link truncation is now decided by Ghostty's authoritative
row soft-wrap flag (GHOSTTY_ROW_DATA_WRAP) instead of a full-row string
heuristic that misjudged rows containing multi-code-unit graphemes; a
link exactly reaching the right edge of an unwrapped row resolves again.

The paste shortcut no longer prevents the default before the async
clipboard read: the native paste event (dispatched synchronously with
the default action) claims a token first when it fires, the clipboard
read covers browsers whose shortcut produces no paste event, and a
denied read leaves the native path intact. The token makes double
pasting impossible in either order.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread apps/web/src/terminal/ghostty/surface.ts Outdated
An empty native paste event (for example a clipboard holding no plain
text) no longer invalidates the pending clipboard read, which remains
the only delivery path in that case.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread apps/web/src/terminal/ghostty/surface.ts
StiensWout and others added 3 commits July 31, 2026 11:20
An empty text/plain paste previously returned before preventDefault, so
a clipboard carrying only richer types could have its converted text
inserted into the hidden textarea and leak through onInput without
bracketed-paste encoding — potentially alongside the async clipboard
read's delivery. The default is now always suppressed; the paste event
and the clipboard read remain the only delivery paths, still
deduplicated by the race token.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Mobile's Vendor/libghostty-vt/VERSION (with the upstream LICENSE beside
it) is now the only copy of the upstream pin. The WASM build embeds the
pinned revision as semver build metadata via the official
-Dlib-version-string option, the web vendor directory holds only the
two artifacts, and the ABI regression test reads the revision back out
of the binary through ghostty_build_info and compares it against
mobile's VERSION — provenance travels inside the artifact instead of a
sibling file that could drift.

Rebuilt ghostty-vt.wasm reproducibly from the pinned revision
(630,932 bytes, +11 over the previous build for the metadata).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The surface now takes an optional font (family and size) and exposes an
async setFont that reloads faces, re-measures cell metrics, resizes the
Ghostty grid, and repaints — nothing is user-facing yet; the drawer
keeps the defaults. A custom text face always keeps the Nerd Font glyph
fallbacks appended so prompt symbols stay covered, and requested sizes
clamp to a sane range.

Per review direction the default text stack is unified to SF Mono with
the bundled JetBrains Mono webfont as the everywhere-else fallback,
dropping the platform faces the bundled font always shadowed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread apps/web/src/terminal/ghostty/surface.ts
Comment thread apps/web/src/terminal/ghostty/surface.ts
StiensWout and others added 4 commits July 31, 2026 12:24
Three renderer reliability fixes:

- Register the write-PTY trampoline with table.grow(1) + table.set
  instead of a grow init value: WebKit stores the init value with broken
  type information and every later call_indirect through it traps with a
  signature mismatch, which killed the surface on the first terminal
  reply (resize size reports made it look like resizing broke the
  terminal: cleared canvas, dead render loop, lost focus). Verified with
  an isolated WebKit probe: grow-init traps, grow-then-set delivers.

- Selection no longer splits text runs: since selection tints only the
  background overlay, splitting runs at selection edges just shifted
  glyph spacing whenever the face's true advance differed from the
  measured cell width.

- Re-measure cell metrics when document.fonts finishes loading faces:
  a cold load could measure the grid from a fallback font and render
  with the real one, producing wrong advances and a cursor detached
  from the text. The surface now refits itself when the loaded faces
  change the measurement.

End-to-end verified in WebKit against the dev server: typing after
resize, drag selection with the action menu, no page errors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Resizing the drawer made lines snap up and down: the grid was anchored
to the top padding, so the sub-row remainder accumulated at the bottom
and the prompt jumped a full row whenever a drag crossed a row
boundary. The grid origin now anchors to the bottom edge — the
remainder sits above row 0, the bottom edge (where the prompt lives)
tracks the container exactly, and row changes grow or shrink the
history at the top. The origin threads through the renderer, hit
testing, IME positioning, the selection-action rect, and the mouse
encoder's per-side vertical padding. This also smooths the equivalent
snapping that existed with xterm on main.

setFont now resolves the requested face and size into locals, tags the
call with an epoch, and only commits fields plus re-measured metrics
after its font load wins — overlapping calls can no longer finish out
of order or leave the fields ahead of the metrics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two remaining sources of resize jitter:

- The canvas backing store was resized in a deferred animation frame,
  so every drag step painted one frame with the old backing stretched
  into the new element box before the repaint landed. ResizeObserver
  callbacks run before paint, so fit() now applies the backing size,
  DPR transform, and a full repaint synchronously — the browser never
  composites a stale frame.

- Every row-boundary crossing notified the PTY immediately, making the
  shell reprint its prompt mid-drag. The local Ghostty grid still
  reflows instantly, but the resize RPC now settles 150ms after the
  last change, so applications redraw once for the final size.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Prompt glyphs and devicons previously depended on a locally installed
Nerd Font. The surface now vendors Symbols Nerd Font Mono (MIT, 1.2MB
woff2) and registers it through the FontFace API when the first
terminal mounts — lazy, cached, and awaited before cell metrics. The
face carries no regular text glyphs, so it composes with the default
stack and any future user-chosen face without affecting metrics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread apps/web/src/terminal/ghostty/surface.ts Outdated
StiensWout and others added 2 commits July 31, 2026 12:47
The bottom-anchored origin was recomputed from the live mount height at
render time, but an animation-frame render can run before the same
frame's ResizeObserver fit. A partial dirty-row redraw could therefore
composite rows at a shifted origin over rows painted at the previous
one, leaving interleaved stale fragments — visible when shell history
recall rewrote wrapped prompt lines during or after a resize. The
origin is now cached and only updated inside fit(), where any change
forces the full repaint that keeps every consumer consistent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A dispose inside the 150ms settle window silently dropped the final
dimensions; the RPC now flushes synchronously on teardown.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread apps/web/src/terminal/ghostty/surface.ts Outdated
Comment thread apps/web/src/terminal/ghostty/surface.ts Outdated
StiensWout and others added 2 commits July 31, 2026 12:52
The dispose flush was mistakenly duplicated into notifyResize's timer
reset, which re-sent the resize RPC on every drag step and defeated the
settle window. The mid-debounce reset is a plain clear again; the
synchronous flush remains only in dispose.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Terminal sessions are keyed by (threadId, terminalId) and ids are
client-allocated, but the drawer seeded its allocator from the
server-known sessions with panel terminals filtered out, while other
call sites ignored panel ids or freshly opened local ids the server
does not know yet. A drawer terminal could therefore mint the id of an
existing panel terminal (deterministically when the drawer was empty),
attaching two viewports to one PTY that mirrored each other.

Every allocator now seeds from the union of server-known ids, the
drawer's local UI-state ids, and the right panel's surface ids, via one
allocatable-ids memo per scope.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread apps/web/src/components/ChatView.tsx
Comment thread apps/web/src/components/ChatView.tsx
StiensWout and others added 2 commits July 31, 2026 13:05
…nges

The allocation-seed swap left several useCallback dependency arrays on
the old sources, so a callback could close over a stale allocatable set
when only a panel terminal or a not-yet-server-known id changed — and
mint a duplicate id anyway. All seven allocator callbacks now depend on
the allocatable memo itself (the script-run callback additionally keeps
its direct activeKnownTerminalIds use).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A short session showed the bottom-anchor's sub-row slack as a visible
gap above the first line. The grid now stays top-anchored like a fresh
terminal until scrollback exists — the point where the prompt actually
lives on the bottom row and resize stability matters. The origin is
recomputed in the render frame (covering both resize- and
content-driven flips) and still only moves together with a forced full
repaint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@juliusmarminge
juliusmarminge merged commit acf761b into pingdotgg:main Jul 31, 2026
16 of 17 checks passed
github-actions Bot added a commit to omarcresp/t3code-flake that referenced this pull request Jul 31, 2026
## What's Changed
* feat(cli): `npx t3 pair` - generate QR code from a running server by @t3dotgg in pingdotgg/t3code#4955
* fix(server): self-update no longer rolls itself back on restart by @t3dotgg in pingdotgg/t3code#5095
* fix(ci): rotate iPad showcase captures without Simulator UI scripting by @juliusmarminge in pingdotgg/t3code#5094
* feat(web): render terminals with libghostty-vt by @StiensWout in pingdotgg/t3code#4860
* refactor: move the canonical libghostty-vt vendor to the repository root by @StiensWout in pingdotgg/t3code#5102


**Full Changelog**: pingdotgg/t3code@v0.0.32-nightly.20260731.964...v0.0.32-nightly.20260731.965

Upstream release: https://github.com/pingdotgg/t3code/releases/tag/v0.0.32-nightly.20260731.965
PauloDaniel1993 added a commit to PauloDaniel1993/t3code that referenced this pull request Jul 31, 2026
Brings in 30 upstream commits since abc409c, including the libghostty-vt
web terminal (pingdotgg#4860), `npx t3 pair` (pingdotgg#4955), project favicon caching (pingdotgg#4767),
and forward-compatible config union decoding (pingdotgg#5055).

Three files needed manual resolution; the rest auto-merged.

- modelOptions.test.ts / AssetAccess.test.ts: both sides added tests and
  imports at the same spot, so both sides are kept.
- ThreadTerminalDrawer: upstream replaced xterm.js with the libghostty-vt
  surface, so the fork's terminal typography feature was reimplemented on
  the new API. Font family and size now flow through GhosttyTerminalSurface
  `font` options and `setFont`, which owns loading, cell remeasurement,
  refit, and re-render, replacing the manual FitAddon dance.

The appearance default font stack ends in `monospace`, which would swallow
the Nerd Font glyph fallbacks the renderer appends to any face it is given,
so the default now defers to the renderer's own stack and only a genuinely
custom face is forwarded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL 1,000+ changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Investigate integrating libghostty

2 participants