feat(web): render terminals with libghostty-vt - #4860
Conversation
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
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. Comment |
ApprovabilityVerdict: 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. |
9a3b61d to
92bd4ec
Compare
…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>
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>
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>
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>
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>
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>
…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>
## 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
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>
Closes #1310.
What changed
Replaces the web terminal's xterm.js parser and renderer with a T3-owned browser adapter over the official
libghostty-vtC ABI.The implementation stays deliberately close to mobile:
A 112-byte companion WASM trampoline connects Ghostty's official
write_ptycallback 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 gzipghostty-write-pty.wasm: 112 bytesThis is an independent implementation of the worthwhile idea behind #3820, avoiding the lifecycle and input limitations of the third-party
ghostty-webwrapper.Expected benefits
Review fixes and reliability pass
All review findings to date are fixed and their threads resolved:
A review-driven polish pass hardened the rest of the adapter:
deltaModewith fractional accumulation, and wheel motion on the alternate screen becomes arrow keys (honoring DECCKM) so vim/less scroll without mouse trackingkeyCode229 composition keydown is guardedValidation
mainCSI > 3 uimages
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
GhosttyRuntimeto load and interact with the WASM module,GhosttyTerminalCoreto manage terminal state and I/O, andrenderGhosttySnapshotto paint dirty rows to a canvas with cursor and selection support.ghostty-vt.wasmandghostty-write-pty.wasmfrom the pinned Ghostty revision.TerminalViewportto useGhosttyTerminalSurfacefor resize, selection, copy, link activation, and exit handling.'wasm-unsafe-eval'inscript-srcto 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-vtWebAssembly, aligned with the Android terminal’s Ghostty revision and C ABI.Adds a reproducible Zig build (
build-libghostty-wasm.sh) forghostty-vt.wasmand a tiny PTY callback trampoline, plus modules for runtime loading, terminal core, rendering, and browser input/selection/IME/links.ThreadTerminalDrawernow mountsGhosttyTerminalSurfaceasynchronously (theme, resize, selection, exit handling) and drops xterm dependencies and CSS.Desktop CSP gains
wasm-unsafe-evalfor WASM.ChatViewwidens 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
GhosttyTerminalSurfaceinstead of xterm.js, with async creation,onResize-driven PTY sizing, and theme updates viasetTheme.@xterm/xtermand@xterm/addon-fitdependencies; adds a build script that downloads Zig and compilesghostty-vt.wasmandghostty-write-pty.wasmfrom the same pinned Ghostty revision used by the mobile app.'wasm-unsafe-eval'to the desktop Electron Content-Security-Policy to allow WASM execution.ChatViewto use the union of all known terminal ID sets when creating or splitting terminals.'wasm-unsafe-eval'in CSP; the custom scrollbar and canvas cursor replace all previous xterm-derived styles.Macroscope summarized 0b3c1fc.