Skip to content

feat(client)!: own the terminal layer on official libghostty-vt - #48

Merged
jesse23 merged 297 commits into
mainfrom
feat/client-libghostty-vt
Oct 15, 2026
Merged

jesse23 merged 297 commits into
mainfrom
feat/client-libghostty-vt

Conversation

@jesse23

@jesse23 jesse23 commented Oct 14, 2026 •

Copy link
Copy Markdown
Owner

Description

Moves webtty's terminal layer onto Ghostty's official libghostty-vt, rebuilds scrolling, input and output delivery on top of it, adds the Ghostty terminal features that come with it, and reorganises the code into feature modules. Released as 2.0.0 (see the breaking changes at the end).

Terminal engine

  • Vendors ghostty-web's TypeScript layer (now owned and maintained here; changes listed in src/client/vendor/ghostty-web/PROVENANCE.md) and drops the npm dependency.
  • Replaces Coder's patched Ghostty wasm with the official, signed libghostty-vt wasm (ghostty-vt-small.wasm, verified offline on every test run) and a new binding. The Coder engine is deleted.
  • Fixes eight long-standing ghostty-web bugs at the source (pointer, hover reports, DPR, per-frame viewport decode, DECSCUSR, wheel, fit, focus) and deletes the webtty workarounds for them.
  • Terminal history is now set in lines with scrollbackLines (default 10,000, 0 = no line limit, 32 MiB ceiling) instead of a few hundred rows derived from scrollback.
  • Mouse reports, cursor style and paste go through libghostty-vt's own encoders: fixes Alt/Cmd modifiers, drags released outside the terminal and extra mouse formats; bracketed paste with unsafe bytes stripped.

Terminal features (as Ghostty does them)

  • Selection words, lines and text come from libghostty-vt; drag, double click and right-click follow an app's mouse tracking (Shift overrides).
  • Programs can write the clipboard with OSC 52 (clipboardWrite).
  • Focus reports, the app's title in the tab and the bell (bellTitle).
  • Copy, paste and clear-screen keys bound as in Ghostty.
  • Find in the scrollback (Cmd+F on macOS, Ctrl+Shift+F elsewhere) with libghostty-vt's search.
  • Desktop notifications from OSC 9 and OSC 777 (desktopNotifications).
  • Jump between shell prompts and select a command's output with OSC 133, with prompt-mark snippets for zsh and bash.

Scrolling

  • A whole-line scroll copies the rows still in view and draws only the new ones; overscan rows let pixel-smooth scrolling move the canvas across lines (scrolled frames about 15x cheaper, pixel-identical).
  • Mouse wheel notches glide, trackpads stay immediate, and TrackPoint-style sparse streams glide at the input's speed (with a spring for hard pushes); inside apps the scroll ticks are paced instead of sent in bursts. Alternate-screen apps such as less now scroll with the wheel and trackpad. A wheel is told from a TrackPoint at any browser zoom.
  • Output keeps a scrolled-back view on its text instead of pulling it to the bottom, and painting is held during an app's synchronized update (mode 2026), as in Ghostty: pi and Claude Code's fullscreen modes no longer blink while scrolling with any input.
  • A scroll benchmark (bun run bench:scroll, SPEC docs/specs/scroll-benchmark.md) records the screen in headless Chrome and counts blinks, paints and kept-view frames for pi, Claude Code, vim, nvim, yazi, less and a shell, with a trackpad, a mouse wheel and a TrackPoint. It is the reference for future scrolling changes.

Server

  • PTY output is decoded as a stream, so multi-byte characters split across chunks no longer turn into U+FFFD.
  • Output flow control by client acknowledgements: under Bun, the ws import is Bun's built-in shim, which reorders, duplicates and drops messages once a client is ~16 MB behind (also on main). Clients now acknowledge parsed output and the server paces each one, so output always arrives in order.
  • Deleting a session hangs up its shell (SIGHUP); an interactive shell ignored SIGTERM, so its apps kept running.

Code organisation

  • The client is a composition root with feature modules, the vendored layer is split with provenance kept, the server is built by createServer with a session store and a route table, and the CLI is command modules with injected dependencies. Every listener is released on dispose.
  • Architecture and coverage checks (scripts/check-architecture.ts, scripts/check-coverage.ts) enforce the layout and per-module coverage targets.

Other fixes

  • Graphemes drawn from their own row while scrolled; OSC 8 hover underlines only the hovered link; a selection is copied once and only with copyOnSelect; Home/End honour application cursor mode; idle canvas memory down by about 35 MB per tab.

Verified with unit tests (3099), headless Chrome and Firefox matrices (replies, pixels, input bytes, scrolling, history, output ordering), the scroll benchmark, and manual checks in Chrome, Firefox and Safari.

ADRs

BREAKING CHANGE: the terminal runs on the official libghostty-vt engine only. scrollback no longer sets the terminal's history (it stays the server's replay buffer in bytes); set scrollbackLines instead (default 10,000 lines). ?engine=coder and /ghostty-vt.wasm are gone. Cmd+F (Ctrl+Shift+F off macOS) opens webtty's find bar instead of the browser's, a drag in an app that tracks the mouse goes to the app (Shift-drag selects), and Alt-click, not Cmd-click, carries the mouse report's meta bit.

jesse23 added 30 commits October 13, 2026 20:48
A replay was sent as one message, so a large scrollback landed in the
send buffer at once instead of being paced by the client's
acknowledgements. Split it into pieces of the flow chunk size, never
cutting a surrogate pair. What the replay queues is allowed on top of
maxPending, so a scrollback over that limit does not close every tab as
it connects. A legacy client still gets the replay whole.
When the shell exited, clients were closed with 4001 at once, so output
still queued for a client behind the window was dropped, and with it the
last lines the shell wrote. The pacer's end() now sends each client its
queue as acknowledgements allow and closes it once the queue is empty,
or when it goes 2 s without an acknowledgement. The server stops after
the last session only once its clients are closed.
Under load the PTY sometimes writes \r\r\n for a newline, which made the
high-water test count one number short now and then, on the commit that
added it too. Split lines on any run of CRs before a newline.
The server closes a legacy client with 4003 past 8 MB of bufferedAmount,
but under Bun the shim's bufferedAmount grows only once native has 16 MB
queued, and the close frame cannot get past that backlog: the tab sees
1006 a few seconds later. Under Node it gets the 4003. Either way the old
client reconnects to the replay. Say so in ADR 048, the spec and the
limit's comment.
An input frame that is exactly an ack's JSON is taken as an ack and never
reaches the PTY, as with the resize message. Record it in ADR 048, and
why the framing change it would take to tell them apart is not worth it.
A new canvas is 300x150, so the tick's nothing-to-draw check never passed:
the overlay grew to the terminal's size (19.2 MiB at 1400x900, DPR 2) and
read layout every frame with no image placed. Start it at 0x0 and shrink it
back when the last placement goes.
The scratch canvas was sized for the longest run drawn on it, so the first
clear or screenful of output grew it to the view (19.2 MiB at 1400x900,
DPR 2) and it kept that size. Repaint a run in chunks of at most 8 rows,
each with the line below it as before, so it stays 9 rows tall (2.9 MiB).
A row's pixels depend only on its own line and the next, so every row is
left as drawing the run at once leaves it. Record the numbers in ADR 043.
The official engine's hyperlink_id is a 1-bit has-a-link flag, so
matching cells by it underlined every OSC 8 link on screen and repainted
every row holding one on each hover change. Underline the range the OSC 8
provider found, as for regex links, and repaint only its rows.
The selection manager copied on every mouse selection and the client
copied again on onSelectionChange, so the clipboard was written twice,
the text built twice, and copyOnSelect: false still copied. The terminal
now takes copyOnSelect and the selection manager is the only copy.
The input handler's own wheel listener fired when the padding was the
target, which the terminal's capture listener does not stop, so the tick
was sent twice and the second one unpaced. The terminal's paced wheel
handler is now the only path.
A fixed table sent Home, End, PageUp, PageDown, Insert, Delete and F1-F12,
unmodified or with Shift, ignoring application cursor mode (Home and End
sent CSI H/F instead of ESC O H/F under DECCKM) and dropping Shift. Every
key now goes through the official KeyEncoder, as the arrows did, except
Shift+Enter and Shift+Escape, which keep xterm's CR and ESC instead of
Ghostty's modifyOtherKeys CSI 27;2;...~.
webtty's capture listener on the container takes every paste event and
stops it (ADR 037), and a cancelled paste fires no insertFromPaste, so
the handler's paste listeners, handlePaste and paste de-duplication
never ran.
Nothing in webtty calls the terminal's unicode, onKey, onRender,
onCursorMove, writeln, paste, input, clear, blur, programmatic selection,
custom key handler, link provider registration, scrollPages, scrollToTop,
hasBracketedPaste or hasFocusEvents, the selection manager's matching
methods and dirty-row set, or init(). The drag auto-scroll, the one
caller of scrollLines, now calls it typed so the call is visible.
The link providers and the kitty graphics read the active buffer's type,
cursor, length and lines, and each cell's codepoint and hyperlink id.
Remove the rest: onBufferChange, viewportY, baseY, getNullCell, isWrapped
(which cost an isRowWrapped call per line), translateToString, the cell's
text, colour and style getters, and types.ts's unused buffer interfaces.
Nothing in webtty calls the binding's getColors, hasBracketedPaste,
hasFocusEvents or setKittyFlags, or uses types.ts's SGR types,
KittyKeyFlags, RenderStateColors, RGB, Cursor, TerminalConfig,
TerminalMode or its second EventEmitter. The refresh no longer reads the
cursor colour only getColors returned. The binding tests check colours
through OSC 10/11 and cells, and modes through getMode.
webtty resizes from FitAddon.proposeDimensions, so fit() and
observeResize() and their resize state go, and ITerminalCore keeps only
element. Also remove the OSC 8 provider's uncalled findLinkRange,
LinkDetector.invalidateRows, the renderer's setTheme and the palette
array nothing read, and ILink.dispose.
…ty-vt

Decide to encode mouse reports with Ghostty's mouse encoder, take the
cursor shape and blink from Ghostty's state with the config as its
default, and send pastes through ghostty_terminal_paste. ADR 047's
follow-ups for these three now point here, and ADR 013, 017 and 037
note what changes.
Clicks, motion and wheel ticks are encoded by ghostty_mouse_encoder_*
in the tracking mode and format the app set (ADR 049): Alt is +8 and
Cmd is no longer sent as Alt, back and forward are buttons 8 and 9,
UTF-8, urxvt and SGR-pixels formats work, X10 mode 9 gets presses
only, and the wheel follows the format. Positions are in device-pixel
cells with the padding as part of the screen, so the padding reports
the edge cell, and move and release are heard on the document, so a
drag past the edge keeps reporting and its release is sent. Hover
stays 35 and motion stays once per cell, now Ghostty's own check;
the wheel pacer repeats each event's encoded tick. Reports that are
not valid UTF-8 (X10 past column 95) are dropped: text frames cannot
carry them.
The configured cursorStyle and cursorBlink are set as Ghostty's
DEFAULT_CURSOR_STYLE and DEFAULT_CURSOR_BLINK, and the renderer draws
the shape and blink the render state reports, instead of write()
scanning every chunk of output for DECSCUSR (ADR 049). DECSCUSR
overrides the default, Ps 0 and RIS restore it, and a config change
does not override a shape the app chose, as before. Following
Ghostty, mode 12 now also sets blinking, the shape is kept per
screen, and DECSTR restores the default.
Pasted text goes to the terminal's new paste(), which hands it to
ghostty_terminal_paste as a text/plain clipboard paste and sends the
bytes Ghostty writes through onData (ADR 049): bracketed whenever
mode 2004 is on, single lines too, unsafe control bytes replaced with
spaces, and newlines sent as CR without 2004. Text that could run
commands is still sent, never refused (ADR 037). The paste UX is
unchanged: Cmd+V and the context menu through the paste event,
Ctrl+V through the clipboard, and Ctrl+V for an empty text/plain.
A replay's queued pieces were allowed on top of maxPending for the rest
of the connection, so live output could later queue up to the limit plus
the replay before the client was reset. The allowance now ends when the
queue empties (ADR 048).
… cleanup

- Terminal.onSelectionChange had no subscriber after the copy moved into
  the selection manager; removed.
- dispose() removed the wheel listener without capture, so it never
  detached; it now matches how it is added.
- Comments and ADR 047's follow-up rows still described the code ADR 049
  replaced.
Since ADR 049 the cursor's blink came from Ghostty's mode 12 bit, which
both DECSCUSR and mode 12 write. vim's t_ve sends CSI ?12l on exit, so
the cursor stopped blinking for good. Ghostty's app ignores mode 12
whenever cursor-style-blink is set (termio/stream_handler.zig), and
webtty's cursorStyleBlink is always set, but that check is not part of
libghostty-vt.

The binding now keeps the blink the last DECSCUSR, DECSTR or RIS chose
(or the configured default), scanning only chunks that can hold one,
and puts mode 12 back to it after every write and default change.
getSelection copied the spacer cell that follows a wide character as
a space, so "中文字" was copied as "中 文 字", and emoji the same way.
Spacer cells (width 0), including the last column a wide character
wrapped from, are now skipped, and a selection that starts on the
right half of a wide character takes the whole character. Graphemes
and combining marks are copied as before.
The first delta of every gesture comes after a pause, so it was taken as
the start of a sparse stream and glided over up to 125 ms, and the next
over about 33 ms: the view started each gesture behind the fingers. A
stream now turns sparse on its second long gap in a row, as it already
stops being sparse on its second short one; a TrackPoint's first delta
is a few pixels, so its streams still glide (ADR 045).
EdgeExtender copied the canvas's last line and column into the edge
strips on every animation frame, so the page never let the compositor go
idle and every frame was presented. The renderer now counts its draws
(visibleArea.version) and the strips copy only when that or the visible
area changed.
The terminal cancelled every wheel event, and Chrome then holds each
event of a gesture until the page has handled the previous one: with a
trackpad's two events per frame, half missed a frame and scrolling ran
about a frame behind native. Only ctrl+wheel and pinch are cancelled
now, and overscroll-behavior: none keeps the page from rubber-banding or
swiping back. Event to presentation in headless Chrome: about 22 ms to
6.4 ms mean, against about 12 ms for a native scrolling page (ADR 045).
jesse23 and others added 27 commits October 14, 2026 21:16
…ee as built

architecture.md: the server composition, CLI command modules and test strategy
rows are done; the contracts paragraph names the close-code contract test and
drops the temporary ClientConfig alias; the tree drops the flow.ts shim and lists
running-session.ts, the server, CLI and vendor test support as built, and
check-coverage.ts; the ratchet paragraph says the allowlist is empty and strict;
the coverage bullet names the shared src/*.ts layer, PURE_MODULES and what is
not counted.

client.md and kitty-graphics.md name the modules that replaced index.ts (the key
dispatcher, the clipboard, notifications and kitty features, the output
pipeline) and engine/ as the wasm binding; deep-link.md names the renamed pty files.
…files no test loads

check-coverage.ts imports isTestCode from the architecture checker instead of a copy,
and its layer lookup is renamed coverageLayerOf so it no longer shares a name with
source-file.ts's layerOf. Bun writes no lcov record for a file no test loads, so a new
untested module could not fail its layer; the check now lists src/ sources and fails
on any without a record, except the type-only modules and entries in NOT_LOADED.
…res/keyboard/screen-clear.ts

The composition root only builds and wires; screenClearer is keyboard behaviour (ADR 054),
so it and its tests move next to the dispatcher that runs it. The root still builds it and
passes it in as clearScreen. It joins the pure-module coverage list.
…ne link's run

provideLinks was 80 lines, over the SPEC's 60-line budget and not a hot path. The run
read moves to a private linkAt; the scan skips past each run instead of keeping a set of
visited columns, which visits the same cells in the same order.
…e's kind; the root order states the DPR watch move
… the code

- ADRs 059-065 gain an "As built" section: commits, file sizes before and after,
  coverage per layer, and where the work went beyond or differed from each Decision
  (three root moves, not two; features/paste and screen-clear; the shim lifetimes;
  the one commit that does not build).
- The architecture SPEC tree lists recording-context.ts and the vendor tests'
  lifecycle/ folder, and names where the whole-server and subprocess tests sit.
- PROVENANCE records the in-place OSC8LinkProvider.provideLinks restructure.
- AGENTS.md's repo structure names src/ and scripts/ and points at the SPEC.
…view over input profiles and scenarios

A reusable harness in scripts/scroll-bench/ that drives an already running Chrome over
CDP: per server, scenario, input profile (trackpad, wheel, trackpoint) and run it creates
a session, starts the scenario's app, scrolls while recording the screencast, prints one
JSON line and a markdown table of medians. Scenarios come from a JSON file of your own;
scenarios.example.json and stream.sh show the format and the normal-screen stream test.
… and ended as Ghostty does

Every CSI ? 2026 h restarts a one-second timer (Ghostty's sync_reset_ms) and
its expiry turns mode 2026 off in the engine, so DECRQM, the echo render and
the next update agree. The render loop, the echo and the kitty overlay ask
one SyncHold; the write pipeline only reports what it wrote (ADR 067).
… a failed run and a closed tab, and checks its arguments

The step and blink measures look for steps as large as each input makes (the TrackPoint's 156-166 px frames), and the step average leaves out pairs that are a different picture or at the window's edge; a blink needs its neighbours to match outright. A failing run prints an error and the bench goes on, the session is deleted even when the browser fails, CDP calls time out and fail when the socket closes, and screencast acks cannot crash the process. Servers take turns going first, inputs may not repeat, labels are table-safe, and USAGE lists the inputs. Tests cover session create and delete against the in-process server, the ID check from the server, the profile expression and the timeout; dead code and references to lost scratch files are gone.
…runed pages too

Before each write Ghostty's viewport is pinned on the view's top row (or the
part row a smooth scroll shows) and the view goes where the pin went, so the
text stays put through new lines, whole pages pruned at the scrollback limit
and a cleared scrollback. SmoothScroll follows from any write, keeping each
input device's glide on its way; a glide heading for the bottom still ends
there (ADR 066).
…dr, not Claude Code or pi

Claude Code and pi in fullscreen keep their own highlight after copying; the
clear webtty does on release is herdr's behaviour (ADR 051, client and config
SPECs).
…olled view, the sync scan reads bytes, and only the render loop ends an update

- A write no longer resets the view offset and marks every row dirty; the scroll
  tracker repaints a scrolled view in full only for new scrollback, a move or a
  whole changed screen, and dirty rows map to canvas rows from the view's line
- SyncHold scans bytes as bytes; expire(now) ends a stale update from the render
  loop only, and held(now) is a read
- Glide.shift takes the target to end on; SmoothScroll clamps it to the scrollback
- Device tests write during the event run too; a test drives SmoothScroll on the
  real Viewport pin
- ADR 066/067 consequences corrected and dated; config SPEC date; harness header
…h's bar, points where the app scrolls, and can dump each run's frames
…rest is written if the paint throws, and a find-anchored write paints on a frame
…as measured, and the bench header matches the SPEC
…oom, its deltas taken back to 100% and its notches compared by ratio
@jesse23 jesse23 changed the title Own the terminal layer on official libghostty-vt feat(client)!: own the terminal layer on official libghostty-vt Oct 15, 2026
@jesse23
jesse23 merged commit 8e59a07 into main Oct 15, 2026
3 checks passed
@jesse23
jesse23 deleted the feat/client-libghostty-vt branch October 15, 2026 18:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant