Repository navigation
feat(client)!: own the terminal layer on official libghostty-vt - #48
Merged
Merged
Conversation
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).
…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.
…ller at compile time
…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 modules that own them
… 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.
…he last two real gaps
…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
… shell ignored SIGTERM from
…h's bar, points where the app scrolls, and can dump each run's frames
…scroll in which nothing moved
…it ends, as Ghostty's renderer wakes
… ADR 068 on app modes and scroll behaviour
…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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
src/client/vendor/ghostty-web/PROVENANCE.md) and drops the npm dependency.ghostty-vt-small.wasm, verified offline on every test run) and a new binding. The Coder engine is deleted.scrollbackLines(default 10,000,0= no line limit, 32 MiB ceiling) instead of a few hundred rows derived fromscrollback.Terminal features (as Ghostty does them)
clipboardWrite).bellTitle).desktopNotifications).Scrolling
lessnow scroll with the wheel and trackpad. A wheel is told from a TrackPoint at any browser zoom.bun run bench:scroll, SPECdocs/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
wsimport 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.Code organisation
createServerwith a session store and a route table, and the CLI is command modules with injected dependencies. Every listener is released on dispose.scripts/check-architecture.ts,scripts/check-coverage.ts) enforce the layout and per-module coverage targets.Other fixes
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
?engineBREAKING CHANGE: the terminal runs on the official libghostty-vt engine only.
scrollbackno longer sets the terminal's history (it stays the server's replay buffer in bytes); setscrollbackLinesinstead (default 10,000 lines).?engine=coderand/ghostty-vt.wasmare 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.