Terminal: hold the grid server-side, and make resize stop duplicating scrollback - #19
Open
lvwerra wants to merge 6 commits into
Open
Terminal: hold the grid server-side, and make resize stop duplicating scrollback#19lvwerra wants to merge 6 commits into
lvwerra wants to merge 6 commits into
Conversation
Sessions were tmux sessions we shelled out to. They are now PTYs held by this process, each with a libghostty-vt terminal fed from its output. That grid is the authoritative screen, which changes four things: * Reattaching repaints from the grid (replayed scrollback + a snapshot repaint) instead of asking tmux to redraw. Nothing is asked of the agent's TUI, so it works while the agent sits idle. * Several browsers can watch and drive one session. The one-device-at-a-time handover exists because tmux's window-size=latest garbled a shared window; with one server-owned grid there is nothing to hand over. Close code 4001 is no longer sent. * Agent state is a property read. agentInfo() was a synchronous `tmux capture-pane` per session per poll (~5.5ms each, memoized 1.5s to hide it); it now reads text the feed path already rendered (~0.006ms, 0.07ms sampling). Same rendered-text diff as before, so colour-only animation still does not read as work. * capturePane() for the agent-watch API reads the grid too, no subprocess. One grid per session, sized to the SMALLEST attached viewer and broadcast to all of them. A client may request a size but never imposes one: letting each client size itself is what garbles a second device, because the phone resizes the PTY while the laptop keeps drawing into its old geometry. What this costs: tmux outlived the node process, and nothing does now. On a Space that only ever bridged a node restart, since a rebuild or a sleep takes the whole container, and each CLI resumes its own conversation on relaunch. SIGTERM/SIGINT now kill held PTYs so a restart cannot orphan agents. Also gone: copy-mode (tmux owned that mode, so the poller and the overlay hint have nothing to report) and the OSC 52 round trip for selections (scrollback is replayed into the browser, so selections are local). server/migration.test.mjs covers it end to end with a shell session: 23 checks including survive-a-closed-tab, restore-with-scrollback, work done while detached, working/idle transitions, the agent tail API, and two viewers sharing one grid without either being kicked.
Dragging a pane duplicated its scrollback: the same screen re-wrapped at every width the drag passed through, with the welcome logo torn across the copies. The live screen looked right, because the last repaint was. ResizeObserver fires per animation frame, and every tick went straight to pty.resize(). Claude Code's SIGWINCH repaint (measured from a live pane) is `ESC[H`, `ESC[2K ESC[1B` per row, `ESC[H`, reprint — it erases the VISIBLE screen only, and never touches scrollback. So each resize pushes the rows it shrank off the top into scrollback and then prints that content again. One resize leaks a few rows; a drag leaks eighteen partial copies at eighteen widths. So a resize is now coalesced (AM_RESIZE_SETTLE_MS, 120ms) and applied once the asking stops, and the grid is resized BEFORE the PTY so the app's repaint lands in the geometry it was told about. Re-requesting the size a viewer already asked for schedules nothing, which is most of what tab focus and neighbouring panes generate. The browser stops resizing itself. fit.fit() reflowed our buffer against a geometry the session may never adopt — the grid follows the smallest viewer — and drew in-flight bytes at a width they weren't written for; the pane now measures with proposeDimensions() and REQUESTS, then conforms to what the server applied. A collapsed pane asks for nothing at all, rather than shrinking the session for everyone else. After a settled resize every viewer is repainted from the grid, like on attach. That is what covers an app which ignores SIGWINCH (a bash prompt, an agent sitting idle): without it the pane keeps showing the browser's own reflow of its byte log until the app happens to draw something. server/resize.test.mjs pins it with fixtures/repaint-tui.mjs, which repaints the way Claude does so the test costs no tokens: a drag went from 74 duplicated lines and 18 PTY resizes to 0 and 1. On a real claude pane, four copies of the logo in the browser's buffer became one. Also: restore()'s `shared` flag counts subscribers now, like applyGrid, instead of counting sizes — a viewer that hadn't sent its size yet was shared in one and not the other. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Zooming still duplicated lines after the coalescing fix, because coalescing was never the whole story: each zoom click is a real resize, and the duplication does not come from the repaint at all. It comes from REFLOW. Narrowing rewraps the outgoing screen — a 119-column row becomes two rows at 110 — and the excess scrolls up into scrollback, where the TUI's repaint of that same screen leaves it stranded as a copy. One copy per resize. Six clicks, six copies. Measured against both emulators over one drag: the grid archived 80 lines and xterm 119, which is also why the pane looked worse than the grid did. So the screen is cleared before the reflow, leaving it nothing to rewrap, and carried across by hand afterwards from a snapshot re-anchored to the new geometry. snapshotToAnsi positions every row absolutely and emits no newline, so the paint cannot wrap or scroll either. Both emulators go to zero. That is safe only because the screen comes back, so the browser has to skip its reflow exactly when we skip ours — hence `clear` on the grid frame. It resizes inside xterm's write callback: writes are asynchronous, and a resize applied outside the callback overtakes the bytes sent before it. It also assumes the app reprints its screen, which a shell does not — for a shell the rewrapped scrollback IS the log, not a copy, and clearing it would lose real output. So the carry is on for agent CLIs, off for a shell, and corrected at runtime: a screenful of output within 250ms of a resize means the app repainted after all, which is what catches `vim` or a hand-typed `claude` in a shell pane. AM_RESIZE_CARRY=0 turns the whole thing off. resize.test.mjs now drives the browser's own emulator (@xterm/headless, a devDependency, skipped if absent) through the real protocol, so the checks cover what a user sees and not only the grid. Six zoom steps: 0 duplicated tokens in both, against 63 and 101 with AM_RESIZE_CARRY=0 — every check has teeth. Also pinned: a screen the app never repaints survives a resize, and a shell keeps its scrolled-off output. Both suites' boot windows go to 100s: importing the dependency tree off a cold FUSE-mounted workspace took 55s here (express alone 32s), and a boot timeout looks exactly like a broken server. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…re not a copy The carry fixed the duplication by clearing the screen before the reflow, and in doing so quietly ate history: the rows that no longer fitted were dropped, so every zoom-in cost up to a screenful of scrollback and a pane could no longer scroll all the way up. Reported from the dev Space, reproduced at 30 lines gone over six zoom steps. Archiving them instead brought the duplication straight back — 82 duplicated tokens — because for a repainting TUI those rows ARE the screen it is about to reprint. The two failures are the same question with opposite answers, and it cannot be answered at resize time: it depends on what the app does next. So it is answered afterwards. The rows go into limbo, and 250ms later settleArchive looks at whether a screenful arrived: if the app repainted, they were a copy and are discarded; if nothing came — a shell's log, a TUI that exited, an agent sitting idle — they were the only copy and are printed into scrollback with the screen painted back over them. The same verdict updates host.repaints, so a session whose TUI exited stops being treated as one instead of carrying a stale guess forever. Archiving needs rows printed and scrolled off, which took two attempts: a newline on the last row moves the TOP row into scrollback, not the one just printed, so the first version archived blank lines and then painted over the real ones. snapshot.js grows rowsToAnsi for this, and snapshotToAnsi now shares its per-row rendering instead of duplicating it. Verified with the browser's own emulator attached to the real server: six zoom steps lose nothing from the grid and duplicate nothing in either, against 63 and 101 duplicated tokens with AM_RESIZE_CARRY=0. Two losses in that test are NOT ours, and the test says so rather than pretending: bash redraws its prompt on every SIGWINCH and, when wrapped, draws over the rows above it, and xterm's own reflow drops about three lines over a cycle this violent. Both reproduce identically with the carry disabled. The grid — which is what a reattach repaints from — is exact. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…emulator does
Zooming out was painting over the history it had just recovered. Growing a screen
does not push rows into scrollback — it pulls them back DOWN out of it to fill the
new rows from the top (measured: 12 rows -> 30 brings 18 lines back, in libghostty
and xterm alike). The carry painted the outgoing screen top-anchored, straight
over those recovered rows: the history was destroyed, so a zoomed-in pane could
not scroll to the top, and the old screen was left stranded above the app's fresh
repaint, which is the duplication-at-the-top that was reported.
Two changes, both of them the same idea — stop fighting the emulator:
* fitSnapshot anchors at the BOTTOM in both directions, which is where a resize
leaves the screen anyway, so recovered history above it is untouched;
* a resize that only grows takes the plain path entirely — no erase, no carry,
not even a repaint. There is nothing to archive when a screen gets bigger, and
each emulator recovers its own rows; forcing our screen on top of that left a
one-row seam where their counts differed.
The carry paint also stops erasing (snapshotToRows), because the erase was the
other half of what wiped the recovered rows.
While in here, two robustness fixes to the archive verdict, both aimed at real
agent TUIs rather than the fixture: the window is 700ms instead of 250ms so a TUI
that debounces SIGWINCH is not mistaken for one that ignored it, a repaint that
does arrive cancels the wait the moment it lands instead of at the timer, and it
now takes TWO silent resizes to stop treating a session as repainting — one slow
frame used to drop a Claude pane onto the plain reflow path permanently, which is
the duplication this all started with.
Tests: 26 checks, and the zoom-OUT direction is now pinned — every recovered line
survives, and nothing is copied to the top, in the grid and in the browser's own
emulator. The browser scrollback check that used to tolerate a three-line seam now
measures none, so the bound came down with it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…gent does claude-code-3 still showed duplicates on zoom-in, so the fixture was the suspect: it repaints by absolute positioning and always trims its frame to fit, which no agent does. Two better ones, and the answer is in the second. Ink (fixtures/ink-tui.mjs, the framework Claude Code is built on) turns out to be clean: it moves up by its previous frame's height and erases before reprinting, so resizing an Ink app duplicates nothing, however its frame wraps. Ruled out. What reproduces it is a frame TALLER than the screen. An agent renders the tail of its conversation; narrowing the pane wraps those lines; the frame no longer fits; printing it scrolls the overflow into scrollback, where it sits as a copy of what the frame also shows. One copy per print — repaint-tui.mjs now does this with FIXED_LINES set, and a 150->100 column zoom leaves 251 duplicated tokens, rows appearing up to three times because the app painted three times. That part is the app's, not ours: it is what any terminal does with output that overflows, and it measures the same with the resize path disabled. What IS ours is the rewrapped copy reflow archives on top, and that is the difference between 371 duplicated tokens with AM_RESIZE_CARRY=0 and 251 with it on. The new check bounds it at 300 so that share cannot come back unnoticed. Also reverted, having measured it: deferring the carry paint until the app had its say. The theory was that our copy gets scrolled into scrollback by an overflowing print. It does not — such an app erases the screen before printing — so all the deferral bought was a blank pane for a quarter second. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.
Replaces tmux with a libghostty-vt grid held by the backend, then fixes what that
exposed about resizing.
The grid
Every session is one PTY held by the server, with a libghostty-vt terminal fed
from its output. That grid is the authoritative screen, so reopening a pane is a
snapshot repaint plus replayed scrollback rather than asking a TUI to redraw,
several browsers can watch and drive one session at once (they share one grid,
sized to the smallest), and agent state is read from the grid instead of shelling
out
tmux capture-paneper session per poll.What we gave up is tmux outliving the node process. On a Space that only ever
bridged a restart — a rebuild or a sleep takes the whole container — and each CLI
resumes its own conversation on relaunch.
Resize
Reported as duplicated lines and a garbled welcome box after dragging or zooming a
pane. Four separate causes, each measured before and after:
used to resize the PTY. Coalesced now (
AM_RESIZE_SETTLE_MS, 120ms).fit.fit()reflowed its buffer against ageometry the session may never adopt — the grid follows the smallest viewer —
and drew in-flight bytes at a width they weren't written for. The pane now
measures and requests; the server owns the grid.
119-column row becomes two at 110) and scrolls the excess into scrollback,
where the TUI's repaint of that same screen leaves it stranded as a copy. The
screen is cleared before the reflow and carried across by hand instead.
lost 16 lines); archiving them duplicated it, because for a repainting TUI those
rows are the screen it is about to reprint. Whether they are redundant depends
on what the app does next, so the decision is deferred ~700ms: a screenful of
output means copy, silence means history.
Growing a screen is now left entirely alone. It pulls rows back down out of
scrollback (measured: 12 rows → 30 brings 18 lines back), and painting over them
destroyed the history a zoom-out had just recovered.
What is still there, and is not ours
An agent renders the tail of its conversation; narrowing wraps those lines; the
frame becomes taller than the screen; printing it scrolls the overflow into
scrollback as a copy of what the frame also shows. One copy per print, and any
terminal does this: 251 duplicated tokens with this branch, 371 with
AM_RESIZE_CARRY=0. The 120 difference is the share we can prevent, and a checkbounds it so it cannot come back unnoticed.
We could beat a native terminal here — feed the repaint into a throwaway terminal
for a window after each resize and apply only the resulting screen to the grid —
at the cost of holding output back ~250ms, risking lost lines if an agent streams
while you zoom, and needing mode-setting bytes forwarded. Not done here; happy to
add it behind a default-off flag.
Tests
server/resize.test.mjs— 27 checks, driving both the grid and the browser's ownemulator (
@xterm/headless) through the real protocol, over a fixture thatrepaints the way Claude Code does.
AM_RESIZE_CARRY=0fails the duplicationchecks, so they have teeth.
server/migration.test.mjs— 23 checks, unchanged andgreen.
tsc --noEmitandvite buildclean.Two effects the tests document rather than pretend away, both identical with the
resize path disabled: bash draws over the rows above its wrapped prompt on every
SIGWINCH, and xterm's own reflow drops a couple of lines over a violent zoom cycle.
The grid, which a reattach repaints from, is exact.
🤖 Generated with Claude Code