Skip to content

Terminal: hold the grid server-side, and make resize stop duplicating scrollback - #19

Open
lvwerra wants to merge 6 commits into
mainfrom
feat/libghostty-sessions
Open

Terminal: hold the grid server-side, and make resize stop duplicating scrollback#19
lvwerra wants to merge 6 commits into
mainfrom
feat/libghostty-sessions

Conversation

@lvwerra

@lvwerra lvwerra commented Jul 31, 2026

Copy link
Copy Markdown
Member

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-pane per 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:

duplicated tokens
a drag (16 ResizeObserver ticks), before 74
a drag, after 0
six zoom steps, before 63 grid / 101 browser
six zoom steps, after 0 / 0
  1. A resize storm. ResizeObserver fires per animation frame and every tick
    used to resize the PTY. Coalesced now (AM_RESIZE_SETTLE_MS, 120ms).
  2. The browser reflowing itself. fit.fit() reflowed its 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 and requests; the server owns the grid.
  3. Reflow archiving the outgoing screen. Narrowing rewraps every row (a
    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.
  4. Rows a shrink pushes off the top. Dropping them ate history (a 40→24 shrink
    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 check
bounds 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 own
emulator (@xterm/headless) through the real protocol, over a fixture that
repaints the way Claude Code does. AM_RESIZE_CARRY=0 fails the duplication
checks, so they have teeth. server/migration.test.mjs — 23 checks, unchanged and
green. tsc --noEmit and vite build clean.

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

lvwerra and others added 6 commits July 30, 2026 01:19
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>
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