A re-attaching app replays the screen, not a suffix of a diff stream - #47
Merged
Merged
Conversation
bharathm03
force-pushed
the
antgrid/terminal-reattach-repaint
branch
3 times, most recently
from
August 29, 2026 12:00
cef66db to
2a358b8
Compare
Returning to a session left parts of the terminal blank or showing stale content. Two independent causes, both measured with probes rather than inferred. The bridge drops terminal:output while connState.suppressed - a socket drop, a backgrounded app, or a remote-access flip - and keeps bumping the seq regardless. The only recovery, terminal:snapshot:request, fired solely for tabs the app had never seen, and TerminalService's tier-3 hydrator DISCARDED its seq cutoffs instead of refreshing them. Measured after a reconnect: four hydrator pulls, none of them a terminal one. The replay could not have worked either. _applySnapshot erased the grid and fed back a 10,000-char ScrollbackBuffer tail - a suffix of a DIFF stream, since Ink-style TUIs paint their chrome once and rewrite only the rows that changed. Measured: 4 of 50 rows addressable. Enlarging the buffer cannot fix a stream that was never a screen. So the bridge keeps a headless @xterm/headless VT per PTY (terminal-screen.ts), fed BEFORE the suppression drop, and serializes the visible grid on attach - 9.4 KB for a 200x50 screen, smaller than the broken tail and structurally complete. It composes the whole attach sequence and advertises it with a new optional 'composed' flag; the app applies such a blob verbatim. The preamble resets DECSTBM and defaults the modes @xterm/addon-serialize emits set-only, and deliberately never issues 3J: the app's engine holds far more history than the bridge does, and an erase reaching past the screen would destroy the user's own with nothing able to put it back. getAttachSnapshot registers a tail sink across the serialize barrier, re-checks screen identity after it, and reads the seq afterwards. The barrier is a real suspension point - a terminal can exit and a same-id respawn take the slot across it - and a dead screen stamped with the live PTY's seq arms a cutoff above everything that PTY will ever emit: a blank pane in front of a running process. The hydrator now re-pulls a snapshot for every live tab, on transport re-establishment and on a new narrow focusResumed edge. The backgrounded-app trigger reconnects nothing, so it had no other recovery. Cell metrics are measured synchronously up front (terminal_cell_metrics.dart), so a non-driver remount costs zero engine resizes instead of two. ghostty_vte_flutter does not reflow, so every resize leaked stale fragments out of an Ink-style TUI. Known gap, flagged rather than taken: FileService and PreviewService have the same suppression defect with no recovery. The honest fix is redriveHydrators() on AgentTransport, which lives in the Apache-2.0 packages/ tree - the licence boundary is one-way.
bharathm03
force-pushed
the
antgrid/terminal-reattach-repaint
branch
from
August 29, 2026 12:05
2a358b8 to
18e5636
Compare
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.
Returning to a session left parts of the terminal blank or showing stale content. Two independent causes, both measured with probes rather than inferred.
Cause 1 — nothing ever asked for the missed output
The bridge drops
terminal:outputwhileconnState.suppressedand keeps bumping the seq regardless. Three triggers reach that state and none of them recovered:peerOnline = false(socket drop)appFocusPaused(app backgrounded)mayDeliver(remote-access toggle)TerminalService's tier-3 hydrator made it worse: it discarded its seq cutoffs instead of refreshing them. Measured after a reconnect — the app sentfile:tree:snapshot:request,config:read,preview:snapshot:request,session:list, and zero terminal pulls.Cause 2 — the replay could not have worked either
_applySnapshoterased the grid and fed back a 10,000-charScrollbackBuffertail. That tail is a suffix of a diff stream: Ink-style TUIs (Claude Code) paint their chrome once and rewrite only the rows that changed. Measured: 4 of 50 rows addressable. Enlarging the buffer cannot fix a stream that was never a screen.What changed
A headless VT on the bridge.
bridge/src/terminal-screen.tsruns@xterm/headlessper PTY, fed before the suppression drop, and serializes the visible grid on attach — 9.4 KB for a 200×50 screen, smaller than the broken tail and structurally complete. Chosen over a PTY resize-jiggle, which is a write to satisfy a read, does nothing for exited-but-retained terminals or plain shells, and offers no signal for seq alignment.The bridge composes the whole attach sequence, advertised by a new optional
composedflag onterminal:snapshot; the app applies such a blob verbatim. The preamble resets DECSTBM and defaults the modes@xterm/addon-serializeemits set-only, and deliberately never issues3J— the app's engine holds far more history than the bridge does, and an erase reaching past the screen would destroy the user's own with nothing able to put it back.getAttachSnapshotis barrier-safe. It registers a tail sink across the serialize barrier, re-checks screen identity after it, and reads the seq afterwards. The barrier is a real suspension point: a terminal can exit and a same-id respawn take the slot across it, and a dead screen stamped with the live PTY's seq arms a cutoff above everything that PTY will ever emit — a blank pane in front of a running process.The hydrator re-pulls per live tab, on transport re-establishment and on a new narrow
focusResumededge. The backgrounded-app trigger reconnects nothing, so it had no other recovery.Cell metrics are measured synchronously up front (
app/lib/widgets/terminal_cell_metrics.dart), so a non-driver remount costs zero engine resizes instead of two.ghostty_vte_flutterdoes not reflow, so every resize leaked stale fragments out of an Ink-style TUI.Known gaps, flagged not taken
FileServiceandPreviewServicehave the same suppression defect with no recovery.FileServicedropstree:updatewhile suppressed and still bumps its seq. The honest fix isredriveHydrators()onAgentTransport, which lives in the Apache-2.0packages/tree — the licence boundary is one-way, so hoisting it there is not reversible.driverRowstall, but a non-driver viewer sizes rows from its own viewport. Needs a new protocol field plus a product decision about viewer geometry.New dependencies
@xterm/headless6.0.0 and@xterm/addon-serialize0.14.0, both MIT. The bridge ships as a compiled binary, so both are redistributed — added to thebridge/table inTHIRD-PARTY.md.Test plan
bunx tsc --noEmit -p bridgebun run --filter antgrid-bridge testcd app && flutter testcd app && flutter analyzenpm run check:font-tokensThe single bridge failure is
tests/git-status-freshness.test.ts:156("timed out waiting for the staged file") — a pre-existing load-sensitive flake, not a regression: this PR touches no git-status code, the file is unmodified, it passes 3/3 in isolation at ~2.9s, and the same tree produced 0, 1 and 2 failures across three full-suite runs. ItswaitForbudget is 4000 ms while the file alone already burns ~2.9s of it. Worth widening separately; left alone here to keep this diff on topic.