Both symptoms were operator-reported, not diagnosed. Neither turned out to be where it looked. The terminal was never slow to connect or paint — the wait was a cover hiding a screen that was already there. And the reader was not losing its place on the way back: it was never reaching the end in the first place.
The switch was instrumented at every phase and driven through the real
TerminalPane lifecycle with a scripted transport. The phases that could plausibly
have owned a 1–2 second wait own tens of milliseconds:
| Phase, first visit | ms |
|---|---|
| xterm constructed, socket opened | 19 |
| transport open | 20 |
| canonical restore frame | 22 |
| retained output delivered | 25 |
| screen painted | 46 |
| cover removed | 179 |
| usable | 179 |
Everything up to paint is transport and parsing, and all of it fits in about
fifty milliseconds. The gap between painting and uncovering is the whole delay, and it is
the boot cover's own probe: a setTimeout(…, 150) asking, on a delay, a question
that write()'s completion callback already answers exactly.
The probe looks for text in the screen's upper two-thirds. That is the right question for a harness still booting — an agent TUI paints its input bar first and loads its history seconds later — and the wrong one for re-attaching to a session the backend has a snapshot of. A screen that is a bottom-anchored input bar never satisfies it, so the cover sat until its 20-second safety cap: 20.0 seconds of hiding a screen that painted in 10 ms.
Same fixture, same measurement code, seven consecutive warm switches per row. "Usable" means live content on screen with nothing covering it.
| Fixture | before | after | p50 | ||||
|---|---|---|---|---|---|---|---|
| first | p50 | range | first | p50 | range | ||
| shell, top-of-screen prompt | 179 | 171 | 165–176 | 42 | 36 | 17–45 | 5× |
| bottom-only prompt (TUI input bar) | 20009 | 20012 | 20007–20026 | 40 | 13 | 10–45 | 1539× |
| alternate-screen TUI | 173 | 161 | 160–170 | 42 | 40 | 11–46 | 4× |
| large retained screen (2000 lines) | 170 | 168 | 159–178 | 35 | 36 | 26–39 | 5× |
| 240 ms injected (120 open + 120 restore) | 410 | 403 | 402–413 | 273 | 274 | 269–283 | 1.5× |
The last row is the control, re-measured at the injection the committed test uses: 240 ms of transport latency still costs 274 ms afterwards, so 34 ms of work sits on top of the delay and the delay itself is untouched. Only the invented wait is gone. (An earlier version of this page labelled that row 240 ms while reporting numbers from a 30 ms fixture; the row above is the corrected same-fixture measurement.)
The reported symptom was about switching back. The measurement said otherwise: a reader following the latest output was 339 px short of the bottom on first open, before any mode switch, with its "At latest" label already off.
Following the end converges over several passes — pin to the end, render the rows that lands
on, measure them, pin again — because row heights start as a 220 px estimate. In between, the
content below is still estimated, so the distance to the bottom is briefly large while the
scroll position has not moved at all. onScroll re-derived the follow latch from
that distance, and one such sample turned following off for good.
The fix distinguishes the two events that were being conflated: following now ends only when this scroller moves up, not when the distance to its bottom grows. Attributing a scroll event to the wheel that caused it is not available — under automation the scroll can be dispatched before the wheel handler runs — so the rule uses the physical difference instead.
| Scenario | before | after |
|---|---|---|
| At the end, no new output, three round trips | 339 px short, then 372 px | 0 px, label "At latest" |
| At the end, ten exchanges arrive while the terminal is up | 0 px (already worked) | 0 px, at the new end |
| Deliberately scrolled back | same message, offset held | same message, offset held |
| Scrolled back, pressed Latest, then a round trip | 372 px short, label off | 0 px, label "At latest" |
| Switch inside the 150 ms save debounce | one exchange adrift | same message, 24 px |
The first version of this change treated the arrival of a canonical restore frame as proof
that the terminal was being re-attached to a session that already had a screen. Review showed
that is not true of the real server: attach() calls ensureRunning()
and then snapshots, so a session started for that very request is sent a restore frame
too — carrying an empty snapshot of a terminal that has painted nothing yet.
The empty snapshot flipped the probe to whole-screen, so the next frame a cold harness painted — typically its input bar, before its banner and history — removed the cover. Driven through the server's real sequence (empty restore, bottom-only frame at 400 ms, upper-screen repaint at 900 ms), the reviewed revision uncovered at 429 ms: a terminal shown as ready while it was still starting. That trades a visible wait for something worse.
The flag is now set only when the snapshot itself carries a screen. A warm reattachment always does; a cold start does not, and keeps the upper-region rule and its cap. The same sequence now uncovers at 910 ms, on the repaint. The bottom-anchored-TUI case this change exists to fix is unaffected, because that snapshot has content.
Reader mode still opens no socket, keeps no emulator and renders no terminal surface. Verified: a reader-only load opens zero sockets, and thirteen round trips open one transport each and leak none.
An earlier attempt kept a set of "sessions already seen". It was thrown away — but the reasoning that replaced it was also wrong, and section 4 corrects it: a new or starting session is sent a restore frame. What distinguishes the two is whether that snapshot carries a screen, which needs no retained state either.
A reader that deliberately scrolled back still returns to the same message and offset. The bug was not fixed by scrolling every returning reader to the bottom.