Issue #127 · measured on an isolated fixture

Why Reader → Terminal waited, and why coming back missed the end

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.

1 · Where the terminal delay actually was

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 visitms
xterm constructed, socket opened19
transport open20
canonical restore frame22
retained output delivered25
screen painted46
cover removed179
usable179

The screen paints at 46 ms and is hidden until 179 ms

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.

And on a screen whose content sits low, the cover never lifted at all

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.

2 · Time to a usable terminal, before and after

Same fixture, same measurement code, seven consecutive warm switches per row. "Usable" means live content on screen with nothing covering it.

Fixturebeforeafterp50
firstp50rangefirstp50range
shell, top-of-screen prompt179171165–176423617–455×
bottom-only prompt (TUI input bar)200092001220007–20026401310–451539×
alternate-screen TUI173161160–170424011–464×
large retained screen (2000 lines)170168159–178353626–395×
240 ms injected (120 open + 120 restore)410403402–413273274269–2831.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.)

3 · The reader was never reaching the end

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.

A follow decision read out of mid-convergence geometry

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.

Scenariobeforeafter
At the end, no new output, three round trips339 px short, then 372 px0 px, label "At latest"
At the end, ten exchanges arrive while the terminal is up0 px (already worked)0 px, at the new end
Deliberately scrolled backsame message, offset heldsame message, offset held
Scrolled back, pressed Latest, then a round trip372 px short, label off0 px, label "At latest"
Switch inside the 150 ms save debounceone exchange adriftsame message, 24 px

4 · A correction from review: a restore frame is not a warm reattachment

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.

What that cost, before the correction

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.

5 · What the fix does not do

No hidden terminal

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.

No new cache

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.

No forced jump to the end

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.