Files
qwen-code/docs
ChiGaoandShaojin Wen 415726c28c fix(cli): window the OpenTUI transcript to fix the blank-screen resume (#13669)
* fix(cli): window the OpenTUI transcript so a long resume cannot blank the screen

@opentui allocates one native TextBuffer per mounted text element, and the
OpenTUI transcript mounted every history item at once. Resuming a session of a
few thousand records exhausted the process, and the resulting throw was silent:
it lands inside a React host-instance creation, @opentui/react's own boundary
catches it, its red-text fallback calls a jsxDEV the bundle does not provide and
throws too, and React unmounts the whole root, leaving an empty alternate screen
with a live process still accepting input.

Mount only the items near the viewport, budgeted in rows rather than items, with
spacers standing in for the rest so the scroll geometry still spans the whole
session. Estimates mirror the values the ink transcript already uses, real
heights are measured per item id and survive unmount, and the scroll position is
sampled on the renderer's frame event so an idle terminal costs nothing.

Also wrap each mounted item in an error boundary whose fallback renders nothing.
A failed item now costs one row instead of the whole UI, and the error reaches
the OPEN_TUI_TRANSCRIPT debug log.

The session picker's preview pane is clipped and does not scroll, so the view
takes an initial anchor and the preview passes 'top'.

* fix(cli): stop the transcript window moving the reading position

Three defects in the windowing's scroll handling:

The height table was cleared on a width change, which renumbered every row
offset underneath a scroll position that is itself counted in rows, so a
resize moved the visible turn by more than a hundred. The entries a resize
actually invalidates are the mounted ones, and the next frame re-measures
those.

A height correction was written back through the sticky-aware scrollTop
setter. No correction is owed: an item the measuring loop can see is already
painted at the height just read from it, and both spacers derive from offsets
a change inside the window leaves alone. Writing one moved the reading
position and, at the tail, took the shell's bottom pin off for the rest of
the session.

The position was sampled only on the renderer's frame event, which fires
after the paint, so an absolute jump — a scrollbar track click or a thumb
drag, which can land anywhere in the transcript — painted one spacer-only gap
first. The scroll bar emits a public change event synchronously before that
paint; subscribing to it covers every scroll path.

Also: the host-less session preview now follows the height prop across a
resize instead of seeding its viewport rows once at mount; the item failure
log carries the component stack and the error stack, matching ink; the two
windowing options no caller ever set are gone, so the cap and the overscan
are pinned by the tests that exercise them; and the cap's binding condition
is stated correctly in the code and in both design docs.

Adds a scroll-host harness so the frame-driven half is reachable from jsdom
at all, and five tests over it, each mutation-proved against the defect it
pins.

* docs(cli): record the two windowing follow-ups the review named

The bundle-level jsxDEV escalation Decision 8 compensates for at the item is
still live for every other subtree, and a window-only move re-executes every
mounted item's render path. Both are named in the design rather than left to be
rediscovered.

* fix(cli): measure transcript items by identity and settle pulled-in rows

Each mounted item now registers its own node under `(kind, id)` instead of
being recovered from its position among the transcript root's children, so
the tool and task cards one subagent call mints no longer overwrite a single
height slot, and the item key no longer collides as a React key either. The
rows recorded are Yoga's computed height rather than the reported one, which
is clamped to a row and would permanently charge a row to an item that
paints nothing.

An upward window move pulls in items whose spacer rows were charged at the
estimate, so the frame that first measures them now subtracts what they
turned out to cost from the scroll position — guarded to fire only strictly
above the tail, where the reader's own scrolling has already released the
shell's bottom pin. Measuring an item that was already mounted still owes
nothing. Measuring also runs when no scroll host is found, so the session
picker's preview records real heights too.

* docs(cli): state the measured mutant victims in the windowing design doc

* fix(cli): settle a transcript row whose recorded height went stale off-window

The settlement for a row the window pulls in above the viewport only fired
for an identity the height table had never seen. A row measured earlier,
evicted, and then re-measured at a different height moved the offsets under a
scroll position nothing re-anchored, so the page lurched on every wheel tick
after a terminal resize or ctrl+O.

Settle on the charge rather than on first-ever measurement, spend the charge
on the measurement that answers it, and never charge an identity the previous
commit already had mounted — which is what a `task-end` splice above the
window would otherwise do, since it shifts the index space the charging loop
walks while the identities on screen stay put.

* docs(cli): keep the windowing design doc true about the stale-height settlement

Decision 5 said the frame that *first* measures a pulled-in item settles it,
and that the charge is always the estimate; the settlement now spends a charge
on the measurement that answers it and never charges an already-mounted
identity. The mutation ledger named ten tests on the scroll-host harness and a
suite total that both moved.

---------

Co-authored-by: Shaojin Wen <[email protected]>
2026-10-13 07:49:01 +00:00
..
2025-12-05 10:51:57 +08:00
2025-12-19 15:52:11 +08:00