Skip to content

perf(tui): wrap the transcript lazily so resize and resume stay fast - #358

Merged
Max17190 merged 3 commits into
mainfrom
wrap-the-transcript-lazily
Oct 6, 2026
Merged

Max17190 merged 3 commits into
mainfrom
wrap-the-transcript-lazily

Conversation

@Max17190

@Max17190 Max17190 commented Oct 6, 2026 •

Copy link
Copy Markdown
Owner

Why

Resizing the terminal re-wrapped every transcript block before the next frame could paint. The first paint of a resumed session and a replay into a painted window wrapped the whole history the same way. On a long session each of those frames grew with the session's length, so a resize or a resume stalled visibly. Steady frames also did avoidable work: every visible line was cloned twice per frame (into a frame-sized buffer, then into the paragraph that painted it), and each wrapped row kept its own copy of the text it showed.

Summary

  • Lazy wrapping. Each block records the painted cell width of every source line once, when it is pushed. From that it estimates its height at any width. The estimate is exact unless a line wraps and never exceeds the real wrap (each grapheme counts at most a tab's width). A width change, a first paint, or a replay re-measures every block and wraps only the blocks the viewport shows. Other blocks are wrapped as they scroll into view. Block start offsets are rebuilt lazily from the first stale block, and the total height is kept current as heights change.
  • A view that stays put. The scroll offset names an edge of the content. When a block at or below that edge grows from its estimate to its real height, the offset grows by the same amount, so what is on screen does not move. Scrolls, fold toggles, removing the last user message, jumping to a block, selecting the first block, and arrivals while the live tail is in view all wrap the rows they cross before they apply. The overflow check is exact: a history short enough to fit is wrapped whole to measure it.
  • Painting without clones. The frame-sized line buffer and its reuse key are gone. Visible rows, the sticky header and the live tail are painted straight from the block caches into the frame buffer by a new line painter (ui::text::paint_line) that matches a paragraph cell for cell. The selected block's rows are the one exception: they are still cloned so the selection background can sit under them.
  • No per-row text copies. A wrapped row's text is now a byte range into its block's selectable text. The copy text is rebuilt only when a fold changes.
  • Mouse selection stays on the painted text. Painted rows are remembered by block and row within the block, not by absolute row index. A scroll handled before the next paint can grow blocks above the view and move every absolute index below them. Before this, a click or drag in that window selected and copied text from rows above the one under the pointer. A prompt rejected by a hook is removed from above the notices its turn already painted, which shifts every later block down one index, so rows also name their block by an id assigned in push order and never reused; a row of the removed block names nothing. Pointer events and the selection overlay now resolve each row to its current index.
  • Test support. A test-only counting allocator in the binary lets tests bound what a frame allocates.

No docs, CLI help or model-visible prompt bytes change.

Test Plan

  • Red tests that failed on unmodified code and pass now:
    • app::tests::resize_first_paint_and_replay_wrap_only_the_blocks_in_view: a resize, a first paint and a replay over a long history wrap only the blocks the viewport shows.
    • app::tests::steady_frames_allocate_nothing_per_visible_row: a steady repaint's allocations do not grow with the number of visible rows.
    • app::tests::a_selection_after_an_unpainted_scroll_takes_the_painted_text: a press and drag handled after a scroll and before the next paint select the text that was painted under the pointer.
    • app::tests::a_selection_after_a_rejected_prompt_takes_the_painted_notice: a press and drag handled after a hook rejects a prompt and before the next paint select the notice painted under the pointer, not the one that slid into its place.
    • ui::transcript::tests::painted_rows_keep_their_text_when_a_block_above_is_removed: rows painted before a block above them is removed still resolve to the text they painted, and the removed block's rows resolve to nothing.
  • app::tests::lazy_wrapping_paints_exactly_what_eager_wrapping_paints: compares every frame of a lazily wrapped app with one that wraps everything up front. It runs a scripted walk plus seeded random walks over a long and a short history, covering scrolls, resizes, arrivals, streaming, find, block navigation, folds, Ctrl+O and removing the last user message.
  • ui::text::tests::paint_line_paints_exactly_what_a_paragraph_paints: covers wide, zero-width and control graphemes, overflow, line and span styles, alignment and a gutter prefix.
  • Transcript unit tests:
    • paint_rows_paints_what_the_cloned_lines_paint: also checks that every recorded row reference resolves back to the line it painted.
    • estimates_never_exceed_the_wrap_and_are_exact_when_lines_fit.
    • row_maps_slice_exactly_the_text_they_count.
  • Ignored measurement test measure_resize_and_first_paint_at_scale for timing resize and first paint on a large history.
  • Every existing render and selection test passes. Two changes to old tests are setup only:
    • One fold test wraps all blocks up front instead of building the index.
    • The sticky header test clones the header it compares, because the accessor now returns a reference.
  • cargo test --workspace --locked: pass.
  • cargo +1.97.0 clippy --workspace --all-targets --locked -- -D warnings: pass.

RetriggerConfidence Score: 5/5

No outstanding findings block merging.

Summary

The PR makes transcript wrapping and painting lazy to keep resize, resume, and steady frames responsive. Painted rows now retain stable block IDs, so removing a rejected prompt does not make a mouse selection target a different notice.

Reviews (2) · Last reviewed commit: "fix(tui): keep a mouse selection on the ..."

A width change invalidated and re-wrapped every transcript block before
the next frame could paint, and the first paint of a resumed session and a
replay into a painted window wrapped the whole history the same way, so
each of those frames grew with the session's length. Every frame also
cloned each visible line twice (into a frame-sized buffer, then into the
paragraph that painted it), and each wrapped row kept its own copy of the
text it showed.

Blocks now keep the painted width of each source line, measured once at
push, and size themselves from it at any width: an estimate that is exact
unless a line wraps and never exceeds the wrap. A width change, a first
paint, or a replay re-measures every block and wraps only the ones the
viewport shows; the rest are wrapped as they scroll into view. While an
estimate turns exact, the offset grows with any block at or below the
edge it names, and scrolls, folds, and removals wrap the rows they cross
first, so every frame paints what wrapping everything up front would.

Rows are painted straight from the block caches into the frame buffer by
a line painter that matches the paragraph cell for cell, and a wrapped
row's text is a byte range of its block's selectable text instead of a
copy.
…ed scroll

A press, drag, or release handled after wheel or page input but before the
next paint selected and copied text from rows above the one under the
pointer.

Each painted row was remembered by its absolute row index. A scroll wraps
the blocks it crosses, and a block that turns from its estimate to its real
height moves the index of every row below it, so the last frame's indices
named other text until the next paint.

Painted rows are now remembered by block and row within the block, which
other blocks' heights cannot move, and resolved to the current index when a
pointer event or the selection overlay reads them. The frame comment also
names the one exception to painting without clones: the selected block's
rows.
Comment thread crates/tui/src/ui/transcript.rs Outdated
…ed prompt

A prompt rejected by a user_prompt_submit hook is removed from the
transcript after the notices its turn produced may already be on screen.
Painted rows named their block by index, and the removal shifts every
later block down by one, so a press or drag handled before the next paint
selected and copied the notice below the one under the pointer.

Blocks now carry an id assigned in push order and never reused. A painted
row records the id beside the index it had when painted; when the block at
that index no longer has the id, the row is found by searching the ids,
which removal keeps in order, and a row of the removed block names nothing.
@Max17190
Max17190 merged commit 29c4ed9 into main Oct 6, 2026
16 checks passed
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