4.0 KiB
Agent Note: TUI long-session render costs — shared step-timing scan and card line caches
Status: implemented Archived: 2026-08-04
English | 中文
Problem
On a long resumed session (196k events, 2.2k steps, 1.8k tool cards) the TUI took ~12 s to render the transcript and ~800 ms to echo one keystroke. Profiling attributed both to the render path, not to session load (zstd + parse + surface seed is ~1.7 s):
- Every step's timing footer called
stepTimingAt, which replayed the whole event log from index 0 per footer — O(steps × events) on the initial render, ~6 s of CPU. - pi-tui re-renders every component each frame and relies on per-component line caches (its own
Text/Markdowncache by(text, width)).ToolCardComponent.render()andContextCardComponent.render()built throwawaynew Text(...)/new Markdown(...)instances insiderender(width), so every frame — every keystroke — re-wrapped every settled card's output.
Decision
packages/ui/tui/src/chat/timing.ts replaces stepTimingAt with StepTimingTracker: one accumulator per chat mount, created in createTuiChat and threaded through StreamingAssistantComponent into each StepTimingComponent. A query advances a cursor over events appended since the previous query and keeps per-step bucket state in a map, so all footers together cost O(events). The open bucket is accumulated to the query clock at lookup, and a step is pinned at its step/end. The tracker requires the append-only session log (the seq = log length contract).
ToolCardComponent and ContextCardComponent cache their rendered rows keyed by width. The cache drops on every state mutator (updateResult, setVisibility, setExpanded) and on invalidate() (pi-tui's tree-wide cascade), so a state change always re-renders; everything else — including every keystroke frame — returns the cached rows. This restores upstream pi's own component convention (persistent child components plus explicit cachedWidth/cachedLines where rendering is custom, e.g. pi coding-agent bash.ts), which the imperative render(width) bodies here had silently defeated.
Measured on the 196k-event session (tmux, 200×50): resume prompt-ready 12.2 s → 7.2 s; per-keystroke echo 796 ms median → 17 ms (fresh-session parity).
Alternatives considered
- Index
step/startoffsets, keep per-footer replay — removes thefindIndexbut each footer still scans its step's span from a shared array; the tracker's single shared pass is the same complexity win with less bookkeeping. - Restructure the cards into persistent pi-tui child components (upstream pi's primary style) — equivalent steady-state cost, but a larger diff across card state handling for no additional win over the width-keyed cache.
- Cache inside pi-tui's
Container.render— wrong layer: the vendored patch surface would grow, and the contract (components own their caches) already exists upstream.
Consequences
- Typing latency no longer scales with total tool output; the residual per-frame cost is pi-tui's tree traversal and row concatenation, linear in rendered rows. Resume render cost is now dominated by pi-tui's one-time initial layout (~4 s at 196k events) plus load (~1.7 s), both linear.
- The tracker consumes event times as logged and drops the removed implementation's mid-scan
time > atcutoff, which per-footeratvalues make impossible in a shared scan; under a backward wall-clock step each bucket clamps at zero, which can differ from the old cutoff's totals. - Card
render()is no longer a pure function of(state, width)per call — mutators must droplinesCache. A new mutator that forgets to do so shows stale rows; the cache tests inpackages/ui/tui/tests/transcript-card-cache.spec.tspin the contract for the existing mutators. StepTimingTrackerassumes step coordinates are not reused afterstep/end; a duplicatestep/startfor a closed step is ignored rather than restarting the step.