Skip to content

perf(tui): fix horrendous scrolling on very large sessions#3710

Merged
dgageot merged 3 commits into
docker:mainfrom
dgageot:perf/tui-large-session-scrolling
Jul 17, 2026
Merged

perf(tui): fix horrendous scrolling on very large sessions#3710
dgageot merged 3 commits into
docker:mainfrom
dgageot:perf/tui-large-session-scrolling

Conversation

@dgageot

@dgageot dgageot commented Jul 17, 2026

Copy link
Copy Markdown
Member

On very large sessions (profiled against a real ~2,450-item history), every frame that touched the /cost dialog or changed hover/selection state was spending tens to hundreds of milliseconds rebuilding output that had not changed. This made scrolling and navigation feel broken.

The /cost dialog now caches its rendered content lines. The cache is keyed on a cheap fingerprint — content width, item count, and aggregate token/cost values — so it stays fresh during live streaming and is invalidated on theme changes. A regression test covers both cases. The expensive per-frame path of re-gathering cost data and re-styling ~1,200 usage lines drops from ~55–90 ms to ~0.6 ms per frame.

The message list was thrashing its 500-entry rendered-item LRU whenever a hover or selection changed. A session larger than the cache means a sequential rebuild scan misses on every entry, re-parsing all markdown each frame (~59 ms for hover, ~292 ms for focused up/down). Three targeted fixes address this: lrucache.EnsureCapacity lets the cache grow to cover all items before a full rebuild scan (memory cost is negligible since the lines are already retained by renderedLines); reasoning blocks are now cached once their spinners and fades have settled (!NeedsTick()); and invalidateItem unconditionally deletes the cache entry since cacheability is state-dependent. Hot paths after the fix are ~0.5 ms. Plain scrolling was already fast and is unchanged.

No behaviour changes outside the rendering path.

dgageot added 3 commits July 17, 2026 10:28
Grow the renderedItems LRU to cover all items before full rebuilds so a
cache smaller than the item count can't evict every entry during the
sequential scan. Cache reasoning blocks once animations settle, delete
cache entries unconditionally in invalidateItem (cacheability is
state-dependent), and drop the unused renderedItem.view field.
EnsureCapacity is added to pkg/lrucache to support grow-only resizing.

Assisted-By: Claude <[email protected]>
…nges

Key the cache on a cheap session fingerprint (width + item count +
tokens/cost) so usage streamed in while the dialog is open refreshes
the view; clear it on ThemeChangedMsg since cached lines carry styled
ANSI output. Adds unit coverage for lrucache.EnsureCapacity and a
live-refresh regression test.

Assisted-By: Claude <[email protected]>
@dgageot
dgageot requested a review from a team as a code owner July 17, 2026 09:04

@docker-agent docker-agent left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Assessment: 🟢 APPROVE

The performance fixes in this PR are well-structured and correct. The EnsureCapacity method on the LRU cache is a clean, safe addition. The cost dialog cache fingerprint logic correctly captures all the values that affect rendered output (width, item count, tokens, cost). The invalidateItem unconditional delete and the !NeedsTick() gate for reasoning block caching both look correct for their intended semantics. No bugs introduced by the changed lines.

@dgageot
dgageot merged commit e484579 into docker:main Jul 17, 2026
10 of 11 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.

3 participants