Skip to content

perf(tui): eliminate streaming render and persistence hotspots#4

Draft
Blankeos wants to merge 5 commits into
mainfrom
cursor/optimize-tui-rendering-acff
Draft

perf(tui): eliminate streaming render and persistence hotspots#4
Blankeos wants to merge 5 commits into
mainfrom
cursor/optimize-tui-rendering-acff

Conversation

@Blankeos

@Blankeos Blankeos commented Jul 18, 2026

Copy link
Copy Markdown
Owner

Summary

Streaming sessions — especially tool-heavy subagent transcripts — lagged because several O(transcript) costs ran on the UI thread on every refresh, and each frame did avoidable deep copies. This PR removes the hotspots without changing any rendered output or behavior.

Streaming persistence (biggest win)

  • HistoryDAO::replace_messages ran a DELETE plus one autocommitted INSERT per message on the UI thread every 250ms streaming snapshot (per concurrently streaming session). Each statement was its own journal rewrite + fsync, so cost grew linearly with transcript length. It now runs in a single transaction with a cached prepared statement.
  • The database is switched to WAL with synchronous=NORMAL, turning snapshots into one cheap log append and keeping readers non-blocking.
  • The session→persistence message conversion deep-cloned every part's JSON payload on top of the caller's own clone; it now moves the owned parts (one deep copy per snapshot instead of two).

Tool-heavy / subagent transcript layout

  • Every streaming layout refresh reformatted all tool parts of the streaming message: JSON clone → serialize → reparse → row formatting → wrapping → syntect diff highlighting, per part. Rendered tool rows are now cached per (message, part), validated by a structural hash of the payload, and scoped to the actively streaming message so memory stays bounded (cache drops when the stream ends or moves to a new message).
  • Tools that can never join exploration/task groups (e.g. edit/write, whose args embed whole file contents) no longer build parsed tool info at all on the grouping check, avoiding a deep JSON clone per part per refresh.

Subagent tab switching (ctrl+x down, left/right)

  • Render caches are retained for the current session family (root + subagent children), so cycling between subagent tabs is a warm-cache render instead of a full transcript rebuild. Only chats outside the family release their caches, which keeps the memory bound (memory no longer scales with every session visited during a run).
  • The status-bar usage text re-walked the whole transcript on every streaming layout refresh, re-serializing tool args (entire file bodies for edit/write) and re-parsing tool payloads to estimate tokens. The completed-message token base is now cached per (session, message count, streaming index); only the streaming message's already-tracked counter changes per refresh.
  • Editor-location inference over the streaming message deep-cloned tool args per refresh and allocated three display strings per path candidate per rendered line. It now reads payloads by reference, precomputes mention needles once per pass, and reuses a single line-text buffer.

Per-frame allocations

  • Chat::render deep-cloned every visible line (all span strings) each frame, at up to 25fps while streaming. The viewport now borrows lines from the layout cache; highlight/selection helpers were made lifetime-generic with Cow-preserving span splitting so cold paths still work unchanged.
  • The event loop cloned the entire terminal cell grid after every full frame to support the isolated subagent spinner fast-path, even when that path could never run. The copy is now kept only while the fast-path is active and reuses its allocation via clone_from; spinner frames restore the base buffer in place instead of cloning it twice.
  • isolated_subagent_spinner_interval built the full subagent tab list (session walk + string/theme allocations) every event-loop iteration just to answer "is this a child session"; it now does an O(1) parent-id check. The spinner render resolves its one color via a new allocation-free SessionManager::descendant_position instead of materializing all tabs.

Memory

  • Background chats outside the current session family release their rebuildable line caches; Chat::clear now clears the ordered markdown part cache, which previously survived session resets.

Bug fixes found along the way

  • editor_location_for_selection iterated start_line..=end_line where an unclamped selection can have end_line == usize::MAX, spinning a core at 100% (this also made cargo test hang on the pre-existing chat_selection_action_bar_shows_before_mouse_up test). The scan is now bounded by the cached location table.

Testing

  • New unit tests: tool-row cache hit/invalidation produces identical output and re-renders on payload changes; completed messages don't populate the cache; family-scoped cache retention on session switches; the cached usage base agrees with a fresh walk and invalidates on appends; release_render_caches rebuilds byte-identical output; descendant_position matches descendant_sessions ordering; the selection-location scan terminates on unclamped selections.
  • Full suite: 1009 passed. The 4 remaining failures (ollama catalog tests, test_handle_models_shows_ollama_without_connection, test_edit_tool_renders_codex_style_diff_summary) fail identically on main in this environment (hardcoded developer cwd / shared-cache flakiness) and are unrelated.
  • Manual smoke test of the TUI in tmux: home screen, command palette, input, sessions dialog, and quit all work; the database opens in WAL mode against a pre-existing schema.
  • cargo fmt and cargo clippy are clean relative to main (same warning count, none in touched code).
Open in Web Open in Cursor 

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 18, 2026

Copy link
Copy Markdown

Deploying crabcode with  Cloudflare Pages  Cloudflare Pages

Latest commit: b475102
Status: ✅  Deploy successful!
Preview URL: https://875d095d.crabcode.pages.dev
Branch Preview URL: https://cursor-optimize-tui-renderin.crabcode.pages.dev

View logs

cursoragent and others added 4 commits July 19, 2026 03:09
replace_messages ran a delete plus one autocommitted insert per message on
the UI thread every 250ms streaming snapshot, each with its own journal
rewrite and fsync. Wrap the rewrite in a single transaction with a cached
prepared statement and switch the database to WAL with synchronous=NORMAL
so snapshots become one cheap log append.

Co-authored-by: Carlo Taleon <Blankeos@users.noreply.github.com>
…lines

Tool-heavy streaming messages (the common shape for subagent transcripts)
reformatted every tool part on each layout refresh: a JSON clone, a
serialize + reparse round-trip, row formatting, wrapping, and syntect diff
highlighting per part. Cache each rendered tool row keyed by a structural
hash of its payload, scoped to the actively streaming message so memory
stays bounded; skip building parsed tool info for tools that can never
join exploration/task groups.

Chat::render also deep-cloned every visible line (including all span
strings) on each frame at up to 25fps while streaming. Borrow the visible
lines from the layout cache instead, and make the highlight/selection
helpers lifetime-generic with Cow-preserving span splitting.

Also fix an unbounded scan in editor_location_for_selection when the
selection extends past the cached location table (it could spin on
usize::MAX), and clear the ordered markdown cache in Chat::clear which
previously leaked across session resets.

Co-authored-by: Carlo Taleon <Blankeos@users.noreply.github.com>
The event loop cloned the entire terminal cell grid after every full
frame to feed the isolated subagent spinner fast-path, even when that
path could never run. Keep the copy only while the fast-path is active
and reuse its allocation via clone_from; spinner frames also restore the
base buffer in place instead of cloning it twice.

isolated_subagent_spinner_interval built the full subagent tab list
(strings, theme colors, session walks) on every loop iteration just to
answer a boolean, and the spinner render built it again for one color.
Use a cheap parent-id check and a new allocation-free
SessionManager::descendant_position for the tab color instead.

Chats stored away as background session views now release their
rebuildable line caches, so memory no longer scales with every session
visited during a run.

Co-authored-by: Carlo Taleon <Blankeos@users.noreply.github.com>
The session-to-persistence message conversion cloned every part's JSON
payload; combined with the caller's own clone this deep-copied the whole
transcript twice per streaming snapshot. Consume the owned parts instead.

Co-authored-by: Carlo Taleon <Blankeos@users.noreply.github.com>
@Blankeos
Blankeos force-pushed the cursor/optimize-tui-rendering-acff branch from 0523a62 to 6f6b9eb Compare July 18, 2026 19:11
… usage walk

Switching between subagent tabs (ctrl+x down, left/right) rebuilt the
entire transcript layout on every switch because render caches were
released whenever a chat was stored away. Retain caches for the current
session family (shared root) so cycling subagent tabs is a warm-cache
render again, and release only chats outside the family, keeping the
memory bound from the earlier change.

While viewing a streaming session, the status-bar usage text re-walked
the whole transcript on every layout refresh, re-serializing tool args
(entire file bodies for edit/write) and re-parsing tool payloads to
estimate tokens. Cache the completed-message token base keyed by session,
message count, and streaming index; only the streaming message's counter
changes per refresh.

Editor-location inference over the streaming message also deep-cloned
tool args per refresh via ParsedToolMessage and allocated three display
strings per path candidate per line. Read payloads by reference,
precompute mention needles once per pass, and reuse one line-text buffer.

Co-authored-by: Carlo Taleon <Blankeos@users.noreply.github.com>
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.

2 participants