Pitch
A user just had the TUI "basically die" — sustained lag / unresponsiveness during normal use. This is an audit issue covering the live suspects. Earlier perf passes (#310, #313, #325, #326, #337, #339) closed individual hotspots, but a second-pass scan turned up several still-shipping problems that compound on long sessions or large repos.
Confirmed suspects, ranked by likelihood
S1 — Synchronous git shellouts on the UI thread (HIGH)
Where: crates/tui/src/tui/ui.rs:4838 refresh_workspace_context_if_needed → crates/tui/src/tui/ui.rs:4954 run_git_query.
Every WORKSPACE_CONTEXT_REFRESH_SECS (15s) the render loop runs git rev-parse --abbrev-ref HEAD and git status --short --untracked-files=normal synchronously via Command::new(\"git\").output(). On a busy disk or a large repo, git status regularly takes 100–500ms; that is a hard freeze of the UI thread on a 15-second cadence.
Fix: push both queries to a spawn_supervised task with a debounced result channel; render loop reads the latest Option<String> non-blockingly. Guard against pile-up by skipping if a previous query is still in flight.
S2 — On-screen history: Vec<HistoryCell> has no memory ceiling (HIGH on long sessions)
Where: crates/tui/src/tui/app.rs (history: Vec<HistoryCell>); rendered in full every frame via render → history widgets.
#326 added ceilings for api_messages / tool_log but not for the on-screen history Vec that the renderer walks. Long sessions accumulate cells indefinitely; layout cost and per-frame allocation grow linearly. Combined with #78's earlier scrolling lag, this is the most likely "slowly gets worse and then dies" mode.
Fix: soft cap (e.g. 5000 cells). When exceeded, fold the oldest N into a single "… earlier turns collapsed (use /history to view) …" placeholder cell. Keep the api_messages cap independent so model context isn't affected.
S3 — build_file_tree_inner does a sync recursive read_dir on the UI thread (NEW, uncommitted)
Where: crates/tui/src/tui/file_tree.rs:155 (currently uncommitted on feat/v0.8.6).
FileTreeState::new walks the workspace synchronously when the user hits Ctrl+E. Skips only .git/node_modules/target/.DS_Store — does not honor .gitignore, so monorepos with build outputs in non-standard dirs (dist/, .next/, build/, vendor/, .venv/) walk hundreds of thousands of files on the UI thread. Also no depth cap.
Fix: (a) parse .gitignore (we already depend on ignore transitively via ripgrep's ecosystem — or pull in ignore directly); (b) move the initial walk to spawn_blocking and show a Building file tree… placeholder; (c) lazy-load directory contents on first expand instead of walking everything up front.
S4 — history_has_live_motion linear scan per poll (LOW–MEDIUM)
Where: crates/tui/src/tui/ui.rs:1211 calls history_has_live_motion(&app.history) on every poll iteration (every ~24-48ms). Walks the entire history Vec checking for any running tool / streaming cell.
Cheap per-cell, but with S2 unfixed it's O(n) every 24ms on an unbounded Vec.
Fix: maintain an App.live_motion_count: usize incremented when a streaming/running cell is added and decremented when it finalizes. O(1) check per frame.
S5 — 120 FPS streaming cap can starve frames when history is long (MEDIUM)
Where: crates/tui/src/tui/frame_rate_limiter.rs (MIN_FRAME_INTERVAL = 8.33ms).
When streaming long responses with a deep history, terminal.draw itself can take >8ms (full re-layout + diff against backbuffer). The frame cap doesn't help if the frame budget is the bottleneck. Symptom: the terminal feels "sticky" — a keypress takes ~one frame to register.
Fix: when frame time exceeds budget for N consecutive frames, automatically downshift to low-motion mode (30 FPS + smooth-only chunking). User can override with /lowmotion off. We already have the low_motion plumbing — just add the auto-trigger.
S6 — Streaming chunk fan-out can issue many tiny SSE writes (LOW, but worth measuring)
Where: crates/tui/src/tui/ui.rs:481-594 (streaming push/commit path).
Every SSE delta calls streaming_state.push_content + commit_text and flips needs_redraw. The frame-rate limiter coalesces draws, but the per-delta work (sanitize → push → commit → tokenize for context bar) still runs on the UI thread. On a fast network with a long response, that's hundreds of calls per second.
Fix: add a criterion bench for the push/commit path (#230 is the right place to extend). If hot, batch deltas with a 16ms accumulator before the sanitize+commit step.
Lower-priority cleanups found in passing
crates/tui/src/tui/ui.rs:4070 allocates a new Block for the background fill every frame — use a static or theme-cached value.
crates/tui/src/tui/file_mention.rs:694 does sync read_dir for mention-menu population on the UI thread; bounded by workspace size and only fires on @, but worth spawn_blocking if the user has a large flat directory.
crates/tui/src/tui/clipboard.rs:231 has an unwrap() on std::fs::read for a pasted file — not a perf issue but a panic hazard if the pasted path was deleted between paste and read.
Acceptance
References
Pitch
A user just had the TUI "basically die" — sustained lag / unresponsiveness during normal use. This is an audit issue covering the live suspects. Earlier perf passes (#310, #313, #325, #326, #337, #339) closed individual hotspots, but a second-pass scan turned up several still-shipping problems that compound on long sessions or large repos.
Confirmed suspects, ranked by likelihood
S1 — Synchronous
gitshellouts on the UI thread (HIGH)Where:
crates/tui/src/tui/ui.rs:4838refresh_workspace_context_if_needed→crates/tui/src/tui/ui.rs:4954run_git_query.Every
WORKSPACE_CONTEXT_REFRESH_SECS(15s) the render loop runsgit rev-parse --abbrev-ref HEADandgit status --short --untracked-files=normalsynchronously viaCommand::new(\"git\").output(). On a busy disk or a large repo,git statusregularly takes 100–500ms; that is a hard freeze of the UI thread on a 15-second cadence.Fix: push both queries to a
spawn_supervisedtask with a debounced result channel; render loop reads the latestOption<String>non-blockingly. Guard against pile-up by skipping if a previous query is still in flight.S2 — On-screen
history: Vec<HistoryCell>has no memory ceiling (HIGH on long sessions)Where:
crates/tui/src/tui/app.rs(history: Vec<HistoryCell>); rendered in full every frame viarender→ history widgets.#326 added ceilings for
api_messages/tool_logbut not for the on-screenhistoryVec that the renderer walks. Long sessions accumulate cells indefinitely; layout cost and per-frame allocation grow linearly. Combined with #78's earlier scrolling lag, this is the most likely "slowly gets worse and then dies" mode.Fix: soft cap (e.g. 5000 cells). When exceeded, fold the oldest N into a single "… earlier turns collapsed (use /history to view) …" placeholder cell. Keep the api_messages cap independent so model context isn't affected.
S3 —
build_file_tree_innerdoes a sync recursiveread_diron the UI thread (NEW, uncommitted)Where:
crates/tui/src/tui/file_tree.rs:155(currently uncommitted onfeat/v0.8.6).FileTreeState::newwalks the workspace synchronously when the user hits Ctrl+E. Skips only.git/node_modules/target/.DS_Store— does not honor.gitignore, so monorepos with build outputs in non-standard dirs (dist/,.next/,build/,vendor/,.venv/) walk hundreds of thousands of files on the UI thread. Also no depth cap.Fix: (a) parse
.gitignore(we already depend onignoretransitively viaripgrep's ecosystem — or pull inignoredirectly); (b) move the initial walk tospawn_blockingand show aBuilding file tree…placeholder; (c) lazy-load directory contents on first expand instead of walking everything up front.S4 —
history_has_live_motionlinear scan per poll (LOW–MEDIUM)Where:
crates/tui/src/tui/ui.rs:1211callshistory_has_live_motion(&app.history)on every poll iteration (every ~24-48ms). Walks the entire history Vec checking for any running tool / streaming cell.Cheap per-cell, but with S2 unfixed it's O(n) every 24ms on an unbounded Vec.
Fix: maintain an
App.live_motion_count: usizeincremented when a streaming/running cell is added and decremented when it finalizes. O(1) check per frame.S5 — 120 FPS streaming cap can starve frames when history is long (MEDIUM)
Where:
crates/tui/src/tui/frame_rate_limiter.rs(MIN_FRAME_INTERVAL = 8.33ms).When streaming long responses with a deep history,
terminal.drawitself can take >8ms (full re-layout + diff against backbuffer). The frame cap doesn't help if the frame budget is the bottleneck. Symptom: the terminal feels "sticky" — a keypress takes ~one frame to register.Fix: when frame time exceeds budget for N consecutive frames, automatically downshift to low-motion mode (30 FPS + smooth-only chunking). User can override with
/lowmotion off. We already have thelow_motionplumbing — just add the auto-trigger.S6 — Streaming chunk fan-out can issue many tiny SSE writes (LOW, but worth measuring)
Where:
crates/tui/src/tui/ui.rs:481-594(streaming push/commit path).Every SSE delta calls
streaming_state.push_content+commit_textand flipsneeds_redraw. The frame-rate limiter coalesces draws, but the per-delta work (sanitize → push → commit → tokenize for context bar) still runs on the UI thread. On a fast network with a long response, that's hundreds of calls per second.Fix: add a criterion bench for the push/commit path (#230 is the right place to extend). If hot, batch deltas with a 16ms accumulator before the sanitize+commit step.
Lower-priority cleanups found in passing
crates/tui/src/tui/ui.rs:4070allocates a newBlockfor the background fill every frame — use a static or theme-cached value.crates/tui/src/tui/file_mention.rs:694does syncread_dirfor mention-menu population on the UI thread; bounded by workspace size and only fires on@, but worthspawn_blockingif the user has a large flat directory.crates/tui/src/tui/clipboard.rs:231has anunwrap()onstd::fs::readfor a pasted file — not a perf issue but a panic hazard if the pasted path was deleted between paste and read.Acceptance
git statusnever blocks the UI thread; manual test on this repo + a fresh checkout of a large monorepo (e.g.chromium-class) shows no 15s hitch./yoloshows boundedhistoryVec length; transcript collapse placeholder visible.node_modulesreturns under 500ms (or shows a placeholder while building).live_motion_countfield replaces the linear scan.cargo fmt,clippy -D warnings,cargo test --workspace --all-features --locked, parity gates).References
api_messages/tool_logceilings; this issue extends that to on-screenhistoryBlockalloc is in scope