fix(tui): stop freezing the UI on the periodic git-status refresh - #45
Merged
Merged
Conversation
_refresh_git_statuses ran on the message pump. It shells out twice per worktree — `git status --porcelain` walks the whole working tree — and did so for every worktree, serially, every 30 seconds. Measured on a repo with 22 worktrees: 2.8-3.7 s per sweep, so roughly six seconds of every minute with the app unable to process a keystroke, a terminal chunk or a scroll. It also ran on startup and on every worktree create/remove, including the ones driven over MCP. Its two neighbours in the same file are already @work(thread=True); this one was not. Three changes: - Move both sweeps into worker threads and apply the result on the main thread via call_from_thread, mirroring _refresh_selected_diff. The full sweep keeps its place on the rescan paths, where all worktrees genuinely need refreshing for the sidebar. - Poll only the selected worktree on the timer, and drop the interval from 30 s to 60 s. Git subprocesses per minute on that repo: 88 -> 2. The selected worktree is also refreshed when selection moves, so arriving at a worktree does not show a stale badge. - Merge fetched statuses into the cache instead of replacing it, so a single-worktree refresh cannot drop the other sidebar badges. Alt+G refreshes the selected worktree's git status on demand — git status only, no worktree re-list. priority=True so it works while a terminal pane has focus, which is when you want it. test_sweep_does_not_block_the_message_pump is the regression guard: it makes the fake git calls sleep and asserts the call returns immediately. Removing the decorator fails it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Scrolling the sidebar with j/k fired a `git status` for every worktree passed through. `exclusive=True` on the worker cancels *waiting* on a superseded fetch, but a thread worker cannot interrupt a subprocess that has already started — so a quick scroll down a long sidebar still paid for every one. The throttle has to happen before the worker is spawned. The refresh now waits _GIT_STATUS_DEBOUNCE (0.4 s) for the selection to settle, and any move cancels a fetch queued for the worktree just left, including moving to the orchestrator. Alt+G is unaffected: an explicit request fires immediately and drops the pending debounce. The pass-through and cancellation tests assert on the timer rather than on wall-clock sleeps — a 50 ms window raced with the test's own awaits while panels mounted, which would have been flaky on a loaded CI box. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
_refresh_git_statusesran on the message pump. It shells out twice per worktree —git status --porcelainwalks the whole working tree — and did so for every worktree, serially, every 30 seconds.Measured on a repo with 22 worktrees:
During each sweep nothing responds: no keystrokes, no terminal output, no scrolling. It also ran on startup and on every worktree create/remove — including the ones driven over MCP, so an orchestrating agent spawning worktrees triggered a fresh 3-second freeze per worktree.
The two functions immediately below it in the same file (
_refresh_selected_diff,_refresh_pr_status) already carry@work(thread=True). This one did not.Changes
Move it off the message pump. Both sweeps now run in worker threads and apply their result on the main thread via
call_from_thread, mirroring_refresh_selected_diff.Poll only the selected worktree, once a minute. The timer drops from 30 s to 60 s and fetches one worktree instead of all of them. Every worktree still refreshes on the full-rescan paths —
r, create, remove, MCP worktree changes — which is what the sidebar needs. The selected worktree is also refreshed when the selection moves, so arriving at a worktree doesn't show a badge from the last full rescan.Scrolling the sidebar doesn't poll. The selection-triggered refresh is debounced by 0.4 s, so navigating with
j/kfetches status only for the worktree you land on, not every one you pass through.exclusive=Trueon the worker cancels waiting on a superseded fetch, but a thread worker can't interrupt a subprocess that already started — so the throttle has to happen before the worker is spawned. Any move also cancels a fetch queued for the worktree just left, including moving to the orchestrator.Alt+Gis exempt: an explicit request fires immediately and drops the pending debounce.Merge instead of replace.
_git_statusesis updated key-by-key, so a single-worktree refresh cannot drop the other sidebar badges.Alt+Grefreshes the selected worktree's git status on demand — git status only, no worktree re-list (that's stillr).priority=Trueso it works while a terminal pane has focus, which is exactly when you want it: you just committed and want the badge to catch up.Result
Tests
411 → 376 on this branch (it branches from
main, not from the terminal-performance branch, so it doesn't carry those tests).New
TestGitStatusDebouncecovers the pass-through case, the timer actually firing once the selection settles, cancellation when leaving for the orchestrator, andAlt+Gbypassing the window. Those assert on the timer rather than on wall-clock sleeps — a 50 ms window raced with the test's own awaits while panels mounted, which would have been flaky on a loaded CI box.New
TestGitStatusRefreshcovers: the periodic poll touching only the selected worktree; the full rescan still covering all of them; merge-not-replace; theAlt+Gaction refreshing status without re-listing; the action being a no-op with no selection; and the binding being registered withpriority=True.The regression guard is
test_sweep_does_not_block_the_message_pump— it makes the fake git calls sleep 300 ms and asserts the call returns in under 100 ms. Verified it fails if the@work(thread=True)decorators are removed (6 of the 7 new tests fail).Notes
mainand touches no terminal code, so it's independent of perf: 7.5x cheaper terminal frames, 33x smaller scrollback, and no more git-status UI freeze #44. Both add an## [Unreleased]CHANGELOG section, which will need a trivial merge whichever lands second.🤖 Generated with Claude Code