Skip to content

fix(tui): stop freezing the UI on the periodic git-status refresh - #45

Merged
gioalcamofly merged 2 commits into
mainfrom
fix-git-status-refresh
Sep 29, 2026
Merged

gioalcamofly merged 2 commits into
mainfrom
fix-git-status-refresh

Conversation

@gioalcamofly

@gioalcamofly gioalcamofly commented Aug 13, 2026 •

Copy link
Copy Markdown
Owner

_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:

one sweep 2.8 – 3.7 s
frequency every 30 s
UI frozen ~6 seconds of every minute
git subprocesses 88/minute

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/k fetches status only for the worktree you land on, not every one you pass through. exclusive=True on 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+G is exempt: an explicit request fires immediately and drops the pending debounce.

Merge instead of replace. _git_statuses is updated key-by-key, 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 (that's still r). priority=True so 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

before after
UI blocked per sweep 2.8–3.7 s 0 (threaded)
poll frequency 2/min 1/min
worktrees polled all 22 1 (selected)
git subprocesses/min 88 2
one worktree's status+subject — 269 ms, in a thread

Tests

411 → 376 on this branch (it branches from main, not from the terminal-performance branch, so it doesn't carry those tests).

New TestGitStatusDebounce covers the pass-through case, the timer actually firing once the selection settles, cancellation when leaving for the orchestrator, and Alt+G bypassing 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 TestGitStatusRefresh covers: the periodic poll touching only the selected worktree; the full rescan still covering all of them; merge-not-replace; the Alt+G action refreshing status without re-listing; the action being a no-op with no selection; and the binding being registered with priority=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

🤖 Generated with Claude Code

gioalcamofly and others added 2 commits August 13, 2026 12:43
_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>
@gioalcamofly
gioalcamofly merged commit ad76424 into main Sep 29, 2026
3 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.

1 participant