cmux NEXT: an indicator in a hidden pane stops its loop; debug.loops lists every loop

Merged
TE
2d ago
+148-5

Carries #24806 (its lane is gone; this VM publishes only lane/ branches), merged up to current feat-cmux-next. Discussion: manaflow-ai/cmuxterm-hq#3716 (and #3658, whose narrowed slice this is).

What a user sees

Nothing changes on screen. A spinner inside a pane that is parked (another tab or workspace selected) no longer keeps its Core Animation loop running on a hidden layer. Showing the pane starts it again, in phase with every other spinner.

Why

A parked pane or workspace is hidden in place (PaneContentView+Parking.retire(), WindowRootView.withdraw()), and its window stays visible. StatusIndicatorView stopped its loop only for window occlusion. The sidebar list and the tab strip push isWindowVisible to their indicators when they hide (SidebarListView, TabStripView.syncShown), but an indicator inside a pane (the agent pane's loading mark, AgentPaneView+Loading.swift) has no such host.

Change

  • StatusIndicatorView: while an ancestor is hidden, the plan carries no animation. viewDidHide refreshes only a view whose loop is running, and viewDidUnhide refreshes only a view whose loop was left off because of a hidden ancestor. Parking a workspace therefore costs its spinning indicators, not every indicator. The view's own isHidden (which follows its glyph) is not counted, so a refresh never re-enters itself. Existing behaviour is unchanged: the deferred resync on show (owesResync), occlusion tracking and the activation cost.
  • debug.loops (new, DebugLoops.swift): walks each window's layer tree once per call and lists every repeating animation (repeatCount = .infinity, repeatDuration > 0, or a group containing one), with its key, layer, owning view and whether it can be seen (window visible and occlusion-visible, no hidden or transparent layer or view above it). It reports loops and hidden_loops per window and in total, with limit capping the item list. debug.wakeups can't see these loops because they run in the render server. No timer, no observer.

Overlap: #24240, #23074 and #24151 also add lines to AppControl.swift's command list. They register different commands, so there's no shared fix.

Tests

StatusIndicatorHiddenAncestorTests: parking the pane stops a working indicator's loop and showing it starts it again; an indicator configured while parked shows its mark and loops once shown. Existing StatusIndicatorActivationCostTests still bound activation resyncs (the hide/unhide path does not touch the resync counter). Compile and tests run in PR CI. Not built locally. Local: check-no-godfiles.sh --base (no growth), check-concurrency.sh ok, check-objc-selectors.py (same 5 failures as base; NSView viewDidHide is in the probe table).

Measuring it

Before/after from scripts/cmux-next/bench-idle.sh with parked busy workspaces, plus debug.loops hidden_loops (expected 0 after). Whether the render server spends anything on a hidden layer's animation is the open question; this PR doesn't claim a CPU win.

Risk

Low. One flag and two overrides in StatusIndicatorView; a debug-only command.

Agent session: agent session

🤖 Generated with Claude Code


Autofix with [code]smith
Open in [code]smith · Need help on this PR? Tag @codesmith-bot with what you need.


Summary by cubic

Stops a spinner's Core Animation loop when its pane is parked, and adds debug.loops to list every repeating animation and whether anyone can see it.

  • An indicator inside a pane (the agent pane's loading mark) kept looping on a hidden layer; StatusIndicatorView now leaves the loop off while an ancestor is hidden and starts it again when the pane shows. Parking a workspace costs the loops of its spinning indicators alone. The view's own isHidden is not counted, and the existing hidden/occlusion and owesResync behavior is unchanged.
  • debug.loops lives in CmuxNextDesign, since check-app-size forbids CmuxNextApp from growing, and walks each window's layer tree once per call, reporting loops and hidden_loops per window and in total, with limit capping the item list. debug.wakeups can't see these loops because they run in the render server.