Performance
September 3, 2026 · View on GitHub
Bear traps
-
Lazy editor.
EditorPane/DiffPaneviaReact.lazyinTaskView. Don't break. -
Keep terminals mounted, hide with
display:none.TaskView/MainAreatoggledisplay:noneinstead of unmounting.mountedTasks: Set<string>in app store keeps every visited task rendered. NEVER switch back tovisibility:hidden: xterm's renderer pauses only on zero geometry (IntersectionObserver), so visibility-hidden terminals kept running WebGL draws for every background TUI repaint — GPU ~90% busy and ~0.5 core of WebContent CPU with the app nominally idle.display:nonealso blurs the hidden pane, pausing its cursor-blink loop. KNOWN COST: WKWebView zeroes scroll offsets inside adisplay:nonesubtree. xterm does NOT self-heal — its buffer position (ydisp) survives, but the DOM.xterm-viewportscroller stays zeroed and nothing re-syncs it when the on-revealfit()lands on unchanged dims; scrolling reads as locked (wheel-up dead, or the bottom unreachable) until new output scrolls the buffer. Both terminal panes repair it on the ResizeObserver zero → non-zero edge viaresyncViewportAfterReveal(src/lib/xtermViewportSync.ts). CodeMirror and plain overflow divs treat the DOM as the source of truth — any scrollable that must survive hiding needsattachHiddenScrollRestore(src/lib/hiddenScrollRestore.ts), as EditorPane/DiffPane do. ONE EXEMPTION: a hidden PDF tab keeps itsdisplayand goes toopacity: 0instead (keepsDisplayWhenHidden, TaskView). The native PDF view owns the page the reader is on, exposes it to no DOM API, and is destroyed inside adisplay:nonesubtree — so unlike every scroller above, there is nothing left to restore. It is safe there and nowhere else: a PDF is a static image that never repaints, so an invisible one costs a composite, not a draw loop. Do not read it as licence to hide anything else this way. 2b. Hiding the WINDOW does not pause the renderers. Same trap as 2, one level up, and the reason windowless mode has a webview half at all.win.hide()(plusActivationPolicy::Accessory) takes the window off screen but leaves the DOM fully laid out — measured:document.visibilityState === "hidden"while.xterm-screenstill reported 1368×1190 with 7 live canvases. xterm keys its pause on ZERO GEOMETRY, which onlydisplay:noneproduces, so a windowless Termic would keep running WebGL draws for a window nobody can see. Rust emitstermic://windowlesson every windowless edge andMainAreadrops the ACTIVE pane's display exemption (src/lib/windowlessMode.ts), which is what actually stops the draws. Deliberately NOT keyed onvisibilitychange: that also fires for an occluded window or a Space switch, and collapsing panes on every Space switch would churn layout and xterm viewport state for a window still one gesture away. Measured cost, 3 tasks idle, WITH the collapse in place: hidden 0.23% CPU vs visible 0.33%. Both are near zero, so treat that delta as directional, not as a headline saving - and memory is not reclaimed at all (every mounted task keeps its scrollback and React tree). Windowless mode is about keeping agents alive, not about getting cheaper. Under load the comparison was confounded by the measurement harness and no reliable figure was obtained. THE 1 Hz CLAMP IS NOW LOAD-BEARING:--wait,termic listand the work-done indicator all ride the settle signal, and there is exactly one timer behind every settle path (thesetIntervalin TerminalPane's settle effect, periodSAMPLE_MS). A knob tuned under the clamp stops being observable the moment Termic has no window, and nothing else catches it. The four knobs live insrc/lib/settleTiming.tswith the floor asserted insettleTiming.test.ts— tune them freely above the floor; going below should mean deleting a test that says why. -
WebGL non-negotiable. Load AFTER
term.open(host). DisposewebglAddonBEFOREterm.dispose()— render loop fires on half-disposed terminal otherwise (_isDisposedcrash). Same fix in TerminalPane AND AuxTerminal. A LOST context is a different thing from a disposed one and must be RECOVERED, not just disposed: WKWebView reaps GPU resources for an idle webview (sleep, hours backgrounded, memory pressure) and every context in the process dies at once, leaving every pane black with a live PTY behind it.loadTerminalRendererre-attaches a fresh addon on a sliding budget (CONTEXT_LOSS_MAXperCONTEXT_LOSS_WINDOW_MS, then it stays on the DOM renderer and force-refresh()es until the losses age out), and probesgl.isContextLost()on focus/visibilitychange because a suspended webview can drop the context without ever firingwebglcontextlost. Any new path that attaches WebGL must check the budget, or it silently undoes the give-up, and any new path must also attach only where the pane has pixels. A context WebKit RESTORES needs the same rebuild and reports none of this:onContextLossnever fires andisContextLost()is false, while xterm's in-place repair leaves the renderer on a stale glyph atlas, so the rawwebglcontextlost/webglcontextrestoredevents on the addon's canvas are the signals that actually cover it. And a pane can go blank with NONE of that firing and no focus/visibility edge to probe on (a laptop driven over screen sharing, Termic frontmost, display asleep): the first input afterAWAY_MSwithout any (src/lib/userPresence.ts) rebuilds the addon unconditionally, recovering a state nothing can detect. Mechanics and the "don't reduce this to a bare dispose" corollary: gotchas.md. -
lineHeight: 1.0in xterm. Anything else inflates cells; TUIs show ribbons between rows. -
Tight Zustand selectors. Never destructure the whole store. Use frozen empty constants (
EMPTY_TABS) for referential stability — React 19 warns "getSnapshot should be cached". GUARDED:src/store/selectorFanout.test.tsmounts 500useTaskTabssubscribers, runs 1000setSidebarWidthwrites (a sidebar drag) and asserts zero snapshot invalidations. Selector bodies are exported fromapp.ts(selectTaskTabs,selectActiveTabId) so the test measures the real thing. Making a selector derive a fresh array turns that 0 into 500,000 and fails the build — verified by injecting the regression, not assumed. -
Math.roundevery dimension. Sub-pixel widths blur glyphs in WKWebView. All sidebar/right-panel/footer/split setters round on write AND onlocalStorageread. -
Disable transitions during drag.
App.tsxgrid usestransition: var(--cols-transition, …)andResizeHandlesets--cols-transition: noneon<html>while dragging. -
A store setter that writes an UNCHANGED value is an idle CPU leak. Zustand 5's
setStatedoesObject.assign({}, state, next)over the whole ~233-keyAppState, and a freshtabsrecord then invalidates every selector in every mounted task plus three module-leveluseApp.subscribeconsumers (cliAgentState,trayAttention,useAttentionNotifier), each of which walks all tasks × all tabs. So the cost of one write is paid across the whole app, not just the tab that changed.setTabLiveTitlehad no equality check whilesetWorkStateandsetWorkProgressdid, and that asymmetry was worth ~a third of idle CPU: xterm firesonTitleChangefor EVERY OSC 0/2 without comparing it to the previous value (InputHandler.setTitle), and an agent TUI re-emits its unchanged title while it sits at the prompt. Measured on a 16-terminal fixture doing nothing but repainting one title twice a second per terminal: 19.02% of a core and 62 store writes/s, falling to 12.70% and 31 writes/s once the setter bailed. Any new setter on a PTY-driven path needs the same bail, and the invariant is a count assertion (app.test.ts"notifies subscribers ONCE for a title repainted 100 times"), so it can gate a PR where a timing would not. NB the profile of this failure has zero WebCore layout/paint frames — it is allglobalFuncCopyDataPropertiesplus GC — so "the terminals aren't drawing" does not mean the terminals are free. -
PTY firehose. Coalesced in Rust: the flusher batches reader output into ≤1 event per 8ms. The flusher and exit-waiter BLOCK on a condvar the reader signals — no sleep-loop polling. A quiet PTY must cost zero timer wakeups; the old
loop { sleep(8ms) }flusher burned 125 wakeups/s per PTY forever, and the oldsleep(1ms)exit-drain spun at ~1000/s (forever, if an orphan held the PTY slave open). On the JS side, the per-chunklastOutputAtstore patch is coalesced to one per 500ms so streaming doesn't re-render tabs/sidebar at chunk rate. KNOWN COST (CLI Phase 2): role-tagged PTYs (agent tabs, aux shell) additionally append each read into a 256 KiBPtyRingunder a mutex on the reader thread (termic logs/ attach backlog) — one uncontended lock + a bounded VecDeque extend per ≤64 KiB read, off the UI path, zero wakeups when quiet; attach taps only cost when a session is live and are bounded (force-detach on overflow). If profiling ever fingers it, gating the ring on "CLI enabled" is the lever. -
A per-line editor annotation is a layout cost; a per-cursor-line one is not. CodeMirror's own rule (
EditorView.decorations): sets provided as a FUNCTION are computed after the viewport and so may not introduce block widgets, while sets provided DIRECTLY may affect layout but cannot read the viewport. An every-line blame column therefore has only two shapes, and both are bad: viewport-scoped and rebuilt on every scroll frame, or directly-provided and height-relevant on all 15,742 lines oflib.rs. The view'sheightRelevantgetter is the line to read:this.block || !!this.widget && (this.widget.estimatedHeight >= 5 || this.widget.lineBreaks > 0). Inline blame (inlineBlameExt.ts) keeps ONE widget on the cursor's line with the defaultestimatedHeight(-1) andlineBreaks0, so moving the cursor never dirties the height map at all, and the DecorationSet REFERENCE is reused whenever the rendered text is unchanged (an unchanged directly-provided set short-circuits CodeMirror's height-map compare; VS Code suppresses the same way viaisResourceBlameInformationEqual). Two more things that are load-bearing rather than tidy: the git fork happens ONCE per file and every later cursor move is an array index (a 15k-line file is ~200 ms ofgit blame, so a per-move fork would be unusable), and the fetch does not start until the cursor leaves position 0, which is what stops a stack of mounted-but-hidden editor tabs from each forking git on open. If you add an every-line mode, read the CodeMirror rule above first and measure the height map, not the frame rate.
The Activity monitor's own cost
The process monitor (ui.md, src-tauri/src/procmon.rs) measures agents' CPU and memory, so it is the one feature where being cheap is the feature. Four decisions, in the order they matter:
- No sampler thread, ever.
procmon_startallocates a session, the Activity window'ssetTimeoutloop is the clock,procmon_stopfrees everything. With the window closed the module holds nothing and there is nothing to wake up — the alternative would have been asleep-poll loop, i.e. bear trap 9 with extra steps. - Cost proportional to OUR processes, not the machine's. One pass of
PROC_PIDT_SHORTBSDINFOover every pid builds the pid→ppid map (64 bytes, one syscall each); the expensive per-process calls (PROC_PIDTASKINFO,proc_pid_rusage) run only for pids inside one of our subtrees. Shelling out topsper pid, whichsandbox::ppid_ofstill does, would fork dozens of processes a second — if that watcher ever shows up in a profile,procmon's map is the replacement. - Measured, and shown. Every snapshot reports its own wall-clock cost in the window's footer (
sampleMs). On this machine, ~700 pids: 8.5 ms in a debug build, i.e. under 1% of a core at 1 Hz, and the number is on screen rather than in a comment. If a future change regresses it, the user sees it before we do. - An occluded window backs off, it does not stop. 1 Hz visible, 5 s hidden. The tick also carries the live-tab-title request to the main window (ui.md); that is an emit of a small map, and an unchanged reply is dropped before it reaches React, so it does not add a render. Stopping dead was the first design and it was wrong twice: it leaves a hole in the history exactly when the user was doing something else ("what spiked while I was in Chrome?" is the question the window exists to answer), and the cost it saves is ~2 ms every 5 s.
The window itself is not free and the docs should not pretend otherwise: a second WKWebView means a second WebContent process, measured at 21 MB phys_footprint / 55 MB RSS next to the main window's 49 MB. That is the price of not being a modal, and the monitor lists its own sidecars rather than hiding them.
phys_footprint, not RSS, and not a sum of RSS: summing resident size across a process tree double-counts every shared page (an agent and its children share the binary and every dylib), which reads ~2× reality. ri_phys_footprint is what Activity Monitor labels "Memory". Cross-checked against an independent ctypes implementation on the same live process before being believed.
CPU% divides a mach-absolute-time delta by a mach-absolute-time wall delta, so the units cancel and no mach_timebase_info conversion is involved — which is the trap it avoids, since that conversion is 1/1 on Intel and 125/3 on Apple silicon (a raw-ticks-as-nanoseconds bug reads 24× low on every current Mac). The timebase is still needed for the cumulative "CPU time" column, and timebase_is_sane pins it.
Worktree creation
The wait after picking an agent is git fetch + git worktree add + files_to_copy. On APFS the copy is clonefile(2) (copy-on-write; writes still diverge). Linux and Windows still walk + fs::copy. libc::clonefile does not link off Darwin, so the fast path is #[cfg(target_os = "macos")].
The default list includes node_modules. A byte copy of a multi-GB tree was 10-15s before the PTY started. ENOTSUP/EXDEV (other volume, non-APFS) fall back to the walk and do not retry clonefile per file.
Do not drop node_modules from the default list to "fix" a slow create without measuring. The clone is what makes that default viable on Mac.
Sub-pixel / rendering hardening
- Force grayscale font smoothing on
html(-webkit-font-smoothing: antialiased) — subpixel AA produces colored fringing on dark backgrounds. - Dialogs use flexbox centering on a full-viewport wrapper, no transforms on
Dialog.Content—-translate-x-1/2 -translate-y-1/2hits sub-pixel offsets on odd viewport widths. - Streaming output /
preboxes inside dialogs needmin-w-0on grid items (defaultmin-width: autooverflows). ResizeHandleis 1px wide (-ml-px/-mt-px) with 4px invisible hit area each side.- Terminal text lighter than native: WebGL atlas rasterizes via Canvas 2D. Mitigation:
terminalFontWeightpref, Medium (500) closes most of the gap. document.fonts.check()lies in WKWebView — use canvas measurement against two baselines (monospace + serif) instead.
Measuring
Two places, and the split is deliberate: counts can gate a PR, timings cannot.
- CI-gateable (counts, invariants, static facts). Runs in
npm test/cargo test/ the e2e job.selectorFanout.test.tsis the worked example. Machine-independent, so a 3-core CI VM gives the same answer as an M1 Max. - Nightly, ungated (startup, memory).
perf/, run by.github/workflows/perf.ymlat 03:30 UTC and never on a PR. Durations and RSS: measurable on a runner, too noisy there to gate a merge. Reports to the run's step summary and a 90-day JSON artifact. - Local only (CPU, GPU, compositor).
perf/local/. Requires a real GPU, a real display and an undisturbed desktop. Readperf/local/README.mdbefore trusting any number it prints: seven documented traps, every one of which produces a plausible wrong number rather than an error.
make perf # perf/nightly, then the local-only perf/local, reported separately
make perf-ci # nightly suite only
Idle CPU is deliberately absent from CI. Why, and what it would take to gate any of this: docs/perf-ci.md.