Summary
A user report flagged useTick() (120ms / ~8Hz) as a potential
source of TUI jank — multiple useTick callers in Spinner.tsx
and LiveRows.tsx re-rendering every frame. The general direction
is worth investigating, but the report's specific diagnosis has
errors that would mislead anyone acting on it directly. Filing a
narrower, measurement-first audit instead of treating it as a known
bug.
What's correct
useTick is wired to Ink 7's useAnimation at 120ms
(src/cli/ui/ticker.tsx:20,52).
Spinner.tsx:19 calls it for frame rotation.
src/cli/ui/layout/LiveRows.tsx has 5 callsites (line
50 useSlowTick, lines 110 / 164 / 221 / 297 useTick).
What the report got wrong
These need correcting in any follow-up so we don't act on bad
premises:
-
"useAnimation triggers reconciliation of the whole Ink root."
No. The comment at ticker.tsx:5-8 is explicit: Ink consolidates
all useAnimation callers into a single shared interval. Each
caller does a normal React state update — its own subtree
re-renders, not the whole tree.
-
"App.tsx is 3779 lines so the VDOM is huge." Line count is
not VDOM size. Render output determines VDOM cost; file length
doesn't.
-
"8Hz ANSI diff is itself a perf issue." 120ms is a normal
terminal animation cadence. Windows Terminal / Kitty / iTerm2
handle it without strain. Slow terminals (SSH / tmux) can suffer,
but the bottleneck there is per-frame work, not output rate.
The real cost shape is per-tick: subtree re-render → Yoga layout
pass → frame buffer diff → terminal write. The lever isn't fewer
ticks, it's smaller per-tick work.
What to actually do
Order matters. Don't optimize before measuring.
1. Measure first
Capture frame-time stats on three target environments:
- Windows Terminal (native PowerShell)
- macOS / Linux native terminal (Alacritty or iTerm2)
- SSH + tmux on a typical remote (proxy for "slow terminal")
Run a representative session: streaming reply with multiple live
spinners + activity rows. Log per-frame:
- React render time
- Yoga layout time (if Ink exposes it)
- ANSI diff byte count
- Terminal write latency
Without this data, we'd be guessing.
2. Audit useTick callers
For each of the 6 callsites (1 in Spinner, 5 in LiveRows), ask:
does this animation actually need 8Hz? Many UI cues read fine at
3-4Hz:
- "thinking..." dot pulses → 250-300ms is indistinguishable
- elapsed-counter updates → already on
useSlowTick (1Hz)
- glyph rotations on idle status rows → 250ms+ is fine
Downgrade to useSlowTick (1Hz) where the visual difference is
imperceptible. Keep useTick only where the user genuinely sees
8Hz motion.
3. Add React.memo to tick-driven leaves
Spinner is a textbook React.memo candidate: pure props in, single
char out. Memoize so siblings don't re-render when only the spinner
frame changes. Same treatment for the useTick-calling components
in LiveRows where appropriate — wrap each tick-driven leaf in memo
with a stable props equality.
This caps the subtree size that has to re-render per tick.
4. Confirm TickerProvider pause coverage
ticker.tsx:37-39 exposes a disabled prop that flips isActive
to false, pausing all animations. Confirm it's wired to:
- PLAIN_UI mode (already documented)
- Modal overlays (already documented)
- Idle-gate (already documented)
- And any new path that should be quiescent (e.g. plan mode picker
open, image preview, etc.) — verify each one.
A quiescent TUI should be byte-stable; any tick output during idle
is a leak.
Out of scope
- Replacing Ink with a different renderer.
- Lowering the global tick rate below 120ms — too aggressive without
evidence, and would make legitimate animations feel sluggish.
- Rewriting
useTick / useSlowTick themselves — the primitives
are fine; the question is who calls them and how.
Acceptance
- Frame-timing measurements committed for three target envs
(or at minimum: a documented local repro showing the worst case).
- Each
useTick callsite either justified at 120ms or downgraded
to useSlowTick.
React.memo applied to Spinner and the appropriate tick-driven
leaves in LiveRows.
- No regression in animation smoothness on the fast-terminal path.
Why now
Self-noticed via a user report. No measured user-visible jank yet —
this is preventive. Same render-budget family as #551 (dashboard
unresponsive) and #557 (spinner stuck) — closing all three together
gives us a clean main-thread render budget story.
Relates to
Summary
A user report flagged
useTick()(120ms / ~8Hz) as a potentialsource of TUI jank — multiple
useTickcallers inSpinner.tsxand
LiveRows.tsxre-rendering every frame. The general directionis worth investigating, but the report's specific diagnosis has
errors that would mislead anyone acting on it directly. Filing a
narrower, measurement-first audit instead of treating it as a known
bug.
What's correct
useTickis wired to Ink 7'suseAnimationat 120ms(
src/cli/ui/ticker.tsx:20,52).Spinner.tsx:19calls it for frame rotation.src/cli/ui/layout/LiveRows.tsxhas 5 callsites (line50
useSlowTick, lines 110 / 164 / 221 / 297useTick).What the report got wrong
These need correcting in any follow-up so we don't act on bad
premises:
"useAnimation triggers reconciliation of the whole Ink root."
No. The comment at
ticker.tsx:5-8is explicit: Ink consolidatesall
useAnimationcallers into a single shared interval. Eachcaller does a normal React state update — its own subtree
re-renders, not the whole tree.
"App.tsx is 3779 lines so the VDOM is huge." Line count is
not VDOM size. Render output determines VDOM cost; file length
doesn't.
"8Hz ANSI diff is itself a perf issue." 120ms is a normal
terminal animation cadence. Windows Terminal / Kitty / iTerm2
handle it without strain. Slow terminals (SSH / tmux) can suffer,
but the bottleneck there is per-frame work, not output rate.
The real cost shape is per-tick: subtree re-render → Yoga layout
pass → frame buffer diff → terminal write. The lever isn't fewer
ticks, it's smaller per-tick work.
What to actually do
Order matters. Don't optimize before measuring.
1. Measure first
Capture frame-time stats on three target environments:
Run a representative session: streaming reply with multiple live
spinners + activity rows. Log per-frame:
Without this data, we'd be guessing.
2. Audit
useTickcallersFor each of the 6 callsites (1 in Spinner, 5 in LiveRows), ask:
does this animation actually need 8Hz? Many UI cues read fine at
3-4Hz:
useSlowTick(1Hz)Downgrade to
useSlowTick(1Hz) where the visual difference isimperceptible. Keep
useTickonly where the user genuinely sees8Hz motion.
3. Add
React.memoto tick-driven leavesSpinner is a textbook
React.memocandidate: pure props in, singlechar out. Memoize so siblings don't re-render when only the spinner
frame changes. Same treatment for the
useTick-calling componentsin LiveRows where appropriate — wrap each tick-driven leaf in memo
with a stable props equality.
This caps the subtree size that has to re-render per tick.
4. Confirm
TickerProviderpause coverageticker.tsx:37-39exposes adisabledprop that flipsisActiveto false, pausing all animations. Confirm it's wired to:
open, image preview, etc.) — verify each one.
A quiescent TUI should be byte-stable; any tick output during idle
is a leak.
Out of scope
evidence, and would make legitimate animations feel sluggish.
useTick/useSlowTickthemselves — the primitivesare fine; the question is who calls them and how.
Acceptance
(or at minimum: a documented local repro showing the worst case).
useTickcallsite either justified at 120ms or downgradedto
useSlowTick.React.memoapplied to Spinner and the appropriate tick-drivenleaves in LiveRows.
Why now
Self-noticed via a user report. No measured user-visible jank yet —
this is preventive. Same render-budget family as #551 (dashboard
unresponsive) and #557 (spinner stuck) — closing all three together
gives us a clean main-thread render budget story.
Relates to
runningfrom an authoritative inflight set #557 — inflight cards occasionally fail to auto-closesame "expensive work in render path" theme)