on main help sign in
fix(ui): keep tool spinners animating during long-running tasks
commit646a5053bd58635316bf960f3d8e5d4a0996a24b authorChristian Rocha <christian@rocha.is> dateThu 14 May 2026, 12:48 (5 months ago) parente986e5d
3 files changed+190
internal/ui/chat/agent.go+38 internal/ui/chat/tools.go+12 internal/ui/chat/version_bump_test.go+140
internal/ui/chat/agent.go+38
@@ -53,11 +53,21 @@ func NewAgentToolMessageItem(
5353}
5454
5555// Animate progresses the message animation if it should be spinning.
56+//
57+// Bumps the parent's F6 list-cache version on both the parent-tick and
58+// nested-tick branches. Nested tools are not list entries of their
59+// own — their IDs map to this parent's index in idInxMap
60+// (internal/ui/model/chat.go:240-246) and their renders are embedded
61+// inline in this parent's output — so the list only checks the
62+// parent's version. Without the bump, the list cache would serve the
63+// previously rendered frame indefinitely and the spinner would appear
64+// frozen.
5665func (a *AgentToolMessageItem) Animate(msg anim.StepMsg) tea.Cmd {
5766 if a.result != nil || a.Status() == ToolStatusCanceled {
5867 return nil
5968 }
6069 if msg.ID == a.ID() {
70+ a.Bump()
6171 return a.anim.Animate(msg)
6272 }
6373 for _, nestedTool := range a.nestedTools {
@@ -65,6 +75,7 @@ func (a *AgentToolMessageItem) Animate(msg anim.StepMsg) tea.Cmd {
6575 continue
6676 }
6777 if s, ok := nestedTool.(Animatable); ok {
78+ a.Bump()
6879 return s.Animate(msg)
6980 }
7081 }
@@ -211,6 +222,33 @@ func NewAgenticFetchToolMessageItem(
211222 return t
212223}
213224
225+// Animate progresses the message animation if it should be spinning.
226+// See [AgentToolMessageItem.Animate] for the parent-bump rationale —
227+// without an override, the embedded base.Animate would (a) drop
228+// StepMsgs whose ID matches a nested child instead of the parent
229+// (anim.Animate's ID check at internal/ui/anim/anim.go:326-329
230+// silently returns nil), and (b) never invalidate the parent's
231+// list-cache entry on a parent tick.
232+func (a *AgenticFetchToolMessageItem) Animate(msg anim.StepMsg) tea.Cmd {
233+ if a.result != nil || a.Status() == ToolStatusCanceled {
234+ return nil
235+ }
236+ if msg.ID == a.ID() {
237+ a.Bump()
238+ return a.anim.Animate(msg)
239+ }
240+ for _, nestedTool := range a.nestedTools {
241+ if msg.ID != nestedTool.ID() {
242+ continue
243+ }
244+ if s, ok := nestedTool.(Animatable); ok {
245+ a.Bump()
246+ return s.Animate(msg)
247+ }
248+ }
249+ return nil
250+}
251+
214252// NestedTools returns the nested tools.
215253func (a *AgenticFetchToolMessageItem) NestedTools() []ToolMessageItem {
216254 return a.nestedTools
internal/ui/chat/tools.go+12
@@ -296,10 +296,22 @@ func (t *baseToolMessageItem) StartAnimation() tea.Cmd {
296296}
297297
298298// Animate progresses the assistant message animation if it should be spinning.
299+//
300+// Bumps the F6 list-cache version so the next draw re-renders this
301+// item: a spinner tick mutates anim's internal frame counter, which
302+// changes the rendered output but is invisible to the per-item
303+// caches. Without the bump the list cache would serve the previously
304+// rendered frame indefinitely and the spinner would appear frozen.
305+// The ID gate keeps unrelated ticks (routed here by a future change
306+// to chat.Animate's dispatch) from churning the cache.
299307func (t *baseToolMessageItem) Animate(msg anim.StepMsg) tea.Cmd {
300308 if !t.isSpinning() {
301309 return nil
302310 }
311+ if msg.ID != t.toolCall.ID {
312+ return nil
313+ }
314+ t.Bump()
303315 return t.anim.Animate(msg)
304316}
305317
internal/ui/chat/version_bump_test.go+140
@@ -400,6 +400,146 @@ func TestAgenticFetchToolMessageItem_NestedChildInPlaceMutationBumpsParent(t *te
400400 "parent SetNestedTools must bump even when child pointers are unchanged")
401401}
402402
403+// requireNoBump asserts the supplied mutator leaves the item's
404+// Version() unchanged. The mutator runs once; an unexpected bump
405+// would force the F6 list memo to re-render an item whose output
406+// did not change, churning the cache.
407+func requireNoBump(t *testing.T, name string, item versionedItem, mutate func()) {
408+ t.Helper()
409+ before := item.Version()
410+ mutate()
411+ after := item.Version()
412+ require.Equalf(t, before, after,
413+ "%s must not bump Version() (before=%d, after=%d)", name, before, after)
414+}
415+
416+// TestBaseToolMessageItem_AnimateBumpsVersion is the spinner
417+// regression test for non-agent tools: while the tool is spinning,
418+// every anim.StepMsg whose ID matches the tool must bump Version()
419+// so the list-level cache invalidates and the next draw re-renders
420+// the advanced spinner frame. Foreign IDs must not bump (they would
421+// churn the cache on every frame), and a finished tool must not
422+// bump on any ID (the entry is frozen and stays frozen).
423+func TestBaseToolMessageItem_AnimateBumpsVersion(t *testing.T) {
424+ t.Parallel()
425+
426+ sty := styles.CharmtonePantera()
427+ tc := message.ToolCall{ID: "tc-spin", Name: "bash", Input: "{}", Finished: false}
428+ item := NewToolMessageItem(&sty, "msg", tc, nil, false)
429+ v := item.(versionedItem)
430+ a, ok := item.(Animatable)
431+ require.True(t, ok, "base tool message item must implement Animatable")
432+
433+ // Spinning + matching ID → bump.
434+ requireBump(t, "Animate[spinning,own ID]", v, func() {
435+ a.Animate(anim.StepMsg{ID: tc.ID})
436+ })
437+
438+ // Spinning + foreign ID → no bump. Routing this StepMsg here at
439+ // all would mean a future chat.Animate refactor; the item must
440+ // be defensive against it so we don't churn the list cache.
441+ requireNoBump(t, "Animate[spinning,foreign ID]", v, func() {
442+ a.Animate(anim.StepMsg{ID: "some-other-tool"})
443+ })
444+
445+ // Finished → no bump on any ID. The entry is frozen; a stray
446+ // bump would needlessly invalidate frozen entries.
447+ tcFinished := tc
448+ tcFinished.Finished = true
449+ item.SetToolCall(tcFinished)
450+ item.SetResult(&message.ToolResult{ToolCallID: tc.ID, Content: "ok"})
451+ require.True(t, item.Finished(), "tool must report Finished() once the result lands")
452+
453+ requireNoBump(t, "Animate[finished,own ID]", v, func() {
454+ a.Animate(anim.StepMsg{ID: tc.ID})
455+ })
456+ requireNoBump(t, "Animate[finished,foreign ID]", v, func() {
457+ a.Animate(anim.StepMsg{ID: "some-other-tool"})
458+ })
459+}
460+
461+// TestAgentToolMessageItem_AnimateBumpsVersion is the spinner
462+// regression test for agent tools. The parent must bump on both
463+// the parent-tick branch (msg.ID == parent.ID()) and the
464+// nested-tick branch (msg.ID == nested.ID()) because the list
465+// only checks the parent's version — nested tools are not list
466+// entries of their own. Unrelated IDs must not bump, and a parent
467+// with a result must not bump on any ID.
468+func TestAgentToolMessageItem_AnimateBumpsVersion(t *testing.T) {
469+ t.Parallel()
470+
471+ sty := styles.CharmtonePantera()
472+ parentTC := message.ToolCall{ID: "agent-parent", Name: "agent", Input: `{}`, Finished: false}
473+ parent := NewAgentToolMessageItem(&sty, parentTC, nil, false)
474+
475+ childTC := message.ToolCall{ID: "agent-child", Name: "bash", Input: `{}`, Finished: false}
476+ child := NewToolMessageItem(&sty, "msg", childTC, nil, false)
477+ parent.AddNestedTool(child)
478+
479+ // Spinning + parent's own ID → parent bumps.
480+ requireBump(t, "Animate[spinning,parent ID]", parent, func() {
481+ parent.Animate(anim.StepMsg{ID: parentTC.ID})
482+ })
483+
484+ // Spinning + nested child ID → parent bumps. The list only
485+ // invalidates on the parent; without this the nested
486+ // spinner's frame would never reach the screen even though
487+ // the nested anim's step has advanced.
488+ requireBump(t, "Animate[spinning,nested ID]", parent, func() {
489+ parent.Animate(anim.StepMsg{ID: childTC.ID})
490+ })
491+
492+ // Spinning + unrelated ID → no bump.
493+ requireNoBump(t, "Animate[spinning,foreign ID]", parent, func() {
494+ parent.Animate(anim.StepMsg{ID: "unrelated"})
495+ })
496+
497+ // Once the parent has a result, neither branch bumps.
498+ parent.SetResult(&message.ToolResult{ToolCallID: parentTC.ID, Content: "done"})
499+ requireNoBump(t, "Animate[finished,parent ID]", parent, func() {
500+ parent.Animate(anim.StepMsg{ID: parentTC.ID})
501+ })
502+ requireNoBump(t, "Animate[finished,nested ID]", parent, func() {
503+ parent.Animate(anim.StepMsg{ID: childTC.ID})
504+ })
505+}
506+
507+// TestAgenticFetchToolMessageItem_AnimateBumpsVersion is the
508+// agentic-fetch counterpart of the agent-tool Animate bump test.
509+// Without an explicit override the embedded base Animate would
510+// drop nested-child StepMsgs at anim.Animate's ID check and never
511+// bump the parent on its own ticks; this test locks in the
512+// override.
513+func TestAgenticFetchToolMessageItem_AnimateBumpsVersion(t *testing.T) {
514+ t.Parallel()
515+
516+ sty := styles.CharmtonePantera()
517+ parentTC := message.ToolCall{ID: "fetch-parent", Name: "agentic_fetch", Input: `{}`, Finished: false}
518+ parent := NewAgenticFetchToolMessageItem(&sty, parentTC, nil, false)
519+
520+ childTC := message.ToolCall{ID: "fetch-child", Name: "fetch", Input: `{}`, Finished: false}
521+ child := NewToolMessageItem(&sty, "msg", childTC, nil, false)
522+ parent.AddNestedTool(child)
523+
524+ requireBump(t, "Animate[spinning,parent ID]", parent, func() {
525+ parent.Animate(anim.StepMsg{ID: parentTC.ID})
526+ })
527+ requireBump(t, "Animate[spinning,nested ID]", parent, func() {
528+ parent.Animate(anim.StepMsg{ID: childTC.ID})
529+ })
530+ requireNoBump(t, "Animate[spinning,foreign ID]", parent, func() {
531+ parent.Animate(anim.StepMsg{ID: "unrelated"})
532+ })
533+
534+ parent.SetResult(&message.ToolResult{ToolCallID: parentTC.ID, Content: "done"})
535+ requireNoBump(t, "Animate[finished,parent ID]", parent, func() {
536+ parent.Animate(anim.StepMsg{ID: parentTC.ID})
537+ })
538+ requireNoBump(t, "Animate[finished,nested ID]", parent, func() {
539+ parent.Animate(anim.StepMsg{ID: childTC.ID})
540+ })
541+}
542+
403543// TestBaseToolMessageItem_FinishedTransition covers §4.5.1 for
404544// tools: a still-running tool reports Finished() == false; once the
405545// tool call is marked finished and a result lands, Finished()