Skip to content

Commit 2244486

Browse files
committed
Use a global animation coordinator for animation ticks
All spinners and general animations animate off the same global tick. Reduces cpu load considerably (> 5x) when multiple spinners are active (e.g. parallel tool calls) etc. since we avoid a barage of ticks propagating through the event loop. Also keeps animations in sync throughout the app for a more stable appearance Signed-off-by: Christopher Petito <chrisjpetito@gmail.com>
1 parent 640dba1 commit 2244486

11 files changed

Lines changed: 388 additions & 147 deletions

File tree

pkg/tui/animation/coordinator.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
// Package animation provides centralized animation tick management for the TUI.
2+
// All animated components (spinners, fades, etc.) share a single tick stream
3+
// to avoid tick storms and ensure synchronized animations.
4+
//
5+
// Thread safety: All exported functions are safe for concurrent use, though the
6+
// typical usage pattern is single-threaded via Bubble Tea's Update loop.
7+
package animation
8+
9+
import (
10+
"sync"
11+
"time"
12+
13+
tea "charm.land/bubbletea/v2"
14+
)
15+
16+
// TickMsg is broadcast to all animated components on each animation frame.
17+
// Components should handle this message to update their animation state.
18+
type TickMsg struct {
19+
Frame int
20+
}
21+
22+
// Coordinator manages a single tick stream for all animations.
23+
// It tracks active animations and only generates ticks when at least one is active.
24+
type Coordinator struct {
25+
// mu guards all fields. While Bubble Tea's Update loop is single-threaded,
26+
// the mutex protects against accidental misuse from Cmd goroutines and
27+
// ensures StartTickIfFirst is atomic (no race between check and register).
28+
mu sync.Mutex
29+
frame int
30+
active int32
31+
}
32+
33+
// globalCoordinator is the singleton coordinator instance.
34+
var globalCoordinator = &Coordinator{}
35+
36+
// Register increments the active animation count.
37+
// Call this when an animation starts.
38+
func Register() {
39+
globalCoordinator.mu.Lock()
40+
globalCoordinator.active++
41+
globalCoordinator.mu.Unlock()
42+
}
43+
44+
// Unregister decrements the active animation count.
45+
// Call this when an animation stops.
46+
func Unregister() {
47+
globalCoordinator.mu.Lock()
48+
if globalCoordinator.active > 0 {
49+
globalCoordinator.active--
50+
}
51+
globalCoordinator.mu.Unlock()
52+
}
53+
54+
// HasActive returns true if any animations are currently active.
55+
func HasActive() bool {
56+
globalCoordinator.mu.Lock()
57+
active := globalCoordinator.active > 0
58+
globalCoordinator.mu.Unlock()
59+
return active
60+
}
61+
62+
// StartTick starts the global animation tick if any animations are active.
63+
// Call this after processing a TickMsg to continue the tick stream.
64+
func StartTick() tea.Cmd {
65+
globalCoordinator.mu.Lock()
66+
defer globalCoordinator.mu.Unlock()
67+
if globalCoordinator.active <= 0 {
68+
return nil
69+
}
70+
return globalCoordinator.tickLocked()
71+
}
72+
73+
// StartTickIfFirst registers an animation and starts the tick if this is the first.
74+
// This is atomic: no race between checking and registering.
75+
// Returns the tick command if the tick stream was started, nil otherwise.
76+
func StartTickIfFirst() tea.Cmd {
77+
globalCoordinator.mu.Lock()
78+
defer globalCoordinator.mu.Unlock()
79+
wasEmpty := globalCoordinator.active == 0
80+
globalCoordinator.active++
81+
if wasEmpty {
82+
return globalCoordinator.tickLocked()
83+
}
84+
return nil
85+
}
86+
87+
// tickLocked returns a tick command. Must be called with mu held.
88+
// 14 FPS - smooth enough for most animations without being too CPU-intensive.
89+
func (c *Coordinator) tickLocked() tea.Cmd {
90+
return tea.Tick(time.Second/14, func(time.Time) tea.Msg {
91+
c.mu.Lock()
92+
c.frame++
93+
frame := c.frame
94+
c.mu.Unlock()
95+
return TickMsg{Frame: frame}
96+
})
97+
}

pkg/tui/components/message/message.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ func New(msg, previous *types.Message) *messageModel {
5252
// Init initializes the message view
5353
func (mv *messageModel) Init() tea.Cmd {
5454
if mv.message.Type == types.MessageTypeSpinner || mv.message.Type == types.MessageTypeLoading {
55-
return mv.spinner.Tick()
55+
return mv.spinner.Init()
5656
}
5757
return nil
5858
}

pkg/tui/components/messages/messages.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
"github.com/docker/cagent/pkg/session"
1818
"github.com/docker/cagent/pkg/tools"
1919
"github.com/docker/cagent/pkg/tools/builtin"
20+
"github.com/docker/cagent/pkg/tui/animation"
2021
"github.com/docker/cagent/pkg/tui/components/message"
2122
"github.com/docker/cagent/pkg/tui/components/reasoningblock"
2223
"github.com/docker/cagent/pkg/tui/components/scrollbar"
@@ -189,6 +190,14 @@ func (m *model) Update(msg tea.Msg) (layout.Model, tea.Cmd) {
189190
case reasoningblock.BlockMsg:
190191
return m.forwardToReasoningBlock(msg.GetBlockID(), msg)
191192

193+
case animation.TickMsg:
194+
// Invalidate render cache if there's animated content that needs redrawing.
195+
// This ensures fades, spinners, etc. actually update visually on each tick.
196+
if m.hasAnimatedContent() {
197+
m.renderDirty = true
198+
}
199+
// Fall through to forward tick to all views
200+
192201
case tea.KeyPressMsg:
193202
return m.handleKeyPress(msg)
194203
}
@@ -1313,3 +1322,32 @@ func (m *model) handleScrollbarUpdate(msg tea.Msg) (layout.Model, tea.Cmd) {
13131322
m.scrollOffset = m.scrollbar.GetScrollOffset()
13141323
return m, cmd
13151324
}
1325+
1326+
// hasAnimatedContent returns true if the message list contains content that
1327+
// requires tick-driven updates (spinners, fades, etc.). Used to decide whether
1328+
// to invalidate the render cache on animation ticks.
1329+
func (m *model) hasAnimatedContent() bool {
1330+
for i, msg := range m.messages {
1331+
switch msg.Type {
1332+
case types.MessageTypeSpinner, types.MessageTypeLoading:
1333+
// Spinner/loading messages always need ticks
1334+
return true
1335+
case types.MessageTypeToolCall:
1336+
// Tool calls with pending/running status have spinners
1337+
if msg.ToolStatus == types.ToolStatusPending ||
1338+
msg.ToolStatus == types.ToolStatusRunning {
1339+
return true
1340+
}
1341+
case types.MessageTypeAssistantReasoningBlock:
1342+
// Check if reasoning block needs tick updates
1343+
if i < len(m.views) {
1344+
if block, ok := m.views[i].(*reasoningblock.Model); ok {
1345+
if block.NeedsTick() {
1346+
return true
1347+
}
1348+
}
1349+
}
1350+
}
1351+
}
1352+
return false
1353+
}

0 commit comments

Comments
 (0)