Skip to content

Commit e844873

Browse files
authored
Merge pull request #1497 from krissetto/global-animation-ticks
Use a global animation coordinator for animation ticks
2 parents 5799aea + 088d3ba commit e844873

16 files changed

Lines changed: 812 additions & 178 deletions

File tree

AGENTS.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -674,6 +674,27 @@ return events
674674
- `AssistantMessage` - Agent response (text + tool calls)
675675
- `ToolMessage` - Tool execution result
676676

677+
### TUI Animation Coordination
678+
679+
All animated TUI components share a single tick stream via `pkg/tui/animation/`.
680+
681+
```go
682+
// Init: register and maybe start tick
683+
func (m *MyComponent) Init() tea.Cmd {
684+
return animation.StartTickIfFirst()
685+
}
686+
687+
// Update: handle tick
688+
if tick, ok := msg.(animation.TickMsg); ok {
689+
m.frame = tick.Frame
690+
}
691+
692+
// When done: unregister
693+
animation.Unregister()
694+
```
695+
696+
**Rules:** Only call from `Init()`/`Update()`, never from `Cmd` goroutines. Always `Unregister()` when animation stops.
697+
677698
## File Locations and Patterns
678699

679700
### Key Package Structure

docs/CONTRIBUTING.md

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -86,10 +86,6 @@ This agent is an *expert Golang developer specializing in the Docker `cagent` mu
8686
Ask it anything about `cagent`. It can be questions about the current code or about
8787
improvements to the code. It can also fix issues and implement new features!
8888

89-
## Project Architecture
90-
91-
More info about the architecture behind `cagent` can be found [here](/docs/architecture.md)
92-
9389
## Add a new model provider
9490

9591
More details on how to add a new model provider can be found in [PROVIDERS.md](/docs/PROVIDERS.md)

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+
}
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
package animation
2+
3+
import (
4+
"sync"
5+
"testing"
6+
"time"
7+
8+
tea "charm.land/bubbletea/v2"
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
func resetGlobalCoordinator(t *testing.T) {
14+
t.Helper()
15+
globalCoordinator.mu.Lock()
16+
globalCoordinator.active = 0
17+
globalCoordinator.frame = 0
18+
globalCoordinator.mu.Unlock()
19+
}
20+
21+
func getActiveCount() int32 {
22+
globalCoordinator.mu.Lock()
23+
defer globalCoordinator.mu.Unlock()
24+
return globalCoordinator.active
25+
}
26+
27+
func runCmdWithTimeout(t *testing.T, cmd tea.Cmd) tea.Msg {
28+
t.Helper()
29+
require.NotNil(t, cmd)
30+
31+
done := make(chan tea.Msg, 1)
32+
go func() {
33+
done <- cmd()
34+
}()
35+
36+
timeout := time.NewTimer(250 * time.Millisecond)
37+
defer timeout.Stop()
38+
39+
select {
40+
case msg := <-done:
41+
return msg
42+
case <-timeout.C:
43+
t.Fatal("timed out waiting for tick command")
44+
}
45+
46+
return nil
47+
}
48+
49+
func runTickCmd(t *testing.T, cmd tea.Cmd) TickMsg {
50+
t.Helper()
51+
52+
msg := runCmdWithTimeout(t, cmd)
53+
tickMsg, ok := msg.(TickMsg)
54+
require.True(t, ok)
55+
56+
return tickMsg
57+
}
58+
59+
func TestGlobalCoordinatorLifecycle(t *testing.T) {
60+
resetGlobalCoordinator(t)
61+
62+
// No active animations = no tick
63+
require.Nil(t, StartTick())
64+
65+
// First registration starts tick
66+
firstTick := StartTickIfFirst()
67+
tickMsg := runTickCmd(t, firstTick)
68+
assert.Equal(t, 1, tickMsg.Frame)
69+
70+
// Subsequent tick continues
71+
nextTick := StartTick()
72+
tickMsg = runTickCmd(t, nextTick)
73+
assert.Equal(t, 2, tickMsg.Frame)
74+
75+
// Second StartTickIfFirst registers but doesn't return tick (not first)
76+
cmd := StartTickIfFirst()
77+
require.Nil(t, cmd)
78+
assert.Equal(t, int32(2), getActiveCount())
79+
80+
// Unregister one, still active
81+
Unregister()
82+
require.True(t, HasActive())
83+
require.NotNil(t, StartTick())
84+
85+
// Unregister last one
86+
Unregister()
87+
require.False(t, HasActive())
88+
require.Nil(t, StartTick())
89+
}
90+
91+
func TestUnregisterNeverGoesNegative(t *testing.T) {
92+
resetGlobalCoordinator(t)
93+
94+
// Multiple unregisters when already at 0
95+
Unregister()
96+
Unregister()
97+
Unregister()
98+
99+
assert.Equal(t, int32(0), getActiveCount())
100+
require.False(t, HasActive())
101+
}
102+
103+
func TestConcurrentRegisterUnregister(t *testing.T) {
104+
resetGlobalCoordinator(t)
105+
106+
const goroutines = 100
107+
const opsPerGoroutine = 100
108+
109+
var wg sync.WaitGroup
110+
wg.Add(goroutines * 2)
111+
112+
// Half goroutines do register
113+
for range goroutines {
114+
go func() {
115+
defer wg.Done()
116+
for range opsPerGoroutine {
117+
Register()
118+
}
119+
}()
120+
}
121+
122+
// Half goroutines do unregister
123+
for range goroutines {
124+
go func() {
125+
defer wg.Done()
126+
for range opsPerGoroutine {
127+
Unregister()
128+
}
129+
}()
130+
}
131+
132+
wg.Wait()
133+
134+
// Should have exactly goroutines * opsPerGoroutine registers
135+
// minus whatever unregisters succeeded (capped at 0)
136+
// Final count should be >= 0
137+
count := getActiveCount()
138+
assert.GreaterOrEqual(t, count, int32(0), "active count should never be negative")
139+
}
140+
141+
func TestConcurrentStartTickIfFirst(t *testing.T) {
142+
resetGlobalCoordinator(t)
143+
144+
const goroutines = 50
145+
var wg sync.WaitGroup
146+
wg.Add(goroutines)
147+
148+
cmds := make(chan tea.Cmd, goroutines)
149+
150+
// Many goroutines race to be "first"
151+
for range goroutines {
152+
go func() {
153+
defer wg.Done()
154+
cmd := StartTickIfFirst()
155+
cmds <- cmd
156+
}()
157+
}
158+
159+
wg.Wait()
160+
close(cmds)
161+
162+
// Count non-nil commands (ticks started)
163+
ticksStarted := 0
164+
for cmd := range cmds {
165+
if cmd != nil {
166+
ticksStarted++
167+
}
168+
}
169+
170+
// Exactly one should have started the tick
171+
assert.Equal(t, 1, ticksStarted, "exactly one goroutine should start the tick")
172+
// All should have registered
173+
assert.Equal(t, int32(goroutines), getActiveCount())
174+
}

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)