Skip to content

Commit 94e8d52

Browse files
authored
Merge pull request #774 from rumpl/spinner
Add our own spinner
2 parents 5fd9350 + 5c96ac7 commit 94e8d52

9 files changed

Lines changed: 253 additions & 40 deletions

File tree

pkg/tui/components/message/message.go

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,10 @@ import (
55
"regexp"
66
"strings"
77

8-
"charm.land/bubbles/v2/spinner"
98
tea "charm.land/bubbletea/v2"
109

1110
"github.com/docker/cagent/pkg/tui/components/markdown"
11+
"github.com/docker/cagent/pkg/tui/components/spinner"
1212
"github.com/docker/cagent/pkg/tui/core/layout"
1313
"github.com/docker/cagent/pkg/tui/styles"
1414
"github.com/docker/cagent/pkg/tui/types"
@@ -27,7 +27,7 @@ type messageModel struct {
2727
width int
2828
height int
2929
focused bool
30-
spinner spinner.Model
30+
spinner spinner.Spinner
3131
}
3232

3333
// New creates a new message view
@@ -37,7 +37,7 @@ func New(msg *types.Message) *messageModel {
3737
width: 80, // Default width
3838
height: 1, // Will be calculated
3939
focused: false,
40-
spinner: spinner.New(spinner.WithSpinner(spinner.Points)),
40+
spinner: spinner.New(spinner.ModeBoth),
4141
}
4242
}
4343

@@ -46,7 +46,7 @@ func New(msg *types.Message) *messageModel {
4646
// Init initializes the message view
4747
func (mv *messageModel) Init() tea.Cmd {
4848
if mv.message.Type == types.MessageTypeSpinner {
49-
return mv.spinner.Tick
49+
return mv.spinner.Tick()
5050
}
5151
return nil
5252
}
@@ -58,11 +58,10 @@ func (mv *messageModel) SetMessage(msg *types.Message) {
5858
// Update handles messages and updates the message view state
5959
func (mv *messageModel) Update(msg tea.Msg) (layout.Model, tea.Cmd) {
6060
if mv.message.Type == types.MessageTypeSpinner {
61-
var cmd tea.Cmd
62-
mv.spinner, cmd = mv.spinner.Update(msg)
61+
s, cmd := mv.spinner.Update(msg)
62+
mv.spinner = s.(spinner.Spinner)
6363
return mv, cmd
6464
}
65-
6665
return mv, nil
6766
}
6867

pkg/tui/components/sidebar/sidebar.go

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,12 @@ import (
55
"os"
66
"strings"
77

8-
"charm.land/bubbles/v2/spinner"
98
tea "charm.land/bubbletea/v2"
109
"charm.land/lipgloss/v2"
1110

1211
"github.com/docker/cagent/pkg/runtime"
1312
"github.com/docker/cagent/pkg/tools"
13+
"github.com/docker/cagent/pkg/tui/components/spinner"
1414
"github.com/docker/cagent/pkg/tui/components/tool/todotool"
1515
"github.com/docker/cagent/pkg/tui/core/layout"
1616
"github.com/docker/cagent/pkg/tui/service"
@@ -44,7 +44,7 @@ type model struct {
4444
todoComp *todotool.SidebarComponent
4545
working bool
4646
mcpInit bool
47-
spinner spinner.Model
47+
spinner spinner.Spinner
4848
mode Mode
4949
sessionTitle string
5050
}
@@ -55,7 +55,7 @@ func New(manager *service.TodoManager) Model {
5555
height: 24,
5656
usage: &runtime.Usage{},
5757
todoComp: todotool.NewSidebarComponent(manager),
58-
spinner: spinner.New(spinner.WithSpinner(spinner.Dot)),
58+
spinner: spinner.New(spinner.ModeSpinnerOnly),
5959
sessionTitle: "New session",
6060
}
6161
}
@@ -76,8 +76,7 @@ func (m *model) SetTodos(toolCall tools.ToolCall) error {
7676
func (m *model) SetWorking(working bool) tea.Cmd {
7777
m.working = working
7878
if working {
79-
// Start spinner when beginning to work
80-
return m.spinner.Tick
79+
return m.spinner.Init()
8180
}
8281
return nil
8382
}
@@ -115,7 +114,7 @@ func (m *model) Update(msg tea.Msg) (layout.Model, tea.Cmd) {
115114
return m, cmd
116115
case *runtime.MCPInitStartedEvent:
117116
m.mcpInit = true
118-
return m, m.spinner.Tick
117+
return m, m.spinner.Init()
119118
case *runtime.MCPInitFinishedEvent:
120119
m.mcpInit = false
121120
return m, nil
@@ -125,7 +124,9 @@ func (m *model) Update(msg tea.Msg) (layout.Model, tea.Cmd) {
125124
default:
126125
if m.working || m.mcpInit {
127126
var cmd tea.Cmd
128-
m.spinner, cmd = m.spinner.Update(msg)
127+
var model layout.Model
128+
model, cmd = m.spinner.Update(msg)
129+
m.spinner = model.(spinner.Spinner)
129130
return m, cmd
130131
}
131132
return m, nil
@@ -190,7 +191,7 @@ func (m *model) workingIndicator() string {
190191
if m.mcpInit {
191192
label = "Initializing MCP servers..."
192193
}
193-
indicator := styles.ActiveStyle.Render(m.spinner.View() + label)
194+
indicator := styles.ActiveStyle.Render(m.spinner.View() + " " + label)
194195
return indicator
195196
}
196197

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
package spinner
2+
3+
import (
4+
"math/rand/v2"
5+
"sync/atomic"
6+
"time"
7+
8+
tea "charm.land/bubbletea/v2"
9+
"charm.land/lipgloss/v2"
10+
11+
"github.com/docker/cagent/pkg/tui/core/layout"
12+
"github.com/docker/cagent/pkg/tui/styles"
13+
)
14+
15+
type Mode int
16+
17+
const (
18+
ModeBoth Mode = iota
19+
ModeSpinnerOnly
20+
ModeMessageOnly
21+
)
22+
23+
var lastID int64
24+
25+
func nextID() int {
26+
return int(atomic.AddInt64(&lastID, 1))
27+
}
28+
29+
type tickMsg struct {
30+
Time time.Time
31+
tag int
32+
ID int
33+
}
34+
35+
type Spinner struct {
36+
messages []string
37+
mode Mode
38+
currentMessage string
39+
lightPosition int
40+
frame int
41+
id int
42+
tag int
43+
direction int // 1 for forward, -1 for backward
44+
pauseFrames int
45+
}
46+
47+
// Default messages for the spinner
48+
var defaultMessages = []string{
49+
"Working",
50+
"Reticulating splines",
51+
"Computing",
52+
"Thinking",
53+
"Processing",
54+
"Analyzing",
55+
"Calibrating",
56+
"Initializing",
57+
"Generating",
58+
"Evaluating",
59+
"Synthesizing",
60+
"Optimizing",
61+
"Consulting the oracle",
62+
"Summoning electrons",
63+
"Warming up the flux capacitor",
64+
"Reversing the polarity",
65+
"Spinning up the hamster wheels",
66+
"Dividing by zero",
67+
"Herding cats",
68+
"Untangling yarn",
69+
}
70+
71+
func New(mode Mode) Spinner {
72+
return Spinner{
73+
messages: defaultMessages,
74+
mode: mode,
75+
currentMessage: defaultMessages[rand.IntN(len(defaultMessages))],
76+
lightPosition: -3,
77+
frame: 0,
78+
id: nextID(),
79+
direction: 1,
80+
pauseFrames: 0,
81+
}
82+
}
83+
84+
func (s Spinner) Init() tea.Cmd {
85+
return s.Tick()
86+
}
87+
88+
func (s Spinner) Update(msg tea.Msg) (layout.Model, tea.Cmd) {
89+
if msg, ok := msg.(tickMsg); ok {
90+
if msg.ID > 0 && msg.ID != s.id {
91+
return s, nil
92+
}
93+
if msg.tag > 0 && msg.tag != s.tag {
94+
return s, nil
95+
}
96+
s.tag++
97+
98+
s.frame++
99+
100+
if s.pauseFrames > 0 {
101+
s.pauseFrames--
102+
if s.pauseFrames == 0 {
103+
s.direction = -1
104+
}
105+
} else {
106+
s.lightPosition += s.direction
107+
108+
if s.direction == 1 && s.lightPosition > len(s.currentMessage)+2 {
109+
s.pauseFrames = 6
110+
}
111+
112+
if s.direction == -1 && s.lightPosition < -3 {
113+
s.direction = 1
114+
}
115+
}
116+
117+
return s, s.Tick()
118+
}
119+
return s, nil
120+
}
121+
122+
func (s Spinner) View() string {
123+
return s.render()
124+
}
125+
126+
func (s Spinner) SetSize(_, _ int) tea.Cmd {
127+
return nil
128+
}
129+
130+
func (s Spinner) Tick() tea.Cmd {
131+
return tea.Tick(50*time.Millisecond, func(t time.Time) tea.Msg {
132+
return tickMsg{
133+
Time: t,
134+
ID: s.id,
135+
tag: s.tag,
136+
}
137+
})
138+
}
139+
140+
func (s Spinner) render() string {
141+
message := s.currentMessage
142+
output := make([]rune, 0, len(message))
143+
144+
for i, char := range message {
145+
distance := abs(i - s.lightPosition)
146+
147+
var style lipgloss.Style
148+
switch distance {
149+
case 0:
150+
style = styles.SpinnerTextBrightestStyle
151+
case 1:
152+
style = styles.SpinnerTextBrightStyle
153+
case 2:
154+
style = styles.SpinnerTextDimStyle
155+
default:
156+
style = styles.SpinnerTextDimmestStyle
157+
}
158+
159+
output = append(output, []rune(style.Render(string(char)))...)
160+
}
161+
162+
spinnerChars := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
163+
spinnerChar := spinnerChars[s.frame%len(spinnerChars)]
164+
spinnerStyled := styles.SpinnerCharStyle.Render(spinnerChar)
165+
166+
switch s.mode {
167+
case ModeSpinnerOnly:
168+
return spinnerStyled
169+
case ModeMessageOnly:
170+
return string(output)
171+
}
172+
173+
return spinnerStyled + " " + string(output)
174+
}
175+
176+
func (s *Spinner) Render() string {
177+
return s.render()
178+
}
179+
180+
func (s *Spinner) SetMessage(message string) {
181+
s.currentMessage = message
182+
}
183+
184+
func abs(x int) int {
185+
if x < 0 {
186+
return -x
187+
}
188+
return x
189+
}

pkg/tui/components/tool/defaulttool/defaulttool.go

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@ package defaulttool
33
import (
44
"fmt"
55

6-
"charm.land/bubbles/v2/spinner"
76
tea "charm.land/bubbletea/v2"
87
"github.com/charmbracelet/glamour/v2"
98

9+
"github.com/docker/cagent/pkg/tui/components/spinner"
1010
"github.com/docker/cagent/pkg/tui/components/toolcommon"
1111
"github.com/docker/cagent/pkg/tui/core/layout"
1212
"github.com/docker/cagent/pkg/tui/service"
@@ -20,7 +20,7 @@ import (
2020
type Component struct {
2121
message *types.Message
2222
renderer *glamour.TermRenderer
23-
spinner spinner.Model
23+
spinner spinner.Spinner
2424
width int
2525
height int
2626
}
@@ -34,7 +34,7 @@ func New(
3434
return &Component{
3535
message: msg,
3636
renderer: renderer,
37-
spinner: spinner.New(spinner.WithSpinner(spinner.Points)),
37+
spinner: spinner.New(spinner.ModeSpinnerOnly),
3838
width: 80,
3939
height: 1,
4040
}
@@ -48,15 +48,17 @@ func (c *Component) SetSize(width, height int) tea.Cmd {
4848

4949
func (c *Component) Init() tea.Cmd {
5050
if c.message.ToolStatus == types.ToolStatusPending || c.message.ToolStatus == types.ToolStatusRunning {
51-
return c.spinner.Tick
51+
return c.spinner.Init()
5252
}
5353
return nil
5454
}
5555

5656
func (c *Component) Update(msg tea.Msg) (layout.Model, tea.Cmd) {
5757
if c.message.ToolStatus == types.ToolStatusPending || c.message.ToolStatus == types.ToolStatusRunning {
5858
var cmd tea.Cmd
59-
c.spinner, cmd = c.spinner.Update(msg)
59+
var model layout.Model
60+
model, cmd = c.spinner.Update(msg)
61+
c.spinner = model.(spinner.Spinner)
6062
return c, cmd
6163
}
6264

0 commit comments

Comments
 (0)