From 6e43d815ca77d33b9641f976112a129e3803a2ca Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Fri, 17 Oct 2025 17:20:44 -0300 Subject: [PATCH] feat(tui): static spinner Signed-off-by: Carlos Alexandro Becker --- internal/format/spinner.go | 6 +-- internal/tui/components/anim/anim.go | 37 ++++++++++--------- internal/tui/components/anim/static.go | 34 +++++++++++++++++ .../tui/components/chat/messages/messages.go | 6 +-- internal/tui/components/chat/messages/tool.go | 8 ++-- 5 files changed, 63 insertions(+), 28 deletions(-) create mode 100644 internal/tui/components/anim/static.go diff --git a/internal/format/spinner.go b/internal/format/spinner.go index 69e443d0f67adadd1e3f9b9a13129850324b6184..cac659805d307bae782d0bc88d70fe361f6b7c57 100644 --- a/internal/format/spinner.go +++ b/internal/format/spinner.go @@ -20,7 +20,7 @@ type Spinner struct { type model struct { cancel context.CancelFunc - anim *anim.Anim + anim anim.Anim } func (m model) Init() tea.Cmd { return m.anim.Init() } @@ -36,8 +36,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Quit } } - mm, cmd := m.anim.Update(msg) - m.anim = mm.(*anim.Anim) + var cmd tea.Cmd + m.anim, cmd = m.anim.Update(msg) return m, cmd } diff --git a/internal/tui/components/anim/anim.go b/internal/tui/components/anim/anim.go index 05ac4da98281248d1774a10e95f4d8e2f177e048..bfecf0b5878f4b92a1a10a84d253344805cee55f 100644 --- a/internal/tui/components/anim/anim.go +++ b/internal/tui/components/anim/anim.go @@ -28,7 +28,7 @@ const ( // // If the FPS is 20 (50 milliseconds) this means that the ellipsis will // change every 8 frames (400 milliseconds). - ellipsisAnimSpeed = 8 + ellipsisanimSpeed = 8 // The maximum amount of time that can pass before a character appears. // This is used to create a staggered entrance effect. @@ -94,13 +94,11 @@ type Settings struct { GradColorA color.Color GradColorB color.Color CycleColors bool + Static bool } -// Default settings. -const () - -// Anim is a Bubble for an animated spinner. -type Anim struct { +// anim is a Bubble for an animated spinner. +type anim struct { width int cyclingCharWidth int label *csync.Slice[string] @@ -117,9 +115,8 @@ type Anim struct { id int } -// New creates a new Anim instance with the specified width and label. -func New(opts Settings) *Anim { - a := &Anim{} +// New creates a new anim instance with the specified width and label. +func New(opts Settings) Anim { // Validate settings. if opts.Size < 1 { opts.Size = defaultNumCyclingChars @@ -134,6 +131,10 @@ func New(opts Settings) *Anim { opts.LabelColor = defaultLabelColor } + if opts.Static { + return newStatic(opts.Label, opts.LabelColor) + } + a := &anim{} a.id = nextID() a.startTime = time.Now() a.cyclingCharWidth = opts.Size @@ -254,7 +255,7 @@ func New(opts Settings) *Anim { } // SetLabel updates the label text and re-renders it. -func (a *Anim) SetLabel(newLabel string) { +func (a *anim) SetLabel(newLabel string) { a.labelWidth = lipgloss.Width(newLabel) // Update total width @@ -268,7 +269,7 @@ func (a *Anim) SetLabel(newLabel string) { } // renderLabel renders the label with the current label color. -func (a *Anim) renderLabel(label string) { +func (a *anim) renderLabel(label string) { if a.labelWidth > 0 { // Pre-render the label. labelRunes := []rune(label) @@ -295,7 +296,7 @@ func (a *Anim) renderLabel(label string) { } // Width returns the total width of the animation. -func (a *Anim) Width() (w int) { +func (a *anim) Width() (w int) { w = a.width if a.labelWidth > 0 { w += labelGapWidth + a.labelWidth @@ -313,12 +314,12 @@ func (a *Anim) Width() (w int) { } // Init starts the animation. -func (a *Anim) Init() tea.Cmd { +func (a *anim) Init() tea.Cmd { return a.Step() } // Update processes animation steps (or not). -func (a *Anim) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (a *anim) Update(msg tea.Msg) (Anim, tea.Cmd) { switch msg := msg.(type) { case StepMsg: if msg.id != a.id { @@ -334,7 +335,7 @@ func (a *Anim) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if a.initialized.Load() && a.labelWidth > 0 { // Manage the ellipsis animation. ellipsisStep := a.ellipsisStep.Add(1) - if int(ellipsisStep) >= ellipsisAnimSpeed*len(ellipsisFrames) { + if int(ellipsisStep) >= ellipsisanimSpeed*len(ellipsisFrames) { a.ellipsisStep.Store(0) } } else if !a.initialized.Load() && time.Since(a.startTime) >= maxBirthOffset { @@ -347,7 +348,7 @@ func (a *Anim) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } // View renders the current state of the animation. -func (a *Anim) View() string { +func (a *anim) View() string { var b strings.Builder step := int(a.step.Load()) for i := range a.width { @@ -372,7 +373,7 @@ func (a *Anim) View() string { // have been initialized. if a.initialized.Load() && a.labelWidth > 0 { ellipsisStep := int(a.ellipsisStep.Load()) - if ellipsisFrame, ok := a.ellipsisFrames.Get(ellipsisStep / ellipsisAnimSpeed); ok { + if ellipsisFrame, ok := a.ellipsisFrames.Get(ellipsisStep / ellipsisanimSpeed); ok { b.WriteString(ellipsisFrame) } } @@ -381,7 +382,7 @@ func (a *Anim) View() string { } // Step is a command that triggers the next step in the animation. -func (a *Anim) Step() tea.Cmd { +func (a *anim) Step() tea.Cmd { return tea.Tick(time.Second/time.Duration(fps), func(t time.Time) tea.Msg { return StepMsg{id: a.id} }) diff --git a/internal/tui/components/anim/static.go b/internal/tui/components/anim/static.go new file mode 100644 index 0000000000000000000000000000000000000000..b9a00af11170539bde8e9f6734371c0b10e05ba3 --- /dev/null +++ b/internal/tui/components/anim/static.go @@ -0,0 +1,34 @@ +package anim + +import ( + "image/color" + + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/lipgloss/v2" +) + +type Anim interface { + Init() tea.Cmd + Update(tea.Msg) (Anim, tea.Cmd) + View() string + SetLabel(string) +} + +type noAnim struct { + Color color.Color + rendered string +} + +func newStatic(label string, foreground color.Color) Anim { + a := &noAnim{Color: foreground} + a.SetLabel(label) + return a +} + +func (s *noAnim) SetLabel(label string) { + s.rendered = lipgloss.NewStyle().Foreground(s.Color).Render(label + ellipsisFrames[2]) +} + +func (s noAnim) Init() tea.Cmd { return nil } +func (s *noAnim) Update(tea.Msg) (Anim, tea.Cmd) { return s, nil } +func (s *noAnim) View() string { return s.rendered } diff --git a/internal/tui/components/chat/messages/messages.go b/internal/tui/components/chat/messages/messages.go index d931ba7e179255d6639db78ebea5e82b57af1504..ad61a96348f98a6262a15c9f71108f73531c35ea 100644 --- a/internal/tui/components/chat/messages/messages.go +++ b/internal/tui/components/chat/messages/messages.go @@ -54,7 +54,7 @@ type messageCmp struct { // Core message data and state message message.Message // The underlying message content spinning bool // Whether to show loading animation - anim *anim.Anim // Animation component for loading states + anim anim.Anim // Animation component for loading states // Thinking viewport for displaying reasoning content thinkingViewport viewport.Model @@ -99,8 +99,8 @@ func (m *messageCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case anim.StepMsg: m.spinning = m.shouldSpin() if m.spinning { - u, cmd := m.anim.Update(msg) - m.anim = u.(*anim.Anim) + var cmd tea.Cmd + m.anim, cmd = m.anim.Update(msg) return m, cmd } case tea.KeyPressMsg: diff --git a/internal/tui/components/chat/messages/tool.go b/internal/tui/components/chat/messages/tool.go index 7e03674f97243e7d9e569b341fe1c6f1d2450b93..30f9929e091434d95c2f46bbd6dc691d094b8995 100644 --- a/internal/tui/components/chat/messages/tool.go +++ b/internal/tui/components/chat/messages/tool.go @@ -61,8 +61,8 @@ type toolCallCmp struct { permissionGranted bool // Animation state for pending tool calls - spinning bool // Whether to show loading animation - anim util.Model // Animation component for pending states + spinning bool // Whether to show loading animation + anim anim.Anim // Animation component for pending states nestedToolCalls []ToolCallCmp // Nested tool calls for hierarchical display } @@ -159,8 +159,8 @@ func (m *toolCallCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } if m.spinning { - u, cmd := m.anim.Update(msg) - m.anim = u.(util.Model) + var cmd tea.Cmd + m.anim, cmd = m.anim.Update(msg) cmds = append(cmds, cmd) } return m, tea.Batch(cmds...)