feat(tui): static spinner

Carlos Alexandro Becker created

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

Change summary

internal/format/spinner.go                        |  6 +-
internal/tui/components/anim/anim.go              | 37 ++++++++--------
internal/tui/components/anim/static.go            | 34 +++++++++++++++
internal/tui/components/chat/messages/messages.go |  6 +-
internal/tui/components/chat/messages/tool.go     |  8 +-
5 files changed, 63 insertions(+), 28 deletions(-)

Detailed changes

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
 }
 

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}
 	})

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 }

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:

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...)