refactor(update): use pink highlight for spinner

Amolith created

Assisted-by: Kimi K2 Thinking via Crush

Change summary

internal/cmd/update.go     | 25 +------------
internal/format/spinner.go | 73 +++++++++++++++++++++++++++++++++++++--
2 files changed, 71 insertions(+), 27 deletions(-)

Detailed changes

internal/cmd/update.go 🔗

@@ -7,13 +7,10 @@ import (
 	"runtime"
 	"time"
 
-	"charm.land/lipgloss/v2"
 	"github.com/charmbracelet/crush/internal/format"
-	"github.com/charmbracelet/crush/internal/tui/components/anim"
-	"github.com/charmbracelet/crush/internal/tui/styles"
 	"github.com/charmbracelet/crush/internal/update"
 	"github.com/charmbracelet/crush/internal/version"
-	"github.com/charmbracelet/x/term"
+	"github.com/charmbracelet/x/exp/charmtone"
 	"github.com/spf13/cobra"
 )
 
@@ -169,24 +166,8 @@ crush update apply --force
 }
 
 // newUpdateSpinner creates a spinner for update operations.
-func newUpdateSpinner(ctx context.Context, cancel context.CancelFunc, label string) *format.Spinner {
-	t := styles.CurrentTheme()
-
-	// Detect background color for appropriate text color.
-	hasDarkBG := true
-	if term.IsTerminal(os.Stderr.Fd()) {
-		hasDarkBG = lipgloss.HasDarkBackground(os.Stdin, os.Stderr)
-	}
-	defaultFG := lipgloss.LightDark(hasDarkBG)(lipgloss.Color("#fafafa"), t.FgBase)
-
-	return format.NewSpinner(ctx, cancel, anim.Settings{
-		Size:        10,
-		Label:       label,
-		LabelColor:  defaultFG,
-		GradColorA:  t.Primary,
-		GradColorB:  t.Secondary,
-		CycleColors: true,
-	})
+func newUpdateSpinner(ctx context.Context, cancel context.CancelFunc, label string) *format.SimpleSpinner {
+	return format.NewSimpleSpinner(ctx, cancel, label, charmtone.Dolly)
 }
 
 func init() {

internal/format/spinner.go 🔗

@@ -4,14 +4,18 @@ import (
 	"context"
 	"errors"
 	"fmt"
+	"image/color"
 	"os"
 
+	"charm.land/bubbles/v2/spinner"
 	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
 	"github.com/charmbracelet/crush/internal/tui/components/anim"
 	"github.com/charmbracelet/x/ansi"
 )
 
-// Spinner wraps the bubbles spinner for non-interactive mode
+// Spinner wraps the anim spinner for non-interactive mode (used for LLM
+// generation).
 type Spinner struct {
 	done chan struct{}
 	prog *tea.Program
@@ -40,7 +44,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return m, cmd
 }
 
-// NewSpinner creates a new spinner with the given message
+// NewSpinner creates a new spinner with the given message.
 func NewSpinner(ctx context.Context, cancel context.CancelFunc, animSettings anim.Settings) *Spinner {
 	m := model{
 		anim:   anim.New(animSettings),
@@ -55,12 +59,11 @@ func NewSpinner(ctx context.Context, cancel context.CancelFunc, animSettings ani
 	}
 }
 
-// Start begins the spinner animation
+// Start begins the spinner animation.
 func (s *Spinner) Start() {
 	go func() {
 		defer close(s.done)
 		_, err := s.prog.Run()
-		// ensures line is cleared
 		fmt.Fprint(os.Stderr, ansi.EraseEntireLine)
 		if err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, tea.ErrInterrupted) {
 			fmt.Fprintf(os.Stderr, "Error running spinner: %v\n", err)
@@ -68,8 +71,68 @@ func (s *Spinner) Start() {
 	}()
 }
 
-// Stop ends the spinner animation
+// Stop ends the spinner animation.
 func (s *Spinner) Stop() {
 	s.prog.Quit()
 	<-s.done
 }
+
+// SimpleSpinner wraps a dot spinner for non-interactive CLI operations.
+type SimpleSpinner struct {
+	done chan struct{}
+	prog *tea.Program
+}
+
+type simpleModel struct {
+	cancel  context.CancelFunc
+	spinner spinner.Model
+	label   string
+}
+
+func (m simpleModel) Init() tea.Cmd  { return m.spinner.Tick }
+func (m simpleModel) View() tea.View { return tea.NewView(m.spinner.View() + " " + m.label) }
+
+func (m simpleModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	switch msg := msg.(type) {
+	case tea.KeyPressMsg:
+		switch msg.String() {
+		case "ctrl+c", "esc":
+			m.cancel()
+			return m, tea.Quit
+		}
+	case spinner.TickMsg:
+		var cmd tea.Cmd
+		m.spinner, cmd = m.spinner.Update(msg)
+		return m, cmd
+	}
+	return m, nil
+}
+
+// NewSimpleSpinner creates a simple dot spinner for CLI operations.
+func NewSimpleSpinner(ctx context.Context, cancel context.CancelFunc, label string, c color.Color) *SimpleSpinner {
+	s := spinner.New(spinner.WithSpinner(spinner.Dot))
+	if c != nil {
+		s.Style = lipgloss.NewStyle().Foreground(c)
+	}
+	m := simpleModel{cancel: cancel, spinner: s, label: label}
+	p := tea.NewProgram(m, tea.WithOutput(os.Stderr), tea.WithContext(ctx))
+	return &SimpleSpinner{prog: p, done: make(chan struct{}, 1)}
+}
+
+// Start begins the spinner animation.
+func (s *SimpleSpinner) Start() {
+	go func() {
+		defer close(s.done)
+		_, err := s.prog.Run()
+		fmt.Fprint(os.Stderr, ansi.EraseEntireLine)
+		if err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, tea.ErrInterrupted) {
+			fmt.Fprintf(os.Stderr, "Error running spinner: %v\n", err)
+		}
+	}()
+}
+
+// Stop ends the spinner animation.
+func (s *SimpleSpinner) Stop() {
+	s.prog.Quit()
+	<-s.done
+}