feat(tui): reduce animations

Carlos Alexandro Becker created

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

Change summary

internal/config/config.go                         |  5 +-
internal/tui/components/anim/anim.go              | 26 ++++++++--------
internal/tui/components/anim/static.go            | 24 +++++++++++++--
internal/tui/components/chat/messages/messages.go |  8 +++++
internal/tui/components/chat/messages/tool.go     |  2 +
5 files changed, 46 insertions(+), 19 deletions(-)

Detailed changes

internal/config/config.go 🔗

@@ -128,8 +128,9 @@ type LSPConfig struct {
 }
 
 type TUIOptions struct {
-	CompactMode bool   `json:"compact_mode,omitempty" jsonschema:"description=Enable compact mode for the TUI interface,default=false"`
-	DiffMode    string `json:"diff_mode,omitempty" jsonschema:"description=Diff mode for the TUI interface,enum=unified,enum=split"`
+	CompactMode      bool   `json:"compact_mode,omitempty" jsonschema:"description=Enable compact mode for the TUI interface,default=false"`
+	DiffMode         string `json:"diff_mode,omitempty" jsonschema:"description=Diff mode for the TUI interface,enum=unified,enum=split"`
+	ReduceAnimations bool   `json:"reduce_animations,omitempty" jsonschema:"description=Reduce animations in the TUI,default=false"`
 	// Here we can add themes later or any TUI related options
 	//
 

internal/tui/components/anim/anim.go 🔗

@@ -124,6 +124,14 @@ type anim struct {
 
 // New creates a new anim instance with the specified width and label.
 func New(opts Settings) Spinner {
+	if colorIsUnset(opts.LabelColor) {
+		opts.LabelColor = defaultLabelColor
+	}
+
+	if opts.Static {
+		return newStatic(opts.Label, opts.LabelColor)
+	}
+
 	// Validate settings.
 	if opts.Size < 1 {
 		opts.Size = defaultNumCyclingChars
@@ -134,13 +142,7 @@ func New(opts Settings) Spinner {
 	if colorIsUnset(opts.GradColorB) {
 		opts.GradColorB = defaultGradColorB
 	}
-	if colorIsUnset(opts.LabelColor) {
-		opts.LabelColor = defaultLabelColor
-	}
 
-	if opts.Static {
-		return newStatic(opts.Label, opts.LabelColor)
-	}
 	a := &anim{}
 	a.id = nextID()
 	a.startTime = time.Now()
@@ -321,9 +323,7 @@ func (a *anim) Width() (w int) {
 }
 
 // Init starts the animation.
-func (a *anim) Init() tea.Cmd {
-	return a.Step()
-}
+func (a *anim) Init() tea.Cmd { return stepCmd(a.id) }
 
 // Update processes animation steps (or not).
 func (a *anim) Update(msg tea.Msg) (Spinner, tea.Cmd) {
@@ -348,7 +348,7 @@ func (a *anim) Update(msg tea.Msg) (Spinner, tea.Cmd) {
 		} else if !a.initialized.Load() && time.Since(a.startTime) >= maxBirthOffset {
 			a.initialized.Store(true)
 		}
-		return a, a.Step()
+		return a, stepCmd(a.id)
 	default:
 		return a, nil
 	}
@@ -388,10 +388,10 @@ func (a *anim) View() string {
 	return b.String()
 }
 
-// Step is a command that triggers the next step in the animation.
-func (a *anim) Step() tea.Cmd {
+// stepCmd is a command that triggers the next stepCmd in the animation.
+func stepCmd(id int) tea.Cmd {
 	return tea.Tick(time.Second/time.Duration(fps), func(t time.Time) tea.Msg {
-		return StepMsg{id: a.id}
+		return StepMsg{id: id}
 	})
 }
 

internal/tui/components/anim/static.go 🔗

@@ -1,6 +1,7 @@
 package anim
 
 import (
+	"cmp"
 	"image/color"
 
 	tea "github.com/charmbracelet/bubbletea/v2"
@@ -10,18 +11,33 @@ import (
 type noAnim struct {
 	Color    color.Color
 	rendered string
+	id       int
 }
 
 func newStatic(label string, foreground color.Color) Spinner {
 	a := &noAnim{Color: foreground}
 	a.SetLabel(label)
+	a.id = nextID()
 	return a
 }
 
 func (s *noAnim) SetLabel(label string) {
-	s.rendered = lipgloss.NewStyle().Foreground(s.Color).Render(label + ellipsisFrames[2])
+	s.rendered = lipgloss.NewStyle().
+		Foreground(s.Color).
+		Render(cmp.Or(label, "Working") + ellipsisFrames[2])
 }
 
-func (s noAnim) Init() tea.Cmd                      { return nil }
-func (s *noAnim) Update(tea.Msg) (Spinner, tea.Cmd) { return s, nil }
-func (s *noAnim) View() string                      { return s.rendered }
+func (s noAnim) Init() tea.Cmd { return stepCmd(s.id) }
+func (s *noAnim) View() string { return s.rendered }
+func (s *noAnim) Update(msg tea.Msg) (Spinner, tea.Cmd) {
+	switch msg := msg.(type) {
+	case StepMsg:
+		if msg.id != s.id {
+			// Reject messages that are not for this instance.
+			return s, nil
+		}
+		return s, stepCmd(s.id)
+	default:
+		return s, nil
+	}
+}

internal/tui/components/chat/messages/messages.go 🔗

@@ -75,6 +75,7 @@ func NewMessageCmp(msg message.Message) MessageCmp {
 	m := &messageCmp{
 		message: msg,
 		anim: anim.New(anim.Settings{
+			Static:      isReduceAnimations(),
 			Size:        15,
 			GradColorA:  t.Primary,
 			GradColorB:  t.Secondary,
@@ -425,3 +426,10 @@ func (m *assistantSectionModel) IsSectionHeader() bool {
 func (m *messageCmp) ID() string {
 	return m.message.ID
 }
+
+func isReduceAnimations() bool {
+	cfg := config.Get()
+	return cfg.Options != nil &&
+		cfg.Options.TUI != nil &&
+		cfg.Options.TUI.ReduceAnimations
+}

internal/tui/components/chat/messages/tool.go 🔗

@@ -120,6 +120,7 @@ func NewToolCallCmp(parentMessageID string, tc message.ToolCall, permissions per
 	}
 	t := styles.CurrentTheme()
 	m.anim = anim.New(anim.Settings{
+		Static:      isReduceAnimations(),
 		Size:        15,
 		Label:       "Working",
 		GradColorA:  t.Primary,
@@ -129,6 +130,7 @@ func NewToolCallCmp(parentMessageID string, tc message.ToolCall, permissions per
 	})
 	if m.isNested {
 		m.anim = anim.New(anim.Settings{
+			Static:      isReduceAnimations(),
 			Size:        10,
 			GradColorA:  t.Primary,
 			GradColorB:  t.Secondary,