Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should eventually consider documenting these on the README. All of them are missing currently.

Maybe this is a good time to do it.

// Here we can add themes later or any TUI related options
//

Expand Down
6 changes: 3 additions & 3 deletions internal/format/spinner.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ type Spinner struct {

type model struct {
cancel context.CancelFunc
anim *anim.Anim
anim anim.Spinner
}

func (m model) Init() tea.Cmd { return m.anim.Init() }
Expand All @@ -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
}

Expand Down
58 changes: 33 additions & 25 deletions internal/tui/components/anim/anim.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like you mistakenly reverted a rename here.


// The maximum amount of time that can pass before a character appears.
// This is used to create a staggered entrance effect.
Expand Down Expand Up @@ -94,13 +94,18 @@ type Settings struct {
GradColorA color.Color
GradColorB color.Color
CycleColors bool
Static bool
}

// Default settings.
const ()
// Spinner is a Bubble for a spinner.
type Spinner interface {
Init() tea.Cmd
Update(tea.Msg) (Spinner, tea.Cmd)
View() string
SetLabel(string)
}

// Anim is a Bubble for an animated spinner.
type Anim struct {
type anim struct {
width int
cyclingCharWidth int
label *csync.Slice[string]
Expand All @@ -117,9 +122,16 @@ 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) 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
Expand All @@ -130,10 +142,8 @@ func New(opts Settings) *Anim {
if colorIsUnset(opts.GradColorB) {
opts.GradColorB = defaultGradColorB
}
if colorIsUnset(opts.LabelColor) {
opts.LabelColor = defaultLabelColor
}

a := &anim{}
a.id = nextID()
a.startTime = time.Now()
a.cyclingCharWidth = opts.Size
Expand Down Expand Up @@ -254,7 +264,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
Expand All @@ -268,7 +278,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)
Expand All @@ -295,7 +305,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
Expand All @@ -313,12 +323,10 @@ 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) (tea.Model, tea.Cmd) {
func (a *anim) Update(msg tea.Msg) (Spinner, tea.Cmd) {
switch msg := msg.(type) {
case StepMsg:
if msg.id != a.id {
Expand All @@ -334,20 +342,20 @@ 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 {
a.initialized.Store(true)
}
return a, a.Step()
return a, stepCmd(a.id)
default:
return a, nil
}
}

// 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 {
Expand All @@ -372,18 +380,18 @@ 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)
}
}

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

Expand Down
43 changes: 43 additions & 0 deletions internal/tui/components/anim/static.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package anim

import (
"cmp"
"image/color"

tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
)

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(cmp.Or(label, "Working") + ellipsisFrames[2])
Copy link
Member

@andreynering andreynering Oct 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think showing just a white Working... is a bit too boring. I wonder if we can improve it somehow. Perhaps add a colorful meaningful character as a prefix, at least to make it distinct from the actual text.

This is probably a job for @meowgorithm 🙂

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed—I can come up with some stuff.

}

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
}
}
14 changes: 11 additions & 3 deletions internal/tui/components/chat/messages/messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.Spinner // Animation component for loading states

// Thinking viewport for displaying reasoning content
thinkingViewport viewport.Model
Expand All @@ -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,
Expand All @@ -99,8 +100,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:
Expand Down Expand Up @@ -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
}
10 changes: 6 additions & 4 deletions internal/tui/components/chat/messages/tool.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.Spinner // Animation component for pending states

nestedToolCalls []ToolCallCmp // Nested tool calls for hierarchical display
}
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -159,8 +161,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...)
Expand Down
Loading