fix(ui): use theme colors for spinner, remove text, persist during streaming

Spinner now uses theme.Primary/Muted/VeryMuted/MutedBorder instead of
hardcoded red. Removed 'Thinking...' label and message parameter from
NewSpinner/ShowSpinner/SpinnerFunc. Spinner keeps running alongside
streaming text and only hides on step complete via Reset().
This commit is contained in:
Ed Zynda
2026-02-26 12:24:17 +03:00
parent ee21408546
commit 69fba663ef
8 changed files with 149 additions and 135 deletions
+2 -2
View File
@@ -403,10 +403,10 @@ func runNormalMode(ctx context.Context) error {
// Create spinner function for agent creation
var spinnerFunc agent.SpinnerFunc
if !quietFlag {
spinnerFunc = func(message string, fn func() error) error {
spinnerFunc = func(fn func() error) error {
tempCli, tempErr := ui.NewCLI(viper.GetBool("debug"), viper.GetBool("compact"))
if tempErr == nil {
return tempCli.ShowSpinner(message, fn)
return tempCli.ShowSpinner(fn)
}
// Fallback without spinner
return fn()
+5 -5
View File
@@ -790,7 +790,7 @@ func runAgenticStep(ctx context.Context, mcpAgent *agent.Agent, cli *ui.CLI, mes
// Start initial spinner (skip if quiet)
if !config.Quiet && cli != nil {
currentSpinner = ui.NewSpinner("")
currentSpinner = ui.NewSpinner()
currentSpinner.Start()
}
@@ -841,7 +841,7 @@ func runAgenticStep(ctx context.Context, mcpAgent *agent.Agent, cli *ui.CLI, mes
if isStarting {
if !config.Quiet && cli != nil {
// Start spinner for tool execution
currentSpinner = ui.NewSpinner(fmt.Sprintf("Executing %s...", toolName))
currentSpinner = ui.NewSpinner()
currentSpinner.Start()
}
} else {
@@ -889,7 +889,7 @@ func runAgenticStep(ctx context.Context, mcpAgent *agent.Agent, cli *ui.CLI, mes
responseWasStreamed = false
streamingStarted = false
// Start spinner again for next LLM call
currentSpinner = ui.NewSpinner("")
currentSpinner = ui.NewSpinner()
currentSpinner.Start()
}
},
@@ -915,7 +915,7 @@ func runAgenticStep(ctx context.Context, mcpAgent *agent.Agent, cli *ui.CLI, mes
_ = cli.DisplayAssistantMessageWithModel(content, config.ModelName)
lastDisplayedContent = content
// Start spinner again for tool calls
currentSpinner = ui.NewSpinner("")
currentSpinner = ui.NewSpinner()
currentSpinner.Start()
} else if responseWasStreamed {
// Content was already streamed, just track it and manage spinner
@@ -925,7 +925,7 @@ func runAgenticStep(ctx context.Context, mcpAgent *agent.Agent, cli *ui.CLI, mes
currentSpinner = nil
}
// Start spinner again for tool calls
currentSpinner = ui.NewSpinner("")
currentSpinner = ui.NewSpinner()
currentSpinner.Start()
}
},
+3 -3
View File
@@ -10,8 +10,8 @@ import (
)
// SpinnerFunc is a function type for showing spinners during agent creation.
// It executes the provided function while displaying a spinner with the given message.
type SpinnerFunc func(message string, fn func() error) error
// It executes the provided function while displaying an animated spinner.
type SpinnerFunc func(fn func() error) error
// AgentCreationOptions contains options for creating an agent.
// It extends AgentConfig with UI-related options for showing progress during creation.
@@ -55,7 +55,7 @@ func CreateAgent(ctx context.Context, opts *AgentCreationOptions) (*Agent, error
// Show spinner for Ollama models if requested and not quiet
parsedProvider, _, _ := models.ParseModelString(opts.ModelConfig.ModelString)
if opts.ShowSpinner && parsedProvider == "ollama" && !opts.Quiet && opts.SpinnerFunc != nil {
err = opts.SpinnerFunc("Loading Ollama model...", func() error {
err = opts.SpinnerFunc(func() error {
agent, err = NewAgent(ctx, agentConfig)
return err
})
+1 -2
View File
@@ -336,9 +336,8 @@ func (a *App) executeStep(ctx context.Context, prompt string, prog *tea.Program,
func(content string) {
sendFn(ToolCallContentEvent{Content: content})
},
// onStreamingResponse — hide spinner on first chunk
// onStreamingResponse — spinner keeps running alongside streaming text
func(chunk string) {
sendFn(SpinnerEvent{Show: false})
sendFn(StreamChunkEvent{Content: chunk})
},
)
+48 -31
View File
@@ -306,7 +306,7 @@ func TestStreamComponent_Init_ReturnsNil(t *testing.T) {
// --------------------------------------------------------------------------
// TestStreamComponent_SpinnerTransition verifies that SpinnerEvent{Show:true}
// transitions phase from idle → spinner and starts the tick loop.
// starts spinning and transitions phase from idle → active.
// --------------------------------------------------------------------------
func TestStreamComponent_SpinnerTransition(t *testing.T) {
@@ -318,8 +318,11 @@ func TestStreamComponent_SpinnerTransition(t *testing.T) {
_, cmd := c.Update(app.SpinnerEvent{Show: true})
if c.phase != streamPhaseSpinner {
t.Fatalf("expected streamPhaseSpinner after SpinnerEvent{Show:true}, got %v", c.phase)
if !c.spinning {
t.Fatal("expected spinning=true after SpinnerEvent{Show:true}")
}
if c.phase != streamPhaseActive {
t.Fatalf("expected streamPhaseActive after SpinnerEvent{Show:true}, got %v", c.phase)
}
// A tick cmd should have been returned to start the animation loop.
if cmd == nil {
@@ -337,27 +340,33 @@ func TestStreamComponent_SpinnerShowFalse_NoTransitionFromIdle(t *testing.T) {
if c.phase != streamPhaseIdle {
t.Fatalf("expected streamPhaseIdle after SpinnerEvent{Show:false}, got %v", c.phase)
}
if c.spinning {
t.Fatal("expected spinning=false after SpinnerEvent{Show:false}")
}
}
// --------------------------------------------------------------------------
// TestStreamComponent_SpinnerToStreaming_OnFirstChunk verifies that receiving
// a StreamChunkEvent while in spinner phase transitions to streaming phase.
// TestStreamComponent_SpinnerKeepsRunningDuringStreaming verifies that
// receiving a StreamChunkEvent keeps the spinner running alongside text.
// --------------------------------------------------------------------------
func TestStreamComponent_SpinnerToStreaming_OnFirstChunk(t *testing.T) {
func TestStreamComponent_SpinnerKeepsRunningDuringStreaming(t *testing.T) {
c := newTestStream()
// Enter spinner phase.
// Start spinner.
c = sendStreamMsg(c, app.SpinnerEvent{Show: true})
if c.phase != streamPhaseSpinner {
t.Fatalf("precondition: expected streamPhaseSpinner, got %v", c.phase)
if !c.spinning {
t.Fatal("precondition: expected spinning=true")
}
// Receive first chunk.
// Receive first chunk — spinner should keep running.
c = sendStreamMsg(c, app.StreamChunkEvent{Content: "hello"})
if c.phase != streamPhaseStreaming {
t.Fatalf("expected streamPhaseStreaming after first chunk, got %v", c.phase)
if !c.spinning {
t.Fatal("expected spinning=true after first chunk")
}
if c.phase != streamPhaseActive {
t.Fatalf("expected streamPhaseActive after first chunk, got %v", c.phase)
}
if c.streamContent.String() != "hello" {
t.Fatalf("expected streamContent='hello', got %q", c.streamContent.String())
@@ -382,8 +391,8 @@ func TestStreamComponent_ChunkAccumulation(t *testing.T) {
if got != want {
t.Fatalf("expected accumulated content %q, got %q", want, got)
}
if c.phase != streamPhaseStreaming {
t.Fatalf("expected streamPhaseStreaming, got %v", c.phase)
if c.phase != streamPhaseActive {
t.Fatalf("expected streamPhaseActive, got %v", c.phase)
}
}
@@ -399,8 +408,8 @@ func TestStreamComponent_ToolExecution_IsStarting_ShowsSpinner(t *testing.T) {
IsStarting: true,
})
if c.phase != streamPhaseSpinner {
t.Fatalf("expected streamPhaseSpinner during tool execution, got %v", c.phase)
if !c.spinning {
t.Fatal("expected spinning=true during tool execution")
}
if !strings.Contains(c.spinnerMsg, "exec_tool") {
t.Fatalf("expected spinnerMsg to contain tool name, got %q", c.spinnerMsg)
@@ -410,18 +419,23 @@ func TestStreamComponent_ToolExecution_IsStarting_ShowsSpinner(t *testing.T) {
}
}
// TestStreamComponent_ToolExecution_NotStarting goes idle after execution.
func TestStreamComponent_ToolExecution_NotStarting_GoesIdle(t *testing.T) {
// TestStreamComponent_ToolExecution_NotStarting clears label but keeps spinning.
func TestStreamComponent_ToolExecution_NotStarting_KeepsSpinning(t *testing.T) {
c := newTestStream()
c.phase = streamPhaseSpinner // simulating execution in progress
// Start spinning first (simulating execution in progress).
c = sendStreamMsg(c, app.SpinnerEvent{Show: true})
c.spinnerMsg = "Executing some_tool…"
c = sendStreamMsg(c, app.ToolExecutionEvent{
ToolName: "some_tool",
IsStarting: false,
})
if c.phase != streamPhaseIdle {
t.Fatalf("expected streamPhaseIdle after tool execution finished, got %v", c.phase)
if !c.spinning {
t.Fatal("expected spinning=true after tool execution finished (spinner keeps running)")
}
if c.spinnerMsg != "" {
t.Fatalf("expected spinnerMsg cleared after tool finished, got %q", c.spinnerMsg)
}
}
@@ -468,6 +482,9 @@ func TestStreamComponent_Reset(t *testing.T) {
if c.phase != streamPhaseIdle {
t.Fatalf("expected streamPhaseIdle after Reset(), got %v", c.phase)
}
if c.spinning {
t.Fatal("expected spinning=false after Reset()")
}
if c.spinnerFrame != 0 {
t.Fatalf("expected spinnerFrame=0 after Reset(), got %d", c.spinnerFrame)
}
@@ -477,8 +494,8 @@ func TestStreamComponent_Reset(t *testing.T) {
if !c.timestamp.IsZero() {
t.Fatal("expected zero timestamp after Reset()")
}
if c.spinnerMsg != "Thinking…" {
t.Fatalf("expected spinnerMsg reset to default, got %q", c.spinnerMsg)
if c.spinnerMsg != "" {
t.Fatalf("expected spinnerMsg empty after Reset(), got %q", c.spinnerMsg)
}
}
@@ -511,7 +528,7 @@ func TestStreamComponent_SetHeight_Negative_ClampsToZero(t *testing.T) {
func TestStreamComponent_SpinnerTick_AdvancesFrame(t *testing.T) {
c := newTestStream()
// Enter spinner phase first.
// Start spinning first.
c = sendStreamMsg(c, app.SpinnerEvent{Show: true})
initialFrame := c.spinnerFrame
@@ -521,19 +538,19 @@ func TestStreamComponent_SpinnerTick_AdvancesFrame(t *testing.T) {
if c.spinnerFrame != initialFrame+1 {
t.Fatalf("expected spinnerFrame=%d, got %d", initialFrame+1, c.spinnerFrame)
}
// The tick should re-schedule itself.
// The tick should re-schedule itself while spinning.
if cmd == nil {
t.Fatal("expected tick cmd to be re-scheduled in spinner phase")
t.Fatal("expected tick cmd to be re-scheduled while spinning")
}
}
// TestStreamComponent_SpinnerTick_NoReschedule_WhenNotSpinner verifies that a
// tick in non-spinner phase does not re-schedule.
func TestStreamComponent_SpinnerTick_NoReschedule_WhenNotSpinner(t *testing.T) {
// TestStreamComponent_SpinnerTick_NoReschedule_WhenNotSpinning verifies that a
// tick when not spinning does not re-schedule.
func TestStreamComponent_SpinnerTick_NoReschedule_WhenNotSpinning(t *testing.T) {
c := newTestStream()
// phase is idle — tick should be ignored.
// spinning is false — tick should be ignored.
_, cmd := c.Update(streamSpinnerTickMsg{})
if cmd != nil {
t.Fatal("expected no tick reschedule when not in spinner phase")
t.Fatal("expected no tick reschedule when not spinning")
}
}
+5 -5
View File
@@ -76,11 +76,11 @@ func (c *CLI) SetModelName(modelName string) {
}
}
// ShowSpinner displays an animated spinner with the specified message while
// executing the provided action function. The spinner automatically stops when
// the action completes. Returns any error returned by the action function.
func (c *CLI) ShowSpinner(message string, action func() error) error {
spinner := NewSpinner(message)
// ShowSpinner displays an animated spinner while executing the provided action
// function. The spinner automatically stops when the action completes. Returns
// any error returned by the action function.
func (c *CLI) ShowSpinner(action func() error) error {
spinner := NewSpinner()
spinner.Start()
err := action()
+11 -24
View File
@@ -5,8 +5,6 @@ import (
"os"
"sync"
"time"
"charm.land/lipgloss/v2"
)
// Spinner provides an animated loading indicator that displays while
@@ -15,23 +13,20 @@ import (
// capability queries that can leak escape sequences (mode 2026 DECRPM).
//
// The KITT-style frames are generated by knightRiderFrames() in stream.go
// (same package).
// (same package) and use the active theme colors.
type Spinner struct {
message string
frames []string
fps time.Duration
done chan struct{}
once sync.Once
frames []string
fps time.Duration
done chan struct{}
once sync.Once
}
// NewSpinner creates a new animated spinner with the specified message.
// Uses a KITT-style red scanning animation.
func NewSpinner(message string) *Spinner {
// NewSpinner creates a new animated KITT-style spinner using theme colors.
func NewSpinner() *Spinner {
return &Spinner{
message: message,
frames: knightRiderFrames(),
fps: time.Second / 14,
done: make(chan struct{}),
frames: knightRiderFrames(),
fps: time.Second / 14,
done: make(chan struct{}),
}
}
@@ -49,12 +44,6 @@ func (s *Spinner) Stop() {
// run is the animation loop that renders spinner frames to stderr.
func (s *Spinner) run() {
theme := GetTheme()
messageStyle := lipgloss.NewStyle().
Foreground(theme.Text).
Italic(true)
ticker := time.NewTicker(s.fps)
defer ticker.Stop()
@@ -67,9 +56,7 @@ func (s *Spinner) run() {
return
case <-ticker.C:
f := s.frames[frame%len(s.frames)]
fmt.Fprintf(os.Stderr, "\r %s %s",
f,
messageStyle.Render(s.message))
fmt.Fprintf(os.Stderr, "\r %s", f)
frame++
}
}
+74 -63
View File
@@ -10,16 +10,19 @@ import (
)
// knightRiderFrames generates a KITT-style scanning animation where a bright
// red light bounces back and forth across a row of dots with a trailing glow.
// Used by StreamComponent (TUI inline spinner) and Spinner (stderr goroutine spinner).
// light bounces back and forth across a row of dots with a trailing glow.
// Colors are derived from the active theme. Used by StreamComponent (TUI
// inline spinner) and Spinner (stderr goroutine spinner).
func knightRiderFrames() []string {
const numDots = 8
const dot = "▪"
bright := lipgloss.NewStyle().Foreground(lipgloss.Color("#FF0000"))
med := lipgloss.NewStyle().Foreground(lipgloss.Color("#990000"))
dim := lipgloss.NewStyle().Foreground(lipgloss.Color("#440000"))
off := lipgloss.NewStyle().Foreground(lipgloss.Color("#222222"))
theme := GetTheme()
bright := lipgloss.NewStyle().Foreground(theme.Primary)
med := lipgloss.NewStyle().Foreground(theme.Muted)
dim := lipgloss.NewStyle().Foreground(theme.VeryMuted)
off := lipgloss.NewStyle().Foreground(theme.MutedBorder)
// Scanner bounces: 0→7→0
positions := make([]int, 0, 2*numDots-2)
@@ -73,17 +76,15 @@ const (
// streamPhaseIdle is the initial state — nothing to display.
streamPhaseIdle streamPhase = iota
// streamPhaseSpinner shows the KITT-style animation while waiting for the
// first streaming chunk or tool event.
streamPhaseSpinner
// streamPhaseStreaming shows the live streaming text as chunks arrive.
streamPhaseStreaming
// streamPhaseActive means content is being displayed (streaming text
// and/or spinner animation).
streamPhaseActive
)
// StreamComponent is the Bubble Tea child model responsible for the stream
// region: it renders a KITT-style spinner when the agent is thinking, and
// switches to live text once StreamChunkEvents start arriving.
// region: it renders a KITT-style spinner when the agent is working, and
// displays live text as StreamChunkEvents arrive. The spinner remains visible
// alongside streaming text until the step completes and Reset() is called.
//
// Tool calls, tool results, user messages, and other non-streaming content
// are printed immediately by the parent AppModel via tea.Println(). The
@@ -95,21 +96,26 @@ const (
// then calls Reset(); StreamComponent never calls tea.Quit.
//
// Events handled:
// - app.SpinnerEvent{Show:true} → enter spinner phase, start tick loop
// - app.SpinnerEvent{Show:false} → (unused — first chunk transitions automatically)
// - app.StreamChunkEvent → append text, enter streaming phase
// - app.ToolExecutionEvent → show execution spinner during tool run
// - app.SpinnerEvent{Show:true} → start spinner tick loop
// - app.StreamChunkEvent → append text
// - app.ToolExecutionEvent → show execution label on spinner
type StreamComponent struct {
// phase tracks what we are currently showing.
// phase tracks whether the component is idle or active.
phase streamPhase
// spinning is true while the KITT animation tick loop is running.
// It is orthogonal to whether streaming text is present: the spinner
// remains visible alongside streaming text until Reset().
spinning bool
// spinnerFrames are the pre-rendered KITT animation frames.
spinnerFrames []string
// spinnerFrame is the current frame index.
spinnerFrame int
// spinnerMsg is the label shown next to the KITT animation.
// spinnerMsg is the label shown next to the KITT animation (e.g.
// "Executing tool_name…"). Empty string means no label.
spinnerMsg string
// streamContent accumulates all streaming text chunks.
@@ -145,7 +151,6 @@ func NewStreamComponent(compactMode bool, width int, modelName string) *StreamCo
}
return &StreamComponent{
spinnerFrames: knightRiderFrames(),
spinnerMsg: "Thinking…",
compactMode: compactMode,
modelName: modelName,
messageRenderer: NewMessageRenderer(width, false),
@@ -168,8 +173,9 @@ func (s *StreamComponent) SetHeight(h int) {
// agent step. Called by AppModel after a step completes or errors.
func (s *StreamComponent) Reset() {
s.phase = streamPhaseIdle
s.spinning = false
s.spinnerFrame = 0
s.spinnerMsg = "Thinking…"
s.spinnerMsg = ""
s.streamContent.Reset()
s.timestamp = time.Time{}
}
@@ -205,44 +211,46 @@ func (s *StreamComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
s.compactRenderer.SetWidth(s.width)
case streamSpinnerTickMsg:
if s.phase == streamPhaseSpinner {
if s.spinning {
s.spinnerFrame++
return s, streamSpinnerTickCmd()
}
// Phase changed; let the tick loop die naturally.
// Spinning stopped; let the tick loop die naturally.
// ── App-layer events ──────────────────────────────────────────────────
case app.SpinnerEvent:
if msg.Show {
if s.phase == streamPhaseIdle {
s.phase = streamPhaseSpinner
s.timestamp = time.Now()
return s, streamSpinnerTickCmd()
}
}
// Show:false is a no-op; the first StreamChunkEvent transitions phase.
case app.StreamChunkEvent:
if s.phase != streamPhaseStreaming {
s.phase = streamPhaseStreaming
if msg.Show && !s.spinning {
s.phase = streamPhaseActive
s.spinning = true
s.spinnerFrame = 0
if s.timestamp.IsZero() {
s.timestamp = time.Now()
}
return s, streamSpinnerTickCmd()
}
case app.StreamChunkEvent:
s.phase = streamPhaseActive
if s.timestamp.IsZero() {
s.timestamp = time.Now()
}
s.streamContent.WriteString(msg.Content)
case app.ToolExecutionEvent:
if msg.IsStarting {
// Show a KITT spinner with the tool name while the tool executes.
s.phase = streamPhaseSpinner
// Show the tool name on the spinner while the tool executes.
s.spinnerMsg = "Executing " + msg.ToolName + "…"
s.spinnerFrame = 0
return s, streamSpinnerTickCmd()
if !s.spinning {
s.phase = streamPhaseActive
s.spinning = true
return s, streamSpinnerTickCmd()
}
} else {
// Tool finished — clear execution label but keep spinning.
s.spinnerMsg = ""
}
// Tool finished — go idle. Parent will trigger a new spinner for
// the next LLM call if needed.
s.phase = streamPhaseIdle
}
return s, nil
@@ -259,26 +267,28 @@ func (s *StreamComponent) View() tea.View {
// render builds the full content string for the stream region.
func (s *StreamComponent) render() string {
var content string
switch s.phase {
case streamPhaseIdle:
return ""
case streamPhaseSpinner:
content = s.renderSpinner()
case streamPhaseStreaming:
// Live streaming assistant text only. Tool calls, results, and
// other messages are printed immediately by the parent via tea.Println.
text := s.streamContent.String()
if text != "" {
content = s.renderStreamingText(text)
}
default:
if s.phase == streamPhaseIdle {
return ""
}
var parts []string
// Render streaming text if present.
if text := s.streamContent.String(); text != "" {
parts = append(parts, s.renderStreamingText(text))
}
// Render spinner below streaming text (or alone if no text yet).
if s.spinning {
parts = append(parts, s.renderSpinner())
}
if len(parts) == 0 {
return ""
}
content := strings.Join(parts, "\n")
// Clamp to height if constrained: keep the last h lines so the most
// recent output is always visible.
if s.height > 0 && content != "" {
@@ -292,15 +302,16 @@ func (s *StreamComponent) render() string {
return content
}
// renderSpinner renders the KITT-style scanning animation with a message label.
// renderSpinner renders the KITT-style scanning animation with an optional label.
func (s *StreamComponent) renderSpinner() string {
theme := GetTheme()
frame := s.spinnerFrames[s.spinnerFrame%len(s.spinnerFrames)]
if s.spinnerMsg == "" {
return " " + frame
}
theme := GetTheme()
msgStyle := lipgloss.NewStyle().
Foreground(theme.Text).
Italic(true)
return " " + frame + " " + msgStyle.Render(s.spinnerMsg)
}