diff --git a/cmd/root.go b/cmd/root.go index 6bacb69a..d2cdb6b8 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -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() diff --git a/cmd/script.go b/cmd/script.go index c5265df3..11774dcb 100644 --- a/cmd/script.go +++ b/cmd/script.go @@ -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() } }, diff --git a/internal/agent/factory.go b/internal/agent/factory.go index e603c5ff..f049b5b3 100644 --- a/internal/agent/factory.go +++ b/internal/agent/factory.go @@ -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 }) diff --git a/internal/app/app.go b/internal/app/app.go index b611cd3d..651a082f 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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}) }, ) diff --git a/internal/ui/children_test.go b/internal/ui/children_test.go index 00481923..40a2f60a 100644 --- a/internal/ui/children_test.go +++ b/internal/ui/children_test.go @@ -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") } } diff --git a/internal/ui/cli.go b/internal/ui/cli.go index ec9fd652..b292f219 100644 --- a/internal/ui/cli.go +++ b/internal/ui/cli.go @@ -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() diff --git a/internal/ui/spinner.go b/internal/ui/spinner.go index 3498701d..634f7b25 100644 --- a/internal/ui/spinner.go +++ b/internal/ui/spinner.go @@ -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++ } } diff --git a/internal/ui/stream.go b/internal/ui/stream.go index ecf4fdfd..095586b1 100644 --- a/internal/ui/stream.go +++ b/internal/ui/stream.go @@ -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) }