mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-14 03:30:26 +00:00
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:
+2
-2
@@ -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
@@ -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()
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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
@@ -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})
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user