feat: add terminal spinner for CLI agent mode

Fixes #576: shows a rotating Braille dot animation with 'Thinking...'
on stderr while waiting for LLM responses. Spinner runs in all three
agent modes (single message, interactive, simple interactive).
This commit is contained in:
Rahul Bansal 2026-02-21 10:28:15 +05:30
parent 08bb45aeb6
commit 67bbf6f7f4
2 changed files with 73 additions and 0 deletions

View file

@ -65,6 +65,7 @@ func agentCmd() {
cfg, err := loadConfig() cfg, err := loadConfig()
if err != nil { if err != nil {
fmt.Printf("Error loading config: %v\n", err) fmt.Printf("Error loading config: %v\n", err)
fmt.Println("Run 'picoclaw doctor' to check for common problems.")
os.Exit(1) os.Exit(1)
} }
@ -75,6 +76,7 @@ func agentCmd() {
provider, modelID, err := providers.CreateProvider(cfg) provider, modelID, err := providers.CreateProvider(cfg)
if err != nil { if err != nil {
fmt.Printf("Error creating provider: %v\n", err) fmt.Printf("Error creating provider: %v\n", err)
fmt.Println("Run 'picoclaw doctor' to check for common problems.")
os.Exit(1) os.Exit(1)
} }
// Use the resolved model ID from provider creation // Use the resolved model ID from provider creation
@ -105,7 +107,10 @@ func agentCmd() {
if message != "" { if message != "" {
ctx := context.Background() ctx := context.Background()
spin := newSpinner("Thinking...")
spin.Start()
response, err := agentLoop.ProcessDirect(ctx, message, sessionKey) response, err := agentLoop.ProcessDirect(ctx, message, sessionKey)
spin.Stop()
if err != nil { if err != nil {
fmt.Printf("Error: %v\n", err) fmt.Printf("Error: %v\n", err)
os.Exit(1) os.Exit(1)
@ -157,7 +162,10 @@ func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
} }
ctx := context.Background() ctx := context.Background()
spin := newSpinner("Thinking...")
spin.Start()
response, err := agentLoop.ProcessDirect(ctx, input, sessionKey) response, err := agentLoop.ProcessDirect(ctx, input, sessionKey)
spin.Stop()
if err != nil { if err != nil {
fmt.Printf("Error: %v\n", err) fmt.Printf("Error: %v\n", err)
continue continue
@ -192,7 +200,10 @@ func simpleInteractiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
} }
ctx := context.Background() ctx := context.Background()
spin := newSpinner("Thinking...")
spin.Start()
response, err := agentLoop.ProcessDirect(ctx, input, sessionKey) response, err := agentLoop.ProcessDirect(ctx, input, sessionKey)
spin.Stop()
if err != nil { if err != nil {
fmt.Printf("Error: %v\n", err) fmt.Printf("Error: %v\n", err)
continue continue

62
cmd/picoclaw/spinner.go Normal file
View file

@ -0,0 +1,62 @@
// PicoClaw - Ultra-lightweight personal AI agent
// License: MIT
package main
import (
"fmt"
"os"
"sync"
"time"
)
// spinner displays a rotating progress indicator on stderr while the agent
// processes a request. Output goes to stderr so that piped stdout is not
// affected.
type spinner struct {
frames []rune
text string
stop chan struct{}
stopped chan struct{}
once sync.Once
}
func newSpinner(text string) *spinner {
return &spinner{
frames: []rune{'⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'},
text: text,
stop: make(chan struct{}),
stopped: make(chan struct{}),
}
}
// Start launches the spinner animation in a background goroutine.
func (s *spinner) Start() {
go func() {
defer close(s.stopped)
i := 0
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-s.stop:
// Clear the spinner line
fmt.Fprintf(os.Stderr, "\r\033[K")
return
case <-ticker.C:
frame := s.frames[i%len(s.frames)]
fmt.Fprintf(os.Stderr, "\r%c %s", frame, s.text)
i++
}
}
}()
}
// Stop signals the spinner to halt and waits for it to finish.
func (s *spinner) Stop() {
s.once.Do(func() {
close(s.stop)
<-s.stopped
})
}