From 67bbf6f7f4d391264c9b137c56a011313f83375e Mon Sep 17 00:00:00 2001 From: Rahul Bansal Date: Sat, 21 Feb 2026 10:28:15 +0530 Subject: [PATCH] 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). --- cmd/picoclaw/cmd_agent.go | 11 +++++++ cmd/picoclaw/spinner.go | 62 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+) create mode 100644 cmd/picoclaw/spinner.go diff --git a/cmd/picoclaw/cmd_agent.go b/cmd/picoclaw/cmd_agent.go index b705ff8c5..e89f0a418 100644 --- a/cmd/picoclaw/cmd_agent.go +++ b/cmd/picoclaw/cmd_agent.go @@ -65,6 +65,7 @@ func agentCmd() { cfg, err := loadConfig() if err != nil { fmt.Printf("Error loading config: %v\n", err) + fmt.Println("Run 'picoclaw doctor' to check for common problems.") os.Exit(1) } @@ -75,6 +76,7 @@ func agentCmd() { provider, modelID, err := providers.CreateProvider(cfg) if err != nil { fmt.Printf("Error creating provider: %v\n", err) + fmt.Println("Run 'picoclaw doctor' to check for common problems.") os.Exit(1) } // Use the resolved model ID from provider creation @@ -105,7 +107,10 @@ func agentCmd() { if message != "" { ctx := context.Background() + spin := newSpinner("Thinking...") + spin.Start() response, err := agentLoop.ProcessDirect(ctx, message, sessionKey) + spin.Stop() if err != nil { fmt.Printf("Error: %v\n", err) os.Exit(1) @@ -157,7 +162,10 @@ func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) { } ctx := context.Background() + spin := newSpinner("Thinking...") + spin.Start() response, err := agentLoop.ProcessDirect(ctx, input, sessionKey) + spin.Stop() if err != nil { fmt.Printf("Error: %v\n", err) continue @@ -192,7 +200,10 @@ func simpleInteractiveMode(agentLoop *agent.AgentLoop, sessionKey string) { } ctx := context.Background() + spin := newSpinner("Thinking...") + spin.Start() response, err := agentLoop.ProcessDirect(ctx, input, sessionKey) + spin.Stop() if err != nil { fmt.Printf("Error: %v\n", err) continue diff --git a/cmd/picoclaw/spinner.go b/cmd/picoclaw/spinner.go new file mode 100644 index 000000000..a226ff0d6 --- /dev/null +++ b/cmd/picoclaw/spinner.go @@ -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 + }) +}