Files
BlackBean/pigo/internal/cli/tui/spinner.go
T
2026-08-14 23:41:57 +08:00

186 lines
7.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package tui
import (
"fmt"
"math/rand"
"strings"
"time"
)
// This file implements the "working" spinner shown while an agent run is in
// flight, mirroring Claude Code's animated status line: a cycling asterisk
// glyph, a whimsical present-progressive verb ("Whirring…"), and a live stats
// readout — elapsed wall-clock time, an estimate of streamed output tokens, and
// the configured thinking effort. It renders on the row just above the input
// while running and disappears when the run ends.
// spinnerTickMsg advances the spinner animation. The model re-issues a tick
// after each frame while a run is in flight and lets the tick lapse once the run
// ends, so the animation stops without a running goroutine.
type spinnerTickMsg time.Time
// spinnerInterval is the frame cadence. ~120ms is brisk enough to read as motion
// without churning the render loop.
const spinnerInterval = 120 * time.Millisecond
// verbRerollFrames re-picks the verb roughly every this many frames (~5s) so a
// long run cycles through several verbs the way Claude Code does.
const verbRerollFrames = 40
// spinnerFrames is the asterisk animation cycled one glyph per tick. The glyphs
// grow from a dim dot to a full star and back, reading as a pulsing sparkle.
var spinnerFrames = []string{"·", "✢", "✳", "", "✺", "✻", "✽", "✻", "✺", "", "✳", "✢"}
// spinner is the animated working indicator. It is a plain value held by the
// Model: begin() arms it at run start, advance() steps the frame on each tick,
// addTokens() grows the streamed-token estimate, and view() renders the line.
type spinner struct {
theme Theme
running bool
frame int
verb string
start time.Time
chars int // runes streamed this run (the token estimate divides this)
thinking string // thinking-effort label, e.g. "medium"; "" hides that stat
pinned string // when set, overrides the random verb and stops re-rolling
}
// newSpinner builds an idle spinner bound to the theme.
func newSpinner(theme Theme) spinner {
return spinner{theme: theme}
}
// begin arms the spinner for a fresh run: it records the start time, picks the
// first verb, resets the frame and token estimate, and stores the thinking-effort
// label to show in the stats.
func (s *spinner) begin(now time.Time, thinking string) {
s.running = true
s.frame = 0
s.start = now
s.chars = 0
s.thinking = thinking
s.verb = randomVerb()
s.pinned = ""
}
// pin fixes the spinner label to a specific phrase (e.g. "Compacting
// conversation") and stops verb re-rolling until unpin, so a long-running phase
// reads as one steady message rather than cycling words.
func (s *spinner) pin(label string) { s.pinned = label }
// unpin restores the normal cycling verb after a pinned phase ends.
func (s *spinner) unpin() { s.pinned = "" }
// stop parks the spinner when a run ends so view() renders nothing.
func (s *spinner) stop() { s.running = false }
// advance steps the animation one frame and periodically re-rolls the verb so a
// long run does not sit on one word.
func (s *spinner) advance() {
s.frame++
if s.pinned == "" && s.frame%verbRerollFrames == 0 {
s.verb = randomVerb()
}
}
// addTokens folds a streamed text delta into the running output-token estimate.
// The count is approximate (≈4 chars per token) — enough for a live spinner
// readout, not billing.
func (s *spinner) addTokens(delta string) {
s.chars += len([]rune(delta))
}
// view renders the spinner line, e.g. "✻ Whirring… (1m 54s · ↓ 242 tokens ·
// medium effort)". It returns "" when not running or before a width is known.
// The glyph and verb take the accent color; the parenthetical stats are dim.
func (s spinner) view(width int) string {
if !s.running || width <= 0 {
return ""
}
glyph := spinnerFrames[s.frame%len(spinnerFrames)]
verb := s.verb
if s.pinned != "" {
verb = s.pinned
}
head := s.theme.Spinner.Render(glyph + " " + verb + "…")
var stats strings.Builder
fmt.Fprintf(&stats, "%s", formatElapsed(time.Since(s.start)))
if tokens := s.chars / 4; tokens > 0 {
fmt.Fprintf(&stats, " · ↓ %s tokens", humanizeInt(tokens))
}
if s.thinking != "" {
fmt.Fprintf(&stats, " · %s effort", s.thinking)
}
line := head + " " + s.theme.System.Render("("+stats.String()+")")
return TruncateToWidth(line, width)
}
// randomVerb picks one of the built-in present-progressive verbs.
func randomVerb() string {
return spinnerVerbs[rand.Intn(len(spinnerVerbs))]
}
// formatElapsed renders a duration compactly: "42s", "1m 54s", or "1h 2m".
func formatElapsed(d time.Duration) string {
if d < 0 {
d = 0
}
secs := int(d.Seconds())
if secs < 60 {
return fmt.Sprintf("%ds", secs)
}
mins := secs / 60
secs %= 60
if mins < 60 {
return fmt.Sprintf("%dm %ds", mins, secs)
}
hours := mins / 60
mins %= 60
return fmt.Sprintf("%dh %dm", hours, mins)
}
// spinnerVerbs is Claude Code's 185 built-in spinner verbs (present-progressive
// flavor words shown while working). Sourced from the community catalog at
// github.com/wynandw87/claude-code-spinner-verbs.
var spinnerVerbs = []string{
"Accomplishing", "Actioning", "Actualizing", "Architecting", "Baking",
"Beaming", "Beboppin'", "Befuddling", "Billowing", "Blanching",
"Bloviating", "Boogieing", "Boondoggling", "Booping", "Bootstrapping",
"Brewing", "Burrowing", "Calculating", "Canoodling", "Caramelizing",
"Cascading", "Catapulting", "Cerebrating", "Channeling", "Channelling",
"Choreographing", "Churning", "Clauding", "Coalescing", "Cogitating",
"Combobulating", "Composing", "Computing", "Concocting", "Considering",
"Contemplating", "Cooking", "Crafting", "Creating", "Crunching",
"Crystallizing", "Cultivating", "Deciphering", "Deliberating", "Determining",
"Dilly-dallying", "Discombobulating", "Doing", "Doodling", "Drizzling",
"Ebbing", "Effecting", "Elucidating", "Embellishing", "Enchanting",
"Envisioning", "Evaporating", "Fermenting", "Fiddle-faddling", "Finagling",
"Flambeing", "Flibbertigibbeting", "Flowing", "Flummoxing", "Fluttering",
"Forging", "Forming", "Frolicking", "Frosting", "Gallivanting",
"Galloping", "Garnishing", "Generating", "Germinating", "Gitifying",
"Grooving", "Gusting", "Harmonizing", "Hashing", "Hatching",
"Herding", "Honking", "Hullaballooing", "Hyperspacing", "Ideating",
"Imagining", "Improvising", "Incubating", "Inferring", "Infusing",
"Ionizing", "Jitterbugging", "Julienning", "Kneading", "Leavening",
"Levitating", "Lollygagging", "Manifesting", "Marinating", "Meandering",
"Metamorphosing", "Misting", "Moonwalking", "Moseying", "Mulling",
"Mustering", "Musing", "Nebulizing", "Nesting", "Newspapering",
"Noodling", "Nucleating", "Orbiting", "Orchestrating", "Osmosing",
"Perambulating", "Percolating", "Perusing", "Philosophising", "Photosynthesizing",
"Pollinating", "Pondering", "Pontificating", "Pouncing", "Precipitating",
"Prestidigitating", "Processing", "Proofing", "Propagating", "Puttering",
"Puzzling", "Quantumizing", "Razzle-dazzling", "Razzmatazzing", "Recombobulating",
"Reticulating", "Roosting", "Ruminating", "Sauteing", "Scampering",
"Schlepping", "Scurrying", "Seasoning", "Shenaniganing", "Shimmying",
"Simmering", "Skedaddling", "Sketching", "Slithering", "Smooshing",
"Sock-hopping", "Spelunking", "Spinning", "Sprouting", "Stewing",
"Sublimating", "Swirling", "Swooping", "Symbioting", "Synthesizing",
"Tempering", "Thinking", "Thundering", "Tinkering", "Tomfoolering",
"Topsy-turvying", "Transfiguring", "Transmuting", "Twisting", "Undulating",
"Unfurling", "Unravelling", "Vibing", "Waddling", "Wandering",
"Warping", "Whatchamacalliting", "Whirlpooling", "Whirring", "Whisking",
"Wibbling", "Working", "Wrangling", "Zesting", "Zigzagging",
}