first commit

This commit is contained in:
2026-08-14 23:41:57 +08:00
commit 086803a8dd
471 changed files with 91938 additions and 0 deletions
+185
View File
@@ -0,0 +1,185 @@
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",
}