// Package spinner shows a "still working" indicator while nothing is being // printed — the gap between sending a question and the first streamed token, // which on a local engine is long enough (model loading, prompt processing) to // look like a freeze. // // There is one spinner for the whole program rather than a value to pass // around: the REPL runs one generation at a time, and the alternative would be // threading a handle through Genkit's tool callbacks, which we do not control. // // The contract is: Start makes the line ours, Stop gives it back. Anything that // prints must Stop first — hence the calls in dmr and tools. package spinner import ( "fmt" "os" "strings" "sync" "time" ) // frames is the braille cycle. Each frame is one rune wide, so the line never // reflows as it animates. var frames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} const interval = 80 * time.Millisecond var ( mu sync.Mutex stopCh chan struct{} // closed to ask the goroutine to stop doneCh chan struct{} // closed by the goroutine once the line is clean label string // read by the goroutine under mu, so Start can retitle enabled = supported() //nolint:gochecknoglobals // resolved once at startup ) // supported reports whether animating is a good idea. Piped or redirected // output is not a terminal, and writing escape sequences into it would corrupt // whatever is reading — a real risk here, since the point of the exercise is an // agent whose output another program may consume. func supported() bool { if os.Getenv("TERM") == "dumb" || os.Getenv("NO_COLOR") != "" { return false } info, err := os.Stdout.Stat() return err == nil && info.Mode()&os.ModeCharDevice != 0 } // Disable turns the spinner — and Styled — off for the rest of the process. // The ACP front end calls it once at start-up: stdout then belongs to // JSON-RPC, and even on a real terminal (bob --acp run by hand, for a demo) // not one braille frame may land in the protocol stream. supported() cannot // catch that case — a terminal IS a character device. func Disable() { mu.Lock() enabled = false mu.Unlock() } // Styled reports whether stdout may carry escape sequences — same test that // decides whether to animate. Anything that colours its output should ask, // rather than repeat the predicate: piped output is meant to be consumed by // another program, and `bob | jq` should not receive dim codes any more than it // should receive braille frames. func Styled() bool { return enabled } // Start begins animating with the given label, or just retitles a spinner that // is already running. Safe to call when the previous one was never stopped. func Start(text string) { if !enabled { return } mu.Lock() if stopCh != nil { // already spinning: only the wording changes label = text mu.Unlock() return } label = text stop, done := make(chan struct{}), make(chan struct{}) stopCh, doneCh = stop, done mu.Unlock() go spin(stop, done) } // Stop erases the line and waits for the goroutine to be gone, so that whatever // the caller prints next cannot interleave with a frame. Idempotent. func Stop() { mu.Lock() stop, done := stopCh, doneCh stopCh, doneCh = nil, nil mu.Unlock() if stop == nil { return } close(stop) <-done } // spin owns the line until it is told to stop. It restores the cursor on the // way out whatever happens, because a hidden cursor left behind outlives the // program and makes the user's shell look broken. func spin(stop <-chan struct{}, done chan<- struct{}) { started := time.Now() ticker := time.NewTicker(interval) defer func() { ticker.Stop() fmt.Print("\r\033[2K\033[?25h") // erase line, show cursor close(done) }() fmt.Print("\033[?25l") // hide cursor for i := 0; ; i++ { select { case <-stop: return default: } mu.Lock() text := label mu.Unlock() // \r + erase-to-end rather than padding with spaces: the label can // shrink between two frames, and leftovers would stay on screen. fmt.Printf("\r\033[2K\033[2m%s %s… %s\033[0m", frames[i%len(frames)], text, elapsed(time.Since(started))) select { case <-stop: return case <-ticker.C: } } } // elapsed formats the wait the way a person reads it: seconds until a minute, // then minutes and seconds. Showing it is the point — it is what distinguishes // "slow" from "hung". func elapsed(d time.Duration) string { if d < time.Minute { return fmt.Sprintf("%ds", int(d.Seconds())) } return fmt.Sprintf("%dm%02ds", int(d.Minutes()), int(d.Seconds())%60) } // Suspend stops the spinner, runs fn, and starts it again under the same label // — for code that has to print in the middle of a wait. func Suspend(fn func()) { mu.Lock() text, running := label, stopCh != nil mu.Unlock() Stop() fn() if running && strings.TrimSpace(text) != "" { Start(text) } }