| 💾 Saved. d722711 k33g 7h ago | 1 | // Package spinner shows a "still working" indicator while nothing is being |
| 2 | // printed — the gap between sending a question and the first streamed token, |
| 3 | // which on a local engine is long enough (model loading, prompt processing) to |
| 4 | // look like a freeze. |
| 5 | // |
| 6 | // There is one spinner for the whole program rather than a value to pass |
| 7 | // around: the REPL runs one generation at a time, and the alternative would be |
| 8 | // threading a handle through Genkit's tool callbacks, which we do not control. |
| 9 | // |
| 10 | // The contract is: Start makes the line ours, Stop gives it back. Anything that |
| 11 | // prints must Stop first — hence the calls in dmr and tools. |
| 12 | package spinner |
| 13 | |
| 14 | import ( |
| 15 | "fmt" |
| 16 | "os" |
| 17 | "strings" |
| 18 | "sync" |
| 19 | "time" |
| 20 | ) |
| 21 | |
| 22 | // frames is the braille cycle. Each frame is one rune wide, so the line never |
| 23 | // reflows as it animates. |
| 24 | var frames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} |
| 25 | |
| 26 | const interval = 80 * time.Millisecond |
| 27 | |
| 28 | var ( |
| 29 | mu sync.Mutex |
| 30 | stopCh chan struct{} // closed to ask the goroutine to stop |
| 31 | doneCh chan struct{} // closed by the goroutine once the line is clean |
| 32 | label string // read by the goroutine under mu, so Start can retitle |
| 33 | enabled = supported() //nolint:gochecknoglobals // resolved once at startup |
| 34 | ) |
| 35 | |
| 36 | // supported reports whether animating is a good idea. Piped or redirected |
| 37 | // output is not a terminal, and writing escape sequences into it would corrupt |
| 38 | // whatever is reading — a real risk here, since the point of the exercise is an |
| 39 | // agent whose output another program may consume. |
| 40 | func supported() bool { |
| 41 | if os.Getenv("TERM") == "dumb" || os.Getenv("NO_COLOR") != "" { |
| 42 | return false |
| 43 | } |
| 44 | info, err := os.Stdout.Stat() |
| 45 | return err == nil && info.Mode()&os.ModeCharDevice != 0 |
| 46 | } |
| 47 | |
| 48 | // Disable turns the spinner — and Styled — off for the rest of the process. |
| 49 | // The ACP front end calls it once at start-up: stdout then belongs to |
| 50 | // JSON-RPC, and even on a real terminal (bob --acp run by hand, for a demo) |
| 51 | // not one braille frame may land in the protocol stream. supported() cannot |
| 52 | // catch that case — a terminal IS a character device. |
| 53 | func Disable() { |
| 54 | mu.Lock() |
| 55 | enabled = false |
| 56 | mu.Unlock() |
| 57 | } |
| 58 | |
| 59 | // Styled reports whether stdout may carry escape sequences — same test that |
| 60 | // decides whether to animate. Anything that colours its output should ask, |
| 61 | // rather than repeat the predicate: piped output is meant to be consumed by |
| 62 | // another program, and `bob | jq` should not receive dim codes any more than it |
| 63 | // should receive braille frames. |
| 64 | func Styled() bool { |
| 65 | return enabled |
| 66 | } |
| 67 | |
| 68 | // Start begins animating with the given label, or just retitles a spinner that |
| 69 | // is already running. Safe to call when the previous one was never stopped. |
| 70 | func Start(text string) { |
| 71 | if !enabled { |
| 72 | return |
| 73 | } |
| 74 | |
| 75 | mu.Lock() |
| 76 | if stopCh != nil { // already spinning: only the wording changes |
| 77 | label = text |
| 78 | mu.Unlock() |
| 79 | return |
| 80 | } |
| 81 | |
| 82 | label = text |
| 83 | stop, done := make(chan struct{}), make(chan struct{}) |
| 84 | stopCh, doneCh = stop, done |
| 85 | mu.Unlock() |
| 86 | |
| 87 | go spin(stop, done) |
| 88 | } |
| 89 | |
| 90 | // Stop erases the line and waits for the goroutine to be gone, so that whatever |
| 91 | // the caller prints next cannot interleave with a frame. Idempotent. |
| 92 | func Stop() { |
| 93 | mu.Lock() |
| 94 | stop, done := stopCh, doneCh |
| 95 | stopCh, doneCh = nil, nil |
| 96 | mu.Unlock() |
| 97 | |
| 98 | if stop == nil { |
| 99 | return |
| 100 | } |
| 101 | close(stop) |
| 102 | <-done |
| 103 | } |
| 104 | |
| 105 | // spin owns the line until it is told to stop. It restores the cursor on the |
| 106 | // way out whatever happens, because a hidden cursor left behind outlives the |
| 107 | // program and makes the user's shell look broken. |
| 108 | func spin(stop <-chan struct{}, done chan<- struct{}) { |
| 109 | started := time.Now() |
| 110 | ticker := time.NewTicker(interval) |
| 111 | |
| 112 | defer func() { |
| 113 | ticker.Stop() |
| 114 | fmt.Print("\r\033[2K\033[?25h") // erase line, show cursor |
| 115 | close(done) |
| 116 | }() |
| 117 | |
| 118 | fmt.Print("\033[?25l") // hide cursor |
| 119 | for i := 0; ; i++ { |
| 120 | select { |
| 121 | case <-stop: |
| 122 | return |
| 123 | default: |
| 124 | } |
| 125 | |
| 126 | mu.Lock() |
| 127 | text := label |
| 128 | mu.Unlock() |
| 129 | |
| 130 | // \r + erase-to-end rather than padding with spaces: the label can |
| 131 | // shrink between two frames, and leftovers would stay on screen. |
| 132 | fmt.Printf("\r\033[2K\033[2m%s %s… %s\033[0m", |
| 133 | frames[i%len(frames)], text, elapsed(time.Since(started))) |
| 134 | |
| 135 | select { |
| 136 | case <-stop: |
| 137 | return |
| 138 | case <-ticker.C: |
| 139 | } |
| 140 | } |
| 141 | } |
| 142 | |
| 143 | // elapsed formats the wait the way a person reads it: seconds until a minute, |
| 144 | // then minutes and seconds. Showing it is the point — it is what distinguishes |
| 145 | // "slow" from "hung". |
| 146 | func elapsed(d time.Duration) string { |
| 147 | if d < time.Minute { |
| 148 | return fmt.Sprintf("%ds", int(d.Seconds())) |
| 149 | } |
| 150 | return fmt.Sprintf("%dm%02ds", int(d.Minutes()), int(d.Seconds())%60) |
| 151 | } |
| 152 | |
| 153 | // Suspend stops the spinner, runs fn, and starts it again under the same label |
| 154 | // — for code that has to print in the middle of a wait. |
| 155 | func Suspend(fn func()) { |
| 156 | mu.Lock() |
| 157 | text, running := label, stopCh != nil |
| 158 | mu.Unlock() |
| 159 | |
| 160 | Stop() |
| 161 | fn() |
| 162 | if running && strings.TrimSpace(text) != "" { |
| 163 | Start(text) |
| 164 | } |
| 165 | } |