bots-garden/mini-mepublic Fork 0
main
Commits
Clone
git clone https://git.rickub.com/bots-garden/mini-me.git
git clone ssh://git@rickub.com/bots-garden/mini-me.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

spinner.go · 165 lines · 4.8 KBGo Blame HistoryRaw
💾 Saved. d722711 k33g 4h ago1// 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.
12package spinner
13
14import (
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.
24var frames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
25
26const interval = 80 * time.Millisecond
27
28var (
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.
40func 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.
53func 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.
64func 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.
70func 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.
92func 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.
108func 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".
146func 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.
155func 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}