1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
|
// 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)
}
}
|