Files
pmg/internal/ui/spinner.go
T
70db1b3078 fix: redirect spinner output from stdout to stderr (#176)
The spinner goroutine was writing carriage-return + status text to
os.Stdout every 100ms. In the non-interactive TTY path, the child
process also writes directly to os.Stdout, causing both writers to
race on the same file descriptor. The \r emitted by the spinner
resets the cursor to column 0 mid-line, corrupting and truncating
the child process output.

Fix by writing all spinner/status output to os.Stderr, which is the
standard Unix convention for diagnostic and status messages. This is
also consistent with how progress.go and ShowWarning already behave.

https://claude.ai/code/session_012jMiRSS4Jx9Bs7a2F4S6KN

Co-authored-by: Claude <noreply@anthropic.com>
2026-03-06 08:27:11 +05:30

76 lines
1.2 KiB
Go

package ui
import (
"fmt"
"os"
"sync"
"time"
)
var (
spinnerChan chan bool
spinnerMu sync.Mutex
)
func StartSpinner(msg string) {
StartSpinnerWithColor(msg, Colors.Normal)
}
func StartSpinnerWithColor(msg string, c ColorFn) {
if c == nil {
c = Colors.Normal
}
style := `⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏`
frames := []rune(style)
length := len(frames)
spinnerMu.Lock()
// If a previous spinner exists, stop it cleanly before starting a new one
if spinnerChan != nil {
close(spinnerChan)
spinnerChan = nil
}
spinnerChan = make(chan bool)
spinnerMu.Unlock()
ticker := time.NewTicker(100 * time.Millisecond)
go func() {
pos := 0
for {
select {
case <-spinnerChan:
ticker.Stop()
return
case <-ticker.C:
fmt.Fprintf(os.Stderr, "\r%s ... %s", c("PMG: "+msg), string(frames[pos%length]))
pos += 1
}
}
}()
}
func StopSpinner() {
spinnerMu.Lock()
defer spinnerMu.Unlock()
if spinnerChan == nil {
return
}
// Gracefully handle the case where the spinner is already stopped
// and the channel is closed, yet client code calls StopSpinner() again.
defer func() {
_ = recover()
}()
close(spinnerChan)
spinnerChan = nil
fmt.Fprint(os.Stderr, "\r")
fmt.Fprintln(os.Stderr)
}