diff --git a/CHANGELOG.md b/CHANGELOG.md index bd35a5a..c063020 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org). ## [Unreleased] +## [0.5.0] - 2026-08-21 + +### Fixed + +- Prevented interactive progress spinners from printing every animation frame when + their message exceeds the terminal width. + ### Changed - Refreshed the README with a quick-start path, a recorded synthetic TUI demo that diff --git a/go.mod b/go.mod index 54b4445..3015239 100644 --- a/go.mod +++ b/go.mod @@ -6,8 +6,10 @@ require ( github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 github.com/mattn/go-isatty v0.0.20 + github.com/mattn/go-runewidth v0.0.16 github.com/muesli/termenv v0.16.0 github.com/spf13/cobra v1.10.2 + golang.org/x/term v0.35.0 ) require ( @@ -20,7 +22,6 @@ require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.16 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/rivo/uniseg v0.4.7 // indirect diff --git a/go.sum b/go.sum index 08693d6..e91f09a 100644 --- a/go.sum +++ b/go.sum @@ -48,6 +48,8 @@ golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ= +golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA= golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/cli/progress.go b/internal/cli/progress.go index d0d6151..7f75150 100644 --- a/internal/cli/progress.go +++ b/internal/cli/progress.go @@ -8,6 +8,8 @@ import ( "time" "github.com/mattn/go-isatty" + "github.com/mattn/go-runewidth" + "golang.org/x/term" ) // spinner writes an animated, single-line progress indicator to a writer while a @@ -17,6 +19,7 @@ import ( type spinner struct { w io.Writer enabled bool + width func() int mu sync.Mutex messages []string @@ -27,25 +30,53 @@ type spinner struct { var spinnerFrames = []rune{'⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'} -const framesPerMessage = 20 +const ( + framesPerMessage = 20 + spinnerSafetyWidth = 1 +) // newSpinner returns a spinner writing to w. It is enabled only when w is (or -// wraps) an interactive terminal, which we detect via os.Stderr. +// wraps) an interactive terminal whose width can be handled safely. func newSpinner(w io.Writer, messages ...string) *spinner { - return newSpinnerWithTerminal(w, isTerminal(w), messages...) + return newSpinnerWithTerminal(w, spinnerEnabled(w), messages...) } func newSpinnerWithTerminal(w io.Writer, enabled bool, messages ...string) *spinner { - return &spinner{w: w, enabled: enabled, messages: messages} + return &spinner{ + w: w, + enabled: enabled, + width: func() int { return terminalWidth(w) }, + messages: messages, + } } -func isTerminal(w io.Writer) bool { +func spinnerEnabled(w io.Writer) bool { f, ok := w.(*os.File) if !ok { return false } fd := f.Fd() - return isatty.IsTerminal(fd) || isatty.IsCygwinTerminal(fd) + return supportsSpinner( + isatty.IsTerminal(fd), + isatty.IsCygwinTerminal(fd), + terminalWidth(w), + ) +} + +func supportsSpinner(nativeTerminal, cygwinTerminal bool, width int) bool { + return nativeTerminal || (cygwinTerminal && width > 0) +} + +func terminalWidth(w io.Writer) int { + f, ok := w.(*os.File) + if !ok { + return 0 + } + width, _, err := term.GetSize(int(f.Fd())) + if err != nil { + return 0 + } + return width } // start begins animating. It is safe to call when disabled (no-op). @@ -72,12 +103,47 @@ func (s *spinner) run() { case <-ticker.C: msg := s.messageAt(frame) elapsed := time.Since(start).Truncate(time.Second) - fmt.Fprintf(s.w, "\r\033[K%c %s (%s)", spinnerFrames[frame%len(spinnerFrames)], msg, elapsed) + fmt.Fprintf(s.w, "\r\033[K%s", s.renderFrame(frame, msg, elapsed)) frame++ } } } +func (s *spinner) renderFrame(frame int, message string, elapsed time.Duration) string { + width := 0 + if s.width != nil { + width = s.width() + } + return formatSpinnerFrame(spinnerFrames[frame%len(spinnerFrames)], message, elapsed, width) +} + +func formatSpinnerFrame(frame rune, message string, elapsed time.Duration, terminalWidth int) string { + prefix := fmt.Sprintf("%c ", frame) + suffix := fmt.Sprintf(" (%s)", elapsed) + line := prefix + message + suffix + if terminalWidth <= 0 { + return line + } + + maxWidth := terminalWidth - spinnerSafetyWidth + if maxWidth <= 0 { + return "" + } + if runewidth.StringWidth(line) <= maxWidth { + return line + } + + messageWidth := maxWidth - runewidth.StringWidth(prefix) - runewidth.StringWidth(suffix) + if messageWidth <= 0 { + return runewidth.Truncate(string(frame), maxWidth, "") + } + tail := "…" + if runewidth.StringWidth(tail) > messageWidth { + tail = "" + } + return prefix + runewidth.Truncate(message, messageWidth, tail) + suffix +} + // setMessages updates the messages shown next to the spinner. The spinner cycles // through the supplied messages on a fixed cadence, keeping progress copy // deterministic for both users and tests. diff --git a/internal/cli/progress_test.go b/internal/cli/progress_test.go index 66e342f..d68110c 100644 --- a/internal/cli/progress_test.go +++ b/internal/cli/progress_test.go @@ -2,7 +2,11 @@ package cli import ( "bytes" + "strings" "testing" + "time" + + "github.com/mattn/go-runewidth" ) func TestSpinnerDisabledOnNonTerminal(t *testing.T) { @@ -58,6 +62,111 @@ func TestSpinnerMessageUpdates(t *testing.T) { } } +func TestFormatSpinnerFrame(t *testing.T) { + tests := []struct { + name string + message string + terminalWidth int + want string + wantTruncated bool + }{ + { + name: "normal width", + message: "Starting WorkIQ", + terminalWidth: 80, + want: "⠋ Starting WorkIQ (1s)", + }, + { + name: "narrow width", + message: "Starting WorkIQ", + terminalWidth: 16, + wantTruncated: true, + }, + { + name: "unicode display width", + message: "界界界界", + terminalWidth: 12, + wantTruncated: true, + }, + { + name: "ellipsis wider than remaining space", + message: "Starting WorkIQ", + terminalWidth: 9, + }, + { + name: "unknown width", + message: "Starting WorkIQ", + terminalWidth: 0, + want: "⠋ Starting WorkIQ (1s)", + }, + { + name: "extremely narrow width", + message: "Starting WorkIQ", + terminalWidth: 4, + want: "⠋", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := formatSpinnerFrame('⠋', tt.message, time.Second, tt.terminalWidth) + if tt.want != "" && got != tt.want { + t.Errorf("formatSpinnerFrame() = %q, want %q", got, tt.want) + } + if tt.wantTruncated && (!strings.Contains(got, "…") || !strings.HasSuffix(got, " (1s)")) { + t.Errorf("formatSpinnerFrame() = %q, want a truncated message with elapsed time", got) + } + if tt.terminalWidth > 0 && runewidth.StringWidth(got) >= tt.terminalWidth { + t.Errorf("formatSpinnerFrame() width = %d, must be less than terminal width %d", runewidth.StringWidth(got), tt.terminalWidth) + } + }) + } +} + +func TestSpinnerRenderFrameReadsCurrentWidth(t *testing.T) { + widths := []int{80, 16} + sp := newSpinnerWithTerminal(&bytes.Buffer{}, true, "Starting WorkIQ") + sp.width = func() int { + width := widths[0] + widths = widths[1:] + return width + } + + wide := sp.renderFrame(0, "Starting WorkIQ", time.Second) + narrow := sp.renderFrame(0, "Starting WorkIQ", time.Second) + + if strings.Contains(wide, "…") { + t.Errorf("wide frame unexpectedly truncated: %q", wide) + } + if !strings.Contains(narrow, "…") { + t.Errorf("narrow frame was not truncated: %q", narrow) + } +} + +func TestSupportsSpinner(t *testing.T) { + tests := []struct { + name string + native bool + cygwin bool + width int + expected bool + }{ + {"native terminal with width", true, false, 80, true}, + {"native terminal with unknown width", true, false, 0, true}, + {"cygwin terminal with width", false, true, 80, true}, + {"cygwin terminal with unknown width", false, true, 0, false}, + {"non-terminal", false, false, 80, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := supportsSpinner(tt.native, tt.cygwin, tt.width); got != tt.expected { + t.Errorf("supportsSpinner() = %t, want %t", got, tt.expected) + } + }) + } +} + func TestSpinnerNilSafe(t *testing.T) { var sp *spinner // None of these should panic on a nil spinner.