From 5364bb3fb4ad833949bbf8a7630f5d087a38d678 Mon Sep 17 00:00:00 2001 From: Max Beizer Date: Fri, 21 Aug 2026 11:26:32 -0500 Subject: [PATCH 1/4] fix: prevent spinner wrapping in narrow terminals Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 5 +++ go.mod | 3 +- go.sum | 2 + internal/cli/progress.go | 60 +++++++++++++++++++++++++-- internal/cli/progress_test.go | 76 +++++++++++++++++++++++++++++++++++ 5 files changed, 142 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bd35a5a..3c07ba8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org). ## [Unreleased] +### 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..b7f8ff0 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,7 +30,10 @@ 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. @@ -36,7 +42,12 @@ func newSpinner(w io.Writer, messages ...string) *spinner { } 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 { @@ -48,6 +59,18 @@ func isTerminal(w io.Writer) bool { return isatty.IsTerminal(fd) || isatty.IsCygwinTerminal(fd) } +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). func (s *spinner) start() { if s == nil || !s.enabled || s.started { @@ -72,12 +95,43 @@ 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, "") + } + return prefix + runewidth.Truncate(message, messageWidth, "…") + 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..12ac194 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,78 @@ func TestSpinnerMessageUpdates(t *testing.T) { } } +func TestFormatSpinnerFrame(t *testing.T) { + tests := []struct { + name string + message string + terminalWidth int + want string + }{ + { + name: "normal width", + message: "Starting WorkIQ", + terminalWidth: 80, + want: "⠋ Starting WorkIQ (1s)", + }, + { + name: "narrow width", + message: "Starting WorkIQ", + terminalWidth: 16, + want: "⠋ Startin… (1s)", + }, + { + name: "unicode display width", + message: "界界界界", + terminalWidth: 12, + want: "⠋ 界… (1s)", + }, + { + 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 got != tt.want { + t.Errorf("formatSpinnerFrame() = %q, want %q", got, tt.want) + } + 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 TestSpinnerNilSafe(t *testing.T) { var sp *spinner // None of these should panic on a nil spinner. From 343917ef5b917930d4a4e0917bf89f4d2ee493d9 Mon Sep 17 00:00:00 2001 From: Max Beizer Date: Fri, 21 Aug 2026 11:32:44 -0500 Subject: [PATCH 2/4] fix: handle spinner portability edge cases Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/cli/progress.go | 16 ++++++++++++---- internal/cli/progress_test.go | 34 +++++++++++++++++++++++++++++++--- 2 files changed, 43 insertions(+), 7 deletions(-) diff --git a/internal/cli/progress.go b/internal/cli/progress.go index b7f8ff0..5bb9788 100644 --- a/internal/cli/progress.go +++ b/internal/cli/progress.go @@ -36,9 +36,9 @@ const ( ) // 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 { @@ -50,13 +50,21 @@ func newSpinnerWithTerminal(w io.Writer, enabled bool, messages ...string) *spin } } -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 { diff --git a/internal/cli/progress_test.go b/internal/cli/progress_test.go index 12ac194..3c1584d 100644 --- a/internal/cli/progress_test.go +++ b/internal/cli/progress_test.go @@ -68,6 +68,7 @@ func TestFormatSpinnerFrame(t *testing.T) { message string terminalWidth int want string + wantTruncated bool }{ { name: "normal width", @@ -79,13 +80,13 @@ func TestFormatSpinnerFrame(t *testing.T) { name: "narrow width", message: "Starting WorkIQ", terminalWidth: 16, - want: "⠋ Startin… (1s)", + wantTruncated: true, }, { name: "unicode display width", message: "界界界界", terminalWidth: 12, - want: "⠋ 界… (1s)", + wantTruncated: true, }, { name: "unknown width", @@ -104,9 +105,12 @@ func TestFormatSpinnerFrame(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := formatSpinnerFrame('⠋', tt.message, time.Second, tt.terminalWidth) - if got != tt.want { + 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) } @@ -134,6 +138,30 @@ func TestSpinnerRenderFrameReadsCurrentWidth(t *testing.T) { } } +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. From a5ca229440feaa4d7860eeb5471c8a15bf02126d Mon Sep 17 00:00:00 2001 From: Max Beizer Date: Fri, 21 Aug 2026 11:36:50 -0500 Subject: [PATCH 3/4] fix: keep ellipsis within spinner width budget Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/cli/progress.go | 6 +++++- internal/cli/progress_test.go | 5 +++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/internal/cli/progress.go b/internal/cli/progress.go index 5bb9788..7f75150 100644 --- a/internal/cli/progress.go +++ b/internal/cli/progress.go @@ -137,7 +137,11 @@ func formatSpinnerFrame(frame rune, message string, elapsed time.Duration, termi if messageWidth <= 0 { return runewidth.Truncate(string(frame), maxWidth, "") } - return prefix + runewidth.Truncate(message, messageWidth, "…") + suffix + 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 diff --git a/internal/cli/progress_test.go b/internal/cli/progress_test.go index 3c1584d..d68110c 100644 --- a/internal/cli/progress_test.go +++ b/internal/cli/progress_test.go @@ -88,6 +88,11 @@ func TestFormatSpinnerFrame(t *testing.T) { terminalWidth: 12, wantTruncated: true, }, + { + name: "ellipsis wider than remaining space", + message: "Starting WorkIQ", + terminalWidth: 9, + }, { name: "unknown width", message: "Starting WorkIQ", From 4c3225d6fba794e798221fcce8003e3a7621e0a6 Mon Sep 17 00:00:00 2001 From: Max Beizer Date: Fri, 21 Aug 2026 12:10:47 -0500 Subject: [PATCH 4/4] chore: prepare v0.5.0 changelog Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c07ba8..c063020 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ 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