Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
80 changes: 73 additions & 7 deletions internal/cli/progress.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -17,6 +19,7 @@ import (
type spinner struct {
w io.Writer
enabled bool
width func() int

mu sync.Mutex
messages []string
Expand All @@ -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).
Expand All @@ -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.
Expand Down
109 changes: 109 additions & 0 deletions internal/cli/progress_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ package cli

import (
"bytes"
"strings"
"testing"
"time"

"github.com/mattn/go-runewidth"
)

func TestSpinnerDisabledOnNonTerminal(t *testing.T) {
Expand Down Expand Up @@ -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.
Expand Down
Loading