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
3 changes: 0 additions & 3 deletions cmd/harnesscli/tui/cancel_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,6 @@ func TestTUI039_EscDismissesBanner(t *testing.T) {
// banner visible (first ctrl+c state) and after the second ctrl+c (Interrupted).
func TestTUI039_VisualSnapshot_80x24(t *testing.T) {
cfg := tui.DefaultTUIConfig()
cfg.SpinnerSeed = 1 // deterministic verb for a stable committed snapshot
m := tui.New(cfg)
m2, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24})

Expand Down Expand Up @@ -309,7 +308,6 @@ func TestTUI039_VisualSnapshot_80x24(t *testing.T) {
// TestTUI039_VisualSnapshot_120x40 renders the TUI at 120x40.
func TestTUI039_VisualSnapshot_120x40(t *testing.T) {
cfg := tui.DefaultTUIConfig()
cfg.SpinnerSeed = 1 // deterministic verb for a stable committed snapshot
m := tui.New(cfg)
m2, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})

Expand All @@ -335,7 +333,6 @@ func TestTUI039_VisualSnapshot_120x40(t *testing.T) {
// TestTUI039_VisualSnapshot_200x50 renders the TUI at 200x50.
func TestTUI039_VisualSnapshot_200x50(t *testing.T) {
cfg := tui.DefaultTUIConfig()
cfg.SpinnerSeed = 1 // deterministic verb for a stable committed snapshot
m := tui.New(cfg)
m2, _ := m.Update(tea.WindowSizeMsg{Width: 200, Height: 50})

Expand Down
126 changes: 69 additions & 57 deletions cmd/harnesscli/tui/components/spinner/model.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
// Package spinner implements the TUI-024 thinking spinner with rotating verbs.
// It provides an immutable BubbleTea-style Model that advances frame-by-frame
// and rotates through a pool of whimsical verbs (e.g. "Thinking", "Reasoning").
// Package spinner implements the TUI thinking spinner. It provides an immutable
// BubbleTea-style Model that advances frame-by-frame while displaying a label
// describing what the run is actually doing.
//
// The label is supplied by the caller through SetAction and changes only when
// the run's state changes — never on a timer. See issue #1415.
package spinner

import (
"fmt"
"math/rand"
"time"

"github.com/charmbracelet/lipgloss"
Expand All @@ -15,9 +17,6 @@ import (
// These are star/asterisk glyphs, not the braille frames in theme.go.
var frames = []string{"✶", "·", "✻", "✽", "✳", "✢"}

// verbRotateEvery controls how many Tick() calls trigger a verb rotation.
const verbRotateEvery = 8

// durationThreshold is the elapsed time after which the spinner shows a duration.
const durationThreshold = 2 * time.Second

Expand Down Expand Up @@ -50,59 +49,43 @@ func DefaultStyles() Styles {
// All mutation methods return a new Model value — never modify in place.
// This keeps it safe for use in BubbleTea's single-goroutine Update().
type Model struct {
frame int // current frame index [0, len(frames))
verb string // current displayed verb
action string // current activity label (e.g. running tool name); overrides verb when set
startTime time.Time // when spinner started (for duration)
tokens int // token count stored on Stop()
active bool // true while spinner is running
done bool // true after Stop()
tickCount int // total ticks received (used for verb rotation)
completionFrames int // ticks remaining to show completion line after Stop()
rng *rand.Rand // seeded rng for deterministic testing

// Seed is the seed used to create rng. Exposed so tests can inspect it.
frame int // current frame index [0, len(frames))
action string // what the run is currently doing; empty falls back to fallbackLabel
startTime time.Time // when spinner started (for duration)
tokens int // token count stored on Stop()
active bool // true while spinner is running
done bool // true after Stop()
tickCount int // total ticks received
completionFrames int // ticks remaining to show completion line after Stop()
// Seed is retained only so the many existing New(seed) call sites keep
// compiling. Nothing reads it: the label comes from run state, not from a
// random source, so rendering is deterministic without a seed. Issue #1415.
Seed int64

// testVerbs overrides DefaultVerbs when non-nil. For testing only.
testVerbs []string

// styles overrides DefaultStyles when non-nil (theme injection point,
// epic #810).
styles *Styles
}

// New creates a new Model with the given seed. The seed makes verb selection
// deterministic which is essential for snapshot and regression tests.
// New creates a new Model. seed is ignored — it is kept only for call-site
// compatibility, since rendering no longer depends on randomness. See Model.Seed.
func New(seed int64) Model {
return Model{
Seed: seed,
rng: rand.New(rand.NewSource(seed)), //nolint:gosec // not for crypto
}
return Model{Seed: seed}
}

// verbPool returns the verb pool in effect: testVerbs override if set,
// otherwise DefaultVerbs.
func (m Model) verbPool() []string {
if m.testVerbs != nil {
return m.testVerbs
}
return DefaultVerbs
}

// Start activates the spinner, records the start time, and picks an initial verb.
// Start activates the spinner and records the start time.
// Returns a new Model; the receiver is unchanged.
func (m Model) Start() Model {
m.active = true
m.done = false
m.startTime = time.Now()
m.frame = 0
m.tickCount = 0
m.verb = pickVerb(m.verbPool(), m.rng)
return m
}

// Tick advances the animation by one frame and potentially rotates the verb.
// Tick advances the animation by one frame. The label is deliberately untouched:
// it changes only when the caller reports a new action.
// When the spinner is done and completionFrames > 0, decrements completionFrames
// toward silence. Has no effect if neither active nor in completion mode.
// Returns a new Model; the receiver is unchanged.
Expand All @@ -120,10 +103,6 @@ func (m Model) Tick() Model {
}
m.tickCount++
m.frame = (m.frame + 1) % len(frames)
// Rotate verb every verbRotateEvery ticks.
if m.tickCount%verbRotateEvery == 0 {
m.verb = pickVerb(m.verbPool(), m.rng)
}
return m
}

Expand All @@ -139,10 +118,9 @@ func (m Model) Stop(tokens int) Model {
return m
}

// SetAction sets the current activity label (e.g. the name of a running tool
// or a short step description). When non-empty, View() displays it in place
// of the rotating verb so the user sees what is actually happening rather
// than a generic placeholder. Pass "" to fall back to verb rotation.
// SetAction sets what the run is currently doing (e.g. "Running bash",
// "Writing response"). This is the label View() renders. Pass "" only when
// nothing is known, which falls back to fallbackLabel.
// Returns a new Model; the receiver is unchanged.
func (m Model) SetAction(action string) Model {
m.action = action
Expand Down Expand Up @@ -187,8 +165,7 @@ func (m Model) ElapsedSeconds() float64 {
// maximum character width; the view degrades gracefully at narrow widths.
//
// States:
// - Active: "✻ Thinking... (esc to interrupt)", or with a known action,
// "✻ Running bash (esc to interrupt)"; a duration is inserted once
// - Active: "✻ Running bash (esc to interrupt)"; a duration is inserted once
// durationThreshold passes.
// - ShowsCompletion() true: CompletionLine using ElapsedSeconds().
// - Done and silent (completionFrames == 0): returns "".
Expand All @@ -208,11 +185,11 @@ func (m Model) View(width int) string {

currentFrame := frames[m.frame]

// Build the base text. When a current action is known, show it instead of
// the rotating verb so the user sees what is actually happening.
label := m.verb + "..."
if m.action != "" {
label = m.action
// The label states what is actually happening. No ellipsis: "Running bash"
// is a fact, and trailing dots would only suggest vagueness it does not have.
label := m.action
if label == "" {
label = fallbackLabel
}
base := currentFrame + " " + label

Expand All @@ -229,17 +206,52 @@ func (m Model) View(width int) string {
base += " " + CancelHint
}

// At narrow widths the label yields before the cancel hint does. Truncating
// from the right would eat "(esc to interrupt)" first, leaving the user
// staring at "(esc to inter" with no way to know how to stop the run — the
// hint is the one part of this line that is actionable. Labels grew long
// enough to hit this when they became truthful ("Waiting for gpt-4.1-mini"
// rather than "Computing..."), so the trade-off is now worth making
// explicit. Issue #1415.
if lipgloss.Width(base) > width {
base = shortenLabel(currentFrame, label, base, width)
}

style := m.stylesOrDefault().Dim
rendered := style.Render(base)

// Clamp to width using MaxWidth.
if width < 80 {
// Final clamp: even a shortened line cannot exceed the terminal.
if lipgloss.Width(base) > width {
rendered = lipgloss.NewStyle().MaxWidth(width).Render(base)
}

return rendered
}

// shortenLabel rebuilds an over-long spinner line so the cancel hint survives.
// It first drops the duration, then truncates the label itself, and gives up
// only when even "<glyph> <hint>" will not fit — at which point the caller's
// MaxWidth clamp takes over.
func shortenLabel(glyph, label, full string, width int) string {
withoutDuration := glyph + " " + label + " " + CancelHint
if lipgloss.Width(withoutDuration) <= width {
return withoutDuration
}

// Budget for the label: width minus the glyph, the hint, and the two spaces
// separating them.
budget := width - lipgloss.Width(glyph) - lipgloss.Width(CancelHint) - 2
if budget < 4 {
// Not even a stub of a label fits; the hint alone is more useful.
return glyph + " " + CancelHint
}
runes := []rune(label)
if len(runes) > budget {
label = string(runes[:budget-1]) + "\u2026"
}
return glyph + " " + label + " " + CancelHint
}

// CompletionLine returns the one-line completion summary shown after the spinner stops.
//
// Format: "✻ Worked for 5s" or "✻ Worked for 1m 30s"
Expand Down
62 changes: 14 additions & 48 deletions cmd/harnesscli/tui/components/spinner/model_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,26 +50,6 @@ func TestTUI024_SpinnerAddsDurationAfterThreshold(t *testing.T) {
}
}

// TestTUI024_SpinnerVerbFromSeed verifies that using the same seed always
// produces the same initial verb after Start().
func TestTUI024_SpinnerVerbFromSeed(t *testing.T) {
const seed = int64(12345)

m1 := New(seed)
m1 = m1.Start()

m2 := New(seed)
m2 = m2.Start()

if m1.verb != m2.verb {
t.Errorf("same seed should produce same verb: m1=%q, m2=%q", m1.verb, m2.verb)
}

if m1.verb == "" {
t.Error("verb should not be empty after Start()")
}
}

// TestTUI024_SpinnerStopsCleanly verifies that Stop() transitions the model to
// done=true, active=false and stores the token count.
func TestTUI024_SpinnerStopsCleanly(t *testing.T) {
Expand Down Expand Up @@ -166,19 +146,6 @@ func TestTUI024_ConcurrentIndependentState(t *testing.T) {
wg.Wait()
}

// TestTUI024_EmptyVerbFallback verifies that an empty verb pool falls back
// to "Thinking".
func TestTUI024_EmptyVerbFallback(t *testing.T) {
m := New(42)
// Override verbs to empty slice via test helper.
m.testVerbs = []string{}
m = m.Start()

if m.verb != "Thinking" {
t.Errorf("empty verb pool should fall back to 'Thinking', got %q", m.verb)
}
}

// TestTUI024_BoundaryWidths verifies that View() does not panic at various
// terminal widths including very narrow and very wide.
func TestTUI024_BoundaryWidths(t *testing.T) {
Expand All @@ -203,8 +170,8 @@ func TestTUI024_BoundaryWidths(t *testing.T) {
}

// TestSpinnerShowsCancelHintWhileActive verifies that View() always surfaces
// the cancel hint while the spinner is active, using the rotating verb when
// no current action has been set.
// the cancel hint while the spinner is active, falling back to the neutral
// label when no current action has been set.
func TestSpinnerShowsCancelHintWhileActive(t *testing.T) {
m := New(42)
m = m.Start()
Expand All @@ -213,15 +180,14 @@ func TestSpinnerShowsCancelHintWhileActive(t *testing.T) {
if !strings.Contains(view, CancelHint) {
t.Errorf("active View() should contain cancel hint %q, got: %q", CancelHint, view)
}
if !strings.Contains(view, m.verb) {
t.Errorf("active View() with no action set should still show the verb %q, got: %q", m.verb, view)
if !strings.Contains(view, fallbackLabel) {
t.Errorf("active View() with no action set should show %q, got: %q", fallbackLabel, view)
}
}

// TestSpinnerShowsCurrentActionInsteadOfVerb verifies that once SetAction is
// called with a non-empty label, View() displays that label instead of the
// rotating verb, while still showing the cancel hint.
func TestSpinnerShowsCurrentActionInsteadOfVerb(t *testing.T) {
// TestSpinnerShowsCurrentAction verifies that SetAction's label is what View()
// renders, while still showing the cancel hint.
func TestSpinnerShowsCurrentAction(t *testing.T) {
m := New(42)
m = m.Start()
m = m.SetAction("Running bash")
Expand All @@ -230,17 +196,17 @@ func TestSpinnerShowsCurrentActionInsteadOfVerb(t *testing.T) {
if !strings.Contains(view, "Running bash") {
t.Errorf("View() should show the current action, got: %q", view)
}
if strings.Contains(view, m.verb+"...") {
t.Errorf("View() should not show the rotating verb once an action is set, got: %q", view)
if strings.Contains(view, fallbackLabel) {
t.Errorf("View() should not show the fallback label once an action is set, got: %q", view)
}
if !strings.Contains(view, CancelHint) {
t.Errorf("View() with an action set should still contain cancel hint %q, got: %q", CancelHint, view)
}
}

// TestSpinnerClearingActionRestoresVerb verifies that SetAction("") reverts
// View() back to the rotating verb.
func TestSpinnerClearingActionRestoresVerb(t *testing.T) {
// TestSpinnerClearingActionRestoresFallback verifies that SetAction("") reverts
// View() to the neutral label.
func TestSpinnerClearingActionRestoresFallback(t *testing.T) {
m := New(42)
m = m.Start()
m = m.SetAction("Running bash")
Expand All @@ -250,8 +216,8 @@ func TestSpinnerClearingActionRestoresVerb(t *testing.T) {
if strings.Contains(view, "Running bash") {
t.Errorf("View() should not show a cleared action, got: %q", view)
}
if !strings.Contains(view, m.verb) {
t.Errorf("View() should fall back to the verb once action is cleared, got: %q", view)
if !strings.Contains(view, fallbackLabel) {
t.Errorf("View() should fall back to %q once the action is cleared, got: %q", fallbackLabel, view)
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
# TUI-024 Spinner Snapshot 120x40
------------------------------------------------------------------------------------------------------------------------
## Active (no duration)
Computing... (esc to interrupt)
Working (esc to interrupt)

## Active (5s elapsed)
Computing... (5.0s) (esc to interrupt)
Working (5.0s) (esc to interrupt)

## Completion Line
✽ Worked for 5.0s
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
# TUI-024 Spinner Snapshot 200x50
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
## Active (no duration)
Computing... (esc to interrupt)
Working (esc to interrupt)

## Active (5s elapsed)
Computing... (5.0s) (esc to interrupt)
Working (5.0s) (esc to interrupt)

## Completion Line
✽ Worked for 5.0s
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
# TUI-024 Spinner Snapshot 80x24
--------------------------------------------------------------------------------
## Active (no duration)
Computing... (esc to interrupt)
Working (esc to interrupt)

## Active (5s elapsed)
Computing... (5.0s) (esc to interrupt)
Working (5.0s) (esc to interrupt)

## Completion Line
✽ Worked for 5.0s
Expand Down
Loading
Loading