Skip to content

Commit 102b225

Browse files
committed
Improve parsing-line spinner with 20-color flowing wave
Add a dedicated wave phase that cycles through 20 hues across the compass glyph, verb label, and typing dots. Restore the default hawk spinner (◐◓◑◒) and simplify the status hint to "(esc stop)".
1 parent a89f883 commit 102b225

8 files changed

Lines changed: 245 additions & 120 deletions

File tree

cmd/braille_spinner.go

Lines changed: 24 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ const (
1313
SpinnerBraille SpinnerStyle = "braille"
1414
SpinnerBrailleWave SpinnerStyle = "braillewave"
1515
SpinnerHawk SpinnerStyle = "hawk"
16+
SpinnerHawkQuad SpinnerStyle = "hawkquad"
1617
SpinnerDNA SpinnerStyle = "dna"
1718
SpinnerScan SpinnerStyle = "scan"
1819
SpinnerPulse SpinnerStyle = "pulse"
@@ -23,7 +24,10 @@ const (
2324
SpinnerRandom SpinnerStyle = "random"
2425
)
2526

26-
// hawkQuadBlockGlyphs is the unicode.framer.website QUADBLOCK spinner (4 frames).
27+
// hawkSpinnerGlyphs is the default TUI spinner — partial-circle compass (smooth, readable).
28+
var hawkSpinnerGlyphs = []string{"◐", "◓", "◑", "◒"}
29+
30+
// hawkQuadBlockGlyphs is the legacy QUADBLOCK animation (kept for tests / bubbles compat).
2731
var hawkQuadBlockGlyphs = []string{"▛", "▜", "▟", "▙"}
2832

2933
// hawkSpinnerBG is the chat viewport background (chat_view.go).
@@ -33,7 +37,8 @@ var hawkSpinnerBG = [3]int{30, 30, 40}
3337
var spinnerFrames = map[SpinnerStyle][]string{
3438
SpinnerBraille: {"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"},
3539
SpinnerBrailleWave: {"⠁⠂⠄⡀", "⠂⠄⡀⢀", "⠄⡀⢀⠠", "⡀⢀⠠⠐", "⢀⠠⠐⠈", "⠠⠐⠈⠁", "⠐⠈⠁⠂", "⠈⠁⠂⠄"},
36-
SpinnerHawk: hawkQuadBlockGlyphs,
40+
SpinnerHawk: hawkSpinnerGlyphs,
41+
SpinnerHawkQuad: hawkQuadBlockGlyphs,
3742
SpinnerDNA: {"⠋⠉⠙⠚", "⠉⠙⠚⠒", "⠙⠚⠒⠂", "⠚⠒⠂⠂", "⠒⠂⠂⠒", "⠂⠂⠒⠲", "⠂⠒⠲⠴", "⠒⠲⠴⠤", "⠲⠴⠤⠄", "⠴⠤⠄⠋", "⠤⠄⠋⠉", "⠄⠋⠉⠙"},
3843
SpinnerScan: {"⡇⠀⠀⠀", "⣿⠀⠀⠀", "⢸⡇⠀⠀", "⠀⣿⠀⠀", "⠀⢸⡇⠀", "⠀⠀⣿⠀", "⠀⠀⢸⡇", "⠀⠀⠀⣿", "⠀⠀⠀⢸", "⠀⠀⠀⠀"},
3944
SpinnerPulse: {"⠀", "⠄", "⠆", "⠇", "⡇", "⣇", "⣧", "⣷", "⣿", "⣷", "⣧", "⣇", "⡇", "⠇", "⠆", "⠄"},
@@ -46,29 +51,18 @@ var spinnerFrames = map[SpinnerStyle][]string{
4651
// hawkTypingDots is the number of trailing typing-indicator dots.
4752
const hawkTypingDots = 3
4853

49-
// colorSpinnerGlyph renders a single glyph in hawk brand orange — the
50-
// spinner is the visual hero of the line and the brand color. The ANSI
51-
// escape constants (ansiOrange, ansiReset, etc.) and the icon glyphs
52-
// (iconDotFilled, iconDotEmpty) live in theme.go so the entire palette
53-
// is editable from one place.
54-
func colorSpinnerGlyph(glyph string) string {
55-
if glyph == "" {
56-
return ""
57-
}
58-
return ansiOrange + glyph + ansiReset
59-
}
60-
61-
// BrailleSpinner renders animated spinners with a single accent color
62-
// (cyan) for both the glyph and the label.
54+
// BrailleSpinner renders the glyph frame (◐◓◑◒) and a 20-color wave on the
55+
// whole status strip: glyph → verb → ▪▫▫.
6356
type BrailleSpinner struct {
64-
mu sync.Mutex
65-
style SpinnerStyle
66-
frames []string
67-
frame int
68-
text string
69-
dots int // 0..hawkTypingDots-1 — position of the highlighted dot
70-
running bool
71-
stopCh chan struct{}
57+
mu sync.Mutex
58+
style SpinnerStyle
59+
frames []string
60+
frame int // glyph animation frame (mod len(frames))
61+
wavePhase int // 0..19 flowing color wave (glyph + verb + dots)
62+
text string
63+
dots int // 0..hawkTypingDots-1 — position of the highlighted dot
64+
running bool
65+
stopCh chan struct{}
7266
}
7367

7468
// NewBrailleSpinner creates a spinner with the given style and label text.
@@ -96,63 +90,23 @@ func (s *BrailleSpinner) SetLabel(text string) {
9690
s.text = text
9791
}
9892

99-
// SetWave is kept for backwards compatibility with existing call sites —
100-
// the line no longer uses a color wave so this is a no-op.
101-
func (s *BrailleSpinner) SetWave(_ bool) {
102-
s.mu.Lock()
103-
defer s.mu.Unlock()
104-
}
105-
106-
func (s *BrailleSpinner) renderGlyphLocked(glyph string) string {
107-
return colorSpinnerGlyph(glyph)
108-
}
93+
// SetWave is kept for backwards compatibility with existing call sites.
94+
// The verb and typing dots always use the 20-color wave; this is a no-op.
95+
func (s *BrailleSpinner) SetWave(_ bool) {}
10996

110-
// renderLabelLocked returns the label in green followed by the trailing
111-
// animated dots (one yellow dot, two dim dots). The whole group is the
112-
// "alive" part of the spinner line.
113-
func (s *BrailleSpinner) renderLabelLocked() string {
114-
if s.text == "" {
115-
return ""
116-
}
117-
out := ansiGreen + s.text + ansiReset
118-
out += " " + s.renderAnimatedDotsLocked()
119-
return out
120-
}
121-
122-
// renderAnimatedDotsLocked returns hawkTypingDots plain circles, with the
123-
// current position rendered in yellow and the rest dim. The filled and
124-
// empty glyphs come from theme.go (iconDotFilled / iconDotEmpty) so they
125-
// stay in sync with the rest of the TUI.
126-
func (s *BrailleSpinner) renderAnimatedDotsLocked() string {
127-
idx := s.dots % hawkTypingDots
128-
out := ""
129-
for i := 0; i < hawkTypingDots; i++ {
130-
if i == idx {
131-
out += ansiYellow + iconDotFilled + ansiReset
132-
} else {
133-
out += ansiDim + iconDotEmpty + ansiReset
134-
}
135-
}
136-
return out
137-
}
138-
139-
// Frame returns the current rendered frame (spinner + label).
97+
// Frame returns the current rendered frame (glyph + verb + dots, all in the wave).
14098
func (s *BrailleSpinner) Frame() string {
14199
s.mu.Lock()
142100
defer s.mu.Unlock()
143101
glyph := s.frames[s.frame%len(s.frames)]
144-
spinner := s.renderGlyphLocked(glyph)
145-
label := s.renderLabelLocked()
146-
if label == "" {
147-
return spinner
148-
}
149-
return spinner + " " + label
102+
return renderSpinnerWaveLine(glyph, s.text, s.wavePhase, s.dots)
150103
}
151104

152105
// Tick advances to the next frame and cycles the dot highlight.
153106
func (s *BrailleSpinner) Tick() string {
154107
s.mu.Lock()
155108
s.frame++
109+
s.wavePhase = (s.wavePhase + 1) % spinnerWaveLen
156110
s.dots = (s.dots + 1) % hawkTypingDots
157111
s.mu.Unlock()
158112
return s.Frame()

cmd/braille_spinner_test.go

Lines changed: 26 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,16 @@ func TestBrailleSpinner_Tick(t *testing.T) {
1212
if f1 == f2 {
1313
t.Error("expected different frames after tick")
1414
}
15-
// Glyph is hawk brand orange.
16-
if !strings.Contains(f1, ansiOrange) {
17-
t.Error("expected orange spinner glyph")
15+
// Glyph uses the 20-color wave (not fixed orange).
16+
if !frameContainsSpinnerWave(f1) {
17+
t.Error("expected wave-colored spinner glyph")
1818
}
19-
// Label is bright green.
20-
if !strings.Contains(f1, ansiGreen) {
21-
t.Error("expected green verb label")
19+
// Verb + dots use the 20-color wave palette.
20+
if !frameContainsSpinnerWave(f1) {
21+
t.Error("expected wave-colored verb label")
2222
}
23-
// Filled dot is bright yellow.
24-
if !strings.Contains(f1, ansiYellow) {
25-
t.Error("expected yellow filled dot")
23+
if !strings.Contains(f1, iconDotFilled) {
24+
t.Error("expected filled typing dot")
2625
}
2726
}
2827

@@ -49,31 +48,38 @@ func TestBrailleSpinner_Random(t *testing.T) {
4948
}
5049
}
5150

52-
func TestHawkQuadBlock_Frames(t *testing.T) {
53-
if len(hawkQuadBlockGlyphs) != 4 {
54-
t.Fatalf("expected 4 QuadBlock glyphs, got %d", len(hawkQuadBlockGlyphs))
51+
func TestHawkSpinner_Frames(t *testing.T) {
52+
if len(hawkSpinnerGlyphs) != 4 {
53+
t.Fatalf("expected 4 compass glyphs, got %d", len(hawkSpinnerGlyphs))
5554
}
56-
if hawkQuadBlockGlyphs[3] != "" {
57-
t.Fatalf("expected last QuadBlock frame , got %q", hawkQuadBlockGlyphs[3])
55+
if hawkSpinnerGlyphs[0] != "" {
56+
t.Fatalf("expected first compass frame , got %q", hawkSpinnerGlyphs[0])
5857
}
5958
s := NewBrailleSpinner(SpinnerHawk, "Working")
6059
f0 := s.Frame()
61-
if !strings.Contains(f0, "") {
62-
t.Fatalf("expected QuadBlock glyph, got %q", f0)
60+
if !strings.Contains(f0, "") {
61+
t.Fatalf("expected compass glyph, got %q", f0)
6362
}
6463
s.Tick()
6564
s.Tick()
6665
s.Tick()
67-
if !strings.Contains(s.Frame(), "▙") {
68-
t.Fatalf("expected frame cycle to reach ▙, got %q", s.Frame())
66+
if !strings.Contains(s.Frame(), "◒") {
67+
t.Fatalf("expected frame cycle to reach ◒, got %q", s.Frame())
68+
}
69+
}
70+
71+
func TestHawkQuadBlock_LegacyFrames(t *testing.T) {
72+
s := NewBrailleSpinner(SpinnerHawkQuad, "Working")
73+
if !strings.Contains(s.Frame(), "▛") {
74+
t.Fatalf("expected QuadBlock glyph, got %q", s.Frame())
6975
}
7076
}
7177

7278
func TestHawkAnimatedDots_PresentInFrame(t *testing.T) {
7379
s := NewBrailleSpinner(SpinnerHawk, "Crafting")
7480
f := s.Frame()
75-
// Three plain circles ride after the verb: one bold cyan, two dim.
76-
total := strings.Count(f, "●") + strings.Count(f, "○")
81+
// Three progress dots ride after the verb: one bright, two dim.
82+
total := strings.Count(f, iconDotFilled) + strings.Count(f, iconDotEmpty)
7783
if total != hawkTypingDots {
7884
t.Errorf("expected %d trailing circle-dots, got %d in %q", hawkTypingDots, total, f)
7985
}

cmd/chat.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -286,7 +286,6 @@ func newChatModel(ref *progRef, systemPrompt string, settings hawkconfig.Setting
286286
m.modeManager = shellmode.NewModeManager()
287287
m.modeManager.LoadPersistedMode()
288288
m.brailleSpinner = NewBrailleSpinner(SpinnerHawk, "")
289-
m.brailleSpinner.SetWave(true)
290289
m.brailleSpinner.SetLabel(m.spinnerVerb)
291290

292291
// Initialize BMAD/Aeon features

cmd/chat_model.go

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -55,12 +55,11 @@ var (
5555
toolColor = toolGold
5656
)
5757

58-
// hawkSpinnerFrames uses plain QuadBlock glyphs for the compact bubbles spinner.
59-
var hawkSpinnerFrames = hawkQuadBlockGlyphs
58+
// hawkSpinnerFrames feeds the bubbles spinner (matches BrailleSpinner default).
59+
var hawkSpinnerFrames = hawkSpinnerGlyphs
6060

61-
// hawkSpinnerFrameInterval — QuadBlock frame cadence (slightly slower
62-
// than the original 70ms for a calmer feel).
63-
const hawkSpinnerFrameInterval = 100 * time.Millisecond
61+
// hawkSpinnerFrameInterval — compass frame cadence.
62+
const hawkSpinnerFrameInterval = 80 * time.Millisecond
6463

6564
// Spinner verbs (from hawk-archive) — picked randomly per session
6665
var spinnerVerbs = []string{

cmd/chat_view.go

Lines changed: 19 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -386,20 +386,7 @@ func (m *chatModel) updateViewportContent() {
386386
chatContent.WriteString(hawkC + iconAssistantPrefix + " " + rst + renderMarkdown(partial, viewWidth-3))
387387
chatContent.WriteString("\n\n")
388388
} else {
389-
// Hawk-native spinner line: brand-orange glyph + green verb
390-
// + yellow dot indicator, then ◆ blue time ◆ magenta ↓ / cyan
391-
// ↑ ◆ dim hint. The ◆ separator is bright white so it stands
392-
// out as a structural divider; the hint stays dim so it
393-
// doesn't compete with the data.
394-
elapsed := m.spinnerElapsed()
395-
sep := ansiWhite + iconSeparator + ansiReset
396-
hint := dimStyle.Render("(Press ESC to stop)")
397-
timeStr := ansiBlue + fmt.Sprintf("%.1fs", elapsed.Seconds()) + ansiReset
398-
spinnerLine := m.brailleSpinner.Frame()
399-
spinnerLine += " " + sep + " " + timeStr
400-
spinnerLine += " " + sep + " " + m.renderTokenCounters()
401-
spinnerLine += " " + sep + " " + hint
402-
chatContent.WriteString(spinnerLine + "\n\n")
389+
chatContent.WriteString(m.renderWaitingSpinnerLine() + "\n\n")
403390
}
404391
}
405392

@@ -612,6 +599,21 @@ func renderReflectionBox(reflection string, width int) string {
612599
return border.Render(strings.TrimRight(b.String(), "\n"))
613600
}
614601

602+
// renderWaitingSpinnerLine is the live status strip while the model works.
603+
func (m chatModel) renderWaitingSpinnerLine() string {
604+
sep := ansiDim + " " + iconSpinnerSep + " " + ansiReset
605+
606+
var b strings.Builder
607+
b.WriteString(m.brailleSpinner.Frame())
608+
b.WriteString(sep)
609+
b.WriteString(ansiTeal + fmt.Sprintf("%.1fs", m.spinnerElapsed().Seconds()) + ansiReset)
610+
b.WriteString(sep)
611+
b.WriteString(m.renderTokenCounters())
612+
b.WriteString(" ")
613+
b.WriteString(ansiDim + "(esc stop)" + ansiReset)
614+
return b.String()
615+
}
616+
615617
// renderTokenCounters formats the live per-turn token counters that ride
616618
// next to the spinner. Uses ↑ for input (prompt) and ↓ for output
617619
// (completion) tokens. The displayed numbers are lerped each render
@@ -627,13 +629,13 @@ func (m *chatModel) renderTokenCounters() string {
627629
outTok := int(m.displayOutTok + 0.5)
628630

629631
var b strings.Builder
632+
b.WriteString(ansiMagenta + ansiBold + "↓" + ansiReset)
630633
b.WriteString(ansiMagenta)
631-
b.WriteString("↓ ")
632634
b.WriteString(formatHawkTokenCount(outTok))
633635
b.WriteString(ansiReset)
634-
b.WriteString(dimStyle.Render(" "))
636+
b.WriteString(ansiDim + " " + ansiReset)
637+
b.WriteString(ansiCyan + ansiBold + "↑" + ansiReset)
635638
b.WriteString(ansiCyan)
636-
b.WriteString("↑ ")
637639
b.WriteString(formatHawkTokenCount(inTok))
638640
b.WriteString(ansiReset)
639641
return b.String()

0 commit comments

Comments
 (0)