Skip to content

Commit 013177a

Browse files
committed
feat(tui): animate spinner — wave verb, 3 dots, ↑/↓ token counters
Make the chat-spinner line come alive during model activity: ▛ Elucidating ●○○ ↑ 1.2k ↓ 0.3k (1.4s) (Press ESC to stop) - braille_spinner.go: add SetWave(true) so the verb label is colored per character from hawkRandomPalette, with the color phase shifted by Tick() so the bright peak sweeps across the word. In wave mode three trailing dots (○/●) ride after the verb; one is highlighted in hawk orange and cycles 0→1→2 each tick, like a typing indicator. - chat_model.go: add per-turn turnInputTokens/turnOutputTokens fields and a usageUpdateMsg{usage *engine.StreamUsage} message. - chat.go: forward 'usage' stream events to the TUI (was a no-op for stream-json print mode only) and reset the per-turn counters each time the spinner starts (Enter pressed, queued message, slash loop). - chat_view.go: append a renderTokenCounters(input, output) segment to the spinner line. Uses ↑ (soft blue) for input and ↓ (soft amber) for output, formatted via formatModelTableContext. Hidden until the first usage event lands so it doesn't pop in empty. - braille_spinner_test.go: TestHawkRandomSolidLabel now opts out of wave mode; new TestHawkWaveLabel_MultipleColorsPerChar and TestHawkWaveAnimatedDots_PresentInFrame lock the new behavior in.
1 parent 541b0aa commit 013177a

5 files changed

Lines changed: 150 additions & 9 deletions

File tree

cmd/braille_spinner.go

Lines changed: 68 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package cmd
33
import (
44
"fmt"
55
"math/rand"
6+
"strings"
67
"sync"
78
"time"
89
)
@@ -97,6 +98,8 @@ type BrailleSpinner struct {
9798
text string
9899
glyphColor [3]int
99100
labelColor [3]int
101+
wave bool // when true, label is colored as a moving wave
102+
dots int // 0..2 — position of the highlighted dot in the trailing animation
100103
running bool
101104
stopCh chan struct{}
102105
}
@@ -138,21 +141,82 @@ func (s *BrailleSpinner) SetLabel(text string) {
138141
}
139142
}
140143

144+
// SetWave enables or disables color-wave animation on the label. When on,
145+
// each character in the label is colored from a rotating slice of the
146+
// hawk palette, producing a visible wave that moves with each tick.
147+
func (s *BrailleSpinner) SetWave(on bool) {
148+
s.mu.Lock()
149+
defer s.mu.Unlock()
150+
s.wave = on
151+
}
152+
141153
func (s *BrailleSpinner) renderGlyphLocked(glyph string) string {
142154
if s.style == SpinnerHawk {
143155
return colorHawkRGB(s.glyphColor, glyph)
144156
}
145157
return colorSpinnerGlyph(glyph)
146158
}
147159

160+
// renderWaveLabel colors each character of text with a per-position color
161+
// drawn from hawkRandomPalette, shifted by the current frame so the
162+
// bright peak slides across the word.
163+
func (s *BrailleSpinner) renderWaveLabelLocked(text string) string {
164+
if text == "" {
165+
return ""
166+
}
167+
const reset = "\033[0m"
168+
var b strings.Builder
169+
palette := len(hawkRandomPalette)
170+
for i, r := range text {
171+
c := hawkRandomPalette[(i*3+s.frame)%palette]
172+
fmt.Fprintf(&b, "\033[38;2;%d;%d;%dm%c", c[0], c[1], c[2], r)
173+
}
174+
b.WriteString(reset)
175+
return b.String()
176+
}
177+
178+
// renderAnimatedDots returns three dots where one is filled and the others
179+
// are dim, cycling through positions 0..2 with each tick. Used after the
180+
// verb to show that work is ongoing.
181+
func (s *BrailleSpinner) renderAnimatedDotsLocked() string {
182+
const reset = "\033[0m"
183+
dim := "\033[2m"
184+
// Bright dot uses hawk orange so it pops against the muted dots.
185+
const peakR, peakG, peakB = 255, 94, 14 // matches hawkColor
186+
highlightIdx := s.dots % 3
187+
var b strings.Builder
188+
for i := 0; i < 3; i++ {
189+
glyph := "○"
190+
if i == highlightIdx {
191+
glyph = "●"
192+
}
193+
if i == highlightIdx {
194+
fmt.Fprintf(&b, "\033[38;2;%d;%d;%dm%s", peakR, peakG, peakB, glyph)
195+
} else {
196+
fmt.Fprintf(&b, "%s%s", dim, glyph)
197+
}
198+
}
199+
b.WriteString(reset)
200+
return b.String()
201+
}
202+
148203
func (s *BrailleSpinner) renderLabelLocked() string {
149204
if s.text == "" {
150205
return ""
151206
}
152-
if s.style == SpinnerHawk {
153-
return colorHawkRGB(s.labelColor, s.text)
207+
var rendered string
208+
if s.wave {
209+
rendered = s.renderWaveLabelLocked(s.text)
210+
} else if s.style == SpinnerHawk {
211+
rendered = colorHawkRGB(s.labelColor, s.text)
212+
} else {
213+
rendered = colorSpinnerGlyph(s.text)
214+
}
215+
// Trailing animated dots ride with every Hawk spinner in wave mode.
216+
if s.wave {
217+
rendered += " " + s.renderAnimatedDotsLocked()
154218
}
155-
return colorSpinnerGlyph(s.text)
219+
return rendered
156220
}
157221

158222
// Frame returns the current rendered frame (spinner + label).
@@ -172,6 +236,7 @@ func (s *BrailleSpinner) Frame() string {
172236
func (s *BrailleSpinner) Tick() string {
173237
s.mu.Lock()
174238
s.frame++
239+
s.dots = (s.dots + 1) % 3
175240
if s.style == SpinnerHawk {
176241
s.refreshGlyphColorLocked()
177242
}

cmd/braille_spinner_test.go

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,13 +122,50 @@ func absInt(n int) int {
122122

123123
func TestHawkRandomSolidLabel(t *testing.T) {
124124
s := NewBrailleSpinner(SpinnerHawk, "Crafting")
125+
// Default mode: solid color label (no wave).
126+
s.SetWave(false)
125127
f := s.Frame()
126-
// entire label should be one color — no reset mid-word for multi-char shimmer
128+
// Solid label has at most two reset codes: one for glyph, one for label.
127129
if strings.Count(f, "\033[0m") > 2 {
128130
t.Errorf("expected solid label color, got mixed resets: %q", f)
129131
}
130132
}
131133

134+
func TestHawkWaveLabel_MultipleColorsPerChar(t *testing.T) {
135+
s := NewBrailleSpinner(SpinnerHawk, "Crafting")
136+
s.SetWave(true)
137+
f := s.Frame()
138+
// Wave mode: each character of the verb gets its own foreground color,
139+
// so we expect N color codes for the N-char verb plus resets.
140+
if strings.Count(f, "\033[38;2;") < len("Crafting") {
141+
t.Errorf("expected per-character colors in wave mode, got %q", f)
142+
}
143+
// Wave frames must change as the spinner ticks (color phase shifts).
144+
f0 := f
145+
s.Tick()
146+
s.Tick()
147+
if s.Frame() == f0 {
148+
t.Error("expected wave frame to change across ticks")
149+
}
150+
}
151+
152+
func TestHawkWaveAnimatedDots_PresentInFrame(t *testing.T) {
153+
s := NewBrailleSpinner(SpinnerHawk, "Crafting")
154+
s.SetWave(true)
155+
f := s.Frame()
156+
// Three dots (○ or ●) ride after the verb in wave mode.
157+
dots := strings.Count(f, "○") + strings.Count(f, "●")
158+
if dots != 3 {
159+
t.Errorf("expected 3 trailing dots in wave mode, got %d in %q", dots, f)
160+
}
161+
// Tick advances the highlighted dot position.
162+
idxBefore := s.dots
163+
s.Tick()
164+
if s.dots == idxBefore {
165+
t.Error("expected dot phase to advance on tick")
166+
}
167+
}
168+
132169
func TestColorHawkRGB(t *testing.T) {
133170
got := colorHawkRGB([3]int{255, 94, 14}, "Hi")
134171
if strings.Contains(got, "\033[2m") {

cmd/chat.go

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,7 @@ 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)
289290
m.brailleSpinner.SetLabel(m.spinnerVerb)
290291

291292
// Initialize BMAD/Aeon features
@@ -852,6 +853,8 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
852853
m.viewDirty = true
853854
m.spinnerVerb = spinnerVerbs[rand.Intn(len(spinnerVerbs))]
854855
m.brailleSpinner.SetLabel(m.spinnerVerb)
856+
m.turnInputTokens = 0
857+
m.turnOutputTokens = 0
855858
m.partial.Reset()
856859
m.startStream()
857860
return m, nil
@@ -978,6 +981,13 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
978981
m.input.SetValue("")
979982
return m, nil
980983

984+
case usageUpdateMsg:
985+
if msg.usage != nil {
986+
m.turnInputTokens += msg.usage.PromptTokens
987+
m.turnOutputTokens += msg.usage.CompletionTokens
988+
m.viewDirty = true
989+
}
990+
981991
case streamDoneMsg:
982992
m.flushPartialDirty()
983993
if m.partial.Len() > 0 {
@@ -1008,6 +1018,8 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
10081018
m.viewDirty = true
10091019
m.spinnerVerb = spinnerVerbs[rand.Intn(len(spinnerVerbs))]
10101020
m.brailleSpinner.SetLabel(m.spinnerVerb)
1021+
m.turnInputTokens = 0
1022+
m.turnOutputTokens = 0
10111023
m.partial.Reset()
10121024
m.startStream()
10131025
}
@@ -1202,8 +1214,11 @@ func runChat() error {
12021214
case "tool_result":
12031215
p.Send(toolResultMsg{name: ev.ToolName, content: ev.Content})
12041216
case "usage":
1205-
// Usage events are only emitted in stream-json print mode
1206-
// TUI mode ignores them since cost is tracked separately
1217+
// Forward usage events to TUI so the spinner line can show
1218+
// running ↑/↓ token counts for the current turn.
1219+
if ev.Usage != nil {
1220+
p.Send(usageUpdateMsg{usage: ev.Usage})
1221+
}
12071222
case "error":
12081223
p.Send(streamErrMsg{err: fmt.Errorf("%s", ev.Content)})
12091224
return

cmd/chat_model.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ type (
7474
streamErrMsg struct{ err error }
7575
blinkTickMsg struct{}
7676
spinnerVerbTickMsg struct{}
77+
usageUpdateMsg struct{ usage *engine.StreamUsage }
7778
)
7879

7980
type (
@@ -163,7 +164,11 @@ type chatModel struct {
163164
configPendingOllamaURL string
164165
pluginRuntime *plugin.Runtime
165166
spinnerVerb string
166-
lastCtrlC time.Time
167+
// Per-turn token counters shown next to the spinner (↑ input, ↓ output).
168+
// Reset each time the user submits a message; updated by usageUpdateMsg.
169+
turnInputTokens int
170+
turnOutputTokens int
171+
lastCtrlC time.Time
167172
history []string
168173
historyIdx int
169174
historyDraft string // unsent text before navigating history

cmd/chat_view.go

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -365,14 +365,16 @@ func (m *chatModel) updateViewportContent() {
365365
chatContent.WriteString(hawkC + "⛬ " + rst + renderMarkdown(partial, viewWidth-3))
366366
chatContent.WriteString("\n\n")
367367
} else {
368-
// Hawk QuadBlock spinner: random color glyph + verb label
368+
// Hawk QuadBlock spinner: animated glyph + color-waved verb label
369+
// + trailing 3-dot typing indicator + live ↑/↓ token counters.
369370
spinnerLine := m.brailleSpinner.Frame()
370371
if !m.toolStartTime.IsZero() {
371372
if elapsed := time.Since(m.toolStartTime); elapsed > 2*time.Second {
372373
spinnerLine += fmt.Sprintf(" (%.1fs)", elapsed.Seconds())
373374
}
374375
}
375-
spinnerLine += " " + dimStyle.Render("(Press ESC to stop)")
376+
spinnerLine += " " + renderTokenCounters(m.turnInputTokens, m.turnOutputTokens)
377+
spinnerLine += " " + dimStyle.Render("(Press ESC to stop)")
376378
chatContent.WriteString(spinnerLine + "\n\n")
377379
}
378380
}
@@ -592,3 +594,20 @@ func renderReflectionBox(reflection string, width int) string {
592594

593595
return border.Render(strings.TrimRight(b.String(), "\n"))
594596
}
597+
598+
// renderTokenCounters formats the live per-turn token counters that ride
599+
// next to the spinner. Uses ↑ for input (prompt) and ↓ for output
600+
// (completion) tokens, formatted via formatModelTableContext (e.g. "1.2k",
601+
// "262k", "1.5m"). Both halves are muted until at least one event has
602+
// arrived; on first usage update the input figure lands first.
603+
func renderTokenCounters(inputTokens, outputTokens int) string {
604+
if inputTokens == 0 && outputTokens == 0 {
605+
return ""
606+
}
607+
upStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#7FB3D5")) // soft blue
608+
downStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#F5B041")) // soft amber
609+
sep := dimStyle.Render(" ")
610+
up := upStyle.Render("↑ " + formatModelTableContext(inputTokens))
611+
down := downStyle.Render("↓ " + formatModelTableContext(outputTokens))
612+
return up + sep + down
613+
}

0 commit comments

Comments
 (0)