Skip to content

Commit f82da2f

Browse files
committed
New spinner animation
1 parent ab15665 commit f82da2f

2 files changed

Lines changed: 192 additions & 3 deletions

File tree

app/session.go

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ type SessionState struct {
9999
SpinnerActive bool `json:"spinner_active"`
100100
SpinnerFrame int `json:"spinner_frame"`
101101
SpinnerMsg string `json:"spinner_msg"`
102+
SpinnerAnim int `json:"spinner_anim"`
102103
OutputQueue []string `json:"output_queue,omitempty"`
103104
Commands []SlashCommand `json:"commands"`
104105
Suggestions []SlashCommand `json:"suggestions,omitempty"`
@@ -214,6 +215,7 @@ func (m sessionModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
214215
m.state.TurnCount++
215216
m.state.SpinnerFrame = 0
216217
m.state.SpinnerMsg = spinnerMessages[rand.Intn(len(spinnerMessages))]
218+
m.state.SpinnerAnim = int(randomSpinnerAnim())
217219
if !m.runtime.ticking {
218220
m.runtime.ticking = true
219221
return m, m.tickSpinner()
@@ -237,6 +239,7 @@ func (m sessionModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
237239
m.state.SpinnerFrame++
238240
if m.state.SpinnerFrame%100 == 0 {
239241
m.state.SpinnerMsg = spinnerMessages[rand.Intn(len(spinnerMessages))]
242+
m.state.SpinnerAnim = int(randomSpinnerAnim())
240243
}
241244
return m, m.tickSpinner()
242245

@@ -434,14 +437,16 @@ func (m sessionModel) View() string {
434437
}
435438
}
436439

437-
// Spinner (when agent active) with elapsed time
440+
// Spinner (when agent active) with animated message and elapsed time
438441
if m.state.SpinnerActive {
439442
bits := randomBinary(6)
443+
localFrame := m.state.SpinnerFrame % 100
444+
animatedMsg := renderAnimatedMsg(t, m.state.SpinnerMsg, spinnerAnimKind(m.state.SpinnerAnim), localFrame)
440445
elapsed := ""
441446
if !m.runtime.agentStartedAt.IsZero() {
442-
elapsed = " (" + formatDuration(time.Since(m.runtime.agentStartedAt)) + ")"
447+
elapsed = t.ANSIDim() + " (" + formatDuration(time.Since(m.runtime.agentStartedAt)) + ")" + t.ANSIReset()
443448
}
444-
fmt.Fprintf(&sb, "\n %s%s%s %s%s%s%s\n", t.ANSI(t.Primary), bits, t.ANSIReset(), t.ANSIDim(), m.state.SpinnerMsg, elapsed, t.ANSIReset())
449+
fmt.Fprintf(&sb, "\n %s%s%s %s%s\n", t.ANSI(t.Primary), bits, t.ANSIReset(), animatedMsg, elapsed)
445450
}
446451

447452
// Permission prompt (if in that state)

app/spinner_anim.go

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
package main
2+
3+
import (
4+
"math/rand"
5+
"strings"
6+
)
7+
8+
type spinnerAnimKind int
9+
10+
const (
11+
animPlain spinnerAnimKind = iota
12+
animGlimmer
13+
animWave
14+
animScramble
15+
animGlitch
16+
animTypewriter
17+
animFadeIn
18+
animCount
19+
)
20+
21+
var glitchChars = []rune{'░', '▒', '▓', '█', '▄', '▀', '▌', '▐'}
22+
23+
func randomSpinnerAnim() spinnerAnimKind {
24+
// Skip animPlain — always use a real animation
25+
return spinnerAnimKind(1 + rand.Intn(int(animCount)-1))
26+
}
27+
28+
func renderAnimatedMsg(t *Theme, msg string, anim spinnerAnimKind, frame int) string {
29+
runes := []rune(msg)
30+
if len(runes) == 0 {
31+
return ""
32+
}
33+
switch anim {
34+
case animGlimmer:
35+
return renderGlimmer(t, runes, frame)
36+
case animWave:
37+
return renderWave(t, runes, frame)
38+
case animScramble:
39+
return renderScramble(t, runes, frame)
40+
case animGlitch:
41+
return renderGlitch(t, runes, frame)
42+
case animTypewriter:
43+
return renderTypewriter(t, runes, frame)
44+
case animFadeIn:
45+
return renderFadeIn(t, runes, frame)
46+
default:
47+
return t.ANSIDim() + msg + t.ANSIReset()
48+
}
49+
}
50+
51+
// renderGlimmer highlights 1-2 random characters with the primary color each frame.
52+
func renderGlimmer(t *Theme, runes []rune, _ int) string {
53+
pos1 := rand.Intn(len(runes))
54+
pos2 := -1
55+
if len(runes) > 3 && rand.Float64() < 0.4 {
56+
pos2 = rand.Intn(len(runes))
57+
}
58+
var sb strings.Builder
59+
sb.WriteString(t.ANSIDim())
60+
for i, r := range runes {
61+
if i == pos1 || i == pos2 {
62+
sb.WriteString(t.ANSIReset())
63+
sb.WriteString(t.ANSI(t.Primary))
64+
sb.WriteRune(r)
65+
sb.WriteString(t.ANSIReset())
66+
sb.WriteString(t.ANSIDim())
67+
} else {
68+
sb.WriteRune(r)
69+
}
70+
}
71+
sb.WriteString(t.ANSIReset())
72+
return sb.String()
73+
}
74+
75+
// renderWave sweeps a bright spot across the text, looping.
76+
func renderWave(t *Theme, runes []rune, frame int) string {
77+
wavePos := (frame / 2) % (len(runes) + 6) - 3
78+
var sb strings.Builder
79+
sb.WriteString(t.ANSIDim())
80+
for i, r := range runes {
81+
dist := i - wavePos
82+
if dist < 0 {
83+
dist = -dist
84+
}
85+
switch {
86+
case dist == 0:
87+
sb.WriteString(t.ANSIReset())
88+
sb.WriteString(t.ANSI(t.Primary))
89+
sb.WriteRune(r)
90+
sb.WriteString(t.ANSIReset())
91+
sb.WriteString(t.ANSIDim())
92+
case dist <= 2:
93+
sb.WriteString(t.ANSIReset())
94+
sb.WriteRune(r)
95+
sb.WriteString(t.ANSIDim())
96+
default:
97+
sb.WriteRune(r)
98+
}
99+
}
100+
sb.WriteString(t.ANSIReset())
101+
return sb.String()
102+
}
103+
104+
// renderScramble starts with random characters and gradually reveals the real text.
105+
func renderScramble(t *Theme, runes []rune, frame int) string {
106+
const resolveOver = 40
107+
var sb strings.Builder
108+
sb.WriteString(t.ANSIDim())
109+
for i, r := range runes {
110+
resolveAt := ((i*7 + 3) % len(runes)) * resolveOver / len(runes)
111+
if frame >= resolveAt || r == ' ' {
112+
sb.WriteRune(r)
113+
} else {
114+
sb.WriteString(t.ANSIReset())
115+
sb.WriteString(t.ANSI(t.Primary))
116+
sb.WriteRune(rune('a' + rand.Intn(26)))
117+
sb.WriteString(t.ANSIReset())
118+
sb.WriteString(t.ANSIDim())
119+
}
120+
}
121+
sb.WriteString(t.ANSIReset())
122+
return sb.String()
123+
}
124+
125+
// renderGlitch randomly replaces characters with block elements.
126+
func renderGlitch(t *Theme, runes []rune, _ int) string {
127+
var sb strings.Builder
128+
sb.WriteString(t.ANSIDim())
129+
for _, r := range runes {
130+
if r != ' ' && rand.Float64() < 0.07 {
131+
sb.WriteString(t.ANSIReset())
132+
sb.WriteString(t.ANSI(t.Primary))
133+
sb.WriteRune(glitchChars[rand.Intn(len(glitchChars))])
134+
sb.WriteString(t.ANSIReset())
135+
sb.WriteString(t.ANSIDim())
136+
} else {
137+
sb.WriteRune(r)
138+
}
139+
}
140+
sb.WriteString(t.ANSIReset())
141+
return sb.String()
142+
}
143+
144+
// renderTypewriter reveals characters left to right with a cursor.
145+
func renderTypewriter(t *Theme, runes []rune, frame int) string {
146+
revealCount := frame * len(runes) / 30
147+
if revealCount > len(runes) {
148+
revealCount = len(runes)
149+
}
150+
var sb strings.Builder
151+
sb.WriteString(t.ANSIDim())
152+
for i, r := range runes {
153+
if i < revealCount {
154+
sb.WriteRune(r)
155+
} else if i == revealCount {
156+
sb.WriteString(t.ANSIReset())
157+
sb.WriteString(t.ANSI(t.Primary))
158+
sb.WriteRune('▌')
159+
sb.WriteString(t.ANSIReset())
160+
sb.WriteString(t.ANSIDim())
161+
} else {
162+
sb.WriteRune(' ')
163+
}
164+
}
165+
sb.WriteString(t.ANSIReset())
166+
return sb.String()
167+
}
168+
169+
// renderFadeIn reveals characters in pseudo-random order from spaces.
170+
func renderFadeIn(t *Theme, runes []rune, frame int) string {
171+
var sb strings.Builder
172+
sb.WriteString(t.ANSIDim())
173+
for i, r := range runes {
174+
threshold := (i*13 + 5) % len(runes)
175+
revealAt := threshold * 35 / len(runes)
176+
if frame >= revealAt || r == ' ' {
177+
sb.WriteRune(r)
178+
} else {
179+
sb.WriteRune(' ')
180+
}
181+
}
182+
sb.WriteString(t.ANSIReset())
183+
return sb.String()
184+
}

0 commit comments

Comments
 (0)