Skip to content

Commit 1446a60

Browse files
halfaipgclaude
andcommitted
feat: quick wins + animations — spinner cycling, celebration sparkles, chime
- Raise subagent turn limit 15→25 for deeper research - Persist session title across resume - Add context_lines param to search_files (like grep -C) - Enhanced write_file output with line diff (45→52 lines, +7) - Rainbow celebration border cycling 6 colors over 3s - Color-cycling tool spinner through accent palette - Cascading sparkle animation on task completion - Success chime — 3-note ascending arpeggio via PCM Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 14b2535 commit 1446a60

7 files changed

Lines changed: 175 additions & 20 deletions

File tree

audio.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"io"
66
"math"
77
"math/rand"
8+
"os"
89
"os/exec"
910
"sync"
1011
)
@@ -422,3 +423,87 @@ func (ap *AudioPlayer) Stop() {
422423
}
423424
ap.cmd.Wait()
424425
}
426+
427+
// ── Success chime ───────────────────────────────────────────
428+
429+
// chimePattern is a short 3-note ascending arpeggio for task completion.
430+
var chimePattern = []patternRow{
431+
{"C-5", "E-5", "---", "---"},
432+
{"E-5", "G-5", "---", "---"},
433+
{"G-5", "C-5", "---", "---"},
434+
}
435+
436+
// PlayChime plays a short success chime (~0.3 seconds).
437+
// Runs in the background. Respects CODEBASE_NOBOOT / CODEBASE_NOSOUND env vars.
438+
func PlayChime() {
439+
if os.Getenv("CODEBASE_NOBOOT") != "" || os.Getenv("CODEBASE_NOSOUND") != "" {
440+
return
441+
}
442+
443+
player := tryChimePlayer()
444+
if player == nil {
445+
return
446+
}
447+
448+
go func() {
449+
synth := &chipSynth{
450+
pattern: chimePattern,
451+
lead: channel{envDecay: 0.9995},
452+
arp: channel{envDecay: 0.9996},
453+
bass: channel{envDecay: 0.9999},
454+
noise: channel{envDecay: 0.999},
455+
}
456+
457+
totalSamples := len(chimePattern) * samplesPerRow
458+
buf := make([]int16, 1024)
459+
pcm := make([]byte, len(buf)*2)
460+
461+
generated := 0
462+
for generated < totalSamples {
463+
chunkSize := len(buf)
464+
remaining := totalSamples - generated
465+
if chunkSize > remaining {
466+
chunkSize = remaining
467+
}
468+
synth.renderSamples(buf[:chunkSize])
469+
for i := 0; i < chunkSize; i++ {
470+
binary.LittleEndian.PutUint16(pcm[i*2:], uint16(buf[i]))
471+
}
472+
if _, err := player.writer.Write(pcm[:chunkSize*2]); err != nil {
473+
break
474+
}
475+
generated += chunkSize
476+
}
477+
player.writer.Close()
478+
player.cmd.Wait()
479+
}()
480+
}
481+
482+
// tryChimePlayer creates a raw PCM audio pipe for the chime.
483+
func tryChimePlayer() *AudioPlayer {
484+
if _, err := exec.LookPath("aplay"); err == nil {
485+
cmd := exec.Command("aplay", "-t", "raw", "-f", "S16_LE", "-r", "44100", "-c", "1", "-q")
486+
stdin, err := cmd.StdinPipe()
487+
if err == nil {
488+
cmd.Stderr = nil
489+
cmd.Stdout = nil
490+
if err := cmd.Start(); err == nil {
491+
return &AudioPlayer{cmd: cmd, writer: stdin, done: make(chan struct{})}
492+
}
493+
stdin.Close()
494+
}
495+
}
496+
if _, err := exec.LookPath("paplay"); err == nil {
497+
cmd := exec.Command("paplay", "--format=s16le", "--rate=44100", "--channels=1", "--raw")
498+
stdin, err := cmd.StdinPipe()
499+
if err == nil {
500+
cmd.Stderr = nil
501+
cmd.Stdout = nil
502+
if err := cmd.Start(); err == nil {
503+
return &AudioPlayer{cmd: cmd, writer: stdin, done: make(chan struct{})}
504+
}
505+
stdin.Close()
506+
}
507+
}
508+
return nil
509+
}

chat.go

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -509,6 +509,8 @@ func (m chatModel) Update(msg tea.Msg) (chatModel, tea.Cmd) {
509509
var cmd tea.Cmd
510510
m.spinner, cmd = m.spinner.Update(msg)
511511
cmds = append(cmds, cmd)
512+
// Cycle spinner color through accent palette
513+
m.spinner.Style = lipgloss.NewStyle().Foreground(spinnerColors[m.notify.frame%len(spinnerColors)])
512514
// Only re-render for spinner if there are pending tools (avoids 80ms full rebuild)
513515
if m.state == chatStreaming && m.toolsPending > 0 {
514516
m.rebuildViewport()
@@ -630,6 +632,9 @@ func (m *chatModel) startAgent(prompt string) {
630632
if session := LoadSession(m.config.WorkDir, m.config.Model); session != nil {
631633
m.agent.history = session.History
632634
m.tokens = session.Tokens
635+
if session.Title != "" {
636+
m.title = session.Title
637+
}
633638
m.segments = append(m.segments, segment{
634639
kind: "text",
635640
text: styleMuted.Render(" Session restored from previous conversation.\n\n"),
@@ -937,11 +942,12 @@ func (m *chatModel) handleAgentEvent(evt AgentEvent) tea.Cmd {
937942
if m.agent != nil {
938943
m.files = m.agent.FilesChanged()
939944
// Persist session to disk
940-
SaveSession(m.agent, m.tokens)
945+
SaveSession(m.agent, m.tokens, m.title)
941946
}
942947
m.state = chatDoneFlash
943-
m.flashFrames = 1
948+
m.flashFrames = 6
944949
m.rebuildViewport()
950+
go PlayChime()
945951

946952
// Glue: celebration + follow-up suggestions (in background)
947953
summary := evt.Text
@@ -1098,7 +1104,18 @@ func (m chatModel) View() string {
10981104
case chatStreaming, chatPermission:
10991105
frame = styleFrameActive.Width(m.width - 2)
11001106
case chatDoneFlash:
1101-
frame = styleFrameDone.Width(m.width - 2)
1107+
colorIdx := len(flashCycleColors) - m.flashFrames
1108+
if colorIdx < 0 {
1109+
colorIdx = 0
1110+
}
1111+
if colorIdx >= len(flashCycleColors) {
1112+
colorIdx = len(flashCycleColors) - 1
1113+
}
1114+
frame = lipgloss.NewStyle().
1115+
Border(lipgloss.RoundedBorder()).
1116+
BorderForeground(flashCycleColors[colorIdx]).
1117+
Padding(0, 1).
1118+
Width(m.width - 2)
11021119
case chatPlanning, chatPlanReview:
11031120
frame = styleFramePlan.Width(m.width - 2)
11041121
default:

notify.go

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -157,10 +157,12 @@ func (nm *notifyManager) renderOne(n Notification, width int) string {
157157

158158
content := fmt.Sprintf(" %s %s", icon, text)
159159

160-
// Celebrate gets sparkle animation
160+
// Celebrate gets cascading sparkle animation
161161
if n.Type == NotifyCelebrate {
162-
sparkle := nm.sparkleFrame()
163-
content = fmt.Sprintf(" %s %s %s", sparkle, style.Render(text), sparkle)
162+
s1 := nm.sparkleFrameAt(nm.frame)
163+
s2 := nm.sparkleFrameAt(nm.frame + 3)
164+
s3 := nm.sparkleFrameAt(nm.frame + 6)
165+
content = fmt.Sprintf(" %s %s %s %s %s", s1, s2, style.Render(text), s3, s1)
164166
return content
165167
}
166168

@@ -174,7 +176,8 @@ func (nm *notifyManager) icon(t NotifyType) string {
174176
return styleMuted.Render("›")
175177
case NotifyProgress:
176178
frames := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
177-
return lipgloss.NewStyle().Foreground(colCyan).Render(frames[nm.frame%len(frames)])
179+
progColor := spinnerColors[(nm.frame/3)%len(spinnerColors)]
180+
return lipgloss.NewStyle().Foreground(progColor).Render(frames[nm.frame%len(frames)])
178181
case NotifySuccess:
179182
return styleOK.Render("✓")
180183
case NotifyWarn:
@@ -186,11 +189,16 @@ func (nm *notifyManager) icon(t NotifyType) string {
186189
}
187190
}
188191

189-
// sparkleFrame returns an animated sparkle character.
192+
// sparkleFrame returns an animated sparkle character at the current frame.
190193
func (nm *notifyManager) sparkleFrame() string {
191-
sparkles := []string{"✦", "✧", "⋆", "✦", "·", "✧", "✦", "⋆"}
194+
return nm.sparkleFrameAt(nm.frame)
195+
}
196+
197+
// sparkleFrameAt returns a sparkle character at a specific frame offset for cascading effects.
198+
func (nm *notifyManager) sparkleFrameAt(frame int) string {
199+
sparkles := []string{"✦", "✧", "⋆", "★", "·", "✧", "✦", "⋆"}
192200
colors := []lipgloss.Color{colPurple, colCyan, colAccent, colSuccess, colOrange, colPurple, colCyan, colAccent}
193-
idx := nm.frame % len(sparkles)
201+
idx := frame % len(sparkles)
194202
return lipgloss.NewStyle().Foreground(colors[idx]).Bold(true).Render(sparkles[idx])
195203
}
196204

session.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ const maxSessionAge = 7 * 24 * time.Hour // 7 days
2121
type SessionData struct {
2222
WorkDir string `json:"work_dir"`
2323
Model string `json:"model"`
24+
Title string `json:"title,omitempty"`
2425
History []ChatMessage `json:"history"`
2526
Tokens TokenUsage `json:"tokens"`
2627
Files int `json:"files"`
@@ -53,7 +54,7 @@ func sessionFile(workDir string) (string, error) {
5354
}
5455

5556
// SaveSession persists the agent's conversation to disk.
56-
func SaveSession(agent *Agent, tokens TokenUsage) error {
57+
func SaveSession(agent *Agent, tokens TokenUsage, title string) error {
5758
if agent == nil || len(agent.history) <= 1 {
5859
return nil // nothing to save (just system prompt)
5960
}
@@ -66,6 +67,7 @@ func SaveSession(agent *Agent, tokens TokenUsage) error {
6667
data := SessionData{
6768
WorkDir: agent.workDir,
6869
Model: agent.client.Model,
70+
Title: title,
6971
History: agent.history,
7072
Tokens: tokens,
7173
Files: agent.files,

subagent.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import (
1515
// Max depth 1 (subagents cannot spawn sub-subagents).
1616
// ──────────────────────────────────────────────────────────────
1717

18-
const subagentMaxTurns = 15
18+
const subagentMaxTurns = 25
1919

2020
const subagentSystemPrompt = `You are a focused research assistant. You help gather information by reading files, searching code, and listing directories.
2121

theme.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,11 @@ var (
9494
colPurple lipgloss.Color
9595
colOrange lipgloss.Color
9696
colCyan lipgloss.Color
97+
98+
// flashCycleColors is the rainbow border color sequence for completion flash.
99+
flashCycleColors []lipgloss.Color
100+
// spinnerColors is the color cycle for the tool execution spinner.
101+
spinnerColors []lipgloss.Color
97102
)
98103

99104
// ──────────────────────────────────────────────────────────────
@@ -172,6 +177,10 @@ func initStyles() {
172177
colOrange = lipgloss.Color(t.Orange)
173178
colCyan = lipgloss.Color(t.Cyan)
174179

180+
// Animation color cycles
181+
flashCycleColors = []lipgloss.Color{colSuccess, colCyan, colAccent, colPurple, colOrange, colSuccess}
182+
spinnerColors = []lipgloss.Color{colAccent, colCyan, colPurple, colSuccess, colOrange}
183+
175184
// Frame styles
176185
styleFrame = lipgloss.NewStyle().
177186
Border(lipgloss.RoundedBorder()).

tools.go

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,10 @@ var toolDefs = []ToolDef{
187187
"type": "string",
188188
"description": "Glob to filter which files to search (e.g. \"*.ts\", \"*.{js,jsx}\").",
189189
},
190+
"context_lines": map[string]interface{}{
191+
"type": "number",
192+
"description": "Number of context lines before and after each match (like grep -C). Default 0.",
193+
},
190194
},
191195
"required": []string{"pattern"},
192196
},
@@ -482,16 +486,30 @@ func toolWriteFile(args map[string]interface{}, workDir string) (string, bool) {
482486
return fmt.Sprintf("Error creating directory: %v", err), false
483487
}
484488

489+
// Read old content for diff summary before overwriting
490+
var oldLineCount int
491+
if existed {
492+
if oldData, readErr := os.ReadFile(absPath); readErr == nil {
493+
oldLineCount = strings.Count(string(oldData), "\n") + 1
494+
}
495+
}
496+
485497
if err := os.WriteFile(absPath, []byte(content), perm); err != nil {
486498
return fmt.Sprintf("Error: %v", err), false
487499
}
488500

489501
lines := strings.Count(content, "\n") + 1
490-
action := "Created"
491502
if existed {
492-
action = "Updated"
503+
lineDiff := lines - oldLineCount
504+
diffNote := ""
505+
if lineDiff > 0 {
506+
diffNote = fmt.Sprintf(", +%d", lineDiff)
507+
} else if lineDiff < 0 {
508+
diffNote = fmt.Sprintf(", %d", lineDiff)
509+
}
510+
return fmt.Sprintf("Updated %s (%d→%d lines, %d bytes%s)", relPath, oldLineCount, lines, len(content), diffNote), true
493511
}
494-
return fmt.Sprintf("%s %s (%d lines, %d bytes)", action, relPath, lines, len(content)), true
512+
return fmt.Sprintf("Created %s (%d lines, %d bytes)", relPath, lines, len(content)), true
495513
}
496514

497515
// ── edit_file ────────────────────────────────────────────────
@@ -881,17 +899,24 @@ func toolSearchFiles(args map[string]interface{}, workDir string) (string, bool)
881899
dirPath = "."
882900
}
883901
include := getString(args, "include")
902+
contextLines := 0
903+
if cl, ok := getFloat(args, "context_lines"); ok && cl > 0 {
904+
contextLines = int(cl)
905+
if contextLines > 10 {
906+
contextLines = 10
907+
}
908+
}
884909

885910
fullPath, err := safePath(workDir, dirPath)
886911
if err != nil {
887912
return fmt.Sprintf("Error: %v", err), false
888913
}
889914

890915
// Try ripgrep first, fall back to grep
891-
output, err := searchWithRg(pattern, fullPath, include, workDir)
916+
output, err := searchWithRg(pattern, fullPath, include, workDir, contextLines)
892917
if err != nil {
893918
// rg not found — try grep
894-
output, err = searchWithGrep(pattern, fullPath, include, workDir)
919+
output, err = searchWithGrep(pattern, fullPath, include, workDir, contextLines)
895920
if err != nil {
896921
return fmt.Sprintf("Error: %v", err), false
897922
}
@@ -910,7 +935,7 @@ func toolSearchFiles(args map[string]interface{}, workDir string) (string, bool)
910935
return truncateOutput(fmt.Sprintf("%d matches for %q in %s:\n\n%s", len(lines), pattern, dirPath, output)), true
911936
}
912937

913-
func searchWithRg(pattern, searchPath, include, workDir string) (string, error) {
938+
func searchWithRg(pattern, searchPath, include, workDir string, contextLines int) (string, error) {
914939
args := []string{
915940
"rg",
916941
"--line-number",
@@ -934,6 +959,10 @@ func searchWithRg(pattern, searchPath, include, workDir string) (string, error)
934959
args = append(args, "--glob", include)
935960
}
936961

962+
if contextLines > 0 {
963+
args = append(args, fmt.Sprintf("-C%d", contextLines))
964+
}
965+
937966
args = append(args, "--", pattern, searchPath)
938967

939968
cmd := exec.Command(args[0], args[1:]...)
@@ -963,13 +992,18 @@ func searchWithRg(pattern, searchPath, include, workDir string) (string, error)
963992
return strings.TrimRight(output, "\n"), nil
964993
}
965994

966-
func searchWithGrep(pattern, searchPath, include, workDir string) (string, error) {
995+
func searchWithGrep(pattern, searchPath, include, workDir string, contextLines int) (string, error) {
967996
incFlag := "*"
968997
if include != "" {
969998
incFlag = include
970999
}
9711000

972-
cmd := exec.Command("grep", "-rn", "--include="+incFlag, pattern, searchPath)
1001+
grepArgs := []string{"-rn", "--include=" + incFlag}
1002+
if contextLines > 0 {
1003+
grepArgs = append(grepArgs, fmt.Sprintf("-C%d", contextLines))
1004+
}
1005+
grepArgs = append(grepArgs, pattern, searchPath)
1006+
cmd := exec.Command("grep", grepArgs...)
9731007
cmd.Dir = workDir
9741008
out, err := cmd.CombinedOutput()
9751009

0 commit comments

Comments
 (0)