Skip to content

Commit d7a250a

Browse files
halfaipgclaude
andcommitted
feat: TUI overhaul — terminal safety, task system, VS Code integration, permission UX
Phase A — Terminal Safety: - Signal handling + panic recovery with deferred restoreTerminal() - Boot Ctrl+C audio cleanup, PlayChime lifecycle management - Context propagation for LLM streaming cancellation - Agent goroutine wait-before-exit with agentDone channel - Cleanup method on appModel for orderly shutdown Phase B — TUI Visual Overhaul: - Status bar shows live tool activity ("reading auth.go", "running go test") - Turn dividers show "continuing..." instead of developer jargon "turn N" - Faster narration: 3s initial delay, 6s interval, tool-triggered - Retro theme with demoscene-inspired C64 palette - PETSCII block character spinner for retro theme - Scanline flash sweep on completion Phase C — VS Code / Cursor Integration: - Terminal detection via TERM_PROGRAM (vscode.go) - OSC 8 file hyperlinks in tool blocks (clickable paths) - OSC 633 shell integration prompt markers - /diff command for VS Code native diff viewer - Boot animation throttled to 8fps in IDE terminals Task System (Claude Code-style): - TaskStore with create/get/update/list, blocked-by dependencies - 4 LLM tools: create_task, update_task, list_tasks, get_task - Live task panel with progress bar and checklist indicators - Active task activeForm shown in status bar spinner - /tasks slash command, 14 unit tests Permission UX: - Enter = allow (default action), single-key y/n/a shortcuts - Bordered permission block with clear action display - First-time tip notification for /trust all - Non-blocking channel sends to prevent TUI freeze - waitForEvent bails out on stopCh close Other: - Token cost estimates in /session command - Tool activity text for all tools (git, web_search, tasks) - estimateCost() with pricing for common models Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 1446a60 commit d7a250a

18 files changed

Lines changed: 1456 additions & 93 deletions

agent.go

Lines changed: 49 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package main
22

33
import (
4+
"context"
45
"encoding/json"
56
"fmt"
67
"os"
@@ -72,17 +73,29 @@ Available tools:
7273
- git_log: Show recent commit history.
7374
- git_commit: Stage files and create a commit.
7475
- git_branch: List, create, or switch branches.
76+
- create_task: Create a task to track progress. The user sees these as a live checklist.
77+
- update_task: Update task status (pending → in_progress → completed).
78+
- list_tasks: List all tasks with status.
79+
- get_task: Get full details of a specific task.
7580
7681
Guidelines:
77-
- You can call multiple tools in parallel — read_file, list_files, search_files, web_search, and dispatch_agent all run concurrently
82+
- You can call multiple tools in parallel — read_file, list_files, search_files, web_search, dispatch_agent, list_tasks, and get_task all run concurrently
7883
- Use list_files and search_files to explore the project before making changes
7984
- Read files before editing them — understand existing code first
8085
- Make targeted, minimal changes — don't rewrite entire files unnecessarily
8186
- For multiple related edits, prefer multi_edit over separate edit_file calls
8287
- Use git tools instead of shell for git operations — they provide structured output
8388
- After you edit files, the system may report diagnostics (errors, warnings) from language tools. If diagnostics appear, fix the issues before moving on.
8489
- If a tool fails, read the error and try a different approach
85-
- When finished, briefly summarize what you changed and why`
90+
- When finished, briefly summarize what you changed and why
91+
92+
Task management:
93+
- For multi-step work (3+ steps), create tasks upfront so the user can track progress
94+
- Set status to "in_progress" BEFORE starting work on a task
95+
- Set status to "completed" only when fully done — not when partially done
96+
- Keep task subjects short and imperative (e.g. "Add auth middleware")
97+
- Provide active_form in present continuous (e.g. "Adding auth middleware") — it shows in the spinner
98+
- For simple or single-step tasks, skip task creation — just do the work directly`
8699

87100
type Agent struct {
88101
client *LLMClient
@@ -94,9 +107,10 @@ type Agent struct {
94107
permCh chan PermissionResponse
95108
permState *PermissionState
96109
diag *DiagnosticsEngine
110+
tasks *TaskStore
97111
}
98112

99-
func NewAgent(client *LLMClient, workDir string, events chan<- AgentEvent, stopCh <-chan struct{}) *Agent {
113+
func NewAgent(client *LLMClient, workDir string, events chan<- AgentEvent, stopCh <-chan struct{}, tasks *TaskStore) *Agent {
100114
sysContent := buildSystemPrompt(workDir)
101115
return &Agent{
102116
client: client,
@@ -109,6 +123,7 @@ func NewAgent(client *LLMClient, workDir string, events chan<- AgentEvent, stopC
109123
permCh: make(chan PermissionResponse, 1),
110124
permState: &PermissionState{TrustedTools: map[string]bool{}},
111125
diag: NewDiagnosticsEngine(workDir),
126+
tasks: tasks,
112127
}
113128
}
114129

@@ -252,9 +267,17 @@ func (a *Agent) Run(prompt string) {
252267

253268
a.events <- AgentEvent{Type: EventTurnStart, Turn: turn}
254269

255-
// Stream LLM call
270+
// Stream LLM call with context derived from stopCh
271+
ctx, cancel := context.WithCancel(context.Background())
272+
go func() {
273+
select {
274+
case <-a.stopCh:
275+
cancel()
276+
case <-ctx.Done():
277+
}
278+
}()
256279
streamCh := make(chan StreamEvent, 64)
257-
go a.client.StreamChat(a.history, toolDefs, streamCh)
280+
go a.client.StreamChat(ctx, a.history, toolDefs, streamCh)
258281

259282
var textContent strings.Builder
260283
var toolCalls []ToolCall
@@ -264,6 +287,7 @@ func (a *Agent) Run(prompt string) {
264287
// Check stop between stream events
265288
select {
266289
case <-a.stopCh:
290+
cancel()
267291
a.events <- AgentEvent{Type: EventDone, Text: "Stopped by user."}
268292
return
269293
default:
@@ -285,6 +309,7 @@ func (a *Agent) Run(prompt string) {
285309
}
286310

287311
case StreamError:
312+
cancel()
288313
a.events <- AgentEvent{Type: EventError, Error: fmt.Errorf("%s", humanizeError(evt.Error))}
289314
a.events <- AgentEvent{Type: EventDone, Text: "Error occurred."}
290315
return
@@ -293,6 +318,7 @@ func (a *Agent) Run(prompt string) {
293318
// handled below
294319
}
295320
}
321+
cancel() // ensure context goroutine exits
296322

297323
_ = lastUsage
298324

@@ -375,7 +401,8 @@ func (a *Agent) executeToolCalls(toolCalls []ToolCall, consecutiveErrors *int) {
375401
defer wg.Done()
376402
var output string
377403
var success bool
378-
if tc.Function.Name == "dispatch_agent" {
404+
switch tc.Function.Name {
405+
case "dispatch_agent":
379406
task := ""
380407
if args != nil {
381408
task, _ = args["task"].(string)
@@ -388,7 +415,11 @@ func (a *Agent) executeToolCalls(toolCalls []ToolCall, consecutiveErrors *int) {
388415
output = res
389416
success = true
390417
}
391-
} else {
418+
case "list_tasks":
419+
output, success = toolListTasks(args, a.tasks)
420+
case "get_task":
421+
output, success = toolGetTask(args, a.tasks)
422+
default:
392423
output, success = ExecuteTool(tc.Function.Name, tc.Function.Arguments, a.workDir)
393424
}
394425
results[idx] = result{tc: tc, args: args, output: output, success: success}
@@ -462,7 +493,8 @@ func (a *Agent) executeToolCalls(toolCalls []ToolCall, consecutiveErrors *int) {
462493

463494
var output string
464495
var success bool
465-
if tc.Function.Name == "dispatch_agent" {
496+
switch tc.Function.Name {
497+
case "dispatch_agent":
466498
task := ""
467499
if argsMap != nil {
468500
task, _ = argsMap["task"].(string)
@@ -475,7 +507,15 @@ func (a *Agent) executeToolCalls(toolCalls []ToolCall, consecutiveErrors *int) {
475507
output = res
476508
success = true
477509
}
478-
} else {
510+
case "create_task":
511+
output, success = toolCreateTask(argsMap, a.tasks)
512+
case "update_task":
513+
output, success = toolUpdateTask(argsMap, a.tasks)
514+
case "list_tasks":
515+
output, success = toolListTasks(argsMap, a.tasks)
516+
case "get_task":
517+
output, success = toolGetTask(argsMap, a.tasks)
518+
default:
479519
output, success = ExecuteTool(tc.Function.Name, tc.Function.Arguments, a.workDir)
480520
}
481521

app.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package main
22

33
import (
44
"os"
5+
"time"
56

67
tea "github.com/charmbracelet/bubbletea"
78
)
@@ -48,6 +49,7 @@ func (m appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
4849
// Global key handling
4950
if keyMsg, ok := msg.(tea.KeyMsg); ok {
5051
if keyMsg.String() == "ctrl+c" && m.screen == screenBoot {
52+
m.boot.audio.Stop()
5153
return m, tea.Quit
5254
}
5355
}
@@ -93,3 +95,32 @@ func (m appModel) View() string {
9395
}
9496
return ""
9597
}
98+
99+
// Cleanup gracefully shuts down background goroutines and processes.
100+
// Called from main.go's defer chain before terminal restoration.
101+
func (m *appModel) Cleanup() {
102+
// Stop boot audio if still playing
103+
m.boot.audio.Stop()
104+
105+
// Signal agent to stop
106+
if m.chat.stopCh != nil {
107+
select {
108+
case <-m.chat.stopCh:
109+
default:
110+
close(m.chat.stopCh)
111+
}
112+
}
113+
114+
// Wait briefly for agent goroutine to finish
115+
if m.chat.agentDone != nil {
116+
select {
117+
case <-m.chat.agentDone:
118+
case <-time.After(2 * time.Second):
119+
}
120+
}
121+
122+
// Stop chime audio
123+
if m.chat.chimePlayer != nil {
124+
m.chat.chimePlayer.Stop()
125+
}
126+
}

audio.go

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -433,19 +433,21 @@ var chimePattern = []patternRow{
433433
{"G-5", "C-5", "---", "---"},
434434
}
435435

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() {
436+
// PlayChimeAsync plays a short success chime (~0.3 seconds) in the background.
437+
// Returns the AudioPlayer so the caller can call Stop() for cleanup.
438+
// Returns nil if audio is disabled or unavailable.
439+
func PlayChimeAsync() *AudioPlayer {
439440
if os.Getenv("CODEBASE_NOBOOT") != "" || os.Getenv("CODEBASE_NOSOUND") != "" {
440-
return
441+
return nil
441442
}
442443

443444
player := tryChimePlayer()
444445
if player == nil {
445-
return
446+
return nil
446447
}
447448

448449
go func() {
450+
defer close(player.done)
449451
synth := &chipSynth{
450452
pattern: chimePattern,
451453
lead: channel{envDecay: 0.9995},
@@ -477,6 +479,8 @@ func PlayChime() {
477479
player.writer.Close()
478480
player.cmd.Wait()
479481
}()
482+
483+
return player
480484
}
481485

482486
// tryChimePlayer creates a raw PCM audio pipe for the chime.

boot.go

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -195,19 +195,30 @@ func buildBootSteps(cfg *Config) []bootStep {
195195
{label: "config", value: "loaded"},
196196
{label: "provider", value: cfg.Model},
197197
{label: "workspace", value: fmt.Sprintf("%s (%d files)", workDisplay, fileCount)},
198+
{label: "terminal", value: TerminalName()},
198199
{label: "status", value: "ready"},
199200
}
200201
}
201202

202203
// ── Bubble Tea ───────────────────────────────────────────────
203204

205+
// demoFPS returns the frame interval for the boot animation.
206+
// Throttled in VS Code/Cursor to prevent PTY flooding that affects other terminals.
207+
func demoFPS() time.Duration {
208+
if termInfo.IsVSCode || termInfo.IsCursor {
209+
return 120 * time.Millisecond // ~8fps — gentle on VS Code's terminal handler
210+
}
211+
return 50 * time.Millisecond // ~20fps — full speed in standalone terminals
212+
}
213+
204214
func (m bootModel) Init() tea.Cmd {
215+
fps := demoFPS()
205216
return tea.Batch(
206217
func() tea.Msg {
207218
// Start boot music in background (nil if no audio device)
208219
return bootAudioMsg{player: StartBootMusic()}
209220
},
210-
tea.Tick(50*time.Millisecond, func(t time.Time) tea.Msg { return demoTickMsg(t) }),
221+
tea.Tick(fps, func(t time.Time) tea.Msg { return demoTickMsg(t) }),
211222
// First boot step after 4 seconds (drawn-out demo intro)
212223
tea.Tick(4*time.Second, func(t time.Time) tea.Msg { return bootTickMsg{} }),
213224
)
@@ -224,7 +235,7 @@ func (m bootModel) Update(msg tea.Msg) (bootModel, tea.Cmd) {
224235

225236
case demoTickMsg:
226237
m.frame++
227-
return m, tea.Tick(50*time.Millisecond, func(t time.Time) tea.Msg { return demoTickMsg(t) })
238+
return m, tea.Tick(demoFPS(), func(t time.Time) tea.Msg { return demoTickMsg(t) })
228239

229240
case bootTickMsg:
230241
if m.current < len(m.steps) {
@@ -522,7 +533,10 @@ func (m bootModel) renderLogo(px []rgb, w, h, ox, oy, scale int, t, reveal float
522533
}
523534
}
524535

525-
// Glow
536+
// Glow (skip in VS Code to reduce ANSI output volume)
537+
if termInfo.IsVSCode || termInfo.IsCursor {
538+
continue
539+
}
526540
for dy := -glowR; dy <= scale+glowR; dy++ {
527541
for dx := -glowR; dx <= scale+glowR; dx++ {
528542
x := ox + lx*scale + dx

boot_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ func TestBootStepAdvancement(t *testing.T) {
3737
m := newBootModel(testBootConfig())
3838
m, _ = m.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
3939

40-
for i := 0; i < 4; i++ {
40+
for i := 0; i < len(m.steps); i++ {
4141
m, _ = m.Update(bootTickMsg{})
4242
if !m.steps[i].done {
4343
t.Errorf("step %d not done after tick", i)

0 commit comments

Comments
 (0)