go/pkg/cli/tracker.go
Claude 228170d610
feat(cli): TUI components, Frame AppShell, command builders (#11-15)
Issues #11-13: WithAppName for variant binaries, NewPassthrough builder
for flag.FlagSet commands, RegisterCommands test coverage with resetGlobals
helper. Fix pre-existing daemon_test.go break.

Issue #14: Rich Table with box-drawing borders (Normal, Rounded, Heavy,
Double), per-column CellStyleFn, WithMaxWidth responsive truncation.
Tree renderer with box-drawing connectors and styled nodes. Parallel
TaskTracker with braille spinners, thread-safe concurrent updates, and
non-TTY fallback. Streaming text renderer with word-wrap and channel
pattern support.

Issue #15: Frame live compositional AppShell using HLCRF regions with
Model interface, Navigate/Back content swapping, alt-screen live mode,
graceful non-TTY fallback. Built-in region components: StatusLine,
KeyHints, Breadcrumb. Zero new dependencies — pure ANSI + x/term.

68 tests, all passing with -race.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 19:45:23 +00:00

291 lines
5.8 KiB
Go

package cli
import (
"fmt"
"io"
"os"
"strings"
"sync"
"time"
"golang.org/x/term"
)
// Spinner frames (braille pattern).
var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
// taskState tracks the lifecycle of a tracked task.
type taskState int
const (
taskPending taskState = iota
taskRunning
taskDone
taskFailed
)
// TrackedTask represents a single task in a TaskTracker.
// Safe for concurrent use — call Update, Done, or Fail from any goroutine.
type TrackedTask struct {
name string
status string
state taskState
tracker *TaskTracker
mu sync.Mutex
}
// Update sets the task status message and marks it as running.
func (t *TrackedTask) Update(status string) {
t.mu.Lock()
t.status = status
t.state = taskRunning
t.mu.Unlock()
}
// Done marks the task as successfully completed with a final message.
func (t *TrackedTask) Done(message string) {
t.mu.Lock()
t.status = message
t.state = taskDone
t.mu.Unlock()
}
// Fail marks the task as failed with an error message.
func (t *TrackedTask) Fail(message string) {
t.mu.Lock()
t.status = message
t.state = taskFailed
t.mu.Unlock()
}
func (t *TrackedTask) snapshot() (string, string, taskState) {
t.mu.Lock()
defer t.mu.Unlock()
return t.name, t.status, t.state
}
// TaskTracker displays multiple concurrent tasks with individual spinners.
//
// tracker := cli.NewTaskTracker()
// for _, repo := range repos {
// t := tracker.Add(repo.Name)
// go func(t *cli.TrackedTask) {
// t.Update("pulling...")
// // ...
// t.Done("up to date")
// }(t)
// }
// tracker.Wait()
type TaskTracker struct {
tasks []*TrackedTask
out io.Writer
mu sync.Mutex
started bool
}
// NewTaskTracker creates a new parallel task tracker.
func NewTaskTracker() *TaskTracker {
return &TaskTracker{out: os.Stdout}
}
// Add registers a task and returns it for goroutine use.
func (tr *TaskTracker) Add(name string) *TrackedTask {
t := &TrackedTask{
name: name,
status: "waiting",
state: taskPending,
tracker: tr,
}
tr.mu.Lock()
tr.tasks = append(tr.tasks, t)
tr.mu.Unlock()
return t
}
// Wait renders the task display and blocks until all tasks complete.
// Uses ANSI cursor manipulation for live updates when connected to a terminal.
// Falls back to line-by-line output for non-TTY.
func (tr *TaskTracker) Wait() {
if !tr.isTTY() {
tr.waitStatic()
return
}
tr.waitLive()
}
func (tr *TaskTracker) isTTY() bool {
if f, ok := tr.out.(*os.File); ok {
return term.IsTerminal(int(f.Fd()))
}
return false
}
func (tr *TaskTracker) waitStatic() {
// Non-TTY: print final state of each task when it completes.
reported := make(map[int]bool)
for {
tr.mu.Lock()
tasks := tr.tasks
tr.mu.Unlock()
allDone := true
for i, t := range tasks {
name, status, state := t.snapshot()
if state != taskDone && state != taskFailed {
allDone = false
continue
}
if reported[i] {
continue
}
reported[i] = true
icon := Glyph(":check:")
if state == taskFailed {
icon = Glyph(":cross:")
}
fmt.Fprintf(tr.out, "%s %-20s %s\n", icon, name, status)
}
if allDone {
return
}
time.Sleep(50 * time.Millisecond)
}
}
func (tr *TaskTracker) waitLive() {
tr.mu.Lock()
n := len(tr.tasks)
tr.mu.Unlock()
// Print initial lines.
frame := 0
for i := range n {
tr.renderLine(i, frame)
}
ticker := time.NewTicker(80 * time.Millisecond)
defer ticker.Stop()
for {
<-ticker.C
frame++
tr.mu.Lock()
count := len(tr.tasks)
tr.mu.Unlock()
// Move cursor up to redraw all lines.
fmt.Fprintf(tr.out, "\033[%dA", count)
for i := range count {
tr.renderLine(i, frame)
}
if tr.allDone() {
return
}
}
}
func (tr *TaskTracker) renderLine(idx, frame int) {
tr.mu.Lock()
t := tr.tasks[idx]
tr.mu.Unlock()
name, status, state := t.snapshot()
nameW := tr.nameWidth()
var icon string
switch state {
case taskPending:
icon = DimStyle.Render(Glyph(":pending:"))
case taskRunning:
icon = InfoStyle.Render(spinnerFrames[frame%len(spinnerFrames)])
case taskDone:
icon = SuccessStyle.Render(Glyph(":check:"))
case taskFailed:
icon = ErrorStyle.Render(Glyph(":cross:"))
}
var styledStatus string
switch state {
case taskDone:
styledStatus = SuccessStyle.Render(status)
case taskFailed:
styledStatus = ErrorStyle.Render(status)
default:
styledStatus = DimStyle.Render(status)
}
fmt.Fprintf(tr.out, "\033[2K%s %-*s %s\n", icon, nameW, name, styledStatus)
}
func (tr *TaskTracker) nameWidth() int {
tr.mu.Lock()
defer tr.mu.Unlock()
w := 0
for _, t := range tr.tasks {
if len(t.name) > w {
w = len(t.name)
}
}
return w
}
func (tr *TaskTracker) allDone() bool {
tr.mu.Lock()
defer tr.mu.Unlock()
for _, t := range tr.tasks {
_, _, state := t.snapshot()
if state != taskDone && state != taskFailed {
return false
}
}
return true
}
// Summary returns a one-line summary of task results.
func (tr *TaskTracker) Summary() string {
tr.mu.Lock()
defer tr.mu.Unlock()
var passed, failed int
for _, t := range tr.tasks {
_, _, state := t.snapshot()
switch state {
case taskDone:
passed++
case taskFailed:
failed++
}
}
total := len(tr.tasks)
if failed > 0 {
return fmt.Sprintf("%d/%d passed, %d failed", passed, total, failed)
}
return fmt.Sprintf("%d/%d passed", passed, total)
}
// String returns the current state of all tasks as plain text (no ANSI).
func (tr *TaskTracker) String() string {
tr.mu.Lock()
tasks := tr.tasks
tr.mu.Unlock()
nameW := tr.nameWidth()
var sb strings.Builder
for _, t := range tasks {
name, status, state := t.snapshot()
icon := "…"
switch state {
case taskDone:
icon = "✓"
case taskFailed:
icon = "✗"
case taskRunning:
icon = "⠋"
}
fmt.Fprintf(&sb, "%s %-*s %s\n", icon, nameW, name, status)
}
return sb.String()
}