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>
291 lines
5.8 KiB
Go
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()
|
|
}
|