feat(01-03): add deterministic rich CLI output

- Inject bonfire.Output from stdin/stdout/stderr with stdlib widgets and x/term
- Degrade spinner, progress, table and prompts without a TTY or color
- Reject plugin command names that are not namespace:verb
This commit is contained in:
Jakub Zych
2026-09-16 13:54:44 +02:00
parent 9b1a25dc94
commit a6004e490f
16 changed files with 884 additions and 78 deletions

210
bonfire/widgets.go Normal file
View File

@@ -0,0 +1,210 @@
package bonfire
import (
"fmt"
"strings"
"sync"
"time"
"unicode/utf8"
)
var spinnerFrames = []rune{'⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'}
const (
boxTopLeft = "┌"
boxTopRight = "┐"
boxBottomLeft = "└"
boxBottomRight = "┘"
boxHorizontal = "─"
boxVertical = "│"
boxTeeRight = "├"
boxTeeLeft = "┤"
boxTeeDown = "┬"
boxTeeUp = "┴"
boxCross = "┼"
)
func (c *console) Spinner(message string, fn func() error) error {
if fn == nil {
fn = func() error { return nil }
}
if !c.tty {
c.Printf("[...] %s\n", message)
return fn()
}
done := make(chan struct{})
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
i := 0
ticker := time.NewTicker(80 * time.Millisecond)
defer ticker.Stop()
c.Printf("\r%s %s", c.style(ansiCyan, string(spinnerFrames[0])), message)
for {
select {
case <-done:
return
case <-ticker.C:
i++
frame := spinnerFrames[i%len(spinnerFrames)]
c.Printf("\r%s %s", c.style(ansiCyan, string(frame)), message)
}
}
}()
err := fn()
close(done)
wg.Wait()
if err != nil {
c.Printf("\r%s %s\n", c.style(ansiRed, symCross), message)
return err
}
c.Printf("\r%s %s\n", c.style(ansiGreen, symCheck), message)
return nil
}
func (c *console) Progress(total int, fn func(Progress) error) error {
if total < 1 {
total = 1
}
bar := &progressBar{c: c, total: total, lastPct: -1}
if fn == nil {
return nil
}
err := fn(bar)
if c.tty && bar.current >= bar.total {
c.Println()
}
return err
}
type progressBar struct {
c *console
total int
current int
lastPct int
}
func (p *progressBar) Advance(step int) {
if step < 1 {
step = 1
}
p.current += step
if p.current > p.total {
p.current = p.total
}
pct := 0
if p.total > 0 {
pct = p.current * 100 / p.total
}
if p.c.tty {
p.renderTTY(pct)
return
}
if p.lastPct >= 0 && pct/10 == p.lastPct/10 && pct != 100 {
return
}
if pct == 0 && p.lastPct < 0 {
p.lastPct = 0
return
}
if pct%10 == 0 || pct == 100 {
p.c.Printf("[%d/%d] %d%%\n", p.current, p.total, pct)
p.lastPct = pct
}
}
func (p *progressBar) renderTTY(pct int) {
width := 40
filled := 0
if p.total > 0 {
filled = p.current * width / p.total
}
if filled > width {
filled = width
}
bar := strings.Repeat("█", filled) + strings.Repeat(" ", width-filled)
color := progressColor(pct)
p.c.Printf("\r %s %5.1f%% %d/%d", p.c.style(color, bar), float64(pct), p.current, p.total)
}
func progressColor(pct int) string {
switch {
case pct >= 100:
return "\x1b[38;5;34m"
case pct >= 50:
return "\x1b[38;5;36m"
default:
return "\x1b[38;5;37m"
}
}
func (c *console) Table(headers []string, rows [][]string) {
if !c.tty {
c.Println(strings.Join(headers, "\t"))
for _, row := range rows {
c.Println(strings.Join(padRow(row, len(headers)), "\t"))
}
return
}
cols := len(headers)
all := make([][]string, 0, 1+len(rows))
all = append(all, headers)
for _, row := range rows {
all = append(all, padRow(row, cols))
}
widths := make([]int, cols)
for _, row := range all {
for i, cell := range row {
if n := utf8.RuneCountInString(cell); n > widths[i] {
widths[i] = n
}
}
}
c.Println(boxLine(widths, boxTopLeft, boxTeeDown, boxTopRight))
c.Println(boxRow(headers, widths, true, c))
c.Println(boxLine(widths, boxTeeRight, boxCross, boxTeeLeft))
for _, row := range rows {
c.Println(boxRow(padRow(row, cols), widths, false, c))
}
c.Println(boxLine(widths, boxBottomLeft, boxTeeUp, boxBottomRight))
}
func padRow(row []string, n int) []string {
out := make([]string, n)
for i := 0; i < n; i++ {
if i < len(row) {
out[i] = row[i]
}
}
return out
}
func boxLine(widths []int, left, mid, right string) string {
var b strings.Builder
b.WriteString(left)
for i, w := range widths {
if i > 0 {
b.WriteString(mid)
}
b.WriteString(strings.Repeat(boxHorizontal, w+2))
}
b.WriteString(right)
return b.String()
}
func boxRow(cells []string, widths []int, header bool, c *console) string {
var b strings.Builder
b.WriteString(boxVertical)
for i, cell := range cells {
pad := widths[i] - utf8.RuneCountInString(cell)
text := cell
if header {
text = c.style("\x1b[1m", cell)
}
fmt.Fprintf(&b, " %s%s %s", text, strings.Repeat(" ", pad), boxVertical)
}
return b.String()
}