feat(bonfire): SSU widgets on charm v2 behind the Output interface

- Spinner, gradient progress bar and the multi-row process view ported
  from SSU to bubbletea v2 (input off, no signal handler, command
  context); boxed tables through lipgloss/table
- Prompts use huh fields and a single-select port of SSU's selector with
  its confirmation screen on an interactive terminal; line prompts off a
  terminal and under --no-ansi are unchanged (piped passwords still work)
- -q hides decoration but keeps data and errors; -n returns defaults and
  Secret fails with a hint on a terminal stdin; --no-ansi and non-TTY
  runs print today's plain lines
- Helpers: GlobalsOf, VerbosityOf, IsQuiet, Interactive, ConfirmProceed,
  Process, Current, Failed, PrintAbove, ItemProgress, ErrAborted
- diary's styled console lines print above a running live view
This commit is contained in:
Jakub Zych
2026-10-21 12:30:53 +02:00
parent cb38c493ce
commit 3f38862b22
15 changed files with 1266 additions and 198 deletions

View File

@@ -27,8 +27,18 @@ Use cases:
- Usage strings and argument-count checks derived from the declared arguments (`<name>` for required, `[name]` for optional).
- Scalar flags, bare flags (`bonfire.Flag.Bare`, so `--force` alone stores `true`) and ordered repeatable flags (`bonfire.Flag.Repeatable`, read back through `bonfire.Input.Flags`).
- Styled status lines: `bonfire.Output.Info`, `bonfire.Output.Success`, `bonfire.Output.Warning` and `bonfire.Output.Error` (the last one writes to the error stream).
- Widgets: box-drawn tables (`bonfire.Output.Table`), a spinner around a function (`bonfire.Output.Spinner`) and a progress bar (`bonfire.Output.Progress`); both fall back to plain lines when output is not a terminal.
- Prompts: `bonfire.Output.Ask`, `bonfire.Output.Confirm`, `bonfire.Output.Choice` and `bonfire.Output.Secret`, which reads a hidden value on a terminal. Prompts return their defaults when input ends, and `bonfire.Output.Confirm` returns its default without asking when the session is not interactive.
- Widgets on the charm stack, ported from SSU's terminal UI: box-drawn tables (`bonfire.Output.Table`, lipgloss), a spinner around a function (`bonfire.Output.Spinner`), a gradient progress bar (`bonfire.Output.Progress`, whose `bonfire.Progress` also implements `bonfire.ItemProgress`) and a multi-row process view for bulk loops (`bonfire.Process`), each a bubbletea program that keeps stdin and signals to the command. Log lines and output printed while a view runs appear above it (`bonfire.PrintAbove`).
- Prompts: `bonfire.Output.Ask`, `bonfire.Output.Confirm` and `bonfire.Output.Secret` use huh fields on an interactive terminal, and `bonfire.Output.Choice` an arrow-key selector with a confirmation screen. Off a terminal they read lines from stdin as before (a piped password still works), return their defaults when input ends, and `bonfire.Output.Confirm` returns its default without asking. A cancelled prompt returns `bonfire.ErrAborted`. `bonfire.ConfirmProceed` asks before a destructive step only on an interactive terminal.
- Degradation: every widget follows the terminal and the global options, and the `bonfire.Output` interface is unchanged for plugins:
| Run | Animation | Decoration | Data | Prompts |
|-----|-----------|------------|------|---------|
| Terminal | spinner, progress bar, process view | colors, symbols, boxed tables | printed | huh fields and the selector |
| Not a terminal | none: `[...] message` and a line per tenth of progress | symbols, no colors unless `FORCE_COLOR` | printed, tables tab-separated | read a line from stdin; `Confirm` returns its default |
| `-q` | none | info, success and warning lines hidden; errors kept | printed (`Println`, `Printf`, tables) | defaults, as with `-n` |
| `--no-ansi` | none | symbols, no colors | printed | plain line prompts |
| `-n` | none | as on the terminal | printed | defaults; `Secret` reads piped stdin or fails with a hint |
- Command state for helpers: `bonfire.GlobalsOf`, `bonfire.VerbosityOf`, `bonfire.IsQuiet` and `bonfire.Interactive` read the running command's global options from its `bonfire.Output`.
- Passthrough commands (`bonfire.Command.Passthrough`) skip flag parsing, so every argument, including `--help`, reaches `bonfire.Input.Args` unchanged; this suits commands that forward to another program. `bonfire.Command.Long` adds extended help text that the help command shows instead of the description.
- Artisan-style command list: a bare binary (or `--help`, or `list`) prints a version header (`bonfire.Version.Header`, read from the build information by `bonfire.BuildVersion`), the usage line, the global options and the commands, top-level names first and then one block per namespace, with aligned descriptions.
- Global options on every command: `-v`/`-vv`/`-vvv` (`--verbose`), `-q` (`--quiet`, implies `-n`), `--ansi`/`--no-ansi` (last wins), `-n` (`--no-interaction`) and `--env`. `bonfire.PeekGlobals` reads them from the raw arguments before the root exists, so the generated main can pick the configuration environment and the log verbosity before plugins boot; `bonfire.OnGlobals` hooks receive the parsed `bonfire.Globals` before every command runs (roots built by `bonfire.Call` run no hook).
@@ -139,6 +149,17 @@ if catalog.Has("blog:import") {
| `bonfire.Describe` | Builds the `bonfire.Listing` of a binary from its commands and `bonfire.Version`. |
| `bonfire.CheckDocs` | Reports commands lacking a description, long help, examples or argument and option descriptions, as `bonfire.DocProblem` values. |
| `bonfire.DocProblem` | One command's documentation gaps; `bonfire.DocProblem.String` reads "<command>: missing description, examples". |
| `bonfire.ItemProgress` | The optional side of a `bonfire.Progress` that names the current item (`Current`) and records failures (`Fail`); `bonfire.Output.Progress` hands one over. |
| `bonfire.Current` | Names the current item on a `bonfire.Progress` that is an `bonfire.ItemProgress`. |
| `bonfire.Failed` | Records a failed item on a `bonfire.Progress` that is an `bonfire.ItemProgress`. |
| `bonfire.Process` | Runs a function per item with the process view on a terminal, or one `✓`/`✗` line per item; returns the joined failures. |
| `bonfire.PrintAbove` | Prints a line above the running live view and reports whether one was running. |
| `bonfire.GlobalsOf` | The `bonfire.Globals` of the command an Output belongs to (zero for other Output implementations). |
| `bonfire.VerbosityOf` | The `-v` level of the command an Output belongs to. |
| `bonfire.IsQuiet` | Whether the command runs with `-q`. |
| `bonfire.Interactive` | Whether the Output can ask a person: stdin and stdout are terminals and neither `-n` nor `-q` is set. |
| `bonfire.ConfirmProceed` | Asks before a destructive step on an interactive terminal; returns true without asking otherwise. |
| `bonfire.ErrAborted` | Returned by a prompt the person cancelled. |
| `bonfire.ErrDuplicateCommand` | Returned by `bonfire.NewRoot` when two commands share a name or a command is named `list` or `help`. |
| `bonfire.ErrReservedFlag` | Returned by `bonfire.NewRoot` when a command flag reuses a global option's name or shorthand. |
| `bonfire.NewOutput` | Builds a `bonfire.Output` over the given streams, applying the terminal and color policy. |
@@ -162,12 +183,12 @@ bonfire reads no config keys. Output color follows these environment variables:
| `TERM` | The value `dumb` disables color. |
| `FORCE_COLOR` | Any non-empty value enables color even when output is not a terminal (ignored when color is disabled by `NO_COLOR` or `TERM`). |
Without these variables, color is enabled only when stdout is a terminal.
Without these variables, color is enabled only when stdout is a terminal. `--ansi` forces color and `--no-ansi` turns it off whatever the environment says.
## Dependencies
- SummerCMS modules: `sunscreen` (scrubs error messages).
- Third-party: `github.com/spf13/cobra`, `github.com/spf13/pflag`, `golang.org/x/term`, `charm.land/lipgloss/v2`, `charm.land/fang/v2` and `github.com/charmbracelet/colorprofile` (decided in `.planning/notes/2026-10-21-console-charm-stack.md`).
- Third-party: `github.com/spf13/cobra`, `github.com/spf13/pflag`, `golang.org/x/term`, `charm.land/lipgloss/v2` (with `lipgloss/v2/table`), `charm.land/bubbletea/v2`, `charm.land/bubbles/v2` (spinner, progress, key), `charm.land/huh/v2`, `charm.land/fang/v2` and `github.com/charmbracelet/colorprofile` (decided in `.planning/notes/2026-10-21-console-charm-stack.md`).
- Standard library: `bufio`, `context`, `encoding/json`, `errors`, `fmt`, `io`, `os`, `runtime/debug`, `slices`, `strconv`, `strings`, `sync`, `time`, `unicode/utf8`.
## Testing

142
modules/bonfire/helpers.go Normal file
View File

@@ -0,0 +1,142 @@
package bonfire
import (
"context"
"errors"
tea "charm.land/bubbletea/v2"
)
// consoleState is the optional side of an Output that knows the running
// command's global options. The bonfire console implements it; other
// Output implementations give the zero values.
type consoleState interface {
consoleGlobals() Globals
consoleInteractive() bool
}
func (c *console) consoleGlobals() Globals { return c.globals }
func (c *console) consoleInteractive() bool { return c.canPrompt() }
// GlobalsOf returns the global options of the command out belongs to, or
// the zero Globals when out is not a bonfire console.
func GlobalsOf(out Output) Globals {
if s, ok := out.(consoleState); ok {
return s.consoleGlobals()
}
return Globals{}
}
// VerbosityOf returns the -v level (0 to 3) of the command out belongs to.
func VerbosityOf(out Output) int {
return GlobalsOf(out).Verbosity
}
// IsQuiet reports whether the command runs with -q.
func IsQuiet(out Output) bool {
return GlobalsOf(out).Quiet
}
// Interactive reports whether out can ask a person: stdin and stdout are
// terminals and neither -n nor -q is set. It is false for other Output
// implementations and inside bonfire.Call.
func Interactive(out Output) bool {
if s, ok := out.(consoleState); ok {
return s.consoleInteractive()
}
return false
}
// ConfirmProceed asks question before a destructive step, defaulting to
// no, only when out is Interactive. Off a terminal, with -n or -q and in
// bonfire.Call it returns true without asking, so deploys over SSH and
// scheduled runs never block (D-28).
func ConfirmProceed(out Output, question string) (bool, error) {
if !Interactive(out) {
return true, nil
}
return out.Confirm(question, false)
}
// Current tells p which item is being worked on, when p is an ItemProgress.
func Current(p Progress, item string) {
if ip, ok := p.(ItemProgress); ok {
ip.Current(item)
}
}
// Failed records a failed item on p, when p is an ItemProgress.
func Failed(p Progress, item string, err error) {
if ip, ok := p.(ItemProgress); ok {
ip.Fail(item, err)
}
}
// Process runs fn for each item in turn. On a live console it shows SSU's
// process view: one row per item (waiting, running, done or failed with its
// error) and a progress bar labelled operation. Otherwise it prints one line
// per item, "✓ item" through Output.Success or "✗ item: error" through
// Output.Error, so -q prints only the failures. It stops early when ctx is
// cancelled and returns errors.Join of the failures.
func Process(ctx context.Context, out Output, operation string, items []string, fn func(ctx context.Context, item string) error) error {
if ctx == nil {
ctx = context.Background()
}
if c, ok := out.(*console); ok && c.live() && len(items) > 0 {
var errs []error
_ = runLive(ctx, c.out, c, newProcessModel(c.palette, operation, items), func(send func(tea.Msg)) error {
for i, item := range items {
if err := ctx.Err(); err != nil {
errs = append(errs, err)
return nil
}
send(stepMsg{item: item, state: itemRunning, done: i})
if err := fn(ctx, item); err != nil {
errs = append(errs, failure(item, err))
send(stepMsg{item: item, state: itemFailed, err: err, done: i + 1})
continue
}
send(stepMsg{item: item, state: itemDone, done: i + 1})
}
return nil
})
return errors.Join(errs...)
}
var errs []error
for _, item := range items {
if err := ctx.Err(); err != nil {
errs = append(errs, err)
break
}
if err := fn(ctx, item); err != nil {
errs = append(errs, failure(item, err))
out.Error(failureLine(item, err))
continue
}
out.Success(item)
}
return errors.Join(errs...)
}
// failure wraps err with the item it belongs to.
func failure(item string, err error) error {
return &itemError{item: item, err: err}
}
type itemError struct {
item string
err error
}
func (e *itemError) Error() string { return failureLine(e.item, e.err) }
func (e *itemError) Unwrap() error { return e.err }
// PrintAbove prints line above the running live view (spinner, progress or
// process view) and reports whether one was running. diary's console
// channel uses it so log lines from other goroutines never tear a view; it
// writes directly when PrintAbove returns false.
func PrintAbove(line string) bool {
return printAboveLines(line)
}

387
modules/bonfire/live.go Normal file
View File

@@ -0,0 +1,387 @@
package bonfire
// Live views ported from SSU's terminal UI (internal/cli/tui spinner.go,
// process.go and progress.go) to bubbletea v2, without SSU's git and
// engine types. Each runs as a bubbletea program with input off (stdin
// stays in cooked mode, so Ctrl+C reaches the command's own signal
// handling), no signal handler of its own and the command's context.
import (
"context"
"fmt"
"io"
"strings"
"sync"
"charm.land/bubbles/v2/progress"
"charm.land/bubbles/v2/spinner"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
)
// Progress bar colors: the cyan to green gradient of the earlier bonfire
// bar (256-color 37 and 34).
var (
barFrom = lipgloss.Color("#00afaf")
barTo = lipgloss.Color("#00af00")
)
const barWidth = 40
// Messages sent to the live models from the command's goroutine.
type (
// itemMsg reports progress: done of total, the current item, and a
// failure of the current item when err is set.
itemMsg struct {
done, total int
item string
err error
}
// stepMsg reports one item of the process view changing state.
stepMsg struct {
item string
state itemState
err error
done int
}
// finishMsg ends a live view; err is the result of the work.
finishMsg struct{ err error }
// printAboveMsg is a line to print above the view.
printAboveMsg string
)
// itemState is the display state of one item in the process view.
type itemState int
const (
itemWaiting itemState = iota
itemRunning
itemDone
itemFailed
)
const symWaiting = "○"
// newSpinner is SSU's MiniDot spinner in the current (cyan) style.
func newSpinner(p Palette) spinner.Model {
return spinner.New(spinner.WithSpinner(spinner.MiniDot), spinner.WithStyle(p.Current))
}
// newBar is the gradient progress bar without its own percentage.
func newBar() progress.Model {
bar := progress.New(progress.WithColors(barFrom, barTo), progress.WithoutPercentage())
bar.SetWidth(barWidth)
return bar
}
// spinnerModel shows a spinner, the label and, once known, a done/total
// counter with the current item; it ends with ✓ or ✗ and the label.
type spinnerModel struct {
palette Palette
spin spinner.Model
label string
done int
total int
current string
failed int
finished bool
err error
}
func newSpinnerModel(p Palette, label string) spinnerModel {
return spinnerModel{palette: p, spin: newSpinner(p), label: label}
}
func (m spinnerModel) Init() tea.Cmd { return m.spin.Tick }
func (m spinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case itemMsg:
m.done, m.total, m.current = msg.done, msg.total, msg.item
if msg.err != nil {
m.failed++
}
return m, nil
case finishMsg:
m.finished, m.err = true, msg.err
return m, tea.Quit
case spinner.TickMsg:
var cmd tea.Cmd
m.spin, cmd = m.spin.Update(msg)
return m, cmd
}
return m, nil
}
func (m spinnerModel) View() tea.View {
p := m.palette
if m.finished {
if m.err != nil {
return tea.NewView(p.Error.Render(symCross) + " " + m.label + "\n")
}
return tea.NewView(p.Success.Render(symCheck) + " " + m.label + "\n")
}
line := m.spin.View() + " " + m.label
if m.total > 0 {
line += fmt.Sprintf(" %d/%d", m.done, m.total)
}
if m.current != "" {
line += " " + p.Muted.Render(m.current+"…")
}
if m.failed > 0 {
line += " " + p.ErrorBold.Render(fmt.Sprintf("! %d failed", m.failed))
}
return tea.NewView(line)
}
// progressModel shows the gradient bar, done/total and the current item.
type progressModel struct {
palette Palette
bar progress.Model
done int
total int
current string
failed int
finished bool
}
func newProgressModel(p Palette, total int) progressModel {
return progressModel{palette: p, bar: newBar(), total: total}
}
func (m progressModel) Init() tea.Cmd { return nil }
func (m progressModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.bar.SetWidth(min(max(msg.Width-30, 10), barWidth))
return m, nil
case itemMsg:
m.done, m.total = msg.done, msg.total
if msg.item != "" {
m.current = msg.item
}
if msg.err != nil {
m.failed++
}
return m, nil
case finishMsg:
m.finished = true
return m, tea.Quit
}
return m, nil
}
func (m progressModel) View() tea.View {
pct := 0.0
if m.total > 0 {
pct = float64(m.done) / float64(m.total)
}
line := " " + m.bar.ViewAs(pct) + m.palette.Muted.Render(fmt.Sprintf(" %d/%d", m.done, m.total))
if m.current != "" && !m.finished {
line += " " + m.palette.Muted.Render(m.current)
}
if m.failed > 0 {
line += " " + m.palette.ErrorBold.Render(fmt.Sprintf("! %d failed", m.failed))
}
if m.finished {
line += "\n"
}
return tea.NewView(line)
}
// processItem is one row of the process view.
type processItem struct {
name string
state itemState
err error
}
// processModel is SSU's multi-row view: one row per item (waiting, running
// with a spinner, done or failed with its error) and the bar below.
type processModel struct {
palette Palette
items []processItem
spin spinner.Model
bar progress.Model
operation string
done int
height int
finished bool
}
func newProcessModel(p Palette, operation string, items []string) processModel {
rows := make([]processItem, len(items))
for i, name := range items {
rows[i] = processItem{name: name}
}
return processModel{palette: p, items: rows, spin: newSpinner(p), bar: newBar(), operation: operation}
}
func (m processModel) Init() tea.Cmd { return m.spin.Tick }
func (m processModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.height = msg.Height
return m, nil
case stepMsg:
m.done = msg.done
for i := range m.items {
if m.items[i].name == msg.item && m.items[i].state < msg.state {
m.items[i].state, m.items[i].err = msg.state, msg.err
break
}
}
return m, nil
case finishMsg:
m.finished = true
return m, tea.Quit
case spinner.TickMsg:
var cmd tea.Cmd
m.spin, cmd = m.spin.Update(msg)
return m, cmd
}
return m, nil
}
// window is the range of rows that fits the terminal: around the running
// item, or the last finished ones.
func (m processModel) window() (int, int) {
visible := len(m.items)
if m.height > 0 && visible > m.height-3 {
visible = max(m.height-3, 3)
}
start := 0
if visible < len(m.items) {
active := -1
for i, it := range m.items {
if it.state == itemRunning {
active = i
break
}
}
if active >= 0 {
start = min(max(active-visible/2, 0), len(m.items)-visible)
} else {
start = max(m.done-visible, 0)
}
}
return start, min(start+visible, len(m.items))
}
func (m processModel) View() tea.View {
p := m.palette
var b strings.Builder
start, end := m.window()
for _, it := range m.items[start:end] {
switch it.state {
case itemWaiting:
b.WriteString(p.Muted.Render(" "+symWaiting) + " " + p.Muted.Render(it.name))
case itemRunning:
b.WriteString(" " + m.spin.View() + " " + it.name)
case itemDone:
b.WriteString(p.Success.Render(" "+symCheck) + " " + it.name)
case itemFailed:
b.WriteString(p.Error.Render(" "+symCross) + " " + it.name)
if it.err != nil {
b.WriteString(" " + p.Error.Render(it.err.Error()))
}
}
b.WriteString("\n")
}
total := len(m.items)
pct := 0.0
if total > 0 {
pct = float64(m.done) / float64(total)
}
op := m.operation
if op == "" {
op = "Processing"
}
b.WriteString(fmt.Sprintf("\n %s %d/%d %s %s", op, m.done, total, m.bar.ViewAs(pct), p.Muted.Render(fmt.Sprintf("%d%%", int(pct*100)))))
if m.finished {
b.WriteString("\n")
}
return tea.NewView(b.String())
}
// liveModel wraps a view so lines from PrintAbove print above it.
type liveModel struct {
inner tea.Model
}
func (m liveModel) Init() tea.Cmd { return m.inner.Init() }
func (m liveModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if line, ok := msg.(printAboveMsg); ok {
return m, tea.Println(string(line))
}
inner, cmd := m.inner.Update(msg)
m.inner = inner
return m, cmd
}
func (m liveModel) View() tea.View { return m.inner.View() }
// The running live program, for PrintAbove. Only one live view runs at a
// time; widgets started while one runs print plain lines.
var (
liveMu sync.Mutex
activeProg *tea.Program
)
// liveActive reports whether a live view is running.
func liveActive() bool {
liveMu.Lock()
defer liveMu.Unlock()
return activeProg != nil
}
// runLive runs model as a bubbletea program on out while work runs on the
// calling goroutine. work gets a send function for progress messages. When
// work returns, the program is unregistered (so PrintAbove stops sending to
// it), told to finish and waited for; it never receives lines after that.
func runLive(ctx context.Context, out io.Writer, c *console, model tea.Model, work func(send func(tea.Msg)) error) error {
if ctx == nil {
ctx = context.Background()
}
prog := tea.NewProgram(liveModel{inner: model},
tea.WithInput(nil),
tea.WithOutput(out),
tea.WithoutSignalHandler(),
tea.WithContext(ctx),
tea.WithColorProfile(c.profile()),
)
finished := make(chan struct{})
go func() {
defer close(finished)
_, _ = prog.Run()
}()
liveMu.Lock()
activeProg = prog
liveMu.Unlock()
err := work(prog.Send)
liveMu.Lock()
activeProg = nil
liveMu.Unlock()
prog.Send(finishMsg{err: err})
<-finished
return err
}
// printAboveLines prints s, one or more lines, above the running live view.
// It reports false when no view runs.
func printAboveLines(s string) bool {
liveMu.Lock()
defer liveMu.Unlock()
if activeProg == nil {
return false
}
for _, line := range strings.Split(strings.TrimSuffix(s, "\n"), "\n") {
activeProg.Send(printAboveMsg(line))
}
return true
}

View File

@@ -4,18 +4,14 @@ import (
"fmt"
"io"
"os"
"strings"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/colorprofile"
"golang.org/x/term"
)
const (
ansiReset = "\x1b[0m"
ansiRed = "\x1b[31m"
ansiGreen = "\x1b[32m"
ansiYellow = "\x1b[33m"
ansiCyan = "\x1b[36m"
ansiDim = "\x1b[90m"
symCheck = "✓"
symCross = "✗"
symWarn = "⚠"
@@ -48,16 +44,25 @@ type Progress interface {
}
type console struct {
in io.Reader
out io.Writer
err io.Writer
lookup func(string) string
tty bool
in io.Reader
out io.Writer
err io.Writer
lookup func(string) string
// tty is true when stdout is a terminal; it picks animated widgets
// and boxed tables.
tty bool
// interactive is true when stdin and stdout are terminals.
interactive bool
color bool
// inTTY is true when stdin is a terminal; Secret then reads a hidden
// value instead of a line.
inTTY bool
// color is the environment's color policy (NO_COLOR, TERM=dumb,
// FORCE_COLOR, terminal) for ColorAuto.
color bool
// globals are the parsed global options of the running command, set
// by the root before the command runs.
globals Globals
palette Palette
}
// NewOutput builds Output from injected streams and the process color policy.
@@ -78,16 +83,17 @@ func newConsole(in io.Reader, out, errW io.Writer, lookup func(string) string, t
if lookup == nil {
lookup = os.Getenv
}
c := &console{in: in, out: out, err: errW, lookup: lookup}
c := &console{in: in, out: out, err: errW, lookup: lookup, palette: DefaultPalette()}
if tty != nil {
c.tty = *tty
} else {
c.tty = isTerminal(out)
}
c.inTTY = isTerminal(in)
if interactive != nil {
c.interactive = *interactive
} else {
c.interactive = isTerminal(in) && c.tty
c.interactive = c.inTTY && c.tty
}
c.color = supportsColor(lookup, c.tty)
return c
@@ -115,6 +121,67 @@ func isTerminal(rw any) bool {
return term.IsTerminal(int(f.Fd()))
}
// setGlobals records the parsed global options of the running command.
func (c *console) setGlobals(g Globals) {
c.globals = g
}
// live reports whether widgets animate: stdout is a terminal and none of
// -q, --no-ansi and -n is set (D-03). A live view that is already running
// makes nested widgets print plain lines.
func (c *console) live() bool {
g := c.globals
return c.tty && !g.Quiet && g.Color != ColorNever && !g.NoInteraction && !liveActive()
}
// canPrompt reports whether prompts may wait for input from a person:
// stdin and stdout are terminals and -n (or -q) is not set.
func (c *console) canPrompt() bool {
return c.interactive && !c.globals.NoInteraction
}
// profile is the color profile of styled output: none for --no-ansi or
// when the environment turns color off, at least 256 colors otherwise.
func (c *console) profile() colorprofile.Profile {
switch c.globals.Color {
case ColorNever:
return colorprofile.NoTTY
case ColorAuto:
if !c.color {
return colorprofile.NoTTY
}
}
return DetectProfile(c.out, ColorAlways, c.environ())
}
// environ rebuilds the color-related environment from lookup.
func (c *console) environ() []string {
var env []string
for _, k := range []string{"TERM", "COLORTERM", "NO_COLOR", "FORCE_COLOR", "CLICOLOR", "CLICOLOR_FORCE"} {
if v := c.lookup(k); v != "" {
env = append(env, k+"="+v)
}
}
return env
}
// styled wraps w so palette styles follow the color profile.
func (c *console) styled(w io.Writer) io.Writer {
return &colorprofile.Writer{Forward: w, Profile: c.profile()}
}
// emit writes s, a styled line or block, to w. While a live view runs on
// stdout, lines for stdout print above it instead of tearing it.
func (c *console) emit(w io.Writer, s string) {
if w == nil {
return
}
if w == c.out && printAboveLines(s) {
return
}
_, _ = io.WriteString(c.styled(w), s)
}
func (c *console) Write(p []byte) (int, error) {
if c.out == nil {
return 0, io.ErrClosedPipe
@@ -126,22 +193,38 @@ func (c *console) Println(a ...any) {
if c.out == nil {
return
}
fmt.Fprintln(c.out, a...)
s := fmt.Sprintln(a...)
if printAboveLines(s) {
return
}
_, _ = io.WriteString(c.out, s)
}
func (c *console) Printf(format string, a ...any) {
if c.out == nil {
return
}
fmt.Fprintf(c.out, format, a...)
s := fmt.Sprintf(format, a...)
if strings.HasSuffix(s, "\n") && printAboveLines(s) {
return
}
_, _ = io.WriteString(c.out, s)
}
// status prints a status line unless -q is set.
func (c *console) status(style lipgloss.Style, symbol, message string) {
if c.globals.Quiet {
return
}
c.emit(c.out, style.Render(symbol)+" "+message+"\n")
}
func (c *console) Info(message string) {
c.Println(c.style(ansiCyan, symInfo) + " " + message)
c.status(c.palette.Current, symInfo, message)
}
func (c *console) Success(message string) {
c.Println(c.style(ansiGreen, symCheck) + " " + message)
c.status(c.palette.Success, symCheck, message)
}
func (c *console) Error(message string) {
@@ -149,30 +232,11 @@ func (c *console) Error(message string) {
if w == nil {
w = c.out
}
if w == nil {
return
}
fmt.Fprintln(w, c.style(ansiRed, symCross)+" "+message)
c.emit(w, c.palette.Error.Render(symCross)+" "+message+"\n")
}
func (c *console) Warning(message string) {
c.Println(c.style(ansiYellow, symWarn) + " " + message)
}
func (c *console) style(code, s string) string {
if !c.color {
return s
}
return code + s + ansiReset
}
func (c *console) dim(s string) string {
return c.style(ansiDim, s)
}
// setGlobals records the parsed global options of the running command.
func (c *console) setGlobals(g Globals) {
c.globals = g
c.status(c.palette.Warning, symWarn, message)
}
func boolPtr(v bool) *bool { return &v }

View File

@@ -2,16 +2,60 @@ package bonfire
import (
"bufio"
"errors"
"fmt"
"io"
"os"
"strconv"
"strings"
tea "charm.land/bubbletea/v2"
"charm.land/huh/v2"
"golang.org/x/term"
)
// ErrAborted is returned by a prompt that the person cancelled (esc or
// ctrl+c on a terminal).
var ErrAborted = errors.New("bonfire: prompt aborted")
// errSecretNoInteraction is the error of Secret under -n when stdin is a
// terminal: a secret has no default.
var errSecretNoInteraction = errors.New("bonfire: a secret is needed and -n turned prompts off")
// richPrompts reports whether prompts use huh and the selector: the run is
// interactive and --no-ansi is not set (with --no-ansi a terminal gets the
// plain line prompts).
func (c *console) richPrompts() bool {
return c.canPrompt() && c.globals.Color != ColorNever
}
// runForm runs a huh form on the console's streams.
func (c *console) runForm(field huh.Field) error {
err := huh.NewForm(huh.NewGroup(field)).WithInput(c.in).WithOutput(c.out).Run()
if errors.Is(err, huh.ErrUserAborted) {
return ErrAborted
}
return err
}
func (c *console) Ask(question, def string) (string, error) {
if c.globals.NoInteraction {
return def, nil
}
if c.richPrompts() {
var answer string
input := huh.NewInput().Title(question).Value(&answer)
if def != "" {
input = input.Placeholder(def)
}
if err := c.runForm(input); err != nil {
return "", err
}
if strings.TrimSpace(answer) == "" {
return def, nil
}
return strings.TrimSpace(answer), nil
}
c.writeQuestion(question, def)
line, err := c.readLine()
if err != nil {
@@ -27,9 +71,16 @@ func (c *console) Ask(question, def string) (string, error) {
}
func (c *console) Confirm(question string, def bool) (bool, error) {
if !c.interactive {
if c.globals.NoInteraction || !c.interactive {
return def, nil
}
if c.richPrompts() {
answer := def
if err := c.runForm(huh.NewConfirm().Title(question).Value(&answer)); err != nil {
return false, err
}
return answer, nil
}
hint := "y/N"
if def {
hint = "Y/n"
@@ -38,9 +89,6 @@ func (c *console) Confirm(question string, def bool) (bool, error) {
if err != nil {
return false, err
}
if line == "" {
return def, nil
}
switch strings.ToLower(line) {
case "y", "yes":
return true, nil
@@ -58,7 +106,13 @@ func (c *console) Choice(question string, choices []string, def int) (string, er
if def < 0 || def >= len(choices) {
def = 0
}
c.Println(c.style(ansiCyan, symPrompt) + " " + question)
if c.globals.NoInteraction {
return choices[def], nil
}
if c.richPrompts() {
return c.selectOne(question, choices, def)
}
c.Println(c.styledText(c.palette.Current.Render(symPrompt)) + " " + question)
for i, choice := range choices {
mark := " "
if i == def {
@@ -83,11 +137,47 @@ func (c *console) Choice(question string, choices []string, def int) (string, er
return choices[n-1], nil
}
// Secret reads a hidden password on a TTY via term.ReadPassword.
// When stdin is not a terminal it reads a plain line and never logs the value.
// selectOne runs the ported selector on the terminal.
func (c *console) selectOne(question string, choices []string, def int) (string, error) {
prog := tea.NewProgram(newSelectorModel(c.palette, question, choices, def),
tea.WithInput(c.in),
tea.WithOutput(c.out),
tea.WithoutSignalHandler(),
tea.WithColorProfile(c.profile()),
)
final, err := prog.Run()
if err != nil {
return "", err
}
m, ok := final.(selectorModel)
if !ok || m.cancelled || m.chosen < 0 {
return "", ErrAborted
}
return choices[m.chosen], nil
}
// Secret reads a hidden value: through a password field on an interactive
// terminal, through term.ReadPassword when only stdin is a terminal, and as
// a plain line when stdin is not a terminal (piped input). Under -n a
// terminal stdin fails with a hint, because a secret has no default.
func (c *console) Secret(question string) (string, error) {
if c.globals.NoInteraction {
if c.inTTY {
return "", WithHint(errSecretNoInteraction,
"This command reads the value from a prompt or from stdin.",
`printf '%s\n' "$VALUE" | {bin} <command> -n`)
}
return c.readSecretLine()
}
if c.richPrompts() {
var answer string
if err := c.runForm(huh.NewInput().Title(question).EchoMode(huh.EchoModePassword).Value(&answer)); err != nil {
return "", err
}
return answer, nil
}
c.writeQuestion(question, "")
if f, ok := c.in.(*os.File); ok && term.IsTerminal(int(f.Fd())) {
if f, ok := c.in.(*os.File); ok && c.inTTY {
b, err := term.ReadPassword(int(f.Fd()))
c.Println()
if err != nil {
@@ -95,6 +185,11 @@ func (c *console) Secret(question string) (string, error) {
}
return string(b), nil
}
return c.readSecretLine()
}
// readSecretLine reads one line of piped input; the value is never logged.
func (c *console) readSecretLine() (string, error) {
line, err := c.readLine()
if err != nil {
if err == io.EOF {
@@ -105,12 +200,19 @@ func (c *console) Secret(question string) (string, error) {
return line, nil
}
// styledText applies the color profile to s for output written with Printf.
func (c *console) styledText(s string) string {
var b strings.Builder
_, _ = io.WriteString(c.styled(&b), s)
return b.String()
}
func (c *console) writeQuestion(question, def string) {
suffix := ""
if def != "" {
suffix = " " + c.dim("("+def+")")
suffix = " " + c.palette.Muted.Render("("+def+")")
}
c.Printf("%s %s%s %s ", c.style(ansiCyan, symPrompt), question, suffix, symArrow)
c.Printf("%s", c.styledText(c.palette.Current.Render(symPrompt)+" "+question+suffix+" "+symArrow+" "))
}
func (c *console) readLine() (string, error) {

140
modules/bonfire/selector.go Normal file
View File

@@ -0,0 +1,140 @@
package bonfire
// A single-select port of SSU's selector (internal/cli/tui selector.go,
// selector_keys.go and confirm.go) on bubbletea v2: arrow keys or k/j move,
// enter selects and opens SSU's confirmation screen, enter there confirms
// and esc goes back; esc or ctrl+c in the list cancels. Multi-select, the
// filter and the detail pane are not ported (D-30).
import (
"strings"
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
)
// selectorKeys are the selector's key bindings.
type selectorKeys struct {
Up key.Binding
Down key.Binding
Confirm key.Binding
Back key.Binding
Quit key.Binding
}
func defaultSelectorKeys() selectorKeys {
return selectorKeys{
Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")),
Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")),
Confirm: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")),
Back: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "back")),
Quit: key.NewBinding(key.WithKeys("esc", "ctrl+c"), key.WithHelp("esc", "cancel")),
}
}
// selectorModel picks one item from a list and asks for confirmation.
type selectorModel struct {
palette Palette
keys selectorKeys
title string
items []string
cursor int
height int
confirming bool
chosen int
cancelled bool
}
func newSelectorModel(p Palette, title string, items []string, def int) selectorModel {
if def < 0 || def >= len(items) {
def = 0
}
return selectorModel{palette: p, keys: defaultSelectorKeys(), title: title, items: items, cursor: def, chosen: -1}
}
func (m selectorModel) Init() tea.Cmd { return nil }
func (m selectorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.height = msg.Height
return m, nil
case tea.KeyPressMsg:
if msg.String() == "ctrl+c" {
m.cancelled = true
return m, tea.Quit
}
if m.confirming {
switch {
case key.Matches(msg, m.keys.Confirm):
m.chosen = m.cursor
return m, tea.Quit
case key.Matches(msg, m.keys.Back):
m.confirming = false
}
return m, nil
}
switch {
case key.Matches(msg, m.keys.Up):
if m.cursor > 0 {
m.cursor--
}
case key.Matches(msg, m.keys.Down):
if m.cursor < len(m.items)-1 {
m.cursor++
}
case key.Matches(msg, m.keys.Confirm):
if len(m.items) > 0 {
m.confirming = true
}
case key.Matches(msg, m.keys.Quit):
m.cancelled = true
return m, tea.Quit
}
}
return m, nil
}
// visible is the range of items that fits the terminal around the cursor.
func (m selectorModel) visible() (int, int) {
rows := len(m.items)
if m.height > 0 {
rows = min(rows, max(m.height-4, 3))
}
start := 0
if m.cursor >= rows {
start = m.cursor - rows + 1
}
return start, min(start+rows, len(m.items))
}
func (m selectorModel) View() tea.View {
p := m.palette
var b strings.Builder
if m.chosen >= 0 || m.cancelled {
// Leave one summary line once the program ends.
b.WriteString(p.Current.Render(symPrompt) + " " + m.title)
if m.chosen >= 0 {
b.WriteString(" " + p.Success.Render(m.items[m.chosen]))
}
b.WriteString("\n")
return tea.NewView(b.String())
}
if m.confirming {
b.WriteString(p.Header.Render("Selected:") + "\n\n")
b.WriteString(" " + m.items[m.cursor] + "\n\n")
b.WriteString(p.Muted.Render("enter confirm • esc back"))
return tea.NewView(b.String())
}
b.WriteString(p.Current.Render(symPrompt) + " " + p.Header.Render(m.title) + "\n")
start, end := m.visible()
for i := start; i < end; i++ {
if i == m.cursor {
b.WriteString(p.Current.Render("> ") + m.items[i] + "\n")
continue
}
b.WriteString(" " + m.items[i] + "\n")
}
b.WriteString(p.Muted.Render("↑/k up • ↓/j down • enter select • esc cancel"))
return tea.NewView(b.String())
}

View File

@@ -1,85 +1,60 @@
package bonfire
import (
"fmt"
"context"
"strings"
"sync"
"time"
"unicode/utf8"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"charm.land/lipgloss/v2/table"
)
var spinnerFrames = []rune{'⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'}
const (
boxTopLeft = "┌"
boxTopRight = "┐"
boxBottomLeft = "└"
boxBottomRight = "┘"
boxHorizontal = "─"
boxVertical = "│"
boxTeeRight = "├"
boxTeeLeft = "┤"
boxTeeDown = "┬"
boxTeeUp = "┴"
boxCross = "┼"
)
// ItemProgress is the optional side of a Progress that knows about items:
// Current names the item being worked on, Fail records a failed item. The
// Progress handed to Output.Progress implements it; Current and Failed call
// it when present.
type ItemProgress interface {
Progress
Current(item string)
Fail(item string, err error)
}
func (c *console) Spinner(message string, fn func() error) error {
if fn == nil {
fn = func() error { return nil }
}
if !c.tty {
if c.globals.Quiet {
return fn()
}
if !c.live() {
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
return runLive(context.Background(), c.out, c, newSpinnerModel(c.palette, message), func(func(tea.Msg)) error {
return fn()
})
}
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()
if c.globals.Quiet {
return fn(&quietProgress{c: c})
}
return err
if !c.live() {
return fn(&progressBar{c: c, total: total, lastPct: -1})
}
return runLive(context.Background(), c.out, c, newProgressModel(c.palette, total), func(send func(tea.Msg)) error {
return fn(&liveProgress{send: send, total: total})
})
}
// progressBar prints a line at every tenth of the work when widgets do not
// animate.
type progressBar struct {
c *console
total int
@@ -99,10 +74,6 @@ func (p *progressBar) Advance(step int) {
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
}
@@ -116,29 +87,56 @@ func (p *progressBar) Advance(step int) {
}
}
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 (p *progressBar) Current(string) {}
func (p *progressBar) Fail(item string, err error) {
p.c.Error(failureLine(item, err))
}
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"
// quietProgress prints nothing but failures (-q).
type quietProgress struct {
c *console
}
func (p *quietProgress) Advance(int) {}
func (p *quietProgress) Current(string) {}
func (p *quietProgress) Fail(item string, err error) {
p.c.Error(failureLine(item, err))
}
// liveProgress drives the live progress model.
type liveProgress struct {
send func(tea.Msg)
total int
current int
item string
}
func (p *liveProgress) Advance(step int) {
if step < 1 {
step = 1
}
p.current = min(p.current+step, p.total)
p.send(itemMsg{done: p.current, total: p.total, item: p.item})
}
func (p *liveProgress) Current(item string) {
p.item = item
p.send(itemMsg{done: p.current, total: p.total, item: item})
}
func (p *liveProgress) Fail(item string, err error) {
p.send(itemMsg{done: p.current, total: p.total, item: item, err: err})
p.send(printAboveMsg(failureLine(item, err)))
}
func failureLine(item string, err error) string {
if err == nil {
return item
}
return item + ": " + err.Error()
}
func (c *console) Table(headers []string, rows [][]string) {
@@ -149,27 +147,22 @@ func (c *console) Table(headers []string, rows [][]string) {
}
return
}
cols := len(headers)
all := make([][]string, 0, 1+len(rows))
all = append(all, headers)
padded := make([][]string, 0, len(rows))
for _, row := range rows {
all = append(all, padRow(row, cols))
padded = append(padded, padRow(row, len(headers)))
}
widths := make([]int, cols)
for _, row := range all {
for i, cell := range row {
if n := utf8.RuneCountInString(cell); n > widths[i] {
widths[i] = n
header := c.palette.Header
t := table.New().
Border(lipgloss.NormalBorder()).
Headers(headers...).
Rows(padded...).
StyleFunc(func(row, _ int) lipgloss.Style {
if row == table.HeaderRow {
return header.Padding(0, 1)
}
}
}
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))
return lipgloss.NewStyle().Padding(0, 1)
})
c.emit(c.out, t.String()+"\n")
}
func padRow(row []string, n int) []string {
@@ -181,30 +174,3 @@ func padRow(row []string, n int) []string {
}
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()
}

View File

@@ -0,0 +1,184 @@
package bonfire
import (
"bytes"
"context"
"errors"
"strings"
"testing"
tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/x/ansi"
)
// frame is a model's view without escape codes.
func frame(m tea.Model) string { return ansi.Strip(m.View().Content) }
// TestWidgetMatrixSmoke: -q keeps data and errors but drops decoration, -n
// makes prompts return their defaults without reading stdin (Secret with a
// terminal stdin fails with a hint), ConfirmProceed never asks off a
// terminal, and the ported live models render frames from Update and View.
func TestWidgetMatrixSmoke(t *testing.T) {
t.Run("quiet", func(t *testing.T) {
var stdout, stderr bytes.Buffer
c := newConsole(strings.NewReader(""), &stdout, &stderr, func(string) string { return "" }, boolPtr(true), boolPtr(false))
c.setGlobals(Globals{Quiet: true, NoInteraction: true})
c.Info("info")
c.Success("success")
c.Warning("warning")
if err := c.Spinner("spinning", func() error { return nil }); err != nil {
t.Fatal(err)
}
if err := c.Progress(4, func(p Progress) error {
for range 4 {
p.Advance(1)
}
Failed(p, "item-2", errors.New("broken"))
return nil
}); err != nil {
t.Fatal(err)
}
c.Println("data line")
c.Table([]string{"key"}, [][]string{{"value"}})
c.Error("boom")
out := stdout.String()
for _, gone := range []string{"info", "success", "warning", "spinning", "[4/4]"} {
if strings.Contains(out, gone) {
t.Fatalf("-q printed %q:\n%s", gone, out)
}
}
if !strings.Contains(out, "data line\n") || !strings.Contains(out, "value") {
t.Fatalf("-q dropped data:\n%s", out)
}
if !strings.Contains(stderr.String(), "boom") || !strings.Contains(stderr.String(), "item-2: broken") {
t.Fatalf("-q dropped errors: %q", stderr.String())
}
})
t.Run("no-interaction", func(t *testing.T) {
in := strings.NewReader("typed\n")
var buf bytes.Buffer
c := newConsole(in, &buf, &buf, func(string) string { return "" }, boolPtr(true), boolPtr(true))
c.setGlobals(Globals{NoInteraction: true})
if got, err := c.Ask("name", "bob"); err != nil || got != "bob" {
t.Fatalf("Ask = %q, %v", got, err)
}
if got, err := c.Confirm("sure?", true); err != nil || !got {
t.Fatalf("Confirm = %v, %v", got, err)
}
if got, err := c.Choice("pick", []string{"a", "b", "c"}, 2); err != nil || got != "c" {
t.Fatalf("Choice = %q, %v", got, err)
}
if in.Len() != len("typed\n") || buf.Len() != 0 {
t.Fatalf("-n prompts read stdin or printed: left %d, out %q", in.Len(), buf.String())
}
if ok, err := ConfirmProceed(c, "Drop everything?"); err != nil || !ok {
t.Fatalf("ConfirmProceed = %v, %v", ok, err)
}
if Interactive(c) || !GlobalsOf(c).NoInteraction {
t.Fatal("console with -n reports interactive")
}
c.inTTY = true
_, err := c.Secret("password")
var hint *HintError
if !errors.As(err, &hint) || !strings.Contains(hint.Try, "-n") {
t.Fatalf("Secret with a terminal stdin under -n: %v", err)
}
c.inTTY = false
if got, err := c.Secret("password"); err != nil || got != "typed" {
t.Fatalf("Secret with piped stdin under -n = %q, %v", got, err)
}
})
t.Run("not interactive", func(t *testing.T) {
in := strings.NewReader("y\n")
var buf bytes.Buffer
out := NewOutput(in, &buf, &buf)
if ok, err := ConfirmProceed(out, "Drop everything?"); err != nil || !ok || in.Len() != 2 {
t.Fatalf("ConfirmProceed off a terminal = %v, %v (stdin left %d)", ok, err, in.Len())
}
if VerbosityOf(out) != 0 || IsQuiet(out) {
t.Fatal("zero globals expected")
}
var lines bytes.Buffer
errOut := NewOutput(strings.NewReader(""), &lines, &lines)
err := Process(context.Background(), errOut, "Importing", []string{"a", "b"}, func(_ context.Context, item string) error {
if item == "b" {
return errors.New("bad row")
}
return nil
})
if err == nil || !strings.Contains(err.Error(), "b: bad row") {
t.Fatalf("Process err = %v", err)
}
if got := lines.String(); got != "✓ a\n✗ b: bad row\n" {
t.Fatalf("Process lines = %q", got)
}
})
size := tea.WindowSizeMsg{Width: 80, Height: 24}
p := DefaultPalette()
t.Run("spinner model", func(t *testing.T) {
var m tea.Model = newSpinnerModel(p, "Importing posts")
m, _ = m.Update(size)
m, _ = m.Update(itemMsg{done: 2, total: 5, item: "post-2"})
if v := frame(m); !strings.Contains(v, "Importing posts 2/5") || !strings.Contains(v, "post-2") {
t.Fatalf("spinner frame = %q", v)
}
m, cmd := m.Update(finishMsg{})
if cmd == nil || !strings.Contains(frame(m), symCheck+" Importing posts") {
t.Fatalf("spinner final frame = %q", frame(m))
}
})
t.Run("progress model", func(t *testing.T) {
var m tea.Model = newProgressModel(p, 10)
m, _ = m.Update(size)
m, _ = m.Update(itemMsg{done: 4, total: 10, item: "row 4"})
if v := frame(m); !strings.Contains(v, "4/10") || !strings.Contains(v, "row 4") {
t.Fatalf("progress frame = %q", v)
}
})
t.Run("process model", func(t *testing.T) {
var m tea.Model = newProcessModel(p, "Pushing", []string{"blog", "shop", "forum"})
m, _ = m.Update(size)
m, _ = m.Update(stepMsg{item: "blog", state: itemDone, done: 1})
m, _ = m.Update(stepMsg{item: "shop", state: itemFailed, err: errors.New("rejected"), done: 2})
m, _ = m.Update(stepMsg{item: "forum", state: itemRunning, done: 2})
v := frame(m)
for _, want := range []string{symCheck + " blog", symCross + " shop", "rejected", "forum", "Pushing 2/3"} {
if !strings.Contains(v, want) {
t.Fatalf("process frame missing %q:\n%s", want, v)
}
}
})
t.Run("selector model", func(t *testing.T) {
var m tea.Model = newSelectorModel(p, "Pick a plugin", []string{"blog", "shop", "forum"}, 0)
m, _ = m.Update(size)
if v := frame(m); !strings.Contains(v, "> blog") {
t.Fatalf("selector frame = %q", v)
}
m, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyDown})
m, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
if v := frame(m); !strings.Contains(v, "Selected:") || !strings.Contains(v, "shop") {
t.Fatalf("confirmation frame = %q", v)
}
m, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEscape})
m, _ = m.Update(tea.KeyPressMsg{Code: 'j', Text: "j"})
m, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
m, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
sel := m.(selectorModel)
if cmd == nil || sel.chosen != 2 || sel.items[sel.chosen] != "forum" {
t.Fatalf("selector chose %d (cmd %v)", sel.chosen, cmd != nil)
}
var c tea.Model = newSelectorModel(p, "Pick", []string{"a"}, 0)
c, _ = c.Update(tea.KeyPressMsg{Code: tea.KeyEscape})
if !c.(selectorModel).cancelled {
t.Fatal("esc did not cancel")
}
})
}

View File

@@ -29,7 +29,7 @@ Use cases:
- Component levels: `log.components` sets a minimum level per component (`river: warn`, `blog: debug`); components without one use `log.level`.
- Environment overrides: `SUMMER_LOG__` variables overlay the defaults key by key, and string values parse where YAML gives numbers or lists (`SUMMER_LOG__CHANNELS__DAILY__DAYS=30`, `SUMMER_LOG__CHANNELS__STACK__CHANNELS=console,daily`).
- Validation: an unknown driver, format or level, a stack naming a missing channel or containing itself, and a relative daily path that leaves the application root fail `diary.Setup` with an error naming the key.
- Console lines in bonfire's palette: time, level, the component as a fixed-width prefix, the message and `key=value` attributes. Control characters other than tab are removed from messages and values, so logged data cannot inject terminal escape sequences.
- Console lines in bonfire's palette: time, level, the component as a fixed-width prefix, the message and `key=value` attributes. Control characters other than tab are removed from messages and values, so logged data cannot inject terminal escape sequences. While a bonfire live view (spinner, progress bar, process view) runs, styled lines print above it through `bonfire.PrintAbove`.
- Daily files: the path `storage/logs/summer.log` gives files `storage/logs/summer-2026-10-21.log`, created mode 0640 in directories created 0750, opened on the first record, rotated at local midnight, and deleted after the retention in days (only files of that name pattern). A relative path resolves against the application root. A directory that cannot be written logs one warning from component `diary` on the console and disables the channel; it never fails the start-up.
- Redaction: every channel leaf is wrapped by `sunscreen.Wrap`.
- Runtime verbosity: `diary.SetVerbosity` lets info (1) or debug (2) through for every component and adds source locations on styled lines (3); quiet raises only the console channels to warn.

View File

@@ -39,7 +39,8 @@ type ConsoleOptions struct {
// as dotted key prefixes. Control characters other than tab are removed from
// the message and attribute values, so logged data cannot inject terminal
// escape sequences. Each line is written with one Write call, serialized
// across the handler and its WithAttrs and WithGroup clones.
// across the handler and its WithAttrs and WithGroup clones; while a bonfire
// live view runs, lines print above it through bonfire.PrintAbove.
func NewConsoleHandler(w io.Writer, opts ConsoleOptions) slog.Handler {
source := new(atomic.Bool)
source.Store(opts.AddSource)
@@ -154,6 +155,11 @@ func (h *consoleHandler) Handle(_ context.Context, r slog.Record) error {
b.WriteString(p.Muted.Render(filepath.Base(frame.File) + ":" + strconv.Itoa(frame.Line)))
}
}
// While a bonfire live view (spinner, progress, process view) runs,
// the line prints above it instead of tearing it.
if bonfire.PrintAbove(b.String()) {
return nil
}
b.WriteByte('\n')
h.mu.Lock()