Progress & Spinner Output Helpers¶
- Authors
- Matt Cockayne
- Date
- 31 March 2026
- Status
- DRAFT
Overview¶
GTB provides pkg/forms for interactive input and pkg/output for structured responses, but has no standard components for communicating progress during long-running operations. Tool authors repeatedly implement spinners, progress bars, and status updates using charmbracelet components directly โ each with slightly different patterns.
This spec adds thin, opinionated wrappers in pkg/output that provide consistent progress output across all GTB tools, with automatic fallback to plain text in non-interactive (CI) environments.
Design¶
Spinner¶
A blocking spinner for indeterminate operations (API calls, git operations, AI processing).
// Spin shows a spinner with a message while a function executes.
// Returns the function's result. Falls back to a plain log message
// in non-interactive environments (CI=true or no TTY).
// The context is passed to the function and used for cancellation.
func Spin(ctx context.Context, msg string, fn func(ctx context.Context) error) error
// SpinWithResult is like Spin but returns a value alongside the error.
func SpinWithResult[T any](ctx context.Context, msg string, fn func(ctx context.Context) (T, error)) (T, error)
Behaviour:
- Interactive terminal: animated spinner with message (using charmbracelet/spinner)
- Non-interactive (CI, no TTY, piped output): prints msg... then msg... done or msg... failed
- Respects --output json โ suppresses spinner, logs to stderr
Progress Bar¶
A determinate progress indicator for operations with known total work.
// Progress tracks progress of a known-total operation.
type Progress struct {
total int
current int
}
// NewProgress creates a progress bar with the given total and description.
func NewProgress(total int, description string) *Progress
// Increment advances the progress bar by one unit.
func (p *Progress) Increment()
// IncrementBy advances the progress bar by n units.
func (p *Progress) IncrementBy(n int)
// Done marks the progress as complete and cleans up the display.
func (p *Progress) Done()
Behaviour:
- Interactive terminal: animated progress bar with percentage, count, and ETA
- Non-interactive: periodic log lines (Processing: 50/100 (50%)) at 10% intervals
- Respects --output json โ suppresses bar, logs to stderr
Status Line¶
A live-updating status message for multi-step operations.
// Status displays a live-updating status message.
type Status struct{}
// NewStatus creates a status display.
func NewStatus() *Status
// Update replaces the current status message.
func (s *Status) Update(msg string)
// Success marks the current step as successful and moves to the next line.
func (s *Status) Success(msg string)
// Warn marks the current step as a warning.
func (s *Status) Warn(msg string)
// Fail marks the current step as failed.
func (s *Status) Fail(msg string)
// Done cleans up the status display.
func (s *Status) Done()
Example output:
Behaviour: - Interactive: live-updating with icons (โ, โ , โ, spinner) - Non-interactive: sequential log lines with status prefix
Non-Interactive Detection¶
// IsInteractive returns true if stdout is a TTY and CI mode is not active.
func IsInteractive() bool
Checks:
1. os.Stdout is a terminal (via term.IsTerminal)
2. CI environment variable is not "true"
3. --output json is not set (if accessible via context)
Package Location¶
All helpers live in pkg/output/ alongside the existing Response type. No new package needed.
Dependencies¶
github.com/charmbracelet/bubbles/spinnerโ already an indirect dependency viacharmbracelet/huhgithub.com/charmbracelet/bubbles/progressโ may need addinggolang.org/x/termโ already a dependency
Usage Examples¶
Spinner¶
err := output.Spin(ctx, "Checking for updates", func(ctx context.Context) error {
return updater.Check(ctx)
})
Progress Bar¶
bar := output.NewProgress(len(files), "Processing files")
defer bar.Done()
for _, f := range files {
processFile(f)
bar.Increment()
}
Status Line¶
status := output.NewStatus()
defer status.Done()
status.Update("Loading configuration")
cfg, err := loadConfig()
if err != nil {
status.Fail("Configuration failed: " + err.Error())
return err
}
status.Success("Configuration loaded")
status.Update("Connecting to API")
// ...
status.Success("Connected")
Resolved Questions¶
- Context on Spin: Yes โ
Spinacceptscontext.Contextand passes it to the wrapped function. Cancellation stops the spinner. - Custom progress formatting (bytes): Deferred โ count-based covers the common case. Bytes formatting can be added later via a formatter option.
- MultiProgress for parallel operations: Deferred โ single progress covers 90% of use cases.
- Spinner style: GTB enforces a consistent default style. A
WithStyleoption allows tool authors to override if needed.