Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ fmt.Printf("%q\n", strings.TrimSuffix(output.String(), "\n"))
- Bordered tables by default, plus one compact form, fixed widths, alignment, wrapping, and ASCII fallbacks.
- Line-oriented questions, defaults, confirmation, numbered choices, and non-echoed secret input.
- Concurrency-safe loaders and determinate progress that become stable semantic lines when redirected.
- Optional terminal-owned progress indicators for terminal tabs, windows, and taskbars that support OSC 9;4.
- Configurable writers, input, marks, terminal hooks, environment lookup, and exit behavior.

## Design principles
Expand Down Expand Up @@ -156,6 +157,8 @@ fmt.Print(output.String())

On a terminal a loader animates in place, while progress uses one adaptive bar with a percentage. Narrow terminals fall back to message and percentage. With automatic policy, redirected output and conventional truthy `CI` environments use only a start line and the final success or error, so captured logs stay useful. `AnimationsEnabled` can explicitly opt a terminal back into animation.

Set `TerminalProgressEnabled` to `true` to mirror loader and progress lifecycles through the OSC 9;4 terminal progress protocol. Loaders publish indeterminate activity, determinate progress publishes its current percentage, and every terminal outcome clears the indicator. Supporting terminal emulators decide whether that appears in a tab, window edge, or operating-system taskbar; unsupported terminals ignore the sequence. The option is independent of `AnimationsEnabled`, still requires terminal output with ANSI support, and never writes control sequences to redirected output.

Representative terminal snapshots are shown below; each display redraws one physical line rather than appending these frames. The last line is the exact Unicode rendering at a width of 14 cells.

```text
Expand Down
71 changes: 39 additions & 32 deletions console.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ type Config struct {
UnicodeEnabled *bool
// AnimationsEnabled permits or disables transient loader and progress output. Even when true, stdout must be a terminal.
AnimationsEnabled *bool
// TerminalProgressEnabled permits or disables OSC 9;4 terminal-owned progress indicators for loaders and progress displays.
// Nil and false disable indicators; true still requires terminal output with ANSI support.
TerminalProgressEnabled *bool

// Width fixes the available output width. Values less than one use terminal detection and then an 80-column fallback.
// Configured, detected, and environment widths are capped at 32,768 columns to keep layout allocations practical.
Expand Down Expand Up @@ -156,11 +159,12 @@ type Console struct {
stderr io.Writer
stderrSharesStdout bool

colorEnabled *bool
debugEnabled *bool
interactiveEnabled *bool
unicodeEnabled bool
animationsEnabled *bool
colorEnabled *bool
debugEnabled *bool
interactiveEnabled *bool
unicodeEnabled bool
animationsEnabled *bool
terminalProgressEnabled *bool

width int
loaderInterval time.Duration
Expand All @@ -174,13 +178,15 @@ type Console struct {
readSecret func() (string, error)
newTicker func(time.Duration) loaderTicker

inputMu sync.Mutex
sessionMu sync.RWMutex
outputMu sync.Mutex
transientMu sync.Mutex
active transientOwner
partialLine bool
promptActive bool
inputMu sync.Mutex
sessionMu sync.RWMutex
outputMu sync.Mutex
transientMu sync.Mutex
active transientOwner
terminalProgressMu sync.Mutex
terminalProgressOwner terminalProgressOwner
partialLine bool
promptActive bool
}

var defaultState = struct {
Expand Down Expand Up @@ -259,26 +265,27 @@ func New(config Config) *Console {
}

return &Console{
stdin: bufio.NewReader(stdin),
stdinSource: stdin,
stdout: stdout,
stderr: stderr,
stderrSharesStdout: sameWriter(stdout, stderr),
colorEnabled: cloneBool(config.ColorEnabled),
debugEnabled: cloneBool(config.DebugEnabled),
interactiveEnabled: cloneBool(config.InteractiveEnabled),
unicodeEnabled: unicodeEnabled,
animationsEnabled: cloneBool(config.AnimationsEnabled),
width: config.Width,
loaderInterval: loaderInterval,
marks: marks,
getenv: getenv,
isTerminal: isTerminal,
supportsANSI: supportsANSI,
getSize: getSize,
exit: exit,
readSecret: readSecret,
newTicker: newRealLoaderTicker,
stdin: bufio.NewReader(stdin),
stdinSource: stdin,
stdout: stdout,
stderr: stderr,
stderrSharesStdout: sameWriter(stdout, stderr),
colorEnabled: cloneBool(config.ColorEnabled),
debugEnabled: cloneBool(config.DebugEnabled),
interactiveEnabled: cloneBool(config.InteractiveEnabled),
unicodeEnabled: unicodeEnabled,
animationsEnabled: cloneBool(config.AnimationsEnabled),
terminalProgressEnabled: cloneBool(config.TerminalProgressEnabled),
width: config.Width,
loaderInterval: loaderInterval,
marks: marks,
getenv: getenv,
isTerminal: isTerminal,
supportsANSI: supportsANSI,
getSize: getSize,
exit: exit,
readSecret: readSecret,
newTicker: newRealLoaderTicker,
}
}

Expand Down
26 changes: 16 additions & 10 deletions console_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ func TestNewCopiesMutableConfiguration(t *testing.T) {
interactiveEnabled := true
unicodeEnabled := true
animationsEnabled := true
terminalProgressEnabled := true
marks := Marks{
Action: "action",
Info: "info",
Expand All @@ -115,23 +116,25 @@ func TestNewCopiesMutableConfiguration(t *testing.T) {
}
stdout := &descriptorBuffer{descriptor: 11}
console := New(Config{
Stdout: stdout,
ColorEnabled: &colorEnabled,
DebugEnabled: &debugEnabled,
InteractiveEnabled: &interactiveEnabled,
UnicodeEnabled: &unicodeEnabled,
AnimationsEnabled: &animationsEnabled,
LoaderInterval: 17 * time.Millisecond,
Marks: &marks,
Getenv: getenvFrom(nil),
IsTerminal: func(int) bool { return true },
Stdout: stdout,
ColorEnabled: &colorEnabled,
DebugEnabled: &debugEnabled,
InteractiveEnabled: &interactiveEnabled,
UnicodeEnabled: &unicodeEnabled,
AnimationsEnabled: &animationsEnabled,
TerminalProgressEnabled: &terminalProgressEnabled,
LoaderInterval: 17 * time.Millisecond,
Marks: &marks,
Getenv: getenvFrom(nil),
IsTerminal: func(int) bool { return true },
})

colorEnabled = false
debugEnabled = false
interactiveEnabled = false
unicodeEnabled = false
animationsEnabled = false
terminalProgressEnabled = false
marks.Action = "changed"
marks.SpinnerFrames[0] = "changed"

Expand All @@ -150,6 +153,9 @@ func TestNewCopiesMutableConfiguration(t *testing.T) {
if !console.shouldAnimate() {
t.Fatal("shouldAnimate() = false after caller mutation, want true")
}
if !console.shouldRenderTerminalProgress() {
t.Fatal("shouldRenderTerminalProgress() = false after caller mutation, want true")
}
if got := console.loaderInterval; got != 17*time.Millisecond {
t.Fatalf("loaderInterval = %s, want %s", got, 17*time.Millisecond)
}
Expand Down
25 changes: 18 additions & 7 deletions loader.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package console

import (
"sync"
"sync/atomic"
"time"
)

Expand Down Expand Up @@ -29,13 +30,14 @@ import (
type Loader struct {
console *Console

mu sync.Mutex
message string
state loaderState
dynamic bool
frame int
stop chan struct{}
done chan struct{}
mu sync.Mutex
message string
state loaderState
dynamic bool
frame int
stop chan struct{}
done chan struct{}
terminalDone atomic.Bool
}

// loaderState identifies the one-way loader lifecycle.
Expand Down Expand Up @@ -138,13 +140,15 @@ func (l *Loader) Start() error {
go l.animate(l.stop, l.done)
l.mu.Unlock()
l.console.renderTransient(l)
l.console.setTerminalProgress(l, terminalProgressStateIndeterminate, 0)
return nil
}

l.state = loaderRunning
message := l.message
l.console.Action(message)
l.mu.Unlock()
l.console.setTerminalProgress(l, terminalProgressStateIndeterminate, 0)
return nil
}

Expand Down Expand Up @@ -294,13 +298,15 @@ func (l *Loader) finish(outcome loaderFinish, message string) {
message = normalizeTransientMessage(message)
}
l.state = loaderFinished
l.terminalDone.Store(true)
l.mu.Unlock()

if dynamic {
close(stop)
<-done
l.console.releaseTransient(l, outcome != loaderFinishStop)
}
l.console.clearTerminalProgress(l)

switch outcome {
case loaderFinishSuccess:
Expand All @@ -312,6 +318,11 @@ func (l *Loader) finish(outcome loaderFinish, message string) {
}
}

// terminalProgressFinished lets the console reject loader updates that lost a race with completion.
func (l *Loader) terminalProgressFinished() bool {
return l.terminalDone.Load()
}

// animate advances frames until the winning terminal operation closes stop.
func (l *Loader) animate(stop <-chan struct{}, done chan<- struct{}) {
ticker := l.console.newTicker(l.console.loaderInterval)
Expand Down
48 changes: 39 additions & 9 deletions progress.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"math/bits"
"strings"
"sync"
"sync/atomic"
)

// errInvalidProgressTotal reports a total that cannot represent determinate progress.
Expand Down Expand Up @@ -36,12 +37,13 @@ var errInvalidProgressTotal = errors.New("console: progress total must be greate
type Progress struct {
console *Console

mu sync.Mutex
message string
total int
current int
state progressState
dynamic bool
mu sync.Mutex
message string
total int
current int
state progressState
dynamic bool
terminalDone atomic.Bool
}

// progressState identifies the one-way progress lifecycle.
Expand Down Expand Up @@ -117,6 +119,7 @@ func (p *Progress) Start() error {
}

p.dynamic = p.console.shouldAnimate()
percent := progressPercent(p.current, p.total)
if p.dynamic {
if err := p.console.acquireTransient(p); err != nil {
p.mu.Unlock()
Expand All @@ -125,13 +128,15 @@ func (p *Progress) Start() error {
p.state = progressRunning
p.mu.Unlock()
p.console.renderTransient(p)
p.console.setTerminalProgress(p, terminalProgressStateDeterminate, percent)
return nil
}

p.state = progressRunning
message := p.message
p.console.Action(message)
p.mu.Unlock()
p.console.setTerminalProgress(p, terminalProgressStateDeterminate, percent)
return nil
}

Expand Down Expand Up @@ -161,11 +166,17 @@ func (p *Progress) Set(current int) {
return
}
p.current = clampProgressValue(current, p.total)
dynamic := p.state == progressRunning && p.dynamic
running := p.state == progressRunning
dynamic := running && p.dynamic
current = p.current
total := p.total
p.mu.Unlock()
if dynamic {
p.console.renderTransient(p)
}
if running {
p.console.setTerminalProgress(p, terminalProgressStateDeterminate, progressPercent(current, total))
}
}

// Add changes the completed amount by delta and clamps it between zero and the total.
Expand Down Expand Up @@ -204,11 +215,17 @@ func (p *Progress) Add(delta int) {
} else {
p.current += delta
}
dynamic := p.state == progressRunning && p.dynamic
running := p.state == progressRunning
dynamic := running && p.dynamic
current := p.current
total := p.total
p.mu.Unlock()
if dynamic {
p.console.renderTransient(p)
}
if running {
p.console.setTerminalProgress(p, terminalProgressStateDeterminate, progressPercent(current, total))
}
}

// Step replaces the completed amount and message in one atomic progress update.
Expand Down Expand Up @@ -239,11 +256,17 @@ func (p *Progress) Step(current int, message string) {
}
p.current = clampProgressValue(current, p.total)
p.message = normalizeTransientMessage(message)
dynamic := p.state == progressRunning && p.dynamic
running := p.state == progressRunning
dynamic := running && p.dynamic
current = p.current
total := p.total
p.mu.Unlock()
if dynamic {
p.console.renderTransient(p)
}
if running {
p.console.setTerminalProgress(p, terminalProgressStateDeterminate, progressPercent(current, total))
}
}

// Update changes the progress message and immediately redraws a live terminal display.
Expand Down Expand Up @@ -370,11 +393,13 @@ func (p *Progress) finish(outcome progressFinish, message string) {
message = normalizeTransientMessage(message)
}
p.state = progressFinished
p.terminalDone.Store(true)
p.mu.Unlock()

if dynamic {
p.console.releaseTransient(p, outcome != progressFinishStop)
}
p.console.clearTerminalProgress(p)

switch outcome {
case progressFinishComplete:
Expand All @@ -384,6 +409,11 @@ func (p *Progress) finish(outcome progressFinish, message string) {
}
}

// terminalProgressFinished lets the console reject progress updates that lost a race with completion.
func (p *Progress) terminalProgressFinished() bool {
return p.terminalDone.Load()
}

// renderTransient snapshots one carriage-return frame while the console owns output coordination.
func (p *Progress) renderTransient() string {
p.mu.Lock()
Expand Down
Loading