Skip to content

Commit 3eb7605

Browse files
committed
feat(cli): add terminal width and separate stderr for status output
Terminal: - Add Width() returning terminal column count (defaults to 80) Printer: - Status output (Success, Warning, Error, Info, Spin, Progress) now writes to stderr instead of stdout - Data output (Table, JSON, YAML, Println, Print) still writes to stdout - This prevents spinners and status messages from corrupting piped data: myapp list --json | jq '.name' # spinner goes to stderr, JSON to stdout
1 parent 71818f7 commit 3eb7605

3 files changed

Lines changed: 31 additions & 14 deletions

File tree

cli/printer/printer.go

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -27,42 +27,48 @@ import (
2727
)
2828

2929
// Output handles all terminal output for a CLI command.
30+
//
31+
// Data output (Table, JSON, YAML, Println) goes to the primary writer (stdout).
32+
// Status output (Spin, Warning, Error, Info, Success) goes to the error writer (stderr).
33+
// This separation ensures spinners and status messages don't corrupt
34+
// piped data output (e.g. myapp list --json | jq).
3035
type Output struct {
3136
w io.Writer
37+
errW io.Writer
3238
theme Theme
3339
tty bool
3440
}
3541

36-
// NewOutput creates a new Output that writes to w.
42+
// NewOutput creates a new Output that writes data to w and status to stderr.
3743
// It auto-detects TTY and color support from the writer.
3844
func NewOutput(w io.Writer) *Output {
3945
tty := false
4046
if f, ok := w.(*os.File); ok {
4147
tty = isatty.IsTerminal(f.Fd()) || isatty.IsCygwinTerminal(f.Fd())
4248
}
43-
return &Output{w: w, theme: newTheme(), tty: tty}
49+
return &Output{w: w, errW: os.Stderr, theme: newTheme(), tty: tty}
4450
}
4551

4652
// --- Text output ---
4753

48-
// Success prints a green success message.
54+
// Success prints a green success message to stderr.
4955
func (o *Output) Success(msg string) {
50-
fmt.Fprintln(o.w, o.color(o.theme.Green, msg))
56+
fmt.Fprintln(o.errW, o.color(o.theme.Green, msg))
5157
}
5258

53-
// Warning prints a yellow warning message.
59+
// Warning prints a yellow warning message to stderr.
5460
func (o *Output) Warning(msg string) {
55-
fmt.Fprintln(o.w, o.color(o.theme.Yellow, msg))
61+
fmt.Fprintln(o.errW, o.color(o.theme.Yellow, msg))
5662
}
5763

58-
// Error prints a red error message.
64+
// Error prints a red error message to stderr.
5965
func (o *Output) Error(msg string) {
60-
fmt.Fprintln(o.w, o.color(o.theme.Red, msg))
66+
fmt.Fprintln(o.errW, o.color(o.theme.Red, msg))
6167
}
6268

63-
// Info prints a cyan informational message.
69+
// Info prints a cyan informational message to stderr.
6470
func (o *Output) Info(msg string) {
65-
fmt.Fprintln(o.w, o.color(o.theme.Cyan, msg))
71+
fmt.Fprintln(o.errW, o.color(o.theme.Cyan, msg))
6672
}
6773

6874
// Bold prints a bold message.
@@ -194,15 +200,15 @@ func (o *Output) Spin(label string) *Indicator {
194200
if label != "" {
195201
s.Prefix = label + " "
196202
}
197-
s.Writer = o.w
203+
s.Writer = o.errW
198204
s.Start()
199205
return &Indicator{s}
200206
}
201207

202-
// Progress creates a progress bar.
208+
// Progress creates a progress bar on stderr.
203209
func (o *Output) Progress(max int, description string) *progressbar.ProgressBar {
204210
return progressbar.NewOptions(max,
205-
progressbar.OptionSetWriter(o.w),
211+
progressbar.OptionSetWriter(o.errW),
206212
progressbar.OptionEnableColorCodes(true),
207213
progressbar.OptionSetDescription(description),
208214
progressbar.OptionShowCount(),

cli/terminal/term.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55

66
"github.com/mattn/go-isatty"
77
"github.com/muesli/termenv"
8+
"golang.org/x/term"
89
)
910

1011
// IsTTY checks if the current output is a TTY (teletypewriter) or a Cygwin terminal.
@@ -21,6 +22,16 @@ func IsColorDisabled() bool {
2122
return termenv.EnvNoColor()
2223
}
2324

25+
// Width returns the terminal width in columns. Returns 80 if the width
26+
// cannot be determined (e.g. non-TTY, piped output).
27+
func Width() int {
28+
w, _, err := term.GetSize(int(os.Stdout.Fd()))
29+
if err == nil && w > 0 {
30+
return w
31+
}
32+
return 80
33+
}
34+
2435
// IsCI checks if the code is running in a Continuous Integration (CI) environment.
2536
// This function checks for common environment variables used by popular CI systems
2637
// like GitHub Actions, Travis CI, CircleCI, Jenkins, TeamCity, and others.

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ require (
120120
golang.org/x/net v0.53.0
121121
golang.org/x/sync v0.20.0 // indirect
122122
golang.org/x/sys v0.43.0 // indirect
123-
golang.org/x/term v0.42.0 // indirect
123+
golang.org/x/term v0.42.0
124124
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
125125
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
126126
)

0 commit comments

Comments
 (0)