Interactive vs plain rendering¶
A CLI runs in two very different places: a developer's terminal, and a CI job or a
shell pipeline. output is built so a single code path serves both — it detects
which world it is in and degrades gracefully to plain output when there is no
terminal to animate. This page explains how that works and why it is a property of
the Renderer, not of each call.
One decision, made once¶
Interactivity is a single boolean on the Renderer, fixed at construction:
- If you call
WithInteractive(true|false), that wins. - Otherwise the renderer calls
IsInteractive()— true when stdout is a TTY and theCIenvironment variable is not"true".
Every progress helper the renderer hands out — Status, Progress, Spin —
inherits that one decision. There is no per-call detection and no way for two
pieces of feedback in the same command to disagree about whether they are on a
terminal.
What "degrade gracefully" means¶
The same method produces different output depending on that boolean:
| Helper | Interactive | Non-interactive |
|---|---|---|
Spin |
animated spinner, cleared on completion | msg... then msg... done / ... failed |
Progress |
live bar redrawn in place | one line at each 10% boundary |
Status |
line updated in place, spinner-framed | one sequential line per step |
The non-interactive forms are deliberately log-friendly: no cursor movement, no
ANSI redraw, no floods — just lines a CI log or a tee'd file can keep. You write
the interactive code and get the plain behaviour for free.
Why detection, not configuration alone¶
You could force the mode everywhere with WithInteractive, and tests do exactly
that (a bytes.Buffer with WithInteractive(true) to assert on animated output,
or false to assert on plain lines — fully deterministic, no TTY needed). But
defaulting to detection means a tool behaves correctly the first time, in every
environment, without the author threading a --no-color/--plain flag through
their whole codebase. The CI=true check in particular catches the most common
"looks fine locally, garbles the CI log" trap.
Separate stdout and stderr¶
Because the writer is also per-Renderer, the idiom is two renderers:
out := output.New(output.WithWriter(os.Stdout)) // the actual result (may be piped)
progress := output.New(output.WithWriter(os.Stderr)) // feedback (never piped)
Progress feedback on stderr keeps a mytool | jq pipeline clean: the JSON goes
to stdout, the spinner to the terminal via stderr. Splitting them is a
deliberate two-renderer choice, not a hidden default — the Renderer makes the
destination explicit so you decide, per stream, where output belongs.
Unicode correctness underneath¶
Whichever mode is active, widths are measured by display width
(charmbracelet/x/ansi), not byte length. A status message or table cell
containing CJK, emoji or combining characters clears and aligns correctly, and a
truncated cell never splits a rune mid-sequence. Graceful degradation is about
when to animate; Unicode correctness is about getting the characters right in
either mode.