Skip to content

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 the CI environment variable is not exactly "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.

Why the detector looks at stdout, not the renderer's writer

IsInteractive() inspects os.Stdout, even for a renderer configured to write to os.Stderr. That is deliberate, and it has a consequence worth naming: mytool | jq turns the spinner off, because stdout is a pipe — even though the spinner is going to stderr, which is still a terminal.

That is the behaviour you want. A pipeline means a machine is consuming the run, and a machine-consumed run wants log lines, not cursor movement, on both streams. Detecting per-stream would give you an animated stderr next to a piped stdout, which is exactly the "looks fine locally, garbles the CI log" trap the detector exists to avoid.

When you disagree — a long interactive session that happens to pipe its results — say so explicitly with WithInteractive(true).

Why the CI check is an exact string match

The check is os.Getenv("CI") == "true". Not "1", not "TRUE", not "set to anything". GitLab CI, GitHub Actions, CircleCI and Travis all export exactly CI=true, so the narrow test covers the platforms that matter while refusing to guess at a variable that other tooling also sets for its own reasons.

If you run on something that sets CI=1, the TTY check still catches it — those runners do not attach a terminal — so the exact match is a fast path, not the only line of defence. Where neither holds, pass WithInteractive(false).

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.