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 "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.