Progress, Colour & Rich Output
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages. |
A command-line program talks to two audiences at once: a person watching a terminal and a script or log file reading the same bytes. Progress bars, spinners, tables, colour and links are all ways of serving the first audience without hurting the second. This page compares the libraries that do the work in each language, shows the code each one needs, and explains the mechanics that all of them share: where the output goes, how wide the terminal is, and what the program must do when it is not on a terminal.
The page was written against these versions, current in October 2026: indicatif 0.18.6, owo-colors 4.4.0, termimad 0.35.5,
tqdm (current documentation), Rich 15.0.0, colorama (0.4.6 or later for just_fix_windows_console), Spectre.Console 0.57.2
on .NET 10, Bubbles v2.2.1, Lip Gloss v2.0.6 and Glamour v2.0.1 (the Go libraries use the charm.land import paths), mpb
v8, ora 9.4.1 and chalk 6.0.1 on Node.js 22 or later.
The running scenario is the Bookshelf tool: shelf import loads a catalogue, shelf list prints the books, and
shelf tui is the full-screen browser covered on the Ratatui and Bubble Tea pages. The C example (the progress bar) and the
Swift example (width, colour and a link) were compiled with Apple clang 21 and Swift 6.4 on macOS and run both with
redirected output and inside a pseudo-terminal, and the outputs shown are real. The Node.js check for the terminal-size
properties was run with Node.js 24. The Rust, Python, C#, Go and the remaining JavaScript examples were not compiled or run
while this page was written; they follow the API documentation linked after each example, and the library versions above
are the ones to pin when you try them.
The rules for deciding whether to animate or colour at all (isatty, NO_COLOR, TERM=dumb, --no-color, progress
only on stderr, plain lines when not on a TTY) are covered on
TTY, Colour & Interactivity and are applied here, not repeated.
The escape sequences underneath every library on this page, including SGR colours, the OSC 8 link sequence and Unicode
display width, are explained on
Escape Sequences & Terminal Capabilities, and the terminal
itself on Terminals, TTYs & Pseudo-Terminals. For the Windows console behaviour
that makes colour work there, see Windows Console & Processes. Libraries
for full-screen interfaces are on Other TUI Libraries and the pages it links to.
Where the output goes
Every decoration on this page is subject to one split, the same one that makes pipelines work: data goes to standard
output, everything meant for a person goes to standard error. A shelf list | wc -l must count books, not spinner frames.
| Kind of output | Stream | Why |
|---|---|---|
The result: book rows, JSON, a path |
stdout |
It is what a pipe or a redirect is meant to capture. Colour and links are allowed only when stdout is a terminal. |
Progress bars, spinners, "Importing…" lines |
stderr |
They are status for a person. Most progress libraries default to stderr (indicatif and tqdm draw to stderr, and ora
defaults to |
Warnings and errors |
stderr |
Shown even when stdout is redirected, and colourable independently of stdout. |
Diagnostic logs (debug, trace) |
stderr or a file |
Opt-in with |
Because the two streams can be a terminal independently (shelf list > books.txt leaves stderr on the terminal), the
decision to animate or colour is made per stream: stderr’s state decides about the progress bar, stdout’s state decides
about coloured rows. Rich exposes this as one Console per stream, chalk as a separate chalkStderr instance, and
owo-colors as a Stream argument. A library that decides once for the whole process will be wrong in one of the two
cases.
Progress bars and spinners
How they work
A progress display redraws the same line (or the same few lines) over and over. The mechanism is always the same two
pieces of control: a carriage return (\r) moves the cursor back to column 0, and the erase-line sequence
\x1b[2K (CSI 2 K) clears the line, so the next frame replaces the previous one. Multi-line displays additionally move the
cursor up with CSI A. Libraries differ in what they add: a background thread or timer that redraws at a fixed rate,
rate-limiting so that a fast loop does not flood the terminal, ETA estimation, template or column systems, and the handling
of other output printed while the bar is on screen.
Two practical consequences follow. First, a bar is only worth showing when the work takes longer than a fraction of a
second; the cheapest implementation is to not draw until the loop has run for a while, and several libraries rate-limit
draws for this reason (tqdm waits mininterval, 0.1 seconds by default, between updates; indicatif’s default target
refreshes at most 20 times a second; Rich refreshes about 10 times a second). Second, anything else written to the same
terminal while the bar is up tears the display, so output must go through the library (indicatif’s
ProgressBar::println, Rich’s progress.console.print) or the bar must be suspended around it.
Use a determinate bar when the total is known, a spinner when it is not, and a few lines of plain text when nothing terminal-like is available.
A bar by hand in C
The smallest useful implementation shows everything a library hides: the decision to animate, the width calculation, the redraw and the cleanup. It draws to stderr, prints plain milestone lines when stderr is not a terminal, and clears the bar before printing the result.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/ioctl.h>
static int term_width(int fd) {
struct winsize ws;
if (ioctl(fd, TIOCGWINSZ, &ws) == 0 && ws.ws_col > 0) {
return ws.ws_col;
}
const char *cols = getenv("COLUMNS");
return cols && atoi(cols) > 0 ? atoi(cols) : 80;
}
static void draw(int done, int total, int animated) {
if (!animated) {
if (done % 25 == 0 || done == total) {
fprintf(stderr, "Imported %d of %d books\n", done, total);
}
return;
}
int bar = term_width(STDERR_FILENO) - 24;
if (bar < 10) bar = 10;
if (bar > 40) bar = 40;
int fill = bar * done / total;
fprintf(stderr, "\r\x1b[2KImporting [");
for (int i = 0; i < bar; i++) fputc(i < fill ? '#' : '-', stderr);
fprintf(stderr, "] %3d%%", 100 * done / total);
fflush(stderr);
}
int main(void) {
int animated = isatty(STDERR_FILENO) && getenv("NO_COLOR") == NULL;
const char *term = getenv("TERM");
if (term && strcmp(term, "dumb") == 0) animated = 0;
for (int i = 1; i <= 100; i++) {
draw(i, 100, animated);
usleep(2000);
}
if (animated) fputs("\r\x1b[2K", stderr);
puts("Imported 100 books");
return 0;
}
Compiled with clang -Wall -Wextra -o progress progress.c, the program prints only the result on stdout and four plain
milestone lines on stderr when it is not on a terminal:
$ ./progress 2>err.txt | cat
Imported 100 books
$ cat err.txt
Imported 25 of 100 books
Imported 50 of 100 books
Imported 75 of 100 books
Imported 100 of 100 books
Run inside a pseudo-terminal of 60 columns, the same binary emitted a redraw per step (\r\x1b[2KImporting [####...] 42%
each time, with a bar 36 cells wide) and ended with \r\x1b[2KImported 100 books. Note that the bar width is derived
from the width of the terminal and capped, and that the cleanup sequence runs before the result so the final line starts
clean.
Source: POSIX — tcgetwinsize (the standardised
form of the window-size query; TIOCGWINSZ is the long-standing ioctl behind it) and
POSIX — isatty.
Rust: indicatif
indicatif is the standard progress library in Rust. A ProgressBar is a bar or a spinner and is Send and Sync, so
worker threads can share it; MultiProgress renders several at once; ProgressStyle holds a template such as
[{elapsed_precise}] {bar:40.cyan/blue} {pos:>7}/{len:7} {msg}, in which each {key:options} placeholder can set
alignment, width, truncation and styles. A bar created with ProgressBar::new draws to stderr, and the documentation states
that on a non-terminal the bar is completely hidden. A bar only draws after set_message, inc, set_position or tick
is called, and should be finished explicitly.
use std::time::Duration;
use indicatif::{ProgressBar, ProgressStyle};
fn import(books: &[String]) {
let style = ProgressStyle::with_template(
"[{elapsed_precise}] {bar:40.cyan/blue} {pos:>5}/{len:5} {msg}",
)
.unwrap()
.progress_chars("##-");
let bar = ProgressBar::new(books.len() as u64).with_style(style);
for title in books {
bar.set_message(title.clone());
// ... parse and store one book ...
bar.inc(1);
}
bar.finish_with_message("done");
// For work of unknown length, use a self-ticking spinner.
let spinner = ProgressBar::new_spinner();
spinner.enable_steady_tick(Duration::from_millis(100));
spinner.set_message("Contacting the catalogue server");
// ... wait for the reply ...
spinner.finish_and_clear();
}
Other pieces worth knowing, all documented on the ProgressBar type: println prints a line above the bar (and does
nothing if the bar is hidden, in which case suspend runs a closure with the bar temporarily removed), hidden() makes a
bar that accepts calls but never renders (useful for --quiet), is_hidden reports whether a bar will draw, and
wrap_read wraps an io::Read so a download or a file import advances the bar as bytes are read. The
ProgressIterator trait adds .progress() to iterators for the common case. Whether to show a bar in --quiet or JSON
mode is the program’s decision: pass ProgressBar::hidden() in those modes rather than relying on terminal detection
alone.
Source: indicatif — crate documentation and indicatif — ProgressBar.
Python: tqdm and Rich
tqdm wraps an iterable, writes to stderr by default and can switch itself off when output is not a terminal: its disable
parameter defaults to False, and "if set to None, disable on non-TTY". Its leave parameter keeps (default) or erases the
finished bar, ncols fixes the width (the environment width is used if unspecified), dynamic_ncols=True re-reads the width
when the window is resized, unit and unit_scale label and scale the counter, and ascii=True falls back to ASCII
characters for terminals that cannot draw block characters.
from tqdm import tqdm
def import_books(paths: list[str]) -> None:
for path in tqdm(paths, desc="Importing", unit="file", disable=None, dynamic_ncols=True):
load_one(path) # the actual work
Rich is a broader library: a Console that knows its width, colour system and whether it is a terminal, and a Progress
class built on it. track() wraps an iterable in one line; Progress is a context manager that takes column objects
(SpinnerColumn, BarColumn, TimeRemainingColumn, MofNCompleteColumn, DownloadColumn, TransferSpeedColumn),
supports several tasks at once, shows an indeterminate pulsing bar when total=None, removes itself on exit with
transient=True, and routes other output through progress.console so it appears above the bars. wrap_file and
rich.progress.open do for file reads what wrap_read does in indicatif. The documentation page for Progress does not
cover the disable parameter, which is described on the reference page; the existing TTY page already shows the
decision of when to pass it.
from rich.console import Console
from rich.progress import BarColumn, MofNCompleteColumn, Progress, SpinnerColumn, TextColumn
console = Console(stderr=True)
def import_books(paths: list[str]) -> None:
with Progress(
SpinnerColumn(),
TextColumn("{task.description}"),
BarColumn(),
MofNCompleteColumn(),
console=console,
transient=True,
) as progress:
task = progress.add_task("Importing", total=len(paths))
for path in paths:
progress.console.print(f"loading {path}") # printed above the bars
load_one(path)
progress.advance(task)
Rich reads its own environment variables on top of the usual ones: NO_COLOR disables colour (bold, italic and underline
are kept) and takes precedence over FORCE_COLOR; TERM=dumb or unknown disables colour and cursor-moving features such
as progress bars; COLUMNS and LINES set the size when no explicit width or height is given; and TTY_COMPATIBLE
(1 or 0) overrides terminal detection.
Source: tqdm — documentation, Rich — Progress Display and Rich — The Console.
.NET: Spectre.Console
Spectre.Console starts a progress display with AnsiConsole.Progress(), configures it with Columns(...) and runs the work
in Start (or StartAsync). AddTask(description, maxValue) returns a task that you advance with Increment, switch to an
animated indeterminate bar with IsIndeterminate(), and re-label through Description. AutoClear(true) removes the display
when all tasks finish and HideCompleted(true) removes finished tasks while others run. For work that has no measurable
progress, the documentation recommends the Status display (a spinner with a message) instead.
using Spectre.Console;
AnsiConsole.Progress()
.Columns(
new TaskDescriptionColumn(),
new ProgressBarColumn(),
new PercentageColumn(),
new RemainingTimeColumn())
.AutoClear(true)
.Start(ctx =>
{
var task = ctx.AddTask("Importing books", maxValue: 100);
while (!ctx.IsFinished)
{
task.Increment(1);
Thread.Sleep(50);
}
});
The documentation states that the progress display is not thread safe and must not be combined with prompts, status displays
or other progress displays. It does not describe what the display does when the console is not interactive, so check
Console.IsErrorRedirected (or Console.IsOutputRedirected) before starting one and print plain lines otherwise.
Source: Spectre.Console — Progress Display.
Go: mpb and Bubbles
mpb draws one or more bars from goroutines. mpb.New creates the container (mpb.WithWidth sets the width),
p.AddBar(total, ...) adds a bar with mpb.PrependDecorators and mpb.AppendDecorators, bar.Increment() advances it and
p.Wait() blocks until every bar has completed and been flushed. The decorators in mpb/v8/decor include Name,
Percentage, OnComplete, AverageETA and EwmaETA. The mpb documentation does not describe how it behaves when output
is not a terminal or how to choose the output stream, so decide in your own code whether to create a container at all.
package main
import (
"time"
"github.com/vbauerster/mpb/v8"
"github.com/vbauerster/mpb/v8/decor"
)
func main() {
p := mpb.New(mpb.WithWidth(64))
bar := p.AddBar(100,
mpb.PrependDecorators(decor.Name("Importing:")),
mpb.AppendDecorators(decor.Percentage()),
)
for i := 0; i < 100; i++ {
time.Sleep(20 * time.Millisecond)
bar.Increment()
}
p.Wait()
}
Source: mpb — README.
Inside a full-screen Bubble Tea program the progress bar is a widget, not a printer: Bubbles (charm.land/bubbles/v2)
provides progress and spinner models that the program updates and renders in its own View. progress.New takes
options such as WithWidth, WithColors, WithFillCharacters and WithoutPercentage; the model’s SetPercent and
IncrPercent return a command that animates the change, and ViewAs(percent) renders a given value without animating. The
spinner package offers presets (Line, Dot, MiniDot, Jump, Pulse, Points, Globe, Moon, Monkey, Meter,
Hamburger, Ellipsis), a Tick command and a TickMsg. The Bubble Tea program structure itself is covered on
Go TUIs with Bubble Tea.
package main
import (
"fmt"
"charm.land/bubbles/v2/progress"
)
func main() {
bar := progress.New(progress.WithWidth(40))
fmt.Println(bar.ViewAs(0.42)) // a static rendering of 42 percent
}
Source: Bubbles — progress and Bubbles — spinner.
Node.js: ora
ora is a spinner for Node.js: ora(text) creates it, start() shows it, and succeed() or fail() replace the spinner with
a green check or a red cross and keep the text. It writes to process.stderr by default (stream option). When isEnabled
is not set, the spinner is enabled only if the stream is a TTY and the environment is not CI; when disabled it omits the
spinner, colours and escape codes but still logs the text. isSilent: true disables the spinner and all its text.
import ora from 'ora';
const spinner = ora({ text: 'Contacting the catalogue server', isSilent: process.argv.includes('--quiet') }).start();
try {
const books = await fetchCatalogue();
spinner.succeed(`Fetched ${books.length} books`);
} catch (error) {
spinner.fail(`Could not reach the catalogue: ${error.message}`);
process.exitCode = 1;
}
Source: ora — README.
Choosing a library
| Language | Library | Draws to | Non-terminal behaviour |
|---|---|---|---|
Rust |
indicatif |
stderr by default |
Bar hidden when not a terminal |
Python |
tqdm |
stderr by default |
Off with |
Python |
Rich |
The |
Depends on the console ( |
.NET |
Spectre.Console |
The console in |
Not documented; check interactivity yourself |
Go |
mpb |
Container output |
Not documented; decide in your own code |
Go (inside a TUI) |
Bubbles |
Your program’s |
Not applicable: a TUI needs a terminal |
Node.js |
ora |
stderr by default |
Spinner off, text still logged |
C, C++, Swift |
By hand, or a library such as ncurses |
Your choice |
Your choice |
Tables, trees and Markdown
Libraries that draw boxes and aligned columns all solve the same two problems: measure each cell in terminal cells rather than bytes or characters (see the width section of the escape-sequences page), and fit the whole table to the available width by wrapping or truncating cells. Use them for human-facing output and print machine-readable output (JSON, TSV) when the destination is not a terminal.
Rich (Python). Table(title=..., box=..., show_lines=..., expand=...), add_column(justify=..., style=..., no_wrap=...)
and add_row(...); a cell can contain any renderable, including another table. Rich also ships tree and Markdown
renderables, described in its own documentation.
from rich import box
from rich.console import Console
from rich.table import Table
table = Table(title="Shelf", box=box.ROUNDED)
table.add_column("Title", style="cyan", no_wrap=True)
table.add_column("Loans", justify="right", style="green")
table.add_row("Dune", "3")
table.add_row("Neuromancer", "1")
Console().print(table)
Source: Rich — Tables.
Spectre.Console (.NET). new Table(), AddColumn, AddRow (strings or renderables such as Markup), a border chosen
with a fluent method such as RoundedBorder(), and AnsiConsole.Write(table). Markup tags in strings ([yellow]Name[/])
colour cells. Tree and Markup are separate widgets.
var table = new Table().RoundedBorder();
table.AddColumn("[yellow]Title[/]");
table.AddColumn("Loans");
table.AddRow("Dune", "3");
table.AddRow(new Markup("[green]Neuromancer[/]"), "1");
AnsiConsole.Write(table);
Source: Spectre.Console — Table.
Lip Gloss (Go). Styles are values: lipgloss.NewStyle().Foreground(lipgloss.Color("#0000ff")).Bold(true).Render(text).
The table, list and tree subpackages (charm.land/lipgloss/v2/table, .../list, .../tree) draw structured output,
and lipgloss.Width, Height and Size measure rendered strings. Lip Gloss v2 separates rendering from output: use
lipgloss.Print, Println, Fprintln and the other print functions, which write through a writer that downsamples colours
to what the terminal supports, instead of fmt when you print styled text.
Source: Lip Gloss — package documentation.
Markdown in the terminal. Glamour renders Markdown to styled terminal text in Go:
out, err := glamour.Render(markdown, "dark")
if err != nil {
return err
}
fmt.Print(out)
glamour.NewTermRenderer(glamour.WithWordWrap(40)) wraps at a chosen width (the default is 80), and the style can come from
the GLAMOUR_STYLE environment variable through glamour.RenderWithEnvironmentConfig. Glamour does not downsample colours;
its documentation suggests printing through Lip Gloss. termimad does the same for Rust: MadSkin holds the colours of each
Markdown element, print_text wraps multi-line text to the terminal width, print_inline handles a single snippet and
skin.term_text(&markdown) returns wrapped text you can print.
use termimad::{print_inline, MadSkin};
fn main() {
let mut skin = MadSkin::default();
skin.bold.set_fg(termimad::gray(20));
print_inline("loans: **3**");
let markdown = String::from("A **wrapped** paragraph that adapts to the terminal width.");
println!("{}", skin.term_text(&markdown));
}
The termimad documentation recommends ansi or gray colours for compatibility with older terminals. Rich offers a Markdown
renderable for Python in the same role.
Source: Glamour — README and termimad — crate documentation.
Colour libraries
Colour is a layer of SGR sequences (see the escape-sequences page); a colour library adds three things on top: a friendly API, detection of how many colours the destination supports, and downgrading to that number. What they should not do is decide policy: whether to colour at all follows the rules on the TTY page.
| Library | Language | What it does |
|---|---|---|
anstream, anstyle |
Rust |
|
owo-colors |
Rust |
The |
colorama |
Python |
Makes ANSI sequences work on Windows. Call |
Rich |
Python |
Styles, markup, tables, progress, logging: detects the colour system itself, see the environment variables above. |
chalk |
Node.js |
|
Lip Gloss |
Go |
Style values rendered to strings and printed through colour-downsampling writers; see above. |
Spectre.Console |
.NET |
Markup strings and style objects; the console profile holds the detected capabilities. |
use owo_colors::{OwoColorize, Stream::Stdout};
println!(
"{}",
"overdue".if_supports_color(Stdout, |text| text.red())
);
import colorama
colorama.just_fix_windows_console() # no-op except on a Windows console
print(f"{colorama.Fore.RED}overdue{colorama.Style.RESET_ALL}")
import chalk, { chalkStderr } from 'chalk';
console.log(chalk.red.bold('overdue')); // detected for stdout
console.error(chalkStderr.yellow('warning')); // detected for stderr
Source: anstream, owo-colors, colorama and chalk.
Choose colours that survive other people’s terminals. Meaning must never be carried by colour alone (print overdue, not
only a red title), and sticking to the 16 ANSI colours lets users theme them, which is the guidance in GitHub’s article on
making its CLI accessible; see Accessibility & Internationalization.
NO_COLOR and FORCE_COLOR are community conventions rather than standards and there is no portable way to detect a
terminal’s real colour support beyond TERM and COLORTERM; a library’s detection is a heuristic.
Terminal width and wrapping
Every layout decision starts from the width of the terminal, and every width has to be asked for.
| Environment | How to get the width |
|---|---|
POSIX C |
|
Swift |
|
Windows |
|
Node.js |
|
Python |
|
Rust, Go, .NET |
|
Three behaviours deserve explicit handling. When output is not a terminal there is no width: fall back to the COLUMNS
variable if it is set, otherwise to 80, or do not wrap at all and let the reader’s tool do it; Rich documents that you may
want to set width explicitly when writing to a file. When the terminal is resized, redraw from the new size: a program
that draws one table and exits can ignore this, a progress bar or TUI must handle SIGWINCH (POSIX) or the console
window-buffer-size events (Windows) as covered on the terminals page, and libraries do it for you only when asked (tqdm’s
dynamic_ncols, Node’s 'resize' event). And a pseudo-terminal with no size set reports zero: running a Node.js check
inside a freshly allocated pseudo-terminal printed columns as 0 and a colour depth of 8 (256 colours), so a program that
trusts columns without a fallback divides by zero or draws nothing in some CI terminals.
Wrapping is either soft (the terminal wraps long lines itself, which breaks tables and misleads cursor arithmetic) or
hard (the program inserts the line breaks). Rich’s print offers overflow (fold, crop, ellipsis, ignore) and
soft_wrap=True; Glamour and termimad wrap to a width you give them. When truncating, count terminal cells, not characters:
a CJK title or an emoji takes two cells and a combining mark none, which is the subject of the width section of the
escape-sequences page.
#if canImport(Darwin)
import Darwin
#elseif canImport(Glibc)
import Glibc
#endif
import Foundation
struct Output {
let colour: Bool
let width: Int
init() {
let env = ProcessInfo.processInfo.environment
var size = winsize()
let ok = ioctl(STDOUT_FILENO, UInt(TIOCGWINSZ), &size) == 0 && size.ws_col > 0
width = ok ? Int(size.ws_col) : Int(env["COLUMNS"] ?? "") ?? 80
let noColour = (env["NO_COLOR"] ?? "").isEmpty == false
colour = isatty(STDOUT_FILENO) != 0 && !noColour && env["TERM"] != "dumb"
}
func link(_ text: String, _ url: String) -> String {
colour ? "\u{1B}]8;;\(url)\u{1B}\\\(text)\u{1B}]8;;\u{1B}\\" : "\(text) <\(url)>"
}
}
let out = Output()
print("width=\(out.width) colour=\(out.colour)")
print(out.link("Dune", "https://shelf.example/books/42"))
Compiled with swiftc -O and run with its output piped to cat, it prints width=80 colour=false (no terminal, so the
fallback width) and Dune https://shelf.example/books/42, the plain-text form of the link. The ioctl call and winsize
exist in both Darwin and Glibc; on Windows the WinSDK module and the console API provide the equivalent.
Source: Swift documentation, Node.js — tty and POSIX — tcgetwinsize.
Hyperlinks
OSC 8 lets a terminal show clickable text whose target is a different URL, so shelf list can print a title that opens the
book’s page. The sequence, its id parameter, the length limits and the security considerations (the visible text and the URL
are independent, so never build a URL from untrusted text) are covered on the escape-sequences page and are not repeated. The
points that matter when choosing a library or writing the output code:
-
Support cannot be detected; the specification itself says so. Terminals that follow ECMA-48 ignore an unknown OSC and show the text, but the safest course is to emit links only when the stream is a terminal and the user has not disabled decoration, as the Swift example above does.
-
The link must not be the only way to reach the target: in the non-terminal form print the URL in plain text, for example in angle brackets after the title.
-
The terminator should be ST (
ESCfollowed by a backslash); BEL is accepted by many terminals but the specification recommends ST. Keep URIs short (VTE and iTerm2 cap them at 2083 bytes) and encode anything outside the printable ASCII range. -
Full-screen programs that redraw part of a screen should give links an
idso a link broken across lines or redrawn is still highlighted as one. -
Test inside
tmuxor any multiplexer, which sits between the program and the emulator and may not pass the sequence through. Check the documentation of the library you use for link support; this page does not rely on any.
Source: Hyperlinks in Terminal Emulators (a community specification, not a formal standard).
Logging versus user output
User output is part of the program’s interface: it is documented, stable, formatted for people on a terminal and for scripts otherwise. Logs are for the developer or the operator: timestamps, levels, module names, subject to change. Mixing them makes both worse; the way to keep them apart is to give them different destinations and different switches.
| User output | Logs | |
|---|---|---|
Destination |
stdout (results), stderr (status and errors) |
stderr, a file, or the system log; see Long-Running Processes & Services for journald, |
Enabled by |
Default, silenced by |
|
Format |
Stable, documented, coloured for humans only on a terminal |
Free to change; structured (key-value or JSON lines) when a service consumes it |
Libraries |
Everything on this page |
|
Rich makes the split easy in Python: RichHandler routes the standard logging module through a Rich console, so log lines
get colour, aligned levels and optional tracebacks on a terminal, while normal output still uses print. The handler’s
markup option is off by default.
import logging
import sys
from rich.console import Console
from rich.logging import RichHandler
def configure_logging(verbose: bool) -> None:
logging.basicConfig(
level=logging.DEBUG if verbose else logging.WARNING,
format="%(message)s",
datefmt="[%X]",
handlers=[RichHandler(console=Console(stderr=True), rich_tracebacks=True)],
)
log = logging.getLogger("shelf")
configure_logging(verbose="--verbose" in sys.argv)
log.debug("opening catalogue") # only with --verbose
print("3 books") # the result, always on stdout
The console option is not covered on the page that describes RichHandler; it is listed in the handler’s reference. The
Rust log crate is a facade that libraries write to and the application backs with an implementation such as env_logger,
and Go’s log/slog takes a handler that you point at os.Stderr. In every language the rule is the same: the facade’s
output never goes to stdout, and verbosity is the user’s switch.
Source: Rich — Logging Handler and log — crate documentation.
Windows, macOS and Linux
| Platform | What differs for progress, colour and rich output |
|---|---|
Linux |
Terminal emulators and |
macOS |
Support for truecolor and OSC 8 differs between Terminal.app, iTerm2, Ghostty and other emulators and between versions, so
check |
Windows |
Colour needs virtual-terminal processing ( |
Pitfalls
-
Writing progress to stdout. The first
shelf import | tee logcaptures every frame; send it to stderr. -
Deciding once for the whole process. Redirecting stdout leaves stderr a terminal; decide per stream.
-
Animating in CI. Logs fill with carriage-return frames; prefer plain milestone lines or no output, as in the C example.
-
Printing while a bar is live. Use the library’s own print path (
ProgressBar::println,progress.console.print) or suspend the bar. -
Forgetting cleanup. Finish or clear the bar on every exit path, including errors and interruption; otherwise the shell prompt lands in the middle of the last frame and a hidden cursor stays hidden.
-
Trusting width or colour detection blindly. Both are heuristics; keep an escape hatch (
COLUMNS,--no-color,FORCE_COLOR). -
Meaning by colour alone, or colours that vanish on light or dark backgrounds. Prefer the 16 ANSI colours and say the same thing in words.
-
Styling untrusted text. A book title containing escape sequences is injected into your output; see the injection section of the escape-sequences page and the security page of the AI section.
-
Unquoted claims of library behaviour. Where the official documentation is silent (Spectre.Console and mpb behaviour on a non-terminal, anstream’s environment rules), test the version you ship.
References
-
Hyperlinks in Terminal Emulators (community specification)
-
NO_COLOR and FORCE_COLOR (community conventions)
-
TTY, Colour & Interactivity (this site)