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 process.stderr); a Rich Console writes to stdout unless created with stderr=True, so pass the stream explicitly.

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 --verbose or an environment variable, never mixed into the result. See Logging versus user output.

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.

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.

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

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()
}

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
}

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;
}

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 disable=None; otherwise prints the redraws

Python

Rich Progress

The Console you pass

Depends on the console (TERM, TTY_COMPATIBLE, is_terminal); pass disable explicitly

.NET

Spectre.Console

The console in AnsiConsole

Not documented; check interactivity yourself

Go

mpb

Container output

Not documented; decide in your own code

Go (inside a TUI)

Bubbles progress, spinner

Your program’s View

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)

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);

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.

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.

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

anstream provides AutoStream, a writer that adapts ANSI escape codes to the destination’s capabilities, StripStream, stdout() and stderr() constructors, and print/println/eprint/eprintln macros that go through it; ColorChoice overrides the detection. Its overview page does not list the environment variables it honours, so test the behaviour you rely on.

owo-colors

Rust

The OwoColorize extension trait ("text".red().bold()). With the supports-colors feature, if_supports_color(Stream::Stdout, ...) applies colour only where the stream supports it, and the crate respects NO_COLOR and FORCE_COLOR, TTY state and CI detection, with set_override for explicit control.

colorama

Python

Makes ANSI sequences work on Windows. Call just_fix_windows_console() (0.4.6 or later) once: on a recent Windows 10 or later console it enables the built-in ANSI support, on older ones it wraps sys.stdout and sys.stderr to convert sequences, it leaves redirected streams alone, and elsewhere it does nothing. The older init() adds autoreset, strip and convert options but should not be called twice.

Rich

Python

Styles, markup, tables, progress, logging: detects the colour system itself, see the environment variables above.

chalk

Node.js

chalk.red.bold.underline('text'). chalk.level is 0 (off), 1 (16 colours), 2 (256) or 3 (truecolor); FORCE_COLOR=0 to 3 sets it, FORCE_COLOR=true enables colour and lets the level be detected, and --color / --no-color override. chalkStderr is a separate instance detected for stderr. A reusable module should create its own new Chalk({level}) rather than set the global level.

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

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

tcgetwinsize (POSIX.1-2024) or ioctl(fd, TIOCGWINSZ, &ws); the field is ws_col. Query the stream you draw to, because stdout and stderr can differ.

Swift

ioctl through Darwin or Glibc, as in the example below.

Windows

GetConsoleScreenBufferInfo for the visible window (srWindow), or the runtime’s wrapper such as .NET Console.WindowWidth.

Node.js

stream.columns and stream.rows on a TTY stream, kept current by the 'resize' event; getWindowSize() returns both. Both are undefined on a non-TTY stream.

Python

shutil.get_terminal_size() honours the COLUMNS and LINES variables, then queries the terminal; Rich’s Console.width does the same job for Rich.

Rust, Go, .NET

crossterm::terminal::size, golang.org/x/term.GetSize, Console.WindowWidth respectively.

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.

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 (ESC followed 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 id so a link broken across lines or redrawn is still highlighted as one.

  • Test inside tmux or 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, os_log and the Windows Event Log

Enabled by

Default, silenced by --quiet

--verbose or -v counts, or an environment variable; off by default for a one-shot command

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

logging (Python), log and tracing (Rust), log/slog (Go), Microsoft.Extensions.Logging (.NET), os.Logger (Swift)

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.

Windows, macOS and Linux

Platform What differs for progress, colour and rich output

Linux

Terminal emulators and ssh sessions nearly always understand ANSI sequences. TERM is set and meaningful, and COLORTERM usually advertises truecolor. The Linux virtual console has far fewer colours than a graphical emulator. CI systems provide a pseudo-terminal or a pipe depending on configuration; many set CI, which ora treats as a reason to disable the spinner.

macOS

Support for truecolor and OSC 8 differs between Terminal.app, iTerm2, Ghostty and other emulators and between versions, so check COLORTERM and the emulator’s documentation rather than assuming. Everything else behaves as on Linux.

Windows

Colour needs virtual-terminal processing (ENABLE_VIRTUAL_TERMINAL_PROCESSING) on the console, which Windows Terminal and current consoles support; libraries such as colorama, Rich and Spectre.Console detect a legacy console and adapt. TERM is usually unset in cmd.exe and PowerShell, so its absence does not mean "no colour". A pseudo-console host such as Git Bash’s mintty may look like a pipe to a native program. Console fonts and code pages affect box-drawing characters and block elements: use UTF-8 output and give an ASCII fallback (tqdm’s ascii=True, progress_chars("##-") in indicatif).

Pitfalls

  • Writing progress to stdout. The first shelf import | tee log captures 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.