Input & UX

Interactive Terminal UI with Rich

Build interactive Python CLI interfaces with the Rich library — tables, progress bars, panels, prompts, and live-updating terminal output.

Updated

Rich turns a plain Python CLI into something readable: coloured text, aligned tables, bordered panels, progress bars, and prompts — all from one library with no terminal-escape-code wrangling on your part. This hub surveys the pieces Rich gives you for building a friendly terminal UI, and points you to the deeper guides for each. It is aimed at anyone who has a working CLI and wants the output to stop looking like a 1990s log dump.

TL;DR

  • Everything starts with a Console. Create one, call console.print(...), and you get markup, colour, and word-wrapping for free.
  • Rich ships ready-made building blocks: Table, Panel, styled text, Prompt/Confirm, and Progress for progress bars and spinners.
  • Rich detects when output is not a terminal (piped, redirected, in CI) and degrades gracefully — it drops control codes and respects NO_COLOR so your tool stays scriptable.

The Rich toolkit: a central Rich Console hub surrounded by its building blocks — Tables, Panels, Progress, Prompts, styled text/markup, and Live display — with a note that it degrades gracefully when piped.

The Console: one object for all output

The Console is the entry point for everything Rich draws. Replace print() with console.print() and you immediately get console markup, automatic word-wrap to the terminal width, and theme-aware colour.

from rich.console import Console

console = Console()
console.print("[bold green]✓ deploy succeeded[/]  in 4.2s")
console.print({"region": "eu-west-1", "replicas": 3})  # pretty-prints structures

Create the Console once and pass it around (or stash it on a small app object) rather than constructing a new one per call. That single instance is also what keeps progress bars, prompts, and log lines from fighting over the cursor — a theme that comes up again on the progress bars and spinners page.

Tables and panels for structured output

When your command returns rows — servers, files, test results — reach for Table. For a framed summary or a callout, use Panel.

from rich.table import Table
from rich.panel import Panel

table = Table(title="Servers")
table.add_column("Host")
table.add_column("Status", style="green")
table.add_row("web-01", "up")
table.add_row("web-02", "down")
console.print(table)

console.print(Panel("All systems nominal", title="Status"))

Tables handle column widths, alignment, and wrapping automatically, so you never hand-pad strings again.

Styled text and prompts

Rich markup ([bold], [red], [link=…]) inlines styling without escape codes. For interactive input, rich.prompt gives you typed, validated prompts:

from rich.prompt import Prompt, Confirm

name = Prompt.ask("Project name", default="my-cli")
if Confirm.ask("Initialise a git repo?", default=True):
    ...

Prompt.ask supports choices=[...], default values, and password masking. It is a quick win for interactive flows, though for anything driven by flags or files you will still lean on argument parsing and config files and env vars.

Progress, spinners, and live output

Long-running work deserves feedback. Rich's Progress covers deterministic tasks (a known total — copying N files), indeterminate work (a spinner while you wait on a network call), and several concurrent task bars at once. Live lets you re-render a region of the screen in place for dashboards. This is the single biggest UX upgrade for most CLIs, so it has its own deep dive:

Degrade gracefully when output is not a terminal

This is the rule that separates a polished CLI from a broken one: your tool will be piped into grep, redirected to a file, and run in CI. Rich handles this for you, but only if you let it own the output.

Console auto-detects whether it is attached to a TTY via console.is_terminal. When it is not (a pipe, a file, a CI log), Rich suppresses animations and colour and emits plain text, and it honours the NO_COLOR environment variable. Check is_terminal yourself when you want an explicit fallback:

if console.is_terminal:
    console.print(table)          # full styled output
else:
    for row in rows:
        console.print("\t".join(row))  # script-friendly plain text

Set force_terminal=True/False on the Console to override detection in tests or CI, and use Console(record=True) to capture rendered output for regression tests.