A deploy that rolls out across five regions, a batch of twenty migrations, a test run over a dozen services: when several things progress at once, a single progress bar is not enough and a scrolling log is too much. What people want is a small table that updates in place — one row per task with its state, progress and elapsed time — and that stays on screen as a summary when everything is done. Rich's Live display does exactly that inside an ordinary command, without a full-screen TUI. This guide builds such a dashboard, keeps rendering separate from data so it is testable, makes it fall back to plain log lines when there is no terminal, and covers the details that make live displays behave: refresh rates, printing while live, and Ctrl+C. It belongs to the interactive terminal UI with Rich topic.
Prerequisites
- Python 3.10+ and Rich 13+ (included with Typer).
- A source of status for several concurrent tasks — an API to poll, or results from a thread pool as in parallelising CLI work with thread pools.
How Live works
Live takes over a region of the terminal — not the whole screen — and redraws a renderable (a table, a panel, a group of them) in that region, several times a second. Your code fetches new data, builds a new renderable and hands it to live.update(). When the with block ends, the last frame stays in the scrollback as a permanent summary (unless transient=True, which erases it). Everything printed through the live console appears above the display, so log messages and the dashboard coexist.
The recipe
# src/mytool/dashboard.py
from __future__ import annotations
import sys
import time
from collections.abc import Callable
from dataclasses import dataclass
from rich.console import Console
from rich.live import Live
from rich.table import Table
@dataclass(frozen=True)
class RegionState:
region: str
state: str # waiting | rolling | done | failed
done: int
total: int
elapsed: float | None
STYLE = {"waiting": "dim", "rolling": "yellow", "done": "green", "failed": "bold red"}
def render(states: list[RegionState]) -> Table:
"""Pure function: data in, renderable out. Easy to test and reuse."""
table = Table("REGION", "STATE", "PROGRESS", "ELAPSED", box=None, header_style="bold")
for s in states:
elapsed = "-" if s.elapsed is None else f"{int(s.elapsed // 60)}:{int(s.elapsed % 60):02d}"
table.add_row(s.region, f"[{STYLE[s.state]}]{s.state}[/]", f"{s.done}/{s.total}", elapsed)
return table
def finished(states: list[RegionState]) -> bool:
return all(s.state in ("done", "failed") for s in states)
def watch(fetch: Callable[[], list[RegionState]], *, interval: float = 1.0,
console: Console | None = None, sleep: Callable[[float], None] = time.sleep) -> list[RegionState]:
"""Redraw in place on a terminal; print one line per change everywhere else."""
console = console or Console()
states = fetch()
if console.is_terminal:
with Live(render(states), console=console, refresh_per_second=4, transient=False) as live:
while not finished(states):
sleep(interval)
states = fetch()
live.update(render(states))
return states
last: dict[str, str] = {}
while True:
for s in states:
if last.get(s.region) != s.state:
print(f"{s.region}: {s.state} ({s.done}/{s.total})", file=sys.stderr, flush=True)
last[s.region] = s.state
if finished(states):
return states
sleep(interval)
states = fetch()
A command calls it with a function that returns the current state — polling an API, or reading results collected by worker threads:
import typer
from mytool.dashboard import watch
app = typer.Typer()
@app.command()
def deploy(all_regions: bool = typer.Option(False, "--all-regions")) -> None:
"""Deploy and show progress per region."""
job = start_rollout(all_regions) # returns a handle with .status()
final = watch(job.status, interval=2.0)
failed = [s.region for s in final if s.state == "failed"]
raise typer.Exit(1 if failed else 0)
Why it is built this way
Render is a pure function. render(states) turns data into a Table and does nothing else. Building a fresh renderable per tick avoids mutating a table while Rich is drawing it, and makes the visual logic testable without a terminal.
One code path decides the mode. console.is_terminal is false in pipes, CI logs and files. There, a live display would write a stream of redraws; instead the dashboard prints one line per state change, which is exactly the information someone reading a log needs. The broader rules are in detecting CI environments and non-interactive shells.
Refresh rate is decoupled from polling. refresh_per_second=4 controls redrawing (spinners and elapsed times stay smooth); interval controls how often data is fetched — usually much less often, to be kind to the API.
Sleep is injected. Tests pass a no-op sleep and a scripted fetch function, so the whole loop runs in milliseconds.
Live display or full TUI?
If the user only watches, Live is enough and a fraction of the code of a TUI. The moment they need to act on the display — select a row, cancel a task, filter — a Textual application is the better tool; see building terminal UIs with Textual.
UX considerations
- Leave a summary. With
transient=False, the final table remains after the command finishes — a free report of what happened. Usetransient=Trueonly for purely transitional displays. - Print through the live console.
live.console.print("warning: ...")puts messages above the table. Bareprint()while aLiveis active corrupts the display. - Keep the table short. A live region taller than the terminal cannot be redrawn in place and degrades into scrolling. For many tasks, show the active and failed ones plus a summary line ("37 done, 2 failed, 11 waiting").
- Handle Ctrl+C. Leaving the
withblock — including throughKeyboardInterrupt— stops the display cleanly and leaves the last frame. Catch the interrupt in the command to report what was still running and exit 130. - Colour is a hint, not the message. The state column says "failed", not just red; see respecting NO_COLOR and FORCE_COLOR.
Testing the behaviour
With rendering pure and fetch and sleep injected, every behaviour is testable: the table's content, the live loop running to completion, and the non-terminal fallback printing one line per change:
# tests/test_dashboard.py
import io
from rich.console import Console
from mytool.dashboard import RegionState, render, watch
def scripted(*frames):
it = iter(frames)
last = []
def fetch():
nonlocal last
last = next(it, last)
return last
return fetch
F1 = [RegionState("eu-west-1", "rolling", 3, 10, 20.0), RegionState("us-east-1", "waiting", 0, 10, None)]
F2 = [RegionState("eu-west-1", "done", 10, 10, 41.0), RegionState("us-east-1", "rolling", 6, 10, 33.0)]
F3 = [RegionState("eu-west-1", "done", 10, 10, 41.0), RegionState("us-east-1", "done", 10, 10, 52.0)]
def text_of(renderable) -> str:
console = Console(file=io.StringIO(), width=80, color_system=None)
console.print(renderable)
return console.file.getvalue()
def test_render_is_plain_data_to_table():
out = text_of(render(F2))
assert "eu-west-1" in out and "10/10" in out and "0:41" in out
def test_terminal_mode_redraws_until_finished():
console = Console(file=io.StringIO(), force_terminal=True, width=80)
final = watch(scripted(F1, F2, F3), console=console, sleep=lambda s: None)
assert final == F3
def test_non_terminal_prints_one_line_per_change(capsys):
console = Console(file=io.StringIO(), force_terminal=False)
watch(scripted(F1, F2, F2, F3), console=console, sleep=lambda s: None)
lines = capsys.readouterr().err.splitlines()
assert lines == ["eu-west-1: rolling (3/10)", "us-east-1: waiting (0/10)",
"eu-west-1: done (10/10)", "us-east-1: rolling (6/10)", "us-east-1: done (10/10)"]
The last test is the one that protects CI logs: repeated identical frames (F2, F2) produce no extra lines, and every transition appears exactly once.
Conclusion
Live gives a command a compact, in-place dashboard with very little code: fetch state, build a fresh table from it, hand it to live.update(), and let the final frame remain as a summary. Keep rendering pure, decouple refresh from polling, print through the live console, fall back to one log line per state change when there is no terminal, and inject fetch and sleep so the loop is testable. For watching many things at once, that is usually all the interface a CLI needs.
Frequently asked questions
Can Live combine a progress bar and a table?
Yes. Put a Progress instance and a Table in a rich.console.Group (or a Layout) and pass the group to Live; update the progress normally and rebuild the table per tick.
How do I feed the dashboard from concurrent workers?
Let workers write their latest state into a dictionary keyed by task (guarded by a lock, or via a queue.Queue drained by the main thread), and make fetch return a snapshot of it. The main thread then owns the Live display entirely, workers never touch the terminal, and the same watch function works whether states come from threads, asyncio tasks or a remote API.
Why does my live display flicker?
Usually because the renderable is rebuilt far more often than the data changes, or because something prints with print() while live. Fetch at a sensible interval, update only when data changes, and print through live.console.
Should the final frame go to stdout or stderr?
A live dashboard is narration, so create its console with stderr=True when the command also prints results on stdout. The final summary then stays visible in the terminal while a script capturing stdout receives only the data it asked for.
Does Live work over SSH and in tmux?
Yes. It uses standard cursor movement, which works in any modern terminal, multiplexer or SSH session. With TERM=dumb Rich treats the output as non-interactive.
How do I stop the display from a worker thread?
Set an event that the watching loop checks, and let the loop exit its with block on the main thread. Do not call live.stop() from other threads.