← Home

133. Rich.Live

Live updating terminal dashboard with Rich.Live

133. Rich.Live

📊 Aryan wants the RAM manager dashboard to update in-place — no scrolling, just a live table that refreshes every second. His Live display flickers because he’s recreating the layout object every tick instead of updating it in-place.

💡 Fun fact: Rich.Live works by tracking a fixed region of the terminal and repainting just that area on each update. It uses ANSI escape sequences to move the cursor up and erase lines — the same technique used by htop, vim, and every “interactive” terminal program since the 1980s. Before libraries like Rich, you had to write raw escape codes like \033[2J\033[H to clear the screen.

⚠️ Watch out: Creating a new Live() context inside a loop is a classic mistake. Every with Live(): entry clears the terminal region and prints fresh — so your display scrolls on every tick instead of updating in place. The Live() context must wrap the entire loop, and live.update() is called inside the loop.

🤔 Think about it: Why is [/] a valid way to close Rich markup, even when you opened with [red]? What does it mean to “close all open tags” at once? Is there a case where [/red] and [/] would produce different output?

Learning objectives

  • Use Rich.Live as a context manager wrapping the entire polling loop
  • Call live.update(renderable) inside the loop to refresh in-place
  • Use [/] to close all open Rich markup tags
  • Rebuild Rich renderables each tick (Table, Panel) — they are cheap to create
  • Avoid creating new Live() instances inside a loop

Key concepts

  • Rich.Live — in-place terminal updating
  • live.update(renderable) — refresh without scroll
  • refresh_per_second — max update rate
  • [/] — close all open markup tags
  • Panel — boxed renderable with optional title

Try it

Concept detail

Live Updating Terminal UI with Rich.Live

Rich.Live keeps a region of the terminal updated without scrolling.

Pattern: Live outside loop

from rich.live import Live

with Live(refresh_per_second=4) as live:
    while True:
        renderable = build_table(get_data())
        live.update(renderable)   # replaces previous content
        time.sleep(0.25)

What to render

# Tables
live.update(table)

# Panels with title
live.update(Panel(table, title='[bold]RAM Monitor[/bold]'))

# Group multiple things
from rich.console import Group
live.update(Group(header_text, table, footer_text))

Markup closing tags

# Explicit close
'[red]error[/red]'

# Close all open tags
'[red][bold]error[/]'   # closes both bold and red

# Reset markup
'[/]'

Full dashboard example

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

def build_display(snapshot):
    t = Table(box=box.SIMPLE)
    t.add_column('Process', style='cyan')
    t.add_column('RAM', justify='right')
    for p in snapshot['processes']:
        t.add_row(p['name'], f'{p["rss_mb"]:.0f} MB')
    return Panel(t, title=f'[bold]RAM: {snapshot["percent"]:.0f}%[/]')

with Live(refresh_per_second=2) as live:
    while True:
        live.update(build_display(get_snapshot()))
        time.sleep(0.5)

Solution

import time
from rich.console import Console
from rich.table import Table
from rich.live import Live
from rich.panel import Panel
from rich import box

console = Console()

def make_table(processes: list[dict], ram_percent: float) -> Table:
    """Build the live process table."""
    table = Table(box=box.SIMPLE, expand=True)
    table.add_column('Process', style='cyan')
    table.add_column('RSS (MB)', justify='right')
    for proc in processes:
        color = 'red' if proc['rss_mb'] > 500 else ('yellow' if proc['rss_mb'] > 100 else 'green')
        table.add_row(proc['name'], f'[{color}]{proc["rss_mb"]:.1f}[/{color}]')
    return table

def run_dashboard(get_snapshot_fn, duration: int = 10):
    """
    Run a live dashboard for `duration` seconds.
    get_snapshot_fn() returns {'percent': float, 'processes': list[dict]}
    """
    start = time.time()
    # Live() context is opened ONCE outside the loop
    with Live(console=console, refresh_per_second=4, screen=False) as live:
        while time.time() - start < duration:
            snapshot = get_snapshot_fn()
            table = make_table(snapshot['processes'], snapshot['percent'])
            live.update(table)   # update in-place, no scroll
            time.sleep(1)

def format_header(percent: float) -> str:
    """Return a colored header string for the dashboard."""
    color = 'red' if percent > 80 else ('yellow' if percent > 60 else 'green')
    return f'[{color}]RAM: {percent:.1f}%[/]'   # [/] closes all open tags

Tests

def _snapshot(percent=50.0):
    return {
        'percent': percent,
        'processes': [
            {'name': 'chrome', 'rss_mb': 600.0},
            {'name': 'python', 'rss_mb': 80.0},
        ]
    }

def test_make_table_has_columns():
    table = make_table(_snapshot()['processes'], 50.0)
    assert len(table.columns) == 2

def test_make_table_has_rows():
    table = make_table(_snapshot()['processes'], 50.0)
    assert table.row_count == 2

def test_format_header_high_percent():
    result = format_header(85.0)
    assert 'red' in result
    assert '85.0' in result

def test_format_header_low_percent():
    result = format_header(40.0)
    assert 'green' in result

def test_format_header_closes_markup():
    result = format_header(50.0)
    # Must contain a closing tag [/color] or [/]
    assert '[/' in result, 'Rich markup must have a closing tag [/color] or [/]'

def test_run_dashboard_calls_snapshot_fn():
    calls = []
    def mock_snapshot():
        calls.append(1)
        return _snapshot()
    # Run for a tiny duration
    run_dashboard(mock_snapshot, duration=0)
    # Should have called snapshot at least once (might be 0 if duration=0)
    # Just verify it doesn't crash
    assert True

def test_live_not_created_inside_loop():
    """Verify Live() is used once as outer context, not recreated per tick."""
    import inspect
    src = inspect.getsource(run_dashboard)
    # Check that 'with Live' appears only once in the source
    live_count = src.count('with Live')
    assert live_count <= 1, (
        f'Found {live_count} "with Live" blocks — Live() should be '
        'outside the loop, not recreated every iteration'
    )

Resources