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 tagsTests
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'
)