The quickest way to understand Textual is to build something real with it. This guide builds a small job monitor — the kind of screen a CLI for a task queue, a CI system or a batch-processing service might offer as mytool jobs watch. It lists jobs with their state and progress, refreshes every couple of seconds without freezing, highlights failures in colour, and lets the user cancel a job after confirming in a dialog. Along the way it covers every core Textual idea a CLI author needs: composing widgets, styling with Textual CSS, handling messages, binding keys to actions, loading data in background workers, timers and modal screens. It belongs to the building terminal UIs with Textual topic, which covers when a TUI is worth building at all.
Prerequisites
- Python 3.10+ and Textual 1.0 or newer (
uv add textual); the example was checked against Textual 8. - Basic familiarity with Rich markup, which Textual uses for styled text; see interactive terminal UI with Rich.
- Optional:
textual-dev(uv add --dev textual-dev) for the live developer console.
Step 1: separate the data from the interface
Before any widget, decide where the data comes from — and keep it out of the UI. A small class with a fetch method stands in for an API client here; in a real CLI it is the same client the non-interactive commands use, as described in building an API client CLI with httpx.
# src/jobs/source.py
from __future__ import annotations
import random
from dataclasses import dataclass
@dataclass(frozen=True)
class Job:
id: str
name: str
state: str # queued | running | done | failed
progress: int # 0-100
class JobSource:
"""Where jobs come from. The real one calls an API; tests pass a fake."""
def __init__(self, seed: int = 1) -> None:
self._rng = random.Random(seed)
self._jobs = {f"j{i}": Job(f"j{i}", name, "queued", 0)
for i, name in enumerate(["backup", "reindex", "export", "thumbnails"], 1)}
def fetch(self) -> list[Job]:
for key, job in list(self._jobs.items()):
if job.state in ("queued", "running"):
progress = min(100, job.progress + self._rng.randint(5, 30))
state = "done" if progress == 100 else "running"
self._jobs[key] = Job(job.id, job.name, state, progress)
return list(self._jobs.values())
def cancel(self, job_id: str) -> None:
job = self._jobs[job_id]
self._jobs[job_id] = Job(job.id, job.name, "failed", job.progress)
The simulated source advances jobs randomly on each fetch, so the monitor has something to show. Because the UI only ever calls fetch() and cancel(), tests can hand it a fake, and the TUI never learns how jobs are actually obtained.
Step 2: compose the screen
A Textual App declares its widgets in compose(). They form a tree — Textual calls it the DOM — and CSS lays them out:
Here is the whole application. The sections below walk through it piece by piece.
# src/jobs/app.py
from __future__ import annotations
from textual import work
from textual.app import App, ComposeResult
from textual.containers import Grid
from textual.screen import ModalScreen
from textual.widgets import Button, DataTable, Footer, Header, Label
from jobs.source import Job, JobSource
STATE_STYLE = {"queued": "dim", "running": "bold yellow", "done": "green", "failed": "red"}
class ConfirmCancel(ModalScreen[bool]):
"""Ask before cancelling; dismiss(True) or dismiss(False)."""
def __init__(self, job: Job) -> None:
super().__init__()
self.job = job
def compose(self) -> ComposeResult:
with Grid(id="dialog"):
yield Label(f"Cancel job {self.job.name!r}?", id="question")
yield Button("Cancel job", variant="error", id="yes")
yield Button("Keep running", variant="primary", id="no")
def on_button_pressed(self, event: Button.Pressed) -> None:
self.dismiss(event.button.id == "yes")
class JobMonitor(App[None]):
CSS_PATH = "monitor.tcss"
BINDINGS = [("q", "quit", "Quit"), ("r", "refresh", "Refresh"), ("c", "cancel", "Cancel job")]
def __init__(self, source: JobSource, interval: float = 2.0) -> None:
super().__init__()
self.source = source
self.interval = interval
def compose(self) -> ComposeResult:
yield Header(show_clock=True)
yield DataTable(cursor_type="row", zebra_stripes=True)
yield Footer()
def on_mount(self) -> None:
self.title = "Job monitor"
table = self.query_one(DataTable)
table.add_columns("Job", "State", "Progress")
table.loading = True
self.action_refresh()
self.set_interval(self.interval, self.action_refresh)
def action_refresh(self) -> None:
self.load_jobs()
@work(exclusive=True, thread=True)
def load_jobs(self) -> None:
jobs = self.source.fetch() # blocking I/O, off the event loop
self.call_from_thread(self.show_jobs, jobs)
def show_jobs(self, jobs: list[Job]) -> None:
table = self.query_one(DataTable)
table.loading = False
cursor = table.cursor_row
table.clear()
for job in jobs:
style = STATE_STYLE[job.state]
table.add_row(job.name, f"[{style}]{job.state}[/]", f"{job.progress:>3}%", key=job.id)
if jobs:
table.move_cursor(row=min(cursor, len(jobs) - 1))
running = sum(j.state == "running" for j in jobs)
self.sub_title = f"{running} running, {len(jobs)} total"
def action_cancel(self) -> None:
table = self.query_one(DataTable)
if table.row_count == 0:
return
job_id = table.coordinate_to_cell_key(table.cursor_coordinate).row_key.value
job = next(j for j in self.source.fetch() if j.id == job_id)
def done(confirmed: bool | None) -> None:
if confirmed:
self.source.cancel(job_id)
self.notify(f"cancelled {job.name}")
self.action_refresh()
self.push_screen(ConfirmCancel(job), done)
/* src/jobs/monitor.tcss */
DataTable {
height: 1fr;
}
ConfirmCancel {
align: center middle;
}
#dialog {
grid-size: 2;
grid-gutter: 1 2;
padding: 1 2;
width: 50;
height: 11;
border: thick $accent;
background: $surface;
}
#question {
column-span: 2;
width: 100%;
content-align: center middle;
}
compose() yields a header with a clock, a DataTable and a footer. Header and Footer are docked to the top and bottom by default; the table fills the rest because the stylesheet gives it height: 1fr. The footer shows the app's key bindings automatically, so users can see how to quit, refresh and cancel.
CSS_PATH points at a .tcss file next to the module. Keeping styles out of Python means layout tweaks do not touch behaviour, and textual run --dev jobs.app:JobMonitor reloads the stylesheet live while you edit it.
Step 3: load data without freezing
A terminal UI must keep responding to keys while data loads. Textual runs on an asyncio event loop, so a blocking call — an HTTP request, a slow file read — inside a handler freezes everything. Workers move that work off the loop:
@work(exclusive=True, thread=True)runsload_jobsin a thread.exclusive=Truecancels a previous load still in flight, so rapid refreshes never pile up.- The worker cannot touch widgets directly from its thread;
self.call_from_thread(self.show_jobs, jobs)schedules the update back on the event loop. table.loading = Trueshows Textual's built-in loading indicator until the first result arrives.
For async clients such as httpx.AsyncClient, use @work(exclusive=True) on an async def method instead and await the call directly; no thread is needed. Either way, the table refreshes in place: show_jobs remembers the cursor row, clears and refills the table, and puts the cursor back, so a refresh every two seconds does not yank the user's selection away.
self.set_interval(self.interval, self.action_refresh) in on_mount sets up the periodic refresh. Keep intervals at seconds rather than milliseconds — a monitor that refreshes ten times a second burns CPU for no benefit a human can see.
Step 4: react to keys and messages
Textual delivers user input as messages. Widgets post them — DataTable.RowHighlighted, Button.Pressed, Input.Changed — and they bubble up the DOM until a handler named after the message handles them. on_button_pressed in the modal screen is one such handler.
Bindings connect keys to actions: the tuple ("c", "cancel", "Cancel job") calls action_cancel when c is pressed and labels it in the footer. Choose bindings users already know from other terminal tools:
Step 5: confirm destructive actions in a modal
Cancelling a job cannot be undone, so the monitor asks first. ConfirmCancel is a ModalScreen[bool] — a screen that dims what is underneath, captures focus, and returns a boolean. push_screen(ConfirmCancel(job), done) shows it and registers done as the callback that receives the result of dismiss(...). This is the TUI version of the confirm-before-destroying pattern in adding dry-run and confirmation to destructive commands: the safe option is the primary button, and pressing Escape or clicking "Keep running" leaves everything as it was.
Step 6: run it from the CLI
The monitor is one command in a larger CLI. Import Textual lazily so every other command stays fast, and refuse to start without a terminal:
# src/jobs/cli.py
import sys
import typer
app = typer.Typer()
@app.callback()
def main() -> None:
"""Job tools."""
@app.command()
def watch(interval: float = typer.Option(2.0, min=0.5, help="Seconds between refreshes.")) -> None:
"""Watch jobs live (q to quit)."""
if not sys.stdout.isatty():
typer.echo("error: watch needs a terminal; use 'jobs list --json' in scripts", err=True)
raise typer.Exit(2)
from jobs.app import JobMonitor
from jobs.source import JobSource
JobMonitor(JobSource(), interval=interval).run()
UX considerations
- Show state with more than colour. The state column says "failed" in red, not just a red row, so it reads correctly with
NO_COLORor for colour-blind users. - Keep the cursor stable across refreshes. Nothing is more irritating than a live view that resets the selection every two seconds.
- Put a summary in the header. "1 running, 4 total" in the subtitle answers the most common question without scanning the table.
- Notify, do not print.
self.notify(...)shows a transient toast;printwould corrupt the screen. - Make leaving obvious.
qin the footer, Ctrl+C always, and the terminal restored exactly as it was on exit.
Testing the behaviour
Textual's run_test starts the app headless, and the Pilot drives it. This test waits for the first load, checks the table, cancels a job through the modal and confirms the change reached the table:
# tests/test_app.py
from textual.widgets import DataTable
from jobs.app import ConfirmCancel, JobMonitor
from jobs.source import JobSource
async def test_loads_and_cancels():
app = JobMonitor(JobSource(seed=1), interval=60)
async with app.run_test(size=(80, 24)) as pilot:
await app.workers.wait_for_complete()
await pilot.pause()
table = app.query_one(DataTable)
assert table.row_count == 4
await pilot.press("c")
await pilot.pause()
assert isinstance(app.screen, ConfirmCancel)
await pilot.click("#yes")
await pilot.pause()
await app.workers.wait_for_complete()
await pilot.pause()
assert "failed" in str(table.get_row_at(0)[1])
The fake source is the real JobSource with a fixed seed, so results are deterministic; interval=60 keeps the timer from firing during the test; and app.workers.wait_for_complete() waits for background loads instead of sleeping. The async test needs pytest-asyncio with asyncio_mode = "auto" (or anyio's plugin). The full testing approach, including snapshot tests of the rendered screen, is in testing Textual apps with Pilot.
Conclusion
A first Textual app for a CLI comes together from a small set of ideas: keep data behind a plain class, compose widgets and style them with TCSS, load data in exclusive workers so the interface never freezes, refresh on a sensible timer while preserving the cursor, bind conventional keys to actions, and confirm destructive actions in a modal screen. Launch it from a lazily importing CLI command that refuses to run without a terminal, and test it headless with the Pilot. From here, more screens, filtering and live logs are variations on the same pieces.
Frequently asked questions
Why use a thread worker instead of async?
Only because the data source here is synchronous. With an async client, an async worker avoids threads entirely. Thread workers are the right tool for blocking libraries — many SDKs, database drivers and subprocess calls.
How do I show a log of events below the table?
Add a Log or RichLog widget in compose(), give it a height in the stylesheet, and call write_line() from show_jobs when states change. It scrolls automatically and keeps a bounded history.
Can the monitor accept command-line options like a filter?
Yes — pass them to the app's constructor from the Typer command, exactly as interval is passed here. The app stores them and uses them when loading or rendering.
How do I make it look right in light terminals?
Use Textual's theme variables ($accent, $surface, $panel) in CSS rather than fixed colours, and let users switch themes with the command palette (Ctrl+P). Textual ships light and dark themes that adapt the whole interface.