Input & UX

Building Your First Textual App for a Python CLI

Build a Textual job monitor step by step: compose widgets, style with TCSS, load data in workers, refresh on a timer, bind keys and confirm actions in a modal screen.

Updated

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:

The widget tree of a small browser app The widget tree composed by a Textual app for browsing deployments: a header, a data table and a detail panel side by side, and a footer showing key bindings. The widget tree of a small browser app DeployBrowser(App) compose() Header title + clock DataTable one row per deploy Static#detail selected row Footer key bindings CSS lays out the table and detail panel side by side compose() declares what exists; CSS decides where it goes; handlers decide what happens.

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) runs load_jobs in a thread. exclusive=True cancels 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 = True shows 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

From a key press to an updated panel The user moves the cursor in the data table, the table posts a RowHighlighted message, the app handler looks up the deploy and updates the detail widget. From a key press to an updated panel User DataTable App handler Static#detail down arrow RowHighlighted(row_key) update(details) repaint Handlers are named after messages: on_data_table_row_highlighted receives this one.

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:

Key bindings users expect Conventional key bindings for a terminal user interface and the actions they should trigger. Key bindings users expect Key Action Why q / Ctrl+C quit universal j/k, arrows move vim and non-vim users / search or filter less, vim, htop r refresh monitoring tools ? show help discoverability Show bindings in the footer; users should never have to guess how to leave.

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_COLOR or 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; print would corrupt the screen.
  • Make leaving obvious. q in 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.