Most of a CLI's life is spent as plain commands: arguments in, output out, scriptable and composable. But some tasks do not fit that shape well. Browsing three hundred deployments to find the one that failed, watching a queue drain, triaging a list of alerts, or picking a pull request to check out — these are exploratory, and with plain commands they become a loop of list, grep, show, repeat. A full-screen terminal user interface (TUI), where the user moves through data with the keyboard and details update as they go, can turn that loop into one screen. Textual, from the makers of Rich, brings a modern component model to the terminal: widgets composed into a tree, CSS for layout, events and messages, async by default, and a first-class testing API.
This topic covers adding a TUI to a Python CLI without letting it take over: when a TUI is worth it, how a Textual app is structured, how it plugs into a Typer or Click command, how to test it, and the costs to plan for. It sits in the Advanced Input Parsing & User Experience section beside interactive terminal UI with Rich, which covers the lighter-weight option of rich output and prompts within ordinary commands.
TL;DR
- Add a TUI for exploring or watching data, never as the only way to do something. Every action must also exist as a scriptable command.
- Make the TUI one command (
mytool browse) that loads data through the same core functions as the other commands and returns a result. - Structure the app like a web front end: compose widgets, style with CSS, react to messages in
on_*handlers, bind keys to actions. - Keep logic out of widgets so most of it is tested with ordinary unit tests; use Textual's
run_testand Pilot for interaction tests. - Import Textual lazily inside the command that needs it, so the rest of the CLI starts fast.
When a TUI is the right tool
A TUI trades scriptability for interactivity. That trade is worth it when the user does not know in advance what they are looking for, when the data is too large for one screen of output but not so large that a query language is needed, or when the data changes while they watch. It is not worth it for tasks people repeat, automate, or run in CI — those need commands with flags and machine-readable output. Choosing between a CLI, a prompt flow and a TUI turns this into a concrete decision guide.
Tools that get the balance right — htop, k9s, lazygit, gh dash — share a pattern: the TUI is a view over capabilities that also exist non-interactively. k9s does nothing kubectl cannot; it makes browsing faster.
How a Textual app is structured
An App subclass is the program. Its compose method yields widgets — Textual ships a large library: DataTable, Tree, Input, Log, Markdown, TabbedContent and more — which form a DOM-like tree. CSS (Textual's variant, often in a .tcss file) lays them out and styles them. User actions and state changes produce messages that bubble up the tree; the app or a widget reacts by defining a handler named after the message. Bindings map keys to actions, methods named action_*. For multi-view apps, screens are pushed and popped like pages.
Here is a compact but complete browser for deployments:
# src/mytool/tui/browser.py
from __future__ import annotations
from dataclasses import dataclass
from textual.app import App, ComposeResult
from textual.containers import Horizontal
from textual.widgets import DataTable, Footer, Header, Static
@dataclass(frozen=True)
class Deploy:
id: int
project: str
env: str
status: str
author: str
class DeployBrowser(App[Deploy | None]):
"""Browse deploys; Enter returns the selected one, q quits with None."""
CSS = """
Horizontal { height: 1fr; }
DataTable { width: 2fr; }
#detail { width: 1fr; padding: 1 2; border-left: solid $accent; }
"""
BINDINGS = [("q", "quit", "Quit"), ("r", "refresh", "Refresh")]
def __init__(self, deploys: list[Deploy]) -> None:
super().__init__()
self.deploys = {str(d.id): d for d in deploys}
def compose(self) -> ComposeResult:
yield Header()
with Horizontal():
yield DataTable(cursor_type="row")
yield Static("Select a deploy", id="detail")
yield Footer()
def on_mount(self) -> None:
table = self.query_one(DataTable)
table.add_columns("ID", "Project", "Env", "Status")
for key, d in self.deploys.items():
table.add_row(str(d.id), d.project, d.env, d.status, key=key)
table.focus()
def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
d = self.deploys[event.row_key.value]
self.query_one("#detail", Static).update(
f"#{d.id} {d.project}\nenv: {d.env}\nstatus: {d.status}\nby: {d.author}")
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
self.exit(self.deploys[event.row_key.value])
def action_refresh(self) -> None:
self.notify("refreshed")
App[Deploy | None] declares the app's return type: self.exit(value) ends the app and app.run() returns that value to the caller. That is what lets a TUI act as an interactive picker for a larger command. Building your first Textual app walks through each part in more detail, including screens, workers for loading data without freezing the UI, and styling.
Screens, styling and state
Three more Textual ideas cover most of what a CLI's TUI needs beyond a single view.
Screens for navigation. A detail view, a confirmation dialog or a help overlay is a Screen pushed on top of the current one with self.push_screen(DetailScreen(deploy)) and dismissed with self.dismiss(result). ModalScreen dims what is underneath and captures focus, which suits confirmations. Pushing a screen with a callback — self.push_screen(ConfirmRollback(d), self.on_confirmed) — returns the dialog's answer to the app without any global state. Keep screens small and single-purpose; a TUI with five screens is already a sizeable application.
CSS for everything visual. Textual CSS supports layouts (horizontal, vertical, grid), sizing in cells, fractions and percentages, docking (headers and footers stay put), borders, padding and colours drawn from theme variables such as $accent and $panel. Keeping visual decisions in CSS — ideally a separate .tcss file referenced with CSS_PATH — keeps Python code about behaviour, and Textual's built-in themes (switchable at runtime) mean dark and light terminals both look right without per-theme code. Textual's developer console (textual run --dev with textual console in another terminal) shows messages and logs live and supports live-reloading CSS while you adjust layouts.
Reactive attributes for state. Declaring filter_text = reactive("") on a widget or app makes assignments trigger watch_filter_text automatically, which is the idiomatic way to re-render a table when a search box changes. Reactives keep the update logic in one place instead of scattered across handlers, and they compose with workers: a watcher can start a worker that fetches filtered data, and the worker's completion updates the table.
from textual.reactive import reactive
from textual.widgets import DataTable, Input
class FilterableBrowser(DeployBrowser):
filter_text = reactive("")
def on_input_changed(self, event: Input.Changed) -> None:
self.filter_text = event.value
def watch_filter_text(self, text: str) -> None:
table = self.query_one(DataTable)
table.clear()
for key, d in self.deploys.items():
if text.lower() in f"{d.project} {d.env} {d.status}".lower():
table.add_row(str(d.id), d.project, d.env, d.status, key=key)
With the filter logic in a watcher, the Pilot test for it is simply "type into the input, then count table rows" — and the same filtering rule, extracted into a plain function, can be unit-tested and reused by the non-interactive list --filter command so the two never disagree.
Plugging a TUI into a CLI
The TUI should be one command among many. It parses its arguments like any other command, loads data through the same core functions, runs the app, and acts on the result:
# src/mytool/cli.py
import sys
from typing import Annotated
import typer
app = typer.Typer()
def load_deploys(project: str | None) -> list:
from mytool.tui.browser import Deploy # core would normally live elsewhere
data = [Deploy(4411, "web", "prod", "passed", "ana"), Deploy(4410, "billing", "prod", "failed", "ben")]
return [d for d in data if project in (None, d.project)]
@app.callback()
def main() -> None:
"""Deployment tool."""
@app.command()
def browse(project: Annotated[str | None, typer.Option(help="Only this project.")] = None) -> None:
"""Browse deploys interactively; prints the chosen deploy's ID."""
if not sys.stdout.isatty():
typer.echo("error: browse needs an interactive terminal; use 'mytool deploys list'", err=True)
raise typer.Exit(2)
from mytool.tui.browser import DeployBrowser # lazy: Textual is imported only here
chosen = DeployBrowser(load_deploys(project)).run()
if chosen is None:
raise typer.Exit(1)
typer.echo(chosen.id)
if __name__ == "__main__":
app()
Three details make it a good citizen. The TTY check refuses to start a full-screen app in a pipe or CI job and points at the scriptable equivalent — see detecting CI environments and non-interactive shells. The lazy import keeps Textual's import cost out of every other command. And printing the result makes the TUI composable: mytool deploy rollback $(mytool browse --project web) uses the TUI as an interactive picker inside a shell command.
Testing a TUI
Textual apps are testable without a terminal. app.run_test() starts the app headless at a chosen size and yields a Pilot that presses keys, clicks and waits for events to settle; assertions then query widgets directly:
# tests/test_browser.py
from mytool.tui.browser import Deploy, DeployBrowser
DEPLOYS = [Deploy(1, "web", "prod", "passed", "ana"), Deploy(2, "api", "dev", "failed", "ben")]
async def test_highlight_updates_detail_and_enter_returns_choice():
app = DeployBrowser(DEPLOYS)
async with app.run_test(size=(100, 30)) as pilot:
await pilot.press("down")
await pilot.pause()
assert "status: failed" in str(app.query_one("#detail").render())
await pilot.press("enter")
assert app.return_value == DEPLOYS[1]
Async tests need a runner such as pytest-asyncio (with asyncio_mode = "auto") or anyio's plugin. Testing Textual apps with Pilot covers the full approach, including visual snapshot tests with pytest-textual-snapshot.
Performance and responsiveness
A TUI is judged by how it feels, and two things make Textual apps feel slow: blocking the event loop, and loading everything up front.
Never block the event loop. Textual runs on asyncio; a synchronous HTTP call inside a handler freezes the whole interface until it returns. Load data in a worker — self.run_worker(self.load(), exclusive=True) for async code, or @work(thread=True) for blocking libraries — and update widgets when the worker finishes. Show a loading indicator in the meantime; Textual widgets have a loading property for exactly this.
Load progressively. For large datasets, fetch the first page, render it, and fetch more as the user scrolls or searches, rather than making them wait for everything. The generator-based pagination from paginating API results in a CLI fits naturally into a worker.
Mind startup. Importing Textual and building the widget tree takes a noticeable fraction of a second. That is fine for a command the user runs deliberately to browse, which is another reason to keep it lazily imported and out of every other command's path.
Shipping a CLI that includes a TUI
A TUI changes packaging decisions slightly. Textual is a runtime dependency only of the browse command, so consider making it an extra — pipx install "mytool[tui]" — if many users run the tool only in scripts or CI, where the TUI can never be used anyway. The browse command then checks for the import and prints an installation hint when it is missing, the lazy-optional-dependency pattern from reducing CLI dependency weight. For internal tools used mostly interactively, a normal dependency is simpler.
Keep the stylesheet inside the package and load it with CSS_PATH relative to the app module, so it is included in the wheel like any other package data; see bundling data files with importlib.resources. Standalone binaries built with PyInstaller or Nuitka need Textual's own CSS and data files collected as well, which the doctor-style smoke test in smoke-testing the built wheel in CI will catch if they are missing — importing the TUI module there is enough to surface the problem.
Accessibility and terminals
Full-screen interfaces are harder for some users than line-oriented output: screen readers handle scrolling text better than redrawn regions, and some terminals and multiplexers render box-drawing characters or colours poorly. Textual handles a lot — it adapts to terminal capabilities, supports mouse and keyboard, and has high-contrast themes — but the mitigation that matters most is structural: because every action also exists as a plain command with text output, nobody is ever forced to use the TUI. Respect NO_COLOR, keep key bindings conventional, and show them in the footer, as covered in respecting NO_COLOR and FORCE_COLOR.
Common pitfalls
A handful of mistakes account for most TUI problems in CLI projects:
- Printing from inside the app.
print()andtyper.echo()write to a terminal that Textual is managing, corrupting the display. Useself.log()(visible in the dev console),self.notify()for user-facing messages, or return data from the app and print afterrun()returns. - Doing I/O in
composeoron_mount. Both run on the event loop before the first frame; a slow API call there means a blank screen. Mount the widgets immediately and fill them from a worker. - Logic inside widgets. Filtering, sorting and formatting written in handler methods can only be tested through the Pilot. Extract them into plain functions that the handlers call, and the TUI shrinks to wiring.
- Diverging from the commands. A TUI with its own data-loading and business rules slowly disagrees with the
listandshowcommands. Route both through the same core module. - Forgetting the exit path. Users should always be able to leave with
qor Ctrl+C, and the terminal must be restored afterwards. Textual restores it on normal exit and on exceptions; custom signal handlers oros._exitcalls can bypass that and leave the terminal in a broken state. - Unbounded refresh timers.
set_intervalfor live updates is convenient; make the interval sensible (seconds, not milliseconds) and stop timers when the relevant screen is not visible, or the app burns CPU in the background.
Key takeaways
- Use a TUI for browsing, watching and triage; keep every action available as a scriptable command.
- Make the TUI a single command that reuses core functions and returns a value, so it can act as a picker.
- Structure apps with composed widgets, CSS, message handlers and key-bound actions.
- Keep blocking work in workers, load progressively, and import Textual lazily.
- Test logic with unit tests and interactions with
run_testand Pilot. - Refuse to start without a terminal, and point users at the non-interactive equivalent.
Frequently asked questions
Textual or curses/urwid/prompt_toolkit?
Textual offers the most modern developer experience in Python — components, CSS, async, testing — and renders well across terminals, including on Windows. curses is lower level and Unix-only in the standard library; urwid is mature but older in style; prompt_toolkit excels at rich line editing and prompts rather than full applications. For a new TUI in a Python CLI, Textual is the default choice.
Does Textual work over SSH and in tmux?
Yes. It works in any terminal emulator with reasonable capabilities, including over SSH and inside tmux or screen. Colours and some glyphs degrade gracefully on limited terminals.
Can a Textual app run in a browser?
textual-serve and Textual's web support can serve an app to a browser, which is occasionally useful for sharing an internal tool. Treat it as a bonus rather than a reason to choose Textual.
How big a dependency is Textual?
It depends on Rich and a few small packages; installing it adds a few megabytes. Import time is the bigger cost, which lazy importing inside the TUI command avoids.
How long does it take to build a useful TUI?
A single-screen browser like the one above — a table, a detail panel, a few key bindings — is an afternoon's work once the data-loading functions exist. The time goes into what follows: loading states, error handling for failed fetches, filtering, keyboard polish and tests. Start with one screen that solves one real browsing problem, ship it, and grow it only when users ask.
Should the TUI write to stdout when it exits?
Only its result, and only if the command is designed as a picker. Everything the app draws goes to the terminal on an alternate screen, so once it exits the shell's scrollback is clean and stdout carries just the chosen value.