A common moment in a CLI: the command needs one item from a list the user cannot be expected to remember. Which deployment to roll back, which branch to check out, which of 300 failed jobs to inspect. The scriptable answer is an argument — mytool rollback deploy-8841 — and it must remain available. But for a person at a terminal, a picker is far kinder: a table of candidates, arrow keys to move, a few characters typed to filter, Enter to choose. Textual's DataTable widget makes that a small, self-contained app that returns the chosen record to the command that launched it. This guide builds a reusable picker with filtering and sorting, wires it into a Typer command as an optional interactive path, and tests it with Textual's Pilot. It belongs to the Textual topic.
Prerequisites
- Textual 1.0+ (checked with 8.2) and Typer.
- pytest with
pytest-asyncioinasyncio_mode = "auto"(or anyio's plugin), as set up in testing Textual apps with Pilot. - The basics of app structure from building your first Textual app.
The shape of a picker
The picker is an App whose return value is the result: the selected record, or None if the user cancelled. The command creates it with data it already loaded, runs it, and continues with the result — exactly like a prompt, but for a list. Keeping the app generic (records plus column names) means one picker serves every command that needs a choice.
The recipe
# src/mytool/picker.py
from __future__ import annotations
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.widgets import DataTable, Footer, Input
class Picker(App[dict | None]):
"""Pick one record from a list, with filtering and sorting."""
CSS = "Input { dock: top; }"
BINDINGS = [
Binding("escape", "cancel", "Cancel"),
Binding("s", "sort_status", "Sort by status"),
Binding("/", "focus_filter", "Filter"),
]
def __init__(self, records: list[dict], columns: list[str]) -> None:
super().__init__()
self.records = records
self.columns = columns
def compose(self) -> ComposeResult:
yield Input(placeholder="filter…", id="filter")
yield DataTable(cursor_type="row", zebra_stripes=True, id="table")
yield Footer()
def on_mount(self) -> None:
table = self.query_one(DataTable)
for column in self.columns:
table.add_column(column, key=column)
self.fill("")
table.focus()
def fill(self, text: str) -> None:
table = self.query_one(DataTable)
table.clear()
needle = text.lower()
for index, record in enumerate(self.records):
if needle in " ".join(str(v) for v in record.values()).lower():
table.add_row(*(str(record[c]) for c in self.columns), key=str(index))
def on_input_changed(self, event: Input.Changed) -> None:
self.fill(event.value)
def on_input_submitted(self, event: Input.Submitted) -> None:
self.query_one(DataTable).focus()
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
self.exit(self.records[int(event.row_key.value)])
def action_sort_status(self) -> None:
self.query_one(DataTable).sort("status")
def action_focus_filter(self) -> None:
self.query_one(Input).focus()
def action_cancel(self) -> None:
self.exit(None)
The important design choices:
cursor_type="row"makes the whole row the unit of selection, and the table emitsRowSelectedwhen the user presses Enter on it.- Row keys map back to records. Each row's key is the record's index in the original list, so filtering and sorting never lose track of which record a row represents.
- Filtering rebuilds the rows. For hundreds or a few thousand records, clearing and re-adding rows on each keystroke is fast and simple. For much larger lists, filter in a worker and update in batches.
- Column keys enable sorting.
table.sort("status")sorts by the column added withkey="status". - Bindings show up in the footer, so
/,sandEscare discoverable without documentation.
Adding a preview pane
When rows are too narrow to show everything a user needs to decide, add a pane that shows the highlighted record in full. DataTable posts a RowHighlighted message every time the cursor moves, which is all a preview needs:
# src/mytool/preview.py
from __future__ import annotations
import json
from textual.app import ComposeResult
from textual.containers import Horizontal
from textual.widgets import DataTable, Footer, Input, Static
from mytool.picker import Picker
class PreviewPicker(Picker):
CSS = """
Input { dock: top; }
#table { width: 2fr; }
#preview { width: 1fr; border-left: solid $accent; padding: 0 1; }
"""
def compose(self) -> ComposeResult:
yield Input(placeholder="filter…", id="filter")
with Horizontal():
yield DataTable(cursor_type="row", zebra_stripes=True, id="table")
yield Static(id="preview")
yield Footer()
def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
if event.row_key is None or event.row_key.value is None:
return
record = self.records[int(event.row_key.value)]
self.query_one("#preview", Static).update(json.dumps(record, indent=2))
The subclass reuses everything — filtering, sorting, bindings, the return value — and only changes the layout and adds one handler. The fractional widths give the table two thirds of the screen and the preview one third, and adapt as the terminal is resized; styling Textual apps with TCSS covers the layout rules. A Pilot test can press down, call await pilot.pause() to let the message be handled, and assert on the preview's rendered text.
Wiring it into a command
The picker is the interactive fallback for a missing argument — never the only path:
# src/mytool/cli.py
import sys
from typing import Annotated, Optional
import typer
app = typer.Typer()
DEPLOYS = [
{"id": "deploy-8841", "service": "api", "status": "ok"},
{"id": "deploy-8840", "service": "api", "status": "failed"},
{"id": "deploy-8839", "service": "web", "status": "ok"},
]
@app.callback()
def main() -> None:
"""Deploy tool."""
@app.command()
def rollback(deploy_id: Annotated[Optional[str], typer.Argument()] = None) -> None:
"""Roll back a deployment. Without DEPLOY_ID, choose one interactively."""
if deploy_id is None:
if not sys.stdin.isatty() or not sys.stdout.isatty():
typer.echo("error: DEPLOY_ID is required when not running interactively", err=True)
raise typer.Exit(2)
from mytool.picker import Picker # import Textual only when needed
chosen = Picker(DEPLOYS, ["id", "service", "status"]).run()
if chosen is None:
raise typer.Exit(1)
deploy_id = chosen["id"]
typer.echo(f"rolling back {deploy_id}")
Three rules keep this well-behaved. The argument always works, so scripts and CI never meet the picker. The picker only appears when both stdin and stdout are terminals — the detection from detecting a TTY and adapting output. And Textual is imported inside the branch, so commands that never show a picker do not pay its import cost.
UX considerations
- Start with the cursor on the most likely choice — the newest item, the failed one — using
table.move_cursor(row=...)after filling. - Keep columns few and short. Three or four columns that identify the record; details belong in a preview pane or the command's output, not the picker.
- Make cancel obvious and safe.
EscreturnsNone, and the command exits without doing anything. - Show what Enter will do. A footer or title such as "Select a deployment to roll back" reminds users that choosing is an action.
- Respect small terminals. Textual adapts layout to the window, but very long cell values make tables hard to read; truncate in the data you pass in.
Testing the behaviour
Pilot drives the app with simulated key presses, and the return value is available after the app exits:
# tests/test_picker.py
from mytool.picker import Picker
RECORDS = [
{"name": "web-1", "region": "eu-west", "status": "ok"},
{"name": "web-2", "region": "eu-west", "status": "failed"},
{"name": "db-1", "region": "us-east", "status": "ok"},
]
COLUMNS = ["name", "region", "status"]
async def test_enter_selects_highlighted_row():
app = Picker(RECORDS, COLUMNS)
async with app.run_test() as pilot:
await pilot.press("down", "enter")
assert app.return_value == RECORDS[1]
async def test_filter_then_select():
app = Picker(RECORDS, COLUMNS)
async with app.run_test() as pilot:
await pilot.press("/", *"us-east", "enter", "enter")
assert app.return_value["name"] == "db-1"
async def test_escape_returns_none():
app = Picker(RECORDS, COLUMNS)
async with app.run_test() as pilot:
await pilot.press("escape")
assert app.return_value is None
async def test_sort_by_status_puts_failed_first():
app = Picker(RECORDS, COLUMNS)
async with app.run_test() as pilot:
await pilot.press("s", "enter")
assert app.return_value["status"] == "failed"
The filter test is a small end-to-end flow — focus the input, type, return to the table, select — and runs in well under a second. Test the command's non-interactive path separately with CliRunner: without a TTY, rollback with no argument must exit 2 with a clear message rather than trying to start a TUI.
Conclusion
A Textual DataTable picker turns "which one?" from a lookup into a keystroke: rows keyed back to records, a filter input that rebuilds rows as the user types, column keys for sorting, bindings in the footer, and the selection returned through App.exit(). Launch it only when an argument is missing and both streams are terminals, import Textual lazily, and test the interaction with Pilot and the non-interactive path with CliRunner.
Frequently asked questions
Can the picker allow multiple selections?
Yes — keep a set of selected row keys, toggle membership on Space, style selected rows (or prefix them with a marker column), and exit with the list on Enter. Return an empty list for "nothing selected" and None for "cancelled", so the command can tell them apart.
How do I load records that take time to fetch?
Show the picker immediately with a loading indicator and fetch in a worker, adding rows when the data arrives, as described in running background work in Textual with workers.
Is a full TUI overkill compared with a simple prompt?
For five choices, a prompt is fine — see building interactive prompts and menus. Filtering and sorting start paying off around twenty items, and become essential past a hundred.
Does the picker work over SSH and in tmux?
Yes, Textual works in any modern terminal, including over SSH and inside tmux or screen. Very old terminals or TERM=dumb sessions should get the non-interactive path, which the TTY check already provides.