Input & UX

Browsing Records with a Textual DataTable Picker

Build a Textual record picker for a Python CLI: a DataTable with row cursor, live filtering, sorting, key bindings, returning the chosen record to the command, and Pilot tests.

Updated

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

The shape of a picker

A picker as a function call A command loads records, runs a Textual picker app, and receives the chosen record or None as the app’s return value. A picker as a function call Command argument missing Picker(records) run() User filter, sort, Enter Return value record or None TTY? keys exit() The argument path always exists; the picker is only the interactive fallback.

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 emits RowSelected when 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 with key="status".
  • Bindings show up in the footer, so /, s and Esc are discoverable without documentation.
Choosing a deployment Terminal session showing a rollback command with an explicit ID, and without one in a pipe where it refuses to start the picker. Choosing a deployment bash $ mytool rollback deploy-8840 rolling back deploy-8840 $ mytool rollback # opens the picker; Enter on a row rolling back deploy-8840 $ mytool rollback < /dev/null error: DEPLOY_ID is required when not running interactively Scripts and CI never meet the picker.

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. Esc returns None, 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.
Prompt, picker or argument? A decision guide choosing between a required argument, a simple prompt and a Textual picker depending on how many choices there are and who runs the command. Prompt, picker or argument? How many candidates, and who is choosing? A script or CI job Argument always required there A person, under ~20 items Prompt numbered menu A person, many items Picker filter + sort All three can coexist in one command.

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.