Python TUIs with Textual, Rich & prompt_toolkit

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages.

Python has three well-maintained libraries for the part of a terminal program that is more than print, and they sit at different heights of the same stack. Rich styles and lays out output: it never reads a key. prompt_toolkit reads lines and, if asked, runs a full-screen layout of its own. Textual is a complete application framework with a widget tree, a stylesheet language, a message queue and a test harness, and it uses Rich underneath for rendering. Below all of them is the standard curses module, a thin binding of the C library that is available on Unix only. This page explains what each one is for, how it behaves on Windows, macOS and Linux, and builds the Bookshelf catalogue browser (shelf tui) in Textual, with the prompt-based shelf repl in prompt_toolkit.

The page was written against Textual 8.2.8, Rich 15.0.0, prompt_toolkit 3.0.53, windows-curses 2.4.2, pytest-textual-snapshot 1.1.0 and Python 3.15.1, as listed on PyPI and in the projects' documentation on 2026-10-25. The machine used to write it has only Python 3.9 and none of these libraries installed, so the Python examples were not run; their syntax was checked with the Python 3.9 byte-compiler, and every class, method and keyword argument in them was checked against the official documentation cited after each example. Treat them as starting points and run them before relying on them. Terminals, ttys and pseudo-terminals are explained on Terminals, TTYs & Pseudo-Terminals and escape sequences on Escape Sequences & Terminal Capabilities; the vocabulary of immediate and retained mode, render pipelines and event loops is on TUI Architecture & Concepts. The Python language itself, argparse and packaging are covered by the Python standard library tour and virtual environments and packaging; Typer, Click and argparse for command-line parsing are on CLI Frameworks in Python. The rule that a TUI should be a view over operations that also work without a terminal is on TUIs vs. CLIs and is followed throughout this page.

Choosing a layer

Library What it does Reads input? Platforms

Rich

Styled text, tables, trees, panels, progress bars, live-updating displays, logging and traceback formatting

Only a simple Console.input prompt

Linux, macOS and Windows

prompt_toolkit

Line editing with history, completion and validation; also a full-screen application toolkit with its own layout and key-binding system

Yes

Linux, macOS and Windows

Textual

Application framework: widgets, CSS-like styling, reactive attributes, messages, screens, workers, command palette, test harness, web serving

Yes

Linux, macOS and Windows; inline mode is not supported on Windows

curses

Character-cell windows, pads, colour pairs and key decoding over ncurses

Yes

Unix only in CPython; on Windows through the third-party windows-curses package

A practical rule: use Rich when the program prints and exits, prompt_toolkit when it asks questions line by line, Textual when it owns the screen until the user quits, and curses only when a dependency-free, standard-library program is required and Windows support is not.

Rich

Rich renders to a Console. The console knows whether it is attached to a terminal, how many colours that terminal supports and how wide it is, and strips or keeps control codes accordingly.

python -m pip install rich

Console, markup and terminal detection

from rich.console import Console

console = Console()                    # writes to stdout
err = Console(stderr=True)             # writes to stderr

console.print("[bold green]shelf[/] is ready")
console.log("catalogue loaded")        # adds a time column and the calling location
console.rule("Loans")

if console.is_terminal:                # True when attached to a terminal
    console.print("interactive session")

with console.status("Reading catalogue..."):    # spinner around a block of work
    pass

The constructor parameters that matter for a command-line tool are summarised below, as documented on the console page.

Setting Effect

stderr=True

Write to standard error. Diagnostics and progress belong there so that standard output stays pipeable.

force_terminal=True

Keep control codes even when the output is not a terminal.

color_system set to auto or None

Auto-detect the colour system, or disable colour entirely.

width=80

Fixed width, useful when writing to files.

record=True

Keep a copy of everything printed so that export_html() and the other exporters can produce it afterwards.

file=StringIO()

Send output to any file object; the usual way to capture output in tests.

Rich also reads environment variables. NO_COLOR removes colour but keeps bold and underline; FORCE_COLOR enables colour, with NO_COLOR taking precedence over it; TERM set to dumb or unknown disables colour, styles and cursor movement; and COLUMNS and LINES set the size when no width or height is passed. These conventions, and why a tool should honour them, are explained on TTY, Colour & Interactivity. The Rich documentation does not describe a no_color constructor argument, so use the environment variable.

Square brackets in a string are markup. [bold red]text[/] styles text, [link=URL]text[/link] creates a hyperlink in terminals that support them, and :warning: is an emoji code. Mismatched tags raise MarkupError. A backslash before a tag shows it literally, and rich.markup.escape() escapes dynamic text. markup=False, passed to print or to Console, turns markup off.

Tables, trees and the shelf listing

shelf list prints the catalogue as a table when its output is a person’s terminal and as tab-separated lines when it is not, which is the pattern that keeps a command scriptable.

import sys

from rich import box
from rich.console import Console
from rich.markup import escape
from rich.table import Table
from rich.tree import Tree

BOOKS = [
    ("978-0-13-468599-1", "The Pragmatic Programmer", "Hunt and Thomas", 2019, None),
    ("978-0-262-03384-8", "Introduction to Algorithms", "Cormen et al.", 2009, "ana"),
    ("978-1-4919-5017-4", "Fluent Python", "Ramalho", 2022, None),
]


def list_books(console: Console) -> None:
    if not console.is_terminal:
        for isbn, title, author, year, borrower in BOOKS:
            print(isbn, title, author, year, borrower or "", sep="\t")
        return

    table = Table(title="Bookshelf", box=box.MINIMAL_DOUBLE_HEAD, row_styles=["dim", ""])
    table.add_column("ISBN", no_wrap=True)
    table.add_column("Title", style="magenta")
    table.add_column("Author")
    table.add_column("Year", justify="right", style="cyan")
    table.add_column("Status")
    for isbn, title, author, year, borrower in BOOKS:
        status = f"[yellow]on loan to {escape(borrower)}[/]" if borrower else "[green]available[/]"
        table.add_row(isbn, escape(title), escape(author), str(year), status)
    console.print(table)


def list_by_author(console: Console) -> None:
    tree = Tree("Bookshelf")
    for _, title, author, _, _ in BOOKS:
        tree.add(escape(author)).add(escape(title))
    console.print(tree)


if __name__ == "__main__":
    console = Console()
    list_books(console)
    if len(sys.argv) > 1 and sys.argv[1] == "--by-author":
        list_by_author(console)

Table takes title, show_header, box and row_styles; add_column takes justify, style and no_wrap; add_row accepts text or any renderable and end_section=True to force a line after the row. Tree(label) creates the root, and add() returns the new branch so that branches can be chained. escape() is used above because titles and borrower names are data: a book titled [red] would otherwise be parsed as a style tag.

Live displays and progress

Live keeps a renderable on screen and redraws it; Progress is a Live specialised for task bars.

import time

from rich.console import Console
from rich.live import Live
from rich.progress import (
    BarColumn,
    MofNCompleteColumn,
    Progress,
    SpinnerColumn,
    TextColumn,
    TimeRemainingColumn,
)
from rich.table import Table

console = Console(stderr=True)       # progress goes to stderr, results to stdout


def import_catalogue(paths: list[str]) -> None:
    with Progress(
        SpinnerColumn(),
        TextColumn("{task.description}"),
        BarColumn(),
        MofNCompleteColumn(),
        TimeRemainingColumn(),
        console=console,
        transient=True,                # remove the bar when the import is finished
    ) as progress:
        task = progress.add_task("Importing", total=len(paths))
        for path in paths:
            time.sleep(0.2)            # parse one file here
            progress.console.print(f"imported {path}")    # printed above the bar
            progress.advance(task)


def watch_loans() -> None:
    table = Table("ISBN", "Borrower")
    with Live(table, refresh_per_second=4, transient=True, console=console) as live:
        for isbn, borrower in [("978-0-13-468599-1", "ana"), ("978-1-4919-5017-4", "bo")]:
            time.sleep(0.4)
            table.add_row(isbn, borrower)
            live.update(table)

Live options are refresh_per_second (default 4), auto_refresh (set to False to call refresh() yourself), screen=True to use the alternate screen, transient=True to remove the display on exit, and vertical_overflow (crop, ellipsis as default, or visible, in which mode the display cannot be properly cleared). Printing through live.console, or progress.console for Progress, shows output above the live area instead of corrupting it. Progress refreshes ten times a second by default, add_task with start=False or total=None gives a pulsing bar for work of unknown length, and the built-in columns include BarColumn, TextColumn, TimeElapsedColumn, TimeRemainingColumn, MofNCompleteColumn and SpinnerColumn. When the output is redirected, animated redraws are noise; build the Console with the check shown earlier and print plain lines instead, as discussed on Progress, Colour & Rich Output. The Rich documentation does not describe a disable option on Progress, so do the check yourself.

Logging and tracebacks

import logging

from rich.logging import RichHandler
from rich.traceback import install

install(show_locals=True)            # every uncaught exception is rendered by Rich

logging.basicConfig(
    level="INFO",
    format="%(message)s",
    datefmt="[%X]",
    handlers=[RichHandler(rich_tracebacks=True, markup=False)],
)

log = logging.getLogger("shelf")
log.info("catalogue loaded")

show_locals=True prints the local variables of every frame, which can expose tokens and personal data in a shared log; leave it off in released tools. The logging page notes that markup is off in RichHandler unless enabled, so leave it off for messages built from user data. RichHandler writes to the console it is given, which defaults to standard output: pass console=Console(stderr=True) when the program’s stdout is data.

Textual

Textual runs a Python application in the terminal, in a web browser through textual serve, or in the test harness.

python -m pip install textual textual-dev
python -m pip install pytest pytest-asyncio pytest-textual-snapshot

textual-dev provides the textual command (development console, run --dev, serve, keys). The installation page states that Textual runs on Linux, macOS and Windows, that the macOS default Terminal supports only 256 colours (iTerm2, Ghostty, Kitty and WezTerm are suggested instead) and that Windows Terminal is recommended on Windows.

The application model

A Textual program is an App subclass. compose() returns an iterable of widgets, usually by yielding them, and containers are used as context managers to nest them. run() enters application mode and returns when exit() is called; the value passed to exit() is the return value of run(), and App[str] declares its type. Textual does not exit the process, so a tool should end with sys.exit(app.return_code or 0). Everything printed to the terminal before run() is restored when the application exits.

The widgets form a tree, the DOM. The App is the root, each App has a stack of Screen objects, and each screen contains containers and widgets. query_one() and query() search this tree with CSS selectors or widget types. query_one("#send", Button) raises NoMatches when nothing matches and WrongType when the match has the wrong type, and it searches only the descendants of the widget it is called on, so use self.screen.query_one instead to search the whole screen.

Textual CSS

Styling lives in a stylesheet, either a .tcss file named by CSS_PATH (a relative path resolves from the file that defines the app) or a string in the CSS class variable.

/* shelf.tcss */
Screen { layout: vertical; }

#search { dock: top; margin: 0 1; }

#main { height: 1fr; }

#catalogue { width: 2fr; }

#side { width: 1fr; }

#details { height: 1fr; border: round $primary; padding: 0 1; }

#loans { height: 1fr; }

LoanDialog { align: center middle; }

#dialog {
    width: 50;
    height: auto;
    padding: 1 2;
    border: thick $primary;
    background: $surface;
}

The CSS guide documents these building blocks.

Feature Behaviour

Selectors

Type (Static, matching base classes too), ID (#dialog), class (.success, chainable as .error.disabled), universal (*), descendant (#dialog Button) and child (#sidebar > Button)

Pseudo-classes

Set by Textual from widget state: :hover, :focus, :blur, :disabled, :first-child, :odd, :even and others

Specificity

Compared by ID count, then class count, then type count; !important overrides everything and should be used sparingly

Units

fr for fractions of the free space, %, and auto

Layouts

vertical (the Screen default), horizontal and grid with grid-size, grid-columns, grid-rows, column-span, row-span and grid-gutter

Docking

dock with top, right, bottom or left pins a widget to an edge so it does not scroll away; among widgets docked to the same edge the one yielded later is on top

Variables

$name: value; can be used in property values and can reference other variables; Textual’s design tokens such as $primary and $surface come from the theme

Nesting

Nested rules inherit the outer selector, and & combines with it (since Textual 0.47.0)

Hot reload

textual run my_app.py --dev reloads CSS file changes without restarting the app

A horizontal layout does not fill its parent’s height by default: set height: 100% or 1fr explicitly. The Layout guide shows Horizontal and Vertical containers from textual.containers for the common cases.

Reactive attributes

A reactive class attribute turns a plain attribute into one that refreshes the widget when it changes.

Feature Behaviour

reactive(default)

Assigning a new value triggers a refresh. A callable default, such as datetime.now, is called to get the value.

var(default)

The same machinery without an automatic refresh or relayout.

watch_<name>

Called when the value changes, with the new value or with the old and new values (two parameters). Set always_update=True to call it even when the value is equal.

validate_<name>

Receives the incoming value and returns the value to store, for clamping.

compute_<name>

Cached derived value, recalculated when another reactive changes. Keep it fast. The order is compute, then validate, then watch.

recompose=True

Removes the children and calls compose() again on change; child state is lost and it is slower than a plain refresh.

data_bind()

Binds a child’s reactive to the parent’s, one way only: WorldClock("Europe/Paris").data_bind(clock_time=WorldClockApp.time).

Events, messages and bubbling

Everything that happens in a Textual application is a message put on a widget’s queue: key presses, mouse clicks, mounts, timers, and the messages widgets post to say that something happened (Button.Pressed, Input.Changed, DataTable.RowSelected). A handler is a method whose name is on_, then the message’s namespace converted from CamelCase to snake_case, then an underscore, then the message class name in snake_case. The namespace is the name of the widget class the message is defined inside, so Input.Changed is handled by on_input_changed and DataTable.RowSelected by on_data_table_row_selected. The @on decorator does the same thing without the naming rule and can add a CSS selector: @on with Button.Pressed and the selector #bell calls the method only for the button with that ID.

Messages have a bubble attribute. When it is true, the message is sent to the widget’s parent after the widget has handled it, and so on up to the App, which is the root of the DOM and has nowhere further to send it. A handler stops the journey by calling event.stop(), which is what a text input does with a key it has consumed so that the key does not also trigger a binding. prevent_default() is different: it stops handlers on base classes of the same widget from running, and the documentation warns against it unless you know what the base classes do. A widget defines its own messages as nested classes, posts them with post_message(), and may handle its own message and let it bubble so that subclasses and parents can react too.

flowchart TD APP["ShelfApp (App, DOM root)"] --> SCR["Screen"] SCR --> HDR["Header"] SCR --> SRCH["Input #search"] SCR --> MAIN["Horizontal #main"] SCR --> FTR["Footer"] MAIN --> CAT["Catalogue (DataTable)"] MAIN --> SIDE["Vertical #side"] SIDE --> DET["Details (Static)"] SIDE --> LOANS["DataTable #loans"] CAT -. "1 DataTable.RowSelected (handled by Catalogue, then stopped)" .-> CAT CAT -. "2 post_message Catalogue.BookChosen (bubbles)" .-> MAIN MAIN -. "3 bubbles to Screen, then App" .-> APP APP -. "4 on_catalogue_book_chosen sets Details.book" .-> DET SRCH -. "Input.Changed bubbles to App, on_input_changed filters Catalogue" .-> APP

Message names such as Catalogue.BookChosen are handled by on_catalogue_book_chosen: the namespace is the outer class, converted to snake_case. The Message class documents stop(), prevent_default(), control (the widget the message is about, for the @on selector) and handler_name (the name of the default handler).

Key bindings and actions

BINDINGS on an app, screen or widget maps keys to actions. An entry is a tuple of key, action and description, or a Binding object when more options are needed. priority=True makes a binding win over the focused widget’s bindings, which is how the default Ctrl+Q quit binding works, and show=False hides it from the footer. Textual checks the focused widget first and then walks up the DOM to the app. An action string such as add_bar('red') calls the method action_add_bar with the argument. Several keys can share an action with a comma (for example r,t as the key string). Bindings cannot be changed at runtime; the Actions guide describes dynamic actions for conditional behaviour. For low-level handling, on_key receives every Key event, a method named key_ plus the key name handles one key, and widgets can handle MouseMove, MouseDown, MouseUp, Click, Enter, Leave and the scroll events (Click is preferred over MouseDown and MouseUp; capture_mouse() sends all mouse events to one widget).

Screens, modal dialogs and modes

A Screen fills the terminal and has its own widgets. Only the top screen of the stack is active. push_screen puts a screen on top (an object or an installed name from SCREENS), pop_screen removes it (the stack must keep at least one screen or ScreenStackError is raised) and switch_screen replaces the top one. A ModalScreen also blocks the app’s bindings behind it, so a repeated key press cannot stack copies of the dialog, and makes the background slightly transparent. A screen returns data with dismiss(value), which pops it and passes the value to the callback given to push_screen; ModalScreen[bool] declares the result type. push_screen_wait returns the value directly but must be called from a worker. A mode is a named, independent screen stack: define MODES, switch with switch_mode, and set DEFAULT_MODE; pushes and pops affect only the active mode. A screen that loses focus receives ScreenSuspend and the new one ScreenResume.

Workers

An event handler that blocks freezes the interface, because handlers run on the event loop. A worker runs the slow part elsewhere. @work turns a method into a worker: an async method runs as an asyncio task, and a regular function needs thread=True, otherwise Textual raises an exception. exclusive=True cancels earlier workers before starting the new one, exit_on_error=False stops a worker exception from exiting the app, and run_worker does the same without the decorator. A worker sends Worker.StateChanged (states PENDING, RUNNING, CANCELLED, ERROR and SUCCESS) to the widget that created it. A thread worker must not call widget methods directly: use App.call_from_thread, or post_message, which is thread-safe. The @work decorator’s group argument is not described on the Workers guide.

The shelf TUI

The screen has a search box on top, the catalogue on the left, a details pane and a loans table on the right, and a footer showing the key bindings. The code uses everything above: a custom widget with its own message, a reactive attribute, a modal dialog with a result, a thread worker and bindings.

# shelf_tui.py
from __future__ import annotations

import sys
import time
from dataclasses import dataclass

from textual import on, work
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal, Vertical
from textual.markup import escape
from textual.message import Message
from textual.reactive import reactive
from textual.screen import ModalScreen
from textual.widgets import DataTable, Footer, Header, Input, Label, Static
from textual.worker import get_current_worker


@dataclass(frozen=True)
class Book:
    isbn: str
    title: str
    author: str
    year: int


def read_catalogue() -> list[Book]:
    """Blocking read; in the real tool this calls the same function as 'shelf list'."""
    time.sleep(0.3)
    return [
        Book("978-0-13-468599-1", "The Pragmatic Programmer", "Hunt and Thomas", 2019),
        Book("978-0-262-03384-8", "Introduction to Algorithms", "Cormen et al.", 2009),
        Book("978-1-4919-5017-4", "Fluent Python", "Ramalho", 2022),
    ]


class Catalogue(DataTable):
    """The list of books. Posts BookChosen when the user presses Enter on a row."""

    class BookChosen(Message):
        def __init__(self, isbn: str) -> None:
            super().__init__()
            self.isbn = isbn

    def on_mount(self) -> None:
        self.cursor_type = "row"
        self.zebra_stripes = True
        self.add_columns("Title", "Author", "Year")

    def show(self, books: list[Book]) -> None:
        self.clear()
        for book in books:
            self.add_row(book.title, book.author, str(book.year), key=book.isbn)

    def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
        event.stop()                                  # do not let the App see the raw message
        if event.row_key.value is not None:
            self.post_message(self.BookChosen(event.row_key.value))


class Details(Static):
    """Shows the highlighted book; refreshes itself when 'book' changes."""

    book: reactive[Book | None] = reactive(None)

    def watch_book(self, book: Book | None) -> None:
        if book is None:
            self.update("Select a book")
        else:
            self.update(
                f"[b]{escape(book.title)}[/b]\n{escape(book.author)}, {book.year}\nISBN {book.isbn}"
            )


class LoanDialog(ModalScreen[str | None]):
    """Asks who borrows the book; dismisses with the name, or None when cancelled."""

    BINDINGS = [Binding("escape", "cancel", "Cancel")]

    def __init__(self, title: str) -> None:
        super().__init__()
        self.book_title = title

    def compose(self) -> ComposeResult:
        with Vertical(id="dialog"):
            yield Label(f"Loan: {escape(self.book_title)}")
            yield Input(placeholder="Borrower", id="borrower")

    @on(Input.Submitted, "#borrower")
    def submit(self, event: Input.Submitted) -> None:
        self.dismiss(event.value.strip() or None)

    def action_cancel(self) -> None:
        self.dismiss(None)


class ShelfApp(App[None]):
    CSS_PATH = "shelf.tcss"
    TITLE = "shelf"
    BINDINGS = [
        Binding("s", "focus_search", "Search"),
        Binding("l", "loan", "Loan"),
        Binding("r", "return_book", "Return"),
        Binding("f5", "reload", "Reload"),
    ]

    def __init__(self) -> None:
        super().__init__()
        self.books: list[Book] = []
        self.loans: dict[str, str] = {}

    def compose(self) -> ComposeResult:
        yield Header()
        yield Input(placeholder="Search title or author", id="search")
        with Horizontal(id="main"):
            yield Catalogue(id="catalogue")
            with Vertical(id="side"):
                yield Details(id="details")
                yield DataTable(id="loans", cursor_type="row")
        yield Footer()

    def on_mount(self) -> None:
        self.query_one("#loans", DataTable).add_columns("Title", "Borrower")
        self.query_one(Catalogue).focus()
        self.action_reload()

    # --- background work -------------------------------------------------
    def action_reload(self) -> None:
        self.load_books()

    @work(thread=True, exclusive=True, exit_on_error=False)
    def load_books(self) -> None:
        books = read_catalogue()                      # blocks, so it runs in a thread
        if not get_current_worker().is_cancelled:
            self.call_from_thread(self.set_books, books)

    def set_books(self, books: list[Book]) -> None:
        self.books = books
        self.apply_filter(self.query_one("#search", Input).value)

    # --- messages -------------------------------------------------------
    @on(Input.Changed, "#search")
    def search_changed(self, event: Input.Changed) -> None:
        self.apply_filter(event.value)

    def apply_filter(self, needle: str) -> None:
        needle = needle.casefold()
        shown = [b for b in self.books if needle in b.title.casefold() or needle in b.author.casefold()]
        self.query_one(Catalogue).show(shown)

    @on(DataTable.RowHighlighted, "#catalogue")
    def highlighted(self, event: DataTable.RowHighlighted) -> None:
        self.query_one(Details).book = self.find(event.row_key.value)

    def on_catalogue_book_chosen(self, message: Catalogue.BookChosen) -> None:
        self.query_one(Details).book = self.find(message.isbn)
        self.action_loan()

    def find(self, isbn: str | None) -> Book | None:
        return next((b for b in self.books if b.isbn == isbn), None)

    # --- actions ----------------------------------------------------------
    def action_focus_search(self) -> None:
        self.query_one("#search", Input).focus()

    def action_loan(self) -> None:
        book = self.query_one(Details).book
        if book is None or book.isbn in self.loans:
            self.bell()
            return

        def done(borrower: str | None) -> None:       # called when the dialog is dismissed
            if borrower:
                self.loans[book.isbn] = borrower
                self.refresh_loans()

        self.push_screen(LoanDialog(book.title), done)

    def action_return_book(self) -> None:
        book = self.query_one(Details).book
        if book is not None and self.loans.pop(book.isbn, None) is not None:
            self.refresh_loans()

    def refresh_loans(self) -> None:
        table = self.query_one("#loans", DataTable)
        table.clear()
        for isbn, borrower in self.loans.items():
            book = self.find(isbn)
            table.add_row(book.title if book else isbn, borrower, key=isbn)


if __name__ == "__main__":
    app = ShelfApp()
    app.run()
    sys.exit(app.return_code or 0)

Points worth noticing in the example, each following the guides cited above:

  • Catalogue handles its own DataTable.RowSelected, stops it, and posts a BookChosen message of its own, so the application depends on a domain event rather than on table internals. The handler name on_catalogue_book_chosen is derived from the class names as described earlier.

  • Details.book is a reactive attribute: assigning to it from any handler calls watch_book, which updates the text. The reactive is declared on the class, and Textual’s Static.update accepts a string, a Content object or a Rich renderable.

  • Titles and names are data, so they pass through textual.markup.escape before being put into a markup string. The Content guide documents that function, and the markup=False argument of Static for text that should never be parsed. Textual’s widget markup is its own implementation: use textual.markup.escape there and rich.markup.escape only for Rich consoles.

  • load_books is a thread worker because read_catalogue blocks. It checks is_cancelled before delivering, and the delivery goes through call_from_thread. exclusive=True means that pressing F5 twice cancels the first load.

  • LoanDialog is a ModalScreen whose result type is str | None; the caller supplies a callback, so the dialog knows nothing about loans.

  • Plain-letter bindings (s, l, r) do not fire while the search box has focus, because an input that consumes a key stops the event before it reaches a binding.

  • The shelf tui entry point should call the same domain functions as the one-shot commands; a TUI that duplicates the logic cannot be driven by scripts or agents.

Command palette

Ctrl+P opens the command palette, a fuzzy-searchable list of commands. The binding can be changed with COMMAND_PALETTE_BINDING, and ENABLE_COMMAND_PALETTE = False turns the palette off. Add simple commands by overriding get_system_commands(self, screen) and yielding SystemCommand(title, help, callback) after calling super(); add searchable ones with a Provider subclass registered in COMMANDS.

from collections.abc import Iterable

from textual.app import App, SystemCommand
from textual.command import Hit, Hits, Provider
from textual.screen import Screen


class ShelfCommands(Provider):
    async def search(self, query: str) -> Hits:
        matcher = self.matcher(query)
        label = "Reload catalogue"
        score = matcher.match(label)
        if score > 0:
            yield Hit(score, matcher.highlight(label), self.app.action_reload, help="Read the books again")


class PaletteDemo(App):
    COMMANDS = App.COMMANDS | {ShelfCommands}

    def get_system_commands(self, screen: Screen) -> Iterable[SystemCommand]:
        yield from super().get_system_commands(screen)
        yield SystemCommand("Ring the bell", "Beep", self.bell)

The search method yields Hit objects with a score (0 means no match), the highlighted text and a callback; discover yields DiscoveryHit objects for the empty input, and startup and shutdown are optional hooks. A COMMANDS class variable on a Screen limits commands to that screen. Errors raised by a provider do not exit the app and are logged to the devtools console. The provider above calls action_reload, so it assumes the shelf application from the previous section.

Running modes: terminal, inline, web

python shelf_tui.py                      # full-screen application mode
textual run shelf_tui.py --dev           # same, with live CSS reload and devtools
textual serve shelf_tui.py               # serve the same app in a web browser

run(inline=True) runs the app beneath the shell prompt instead of taking over the screen (added in Textual 0.55.0). The documentation states that inline mode is not supported on Windows. textual serve runs an app, or a command such as the command python -m textual, in a browser, and refreshing the page picks up code changes. Ctrl+Z suspension is disabled by default and can be bound to action_suspend_process; the documentation states it is ignored on Windows and under textual-web. App.suspend is a context manager that temporarily leaves application mode, which is how to hand the terminal to another program such as an editor.

Devtools

Because a Textual app owns the terminal, print output would overwrite the display. The devtools solve this with a second terminal:

textual console                          # terminal 1: receives logs, print output and events
textual run --dev shelf_tui.py           # terminal 2: runs the app connected to the console
textual console -x EVENT -x SYSTEM       # hide message groups; -v is verbose, --port sets the port

self.log and from textual import log send strings, variables, key/value pairs and Rich renderables to the console, TextualHandler forwards standard logging records (strings only), and the console restores print so it appears there instead of on the app’s screen.

Testing with Pilot and snapshots

App.run_test() runs the app headless and yields a Pilot; the default size is 80x24 and size=(cols, rows) changes it. Pilot methods documented on the testing page are press(*keys), click(selector, offset, times, shift, meta, control), hover(selector) and pause(delay), which waits for pending messages. Async tests need pytest-asyncio, configured with asyncio_mode = auto in the pytest configuration (or --asyncio-mode=auto), otherwise each test needs @pytest.mark.asyncio.

# pytest.ini
[pytest]
asyncio_mode = auto
# test_shelf_tui.py
from textual.widgets import DataTable, Input

from shelf_tui import ShelfApp


async def test_search_filters_catalogue():
    app = ShelfApp()
    async with app.run_test(size=(100, 30)) as pilot:
        await pilot.pause(delay=0.5)               # the thread worker delivers the books
        table = app.query_one("#catalogue", DataTable)
        assert table.row_count == 3

        await pilot.press("s")                     # focus the search box
        await pilot.press("p", "y", "t", "h", "o", "n")
        await pilot.pause()
        assert app.query_one("#search", Input).value == "python"
        assert table.row_count == 0


async def test_loan_dialog_records_borrower():
    app = ShelfApp()
    async with app.run_test(size=(100, 30)) as pilot:
        await pilot.pause(delay=0.5)
        await pilot.press("l")                     # open the dialog for the highlighted book
        await pilot.press("a", "n", "a", "enter")
        await pilot.pause()
        assert list(app.loans.values()) == ["ana"]


def test_layout_snapshot(snap_compare):
    assert snap_compare("shelf_tui.py", terminal_size=(100, 30), press=["down"])

The snapshot test uses the snap_compare fixture from pytest-textual-snapshot. The first run always fails because there is no baseline; review the generated report, then save the baseline with pytest --snapshot-update and commit the stored snapshots. run_before takes an async function that receives the pilot and runs setup code before the capture. The testing page does not document a way to resize the terminal from a pilot, so pass size= to run_test instead. How these tests fit with end-to-end tests through a real pseudo-terminal is on Testing Terminal Apps & TUIs.

prompt_toolkit

prompt_toolkit is a pure-Python library for line input and for full-screen applications. It is the engine behind IPython’s prompt and many REPLs.

python -m pip install prompt_toolkit

The shelf repl

A PromptSession keeps history across prompts, and the arguments of prompt() add completion, suggestions, a toolbar and validation.

# shelf_repl.py
from pathlib import Path

from prompt_toolkit import PromptSession
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
from prompt_toolkit.completion import FuzzyCompleter, WordCompleter
from prompt_toolkit.history import FileHistory

COMMANDS = ["list", "add", "loan", "return", "quit"]


def main() -> None:
    history_file = Path.home() / ".shelf_history"
    session = PromptSession(history=FileHistory(str(history_file)))
    completer = FuzzyCompleter(WordCompleter(COMMANDS))

    while True:
        try:
            line = session.prompt(
                "shelf> ",
                completer=completer,
                auto_suggest=AutoSuggestFromHistory(),
                bottom_toolbar=lambda: "Type 'quit' to leave",
            )
        except (EOFError, KeyboardInterrupt):      # Ctrl-D and Ctrl-C
            break
        if line.strip() == "quit":
            break
        print(f"you typed: {line}")


if __name__ == "__main__":
    main()

The prompt page documents these options: FileHistory persists history across runs; FuzzyCompleter wrapped around WordCompleter lets shw match show; AutoSuggestFromHistory shows grey suggestions; bottom_toolbar accepts a callable that is re-rendered on every redraw; Validator.from_callable (with error_message and move_cursor_to_end arguments) builds a validator; multiline=True accepts input with Meta+Enter; is_password=True hides the input. The page warns that complete_while_typing is incompatible with enable_history_search, and for asyncio programs recommends prompt_async() inside patch_stdout() so that other output does not corrupt the prompt. History files hold what the user typed: do not point a password prompt at the same session history, and keep the history file private. How readline, libedit, rustyline and the other line editors compare is on Interactive Prompts & Line Editing.

Full-screen applications

Application with layout, key_bindings and full_screen=True runs in the alternate screen buffer. Layouts are built from containers (HSplit, VSplit, Window) and controls (FormattedTextControl for text, BufferControl for an editable buffer) and wrapped in a Layout, which also tracks focus. Key bindings are registered with a KeyBindings object.

from prompt_toolkit import Application
from prompt_toolkit.buffer import Buffer
from prompt_toolkit.key_binding import KeyBindings
from prompt_toolkit.layout.containers import HSplit, VSplit, Window
from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl
from prompt_toolkit.layout.layout import Layout

kb = KeyBindings()


@kb.add("c-q")
def _(event) -> None:
    event.app.exit()


root = HSplit([
    Window(height=1, content=FormattedTextControl(text="shelf -- Ctrl-Q quits")),
    VSplit([
        Window(content=BufferControl(buffer=Buffer())),                 # search box
        Window(width=1, char="|"),
        Window(content=FormattedTextControl(text="Fluent Python\nRamalho, 2022")),
    ]),
])

Application(layout=Layout(root), key_bindings=kb, full_screen=True).run()

Frame and TextArea are prebuilt widgets for this toolkit; the full-screen page mentions them only in passing and does not document mouse_support, so read the reference pages before depending on them. Compared with Textual, prompt_toolkit has no stylesheet language, no reactive attributes and no test harness of its own: it is the better choice for a prompt, an editor field or a small dialog, and Textual for a screen with many interacting parts.

The curses module

curses in the standard library binds the ncurses API. The documentation lists its availability as Unix, and states that it is not available on Android, iOS or WASI; it is an optional module, so a distribution may omit it. curses.wrapper(func, *args) is the recommended entry point: it calls initscr(), turns on cbreak mode, turns off echo, enables keypad mode, initialises colours, calls the function with the standard screen, and restores the terminal on exit, including when the function raises. Without it, the program must call curses.endwin() in a finally block.

# shelf_curses.py
import curses

BOOKS = ["The Pragmatic Programmer", "Introduction to Algorithms", "Fluent Python"]


def main(stdscr: "curses._CursesWindow") -> None:
    curses.use_default_colors()
    if curses.has_colors():
        curses.init_pair(1, curses.COLOR_CYAN, -1)     # -1 is the terminal's default background
    selected = 0

    while True:
        stdscr.erase()
        stdscr.addstr(0, 0, "shelf -- up/down move, q quits")
        for row, title in enumerate(BOOKS, start=2):
            attr = curses.A_REVERSE if row - 2 == selected else curses.color_pair(1)
            stdscr.addstr(row, 2, title, attr)
        stdscr.refresh()

        key = stdscr.getch()
        if key == curses.KEY_RESIZE:
            curses.update_lines_cols()                 # LINES and COLS now have the new size
        elif key == curses.KEY_UP:
            selected = (selected - 1) % len(BOOKS)
        elif key == curses.KEY_DOWN:
            selected = (selected + 1) % len(BOOKS)
        elif key == ord("q"):
            break


curses.wrapper(main)

Facts from the module documentation to keep in mind:

  • getch() returns an integer key code, -1 when a non-blocking read finds nothing, and a series of byte values for a non-ASCII character (in UTF-8, é arrives as 195 and then 169); get_wch() reads a whole character as a string.

  • KEY_RESIZE is returned by getch() on terminal resize, and update_lines_cols() refreshes LINES and COLS. The documentation does not spell out when it is sent, so test it on each target terminal.

  • set_escdelay(ms) sets how long curses waits after an Escape byte to tell a lone Esc from the start of a sequence.

  • Writing outside a window, or to its lower-right corner, raises curses.error (for the corner, after the character is drawn).

  • start_color() must precede other colour routines, init_pair(n, fg, bg) takes n from 1 to COLOR_PAIRS - 1, and curses.color_pair(n) returns the attribute, which can be combined with the A_ attribute constants using the bitwise or operator.

  • curses.textpad.Textbox provides a one-line or multi-line editor with Emacs-style keys, finished with Ctrl-G.

  • Thread safety depends on the underlying library; the documentation warns that with the default ncurses build the screen state is shared and calls from several threads must be serialised. The curses.panel module is only mentioned there as a panel stack extension.

On Windows the standard library has no curses. The third-party windows-curses package (2.4.2, released 2026-03-31, marked Beta on PyPI) adds the same module on top of PDCurses, with wide-character support and UTF-8 forced as the encoding. Its PyPI page lists wheels for Windows on x86 and x86-64 and Python 3.6 through 3.14, so check that a wheel exists for the Python you target. From version 2.0 it calls resize_term(0, 0) automatically when a read returns KEY_RESIZE, to imitate ncurses' handling of SIGWINCH; the author describes this as a non-standard workaround.

Windows, macOS and Linux

Topic Linux macOS Windows

Terminal

Any terminal emulator; the Linux console has its own guidance in the Textual docs

Default Terminal supports 256 colours only; iTerm2, Ghostty, Kitty and WezTerm suggested

Windows Terminal recommended by the Textual docs

Textual inline mode

Supported

Supported

Not supported

Textual Ctrl+Z suspend

Bindable to action_suspend_process

Bindable to action_suspend_process

Ignored

curses

Standard library (ncurses)

Standard library (ncurses)

Not in the standard library; windows-curses (PDCurses)

Resize notification

KEY_RESIZE from ncurses

KEY_RESIZE from ncurses

KEY_RESIZE from PDCurses, with windows-curses calling resize_term(0, 0)

The Rich and Textual documentation consulted do not state which Windows versions they require or how they enable virtual-terminal processing; for the Win32 background see Windows Console & Processes and Cross-Platform Portability.

Pitfalls and security

  • Escape sequences in data. A book title or borrower name that reaches the terminal unfiltered can carry control sequences. Rich and Textual parse square brackets as markup, which is a second injection channel: always escape dynamic text (rich.markup.escape, textual.markup.escape) or turn markup off. See Security for the wider discussion.

  • Output to the wrong stream. A Rich Console() writes to stdout. Progress bars, spinners and log handlers belong on stderr (Console(stderr=True)), so that shelf list > books.tsv still produces clean data.

  • Blocking the event loop. A slow call inside a Textual handler, or a time.sleep, freezes the interface; use a worker. A thread worker must reach the UI through call_from_thread or post_message.

  • Exiting without cleanup. Textual restores the terminal on exit. With plain curses, use curses.wrapper, which restores the terminal when the function raises; if you call initscr() yourself, call endwin() in a finally block.

  • Printing from inside a TUI. print writes into the screen the framework is drawing. Use self.log and the textual console, or a RichHandler aimed at a file.

  • Missing 24-bit colour. macOS Terminal supports only 256 colours; test the stylesheet there and with NO_COLOR set, and never rely on colour alone to convey a state (the book is "on loan" in text as well as in yellow). Accessibility limits of full-screen interfaces are on Accessibility & Internationalization.

  • show_locals in shared logs. Rich tracebacks with show_locals=True print variable values.

  • Dependency versions. All three libraries release often; pin versions in pyproject.toml and re-run the snapshot tests when upgrading Textual or Rich, because both change rendering details.

  • Non-interactive use. Check Console.is_terminal, or sys.stdin.isatty(), before starting a TUI or a prompt, and fall back to the plain command when the check fails, as the pages in the AI sub-section describe.

For shipping a Python TUI as a single executable see Packaging & Distribution and Native Binaries & Cross-Compilation. The same catalogue browser in other languages is on Ratatui: Getting Started, Go TUIs with Bubble Tea and .NET TUIs with Spectre.Console & Terminal.Gui.