Textual builds terminal user interfaces in Python. pip install textual. App: from textual.app import App, ComposeResult. class MyApp(App): def compose(self) -> ComposeResult: yield Label("Hello"). if __name__ == "__main__": MyApp().run(). CSS: DEFAULT_CSS = "Label { color: green; }". Widgets: from textual.widgets import Button, Input, Label, Header, Footer, DataTable, Static, Switch, Select, Checkbox. Layout: yield Horizontal(Button("OK"), Button("Cancel")). yield Vertical(Label("Name:"), Input()). Events: def on_button_pressed(self, event: Button.Pressed) -> None: self.exit(event.button.id). def on_input_submitted(self, event: Input.Submitted) -> None: .... Reactive: from textual.reactive import reactive. count: reactive[int] = reactive(0). def watch_count(self, val: int) -> None: self.query_one("#counter", Label).update(str(val)). Mount: def on_mount(self) -> None: self.query_one(Input).focus(). Screen: from textual.screen import Screen. class MyScreen(Screen): def compose(self): yield Label("Screen"). self.app.push_screen(MyScreen()). self.app.pop_screen(). DataTable: table = DataTable(); table.add_columns("Name","Score"); table.add_rows(data). Sort: table.sort("Score", reverse=True). Query: self.query_one("#id", Widget). self.query(".class"). Action: def action_quit(self) -> None: self.exit(). BINDINGS = [("q","quit","Quit")]. Notify: self.notify("Saved!", title="Info"). Testing: async with MyApp().run_test() as pilot: await pilot.click("#btn"). Claude Code generates Textual apps, reactive widgets, and DataTable UIs.
CLAUDE.md for Textual
## Textual Stack
- Version: textual >= 0.70 | pip install textual
- App: class MyApp(App): compose() yields widgets; run() starts event loop
- Widgets: Button Label Input DataTable Header Footer Static Switch Select
- Reactive: count: reactive[int] = reactive(0) | watch_count(self, val) callback
- Events: def on_button_pressed(event) | on_input_submitted | on_mount
- Screen: push_screen(MyScreen()) | pop_screen() for modal dialogs
- Test: async with MyApp().run_test() as pilot: await pilot.click("#id")
Textual TUI Pipeline
# app/tui.py — Textual dashboard, form, and DataTable patterns
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from typing import Iterable
from textual import on, work
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Container, Horizontal, ScrollableContainer, Vertical
from textual.reactive import reactive
from textual.screen import ModalScreen, Screen
from textual.widgets import (
Button,
Checkbox,
DataTable,
Footer,
Header,
Input,
Label,
Select,
Static,
Switch,
)
# ─────────────────────────────────────────────────────────────────────────────
# Data model
# ─────────────────────────────────────────────────────────────────────────────
@dataclass
class Task:
id: int
title: str
priority: str # low / medium / high
done: bool = False
created: str = ""
def __post_init__(self) -> None:
if not self.created:
self.created = datetime.now().strftime("%Y-%m-%d %H:%M")
# ─────────────────────────────────────────────────────────────────────────────
# 1. Confirm modal screen
# ─────────────────────────────────────────────────────────────────────────────
class ConfirmScreen(ModalScreen[bool]):
"""A modal dialog that returns True (confirmed) or False (cancelled)."""
DEFAULT_CSS = """
ConfirmScreen {
align: center middle;
}
ConfirmScreen > Container {
width: 40;
height: 10;
border: thick $background 80%;
background: $surface;
padding: 1 2;
}
ConfirmScreen Label {
width: 100%;
content-align: center middle;
margin-bottom: 1;
}
"""
def __init__(self, message: str) -> None:
super().__init__()
self.message = message
def compose(self) -> ComposeResult:
with Container():
yield Label(self.message)
with Horizontal():
yield Button("Yes", id="yes", variant="error")
yield Button("No", id="no", variant="primary")
def on_button_pressed(self, event: Button.Pressed) -> None:
self.dismiss(event.button.id == "yes")
# ─────────────────────────────────────────────────────────────────────────────
# 2. Add task form screen
# ─────────────────────────────────────────────────────────────────────────────
class AddTaskScreen(Screen[Task | None]):
"""
A screen that collects task data and dismisses with a Task or None.
The parent app receives the result via the callback passed to push_screen.
"""
DEFAULT_CSS = """
AddTaskScreen {
align: center middle;
}
AddTaskScreen > Vertical {
width: 50;
height: auto;
border: round $primary;
padding: 1 2;
background: $surface;
}
AddTaskScreen Label.field-label {
margin-top: 1;
}
"""
def compose(self) -> ComposeResult:
with Vertical():
yield Label("Add New Task", id="dialog-title")
yield Label("Title:", classes="field-label")
yield Input(placeholder="Task title…", id="title-input")
yield Label("Priority:", classes="field-label")
yield Select(
[("Low", "low"), ("Medium", "medium"), ("High", "high")],
id="priority-select",
value="medium",
)
with Horizontal():
yield Button("Add", id="add-btn", variant="success")
yield Button("Cancel", id="cancel-btn", variant="default")
def on_mount(self) -> None:
self.query_one("#title-input", Input).focus()
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "cancel-btn":
self.dismiss(None)
return
title_input = self.query_one("#title-input", Input)
priority_select = self.query_one("#priority-select", Select)
title = title_input.value.strip()
if not title:
self.app.notify("Title cannot be empty.", severity="error")
return
task = Task(
id=0, # assigned by parent
title=title,
priority=priority_select.value or "medium",
)
self.dismiss(task)
# ─────────────────────────────────────────────────────────────────────────────
# 3. Task manager DataTable screen
# ─────────────────────────────────────────────────────────────────────────────
class TaskManagerScreen(Screen):
"""Main task list screen with a DataTable and action buttons."""
BINDINGS = [
Binding("a", "add_task", "Add task"),
Binding("d", "delete_task", "Delete"),
Binding("space", "toggle_done", "Toggle done"),
]
DEFAULT_CSS = """
TaskManagerScreen {
layout: vertical;
}
#toolbar {
height: 3;
dock: top;
background: $primary-darken-2;
padding: 0 1;
}
#stats {
height: 1;
dock: bottom;
background: $surface;
color: $text-muted;
padding: 0 1;
}
DataTable {
height: 1fr;
}
"""
tasks: reactive[list[Task]] = reactive([], recompose=False)
_next_id: int = 1
def compose(self) -> ComposeResult:
yield Header()
with Horizontal(id="toolbar"):
yield Button("+ Add", id="add-btn", variant="success")
yield Button("Delete", id="del-btn", variant="error")
yield Button("Toggle Done", id="toggle-btn")
yield DataTable(cursor_type="row")
yield Static(id="stats")
yield Footer()
def on_mount(self) -> None:
table = self.query_one(DataTable)
table.add_columns("✓", "ID", "Title", "Priority", "Created")
# Seed with sample tasks
sample = [
Task(id=1, title="Write documentation", priority="high"),
Task(id=2, title="Add tests", priority="medium"),
Task(id=3, title="Fix bug #42", priority="high"),
]
self._next_id = 4
for t in sample:
self._add_row(table, t)
self._update_stats()
def _add_row(self, table: DataTable, task: Task) -> None:
done_mark = "✓" if task.done else " "
prio_icon = {"high": "🔴", "medium": "🟡", "low": "🟢"}.get(task.priority, "")
table.add_row(
done_mark,
str(task.id),
task.title,
f"{prio_icon} {task.priority}",
task.created,
key=str(task.id),
)
def _update_stats(self) -> None:
table = self.query_one(DataTable)
total = table.row_count
self.query_one("#stats", Static).update(f"{total} task(s)")
# ── Actions ──────────────────────────────────────────────────────────────
def action_add_task(self) -> None:
self.app.push_screen(AddTaskScreen(), self._on_task_added)
def _on_task_added(self, task: Task | None) -> None:
if task is None:
return
task.id = self._next_id
self._next_id += 1
table = self.query_one(DataTable)
self._add_row(table, task)
self._update_stats()
self.app.notify(f"Added: {task.title}", title="Task added")
def action_delete_task(self) -> None:
table = self.query_one(DataTable)
if table.cursor_row is None:
return
def _confirmed(yes: bool) -> None:
if yes:
table.remove_row(table.cursor_row_key)
self._update_stats()
self.app.notify("Task deleted.", severity="warning")
self.app.push_screen(ConfirmScreen("Delete this task?"), _confirmed)
def action_toggle_done(self) -> None:
table = self.query_one(DataTable)
if table.cursor_row is None:
return
row_key = table.cursor_row_key
current = table.get_cell(row_key, "✓") # type: ignore[arg-type]
table.update_cell(row_key, "✓", " " if current.strip() == "✓" else "✓")
# ── Button wiring ─────────────────────────────────────────────────────────
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "add-btn":
self.action_add_task()
elif event.button.id == "del-btn":
self.action_delete_task()
elif event.button.id == "toggle-btn":
self.action_toggle_done()
# ─────────────────────────────────────────────────────────────────────────────
# 4. Root app
# ─────────────────────────────────────────────────────────────────────────────
class TaskApp(App):
"""Task manager TUI — press 'a' to add, 'd' to delete, Space to toggle."""
TITLE = "Task Manager"
BINDINGS = [Binding("q", "quit", "Quit"), Binding("?", "help", "Help")]
def on_mount(self) -> None:
self.push_screen(TaskManagerScreen())
def action_help(self) -> None:
help_text = (
"Keybindings:\n"
" a — Add task\n"
" d — Delete selected\n"
" Space — Toggle done\n"
" q — Quit"
)
self.notify(help_text, title="Help", timeout=8)
# ─────────────────────────────────────────────────────────────────────────────
# 5. Testing with run_test and Pilot
# ─────────────────────────────────────────────────────────────────────────────
async def test_add_task() -> None:
"""
App.run_test() returns an AsyncContextManager[Pilot].
pilot.click("#id") simulates a click; pilot.press("a") sends a key.
No display device needed — runs headlessly in CI.
"""
app = TaskApp()
async with app.run_test(size=(120, 40)) as pilot:
# Open add task dialog
await pilot.press("a")
await pilot.pause()
# Fill in the form
await pilot.click("#title-input")
await pilot.type("Test task from automation")
# Submit
await pilot.click("#add-btn")
await pilot.pause()
print("test_add_task: PASS")
if __name__ == "__main__":
import asyncio
asyncio.run(test_add_task()) # headless test
# Uncomment to launch interactive TUI:
# TaskApp().run()
For the curses alternative — Python’s standard curses module requires managing terminal state manually: clearing and redrawing regions, tracking cursor positions, handling terminal resize events, and implementing your own focus management, while Textual provides a React-style component model where reactive variables trigger automatic re-renders, the compose() method declares layouts with Python generators, push_screen() / pop_screen() handle modal dialogs with typed return values, and App.run_test() with a Pilot object lets you write automated UI tests that click buttons and type text without a real terminal. For the prompt_toolkit alternative — prompt_toolkit is optimized for building rich single-line or multi-line input prompts (auto-complete, syntax highlighting in REPLs and shells), while Textual is a full-screen layout framework with CSS-inspired styling, multiple Screen routing, DataTable with sort/cursor, Checkbox/Switch/Select form widgets, and a complete event system modeled after web browser DOM events. The Claude Skills 360 bundle includes Textual skill sets covering App and Screen subclassing, compose() with Horizontal/Vertical/Container layouts, Button/Label/Input/DataTable widget usage, reactive state with watch_ callbacks, ModalScreen with typed dismiss values, push_screen and pop_screen navigation, BINDINGS keyboard shortcut registration, DataTable add_columns/add_rows/remove_row/update_cell, on_mount focus and initialization, notify for toast messages, and run_test Pilot for headless UI automation. Start with the free tier to try TUI framework code generation.