
Textual Tui
- 47 installs
- 3 repo stars
- Updated June 29, 2026
- tristanmanchester/agent-skills
Helps with ai & agent building tasks during AI-assisted development.
About
textual-tui is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- textual-tui
- AI & Agent Building
- AI-coding skill
Textual Tui by the numbers
- 47 all-time installs (skills.sh)
- +1 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #7,541 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/tristanmanchester/agent-skills --skill textual-tuiAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 47 |
|---|---|
| repo stars | ★ 3 |
| Last updated | June 29, 2026 |
| Repository | tristanmanchester/agent-skills ↗ |
What it does
Helps with ai & agent building tasks during AI-assisted development.
Files
Use this skill when the task is fundamentally about building or changing a Textual app, not merely printing Rich output or writing a non-interactive CLI.
Start by classifying the app
Pick the closest shape before writing code:
1. Single-screen shell One main view with panels, tables, forms, or logs. Prefer containers plus built-in widgets.
2. Multi-screen workflow Large context changes, separate flows, or drill-down views. Prefer Screen / ModalScreen.
3. Multi-mode admin app Persistent top-level areas such as “dashboard / jobs / settings / logs”. Prefer named MODES, screen stacks, and command palette support.
4. Data explorer Records plus details, filters, or side panes. Prefer DataTable, details panel, responsive breakpoints, and keyboard navigation.
5. Document or filesystem tool Prefer DirectoryTree, MarkdownViewer, TextArea, Tree, and delivery APIs for export/download.
6. Chat / streaming / long-running task UI Prefer a scrollable transcript or log plus @work / workers for background operations.
If the user has not chosen an architecture, choose one and proceed.
Default engineering stance
- Prefer built-in widgets first. Only hand-roll behaviour when a built-in widget clearly does not fit.
- Keep the `App` thin. Move screen-specific logic into
Screenclasses and reusable composite widgets. - Prefer `.tcss` files over inline
CSSonce styling grows beyond a toy example. - Use IDs and semantic classes deliberately so styling and Pilot tests stay stable.
- Design for narrow terminals first, then add split panes and breakpoint-driven layouts.
- Leave behind tests whenever behaviour changes.
Choose the right Textual primitive
- Use `Screen` when navigation changes the user’s working context.
- Use `ModalScreen` for short interruptions: confirmations, pickers, destructive actions.
- Use `ContentSwitcher` for wizard steps or one-screen subflows.
- Use named `MODES` when the app has durable top-level areas with separate navigation stacks.
- Use command palette providers when there are many actions, bindings, or discoverability matters.
- Use workers for network, subprocess, parsing, search, sleeps, or anything that may block input.
See:
- Architecture decision tree
- Screens, modes, and command palette
Widget-first selection rules
Before inventing custom widgets, check the widget atlas.
Common defaults:
DataTablefor record-heavy viewsDirectoryTreefor filesystem navigationMarkdownViewerfor rich document viewsTextAreafor editingTabbedContentfor grouped settings or alternate panesLog/RichLogfor live outputSelectionList,OptionList,ListView,Tree,Select,Switch,Input,Buttonfor most interaction needs
Reactivity and workers
Use the playbook in reactivity and workers.
Core rules:
- Put fast derived state in
compute_*, but keep it cheap and side-effect free. - Use
watch_*for UI reactions, not blocking work. - Use
varwhen you want state without automatic refresh machinery. - Use
set_reactivebefore mount when initial state changes should not trip watchers early. - Move blocking work into
@workorrun_worker(...). - Use
exclusive=Truefor stale-search cancellation and similar “latest request wins” flows. - For thread workers, update the UI via messages or
call_from_thread.
Browser, dev loop, and delivery
Textual may run in a terminal or be served to a browser. Build with both in mind when relevant.
- Use
textual run --devwhile iterating. - Use
textual consoleand devtools when behaviour is unclear. - Use
textual servewhen browser parity matters. - Prefer
deliver_text,deliver_binary, ordeliver_screenshotfor browser-friendly exports and downloads. - Use
open_urlwhen handing off to the user’s browser is appropriate.
See:
- Browser and delivery guide
- Packaging and CI
Testing is part of the feature
Default output after any non-trivial change:
1. one smoke test with run_test() 2. one behaviour test for the changed flow 3. one narrow-terminal or alternate-size test when layout matters 4. one snapshot test when the view structure matters visually
See testing matrix.
When working on an existing project
Start with the scripts, then refine by hand:
1. python scripts/inspect_textual_project.py <project> 2. python scripts/audit_textual_project.py <project> 3. Generate scaffolds or tests only after you understand the existing structure.
Use the audit to catch:
- oversized
Appclasses - blocking handlers
- missing breakpoints
- missed built-in widget opportunities
- missing command palette or delivery APIs
- missing Pilot tests
Bundled scripts
scripts/scaffold_textual_app.py
Generate starter apps, TCSS, tests, optional pyproject.toml, and CI workflow.
scripts/inspect_textual_project.py
Inventory app classes, screens, widgets, bindings, IDs, workers, and styling.
scripts/audit_textual_project.py
Heuristic architecture/performance/test audit for an existing Textual project.
scripts/generate_pilot_tests.py
Emit starter smoke and behaviour tests for an existing app.
scripts/dump_dom_and_bindings.py
If Textual is installed, launch an app under run_test() and dump DOM and active bindings.
scripts/emit_textual_pyproject.py
Generate a packageable Hatch-based pyproject.toml.
scripts/emit_github_actions_ci.py
Generate a GitHub Actions workflow for Textual tests.
scripts/build_upstream_pattern_atlas.py
Summarise a local Textual repo snapshot into references/repo-map.md and references/upstream-pattern-atlas.md.
scripts/self_check.py
Compile scripts and scaffold all bundled templates as a package validation step.
Bundled starter templates
Available scaffolds:
dashboardformchatdata-explorerfile-browsersettingswizardlog-monitoreditoradmin-modesdownload-demo
List them with:
python scripts/scaffold_textual_app.py --list-templatesGenerate one with:
python scripts/scaffold_textual_app.py \
--template data-explorer \
--module my_app \
--class-name MyApp \
--app-title "My App" \
--output-dir .Output checklist
Before you finish, aim to leave behind:
- a clear app structure
- stable IDs/classes for styling and tests
- TCSS separated from Python unless the app is tiny
- background work off the main event path
- keyboard-discoverable actions
- responsive layout decisions
- at least a smoke test and one behaviour test
- notes on how to run the app in dev mode
Read next as needed
- Architecture decision tree
- Widget selection atlas
- Reactivity and workers
- Screens, modes, and command palette
- Browser and delivery
- Testing matrix
- Anti-patterns
- Packaging and CI
- Repository map
- Upstream pattern atlas
name: textual-tests
on:
push:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: {{PYTHON_VERSIONS_JSON}}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Upgrade pip
run: python -m pip install --upgrade pip
- name: Install dependencies
run: {{INSTALL_COMMAND}}
- name: Run tests
run: {{PYTEST_COMMAND}}
- name: Upload snapshot report if present
if: always()
uses: actions/upload-artifact@v4
with:
name: snapshot-report-py${{ matrix.python-version }}
path: |
.pytest_textual_snapshot/
tests/**/__snapshots__/
if-no-files-found: ignore
[build-system]
requires = ["hatchling>=1.26.0"]
build-backend = "hatchling.build"
[project]
name = "{{PROJECT_NAME}}"
version = "0.1.0"
description = "Textual application"
readme = "README.md"
requires-python = ">={{PYTHON_MIN}}"
dependencies = [
"textual>=8.1.1",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"pytest-asyncio>=0.23",
"textual-dev>=1.7.0",
"pytest-textual-snapshot>=1.0.0",
]
[project.scripts]
{{COMMAND_NAME}} = "{{MODULE}}:{{ENTRY_FUNCTION}}"
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
from __future__ import annotations
from functools import partial
from textual.app import App, ComposeResult
from textual.command import Hit, Hits, Provider
from textual.widgets import DataTable, Footer, Header, Input, Static
from textual.screen import Screen
class ModeCommands(Provider):
async def search(self, query: str) -> Hits:
matcher = self.matcher(query)
for mode in ("overview", "jobs", "users"):
command = "open {0}".format(mode)
score = matcher.match(command)
if score > 0:
yield Hit(
score,
matcher.highlight(command),
partial(self.app.action_go_to_mode, mode),
help="Switch to the {0} mode".format(mode),
)
class OverviewScreen(Screen):
def compose(self) -> ComposeResult:
yield Header()
yield Static("Overview mode — use 1/2/3 or the command palette to move between stacks.", id="overview-copy")
yield Footer()
class JobsScreen(Screen):
def compose(self) -> ComposeResult:
yield Header()
yield DataTable(id="jobs-table")
yield Footer()
def on_mount(self) -> None:
table = self.query_one("#jobs-table", DataTable)
table.add_columns("Job", "State", "Owner")
table.add_rows(
[
("daily-etl", "running", "data"),
("invoice-rollup", "queued", "finance"),
("reindex", "failed", "search"),
]
)
class UsersScreen(Screen):
def compose(self) -> ComposeResult:
yield Header()
yield Input(placeholder="Search users", id="user-search")
yield Static("Users mode — put a real list or table here.", id="users-copy")
yield Footer()
class {{CLASS_NAME}}(App):
CSS_PATH = "{{MODULE}}.tcss"
TITLE = "{{APP_TITLE}}"
SUB_TITLE = "Modes + command palette"
COMMANDS = App.COMMANDS | {ModeCommands}
BINDINGS = [
("1", "go_to_mode('overview')", "Overview"),
("2", "go_to_mode('jobs')", "Jobs"),
("3", "go_to_mode('users')", "Users"),
]
MODES = {
"overview": OverviewScreen,
"jobs": JobsScreen,
"users": UsersScreen,
}
def on_mount(self) -> None:
self.action_go_to_mode("overview")
def action_go_to_mode(self, mode: str) -> None:
self.switch_mode(mode)
self.sub_title = "Current mode: {0}".format(mode)
def main() -> None:
{{CLASS_NAME}}().run()
if __name__ == "__main__":
main()
OverviewScreen,
JobsScreen,
UsersScreen {
padding: 1;
}
#jobs-table {
height: 1fr;
}
#overview-copy,
#users-copy {
border: round $accent;
padding: 1;
}
from __future__ import annotations
import pytest
from {{MODULE}} import {{CLASS_NAME}}
@pytest.mark.asyncio
async def test_admin_modes_switch() -> None:
app = {{CLASS_NAME}}()
async with app.run_test(size=(100, 30)) as pilot:
await pilot.press("2")
await pilot.pause()
assert app.current_mode == "jobs"
from __future__ import annotations
import asyncio
from textual import work
from textual.app import App, ComposeResult
from textual.containers import Horizontal, VerticalScroll
from textual.reactive import var
from textual.widgets import Button, Footer, Header, Input, Markdown
class MessageBubble(Markdown):
DEFAULT_CSS = """
MessageBubble {
margin: 0 0 1 0;
padding: 0 1;
border: round $primary;
}
MessageBubble.-user {
border: round $accent;
}
MessageBubble.-assistant {
border: round $success;
}
"""
class {{CLASS_NAME}}(App):
CSS_PATH = "{{MODULE}}.tcss"
TITLE = "{{APP_TITLE}}"
SUB_TITLE = "Streaming / chat starter"
BINDINGS = [
("slash", "focus_prompt", "Prompt"),
]
message_count = var(0)
def compose(self) -> ComposeResult:
yield Header()
yield VerticalScroll(id="history")
with Horizontal(id="composer"):
yield Input(placeholder="Ask something…", id="prompt")
yield Button("Send", id="send", variant="primary")
yield Footer()
def on_mount(self) -> None:
self.add_message("Welcome. Replace the fake worker with your real backend.", "assistant")
self.query_one("#prompt", Input).focus()
def add_message(self, markdown: str, role: str) -> None:
history = self.query_one("#history", VerticalScroll)
bubble = MessageBubble(markdown)
bubble.add_class("-{0}".format(role))
history.mount(bubble)
history.anchor()
self.message_count += 1
def submit_prompt(self, text: str) -> None:
text = text.strip()
if not text:
return
self.add_message(text, "user")
self.query_one("#prompt", Input).value = ""
self.generate_reply(text)
def on_input_submitted(self, event: Input.Submitted) -> None:
if event.input.id == "prompt":
self.submit_prompt(event.value)
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "send":
self.submit_prompt(self.query_one("#prompt", Input).value)
@work(exclusive=True)
async def generate_reply(self, prompt: str) -> None:
await asyncio.sleep(0.05)
self.add_message(
"\n".join(
[
"**Starter response**",
"",
"- Prompt: `{0}`".format(prompt),
"- Replace this worker with your real async call.",
"- Keep the history container anchored for streaming output.",
]
),
"assistant",
)
def action_focus_prompt(self) -> None:
self.query_one("#prompt", Input).focus()
def main() -> None:
{{CLASS_NAME}}().run()
if __name__ == "__main__":
main()
Screen {
layout: vertical;
}
#history {
height: 1fr;
padding: 1 2;
}
#composer {
height: auto;
padding: 0 1 1 1;
}
#prompt {
width: 1fr;
margin-right: 1;
}
#send {
width: 12;
}
from __future__ import annotations
import pytest
from {{MODULE}} import {{CLASS_NAME}}
@pytest.mark.asyncio
async def test_chat_roundtrip() -> None:
app = {{CLASS_NAME}}()
async with app.run_test(size=(100, 30)) as pilot:
await pilot.click("#prompt")
await pilot.press("h", "e", "l", "l", "o")
await pilot.press("enter")
await pilot.pause(0.1)
assert app.message_count >= 3
from __future__ import annotations
from textual.app import App, ComposeResult
from textual.containers import Horizontal, Vertical
from textual.widgets import DataTable, Footer, Header, Input, Log, Static
ROWS = [
("api-gateway", "healthy", "42 ms", "platform"),
("queue-worker", "healthy", "77 ms", "ops"),
("billing", "degraded", "240 ms", "finance"),
("search", "healthy", "54 ms", "product"),
("notifications", "incident", "—", "engage"),
]
class {{CLASS_NAME}}(App):
CSS_PATH = "{{MODULE}}.tcss"
TITLE = "{{APP_TITLE}}"
SUB_TITLE = "Service dashboard"
BINDINGS = [
("slash", "focus_filter", "Filter"),
("r", "reload_data", "Reload"),
]
def compose(self) -> ComposeResult:
yield Header()
yield Input(placeholder="Filter services…", id="filter")
with Horizontal(id="body"):
yield DataTable(id="table")
with Vertical(id="side"):
yield Static("Select a row to inspect it.", id="details")
yield Log(id="events")
yield Footer()
def on_mount(self) -> None:
table = self.query_one("#table", DataTable)
table.cursor_type = "row"
table.add_columns("Service", "Status", "Latency", "Owner")
self.load_rows(ROWS)
self.query_one("#filter", Input).focus()
def load_rows(self, rows) -> None:
table = self.query_one("#table", DataTable)
table.clear(columns=False)
table.add_rows(rows)
self.sub_title = "{0} rows".format(len(rows))
log = self.query_one("#events", Log)
log.write_line("Loaded {0} rows".format(len(rows)))
def filtered_rows(self, query: str):
query = query.strip().lower()
if not query:
return ROWS
return [row for row in ROWS if query in " ".join(row).lower()]
def on_input_changed(self, event: Input.Changed) -> None:
if event.input.id == "filter":
self.load_rows(self.filtered_rows(event.value))
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
table = self.query_one("#table", DataTable)
service, status, latency, owner = table.get_row(event.row_key)
self.query_one("#details", Static).update(
"\n".join(
[
"Service: {0}".format(service),
"Status: {0}".format(status),
"Latency: {0}".format(latency),
"Owner: {0}".format(owner),
]
)
)
self.query_one("#events", Log).write_line(
"Selected {0} ({1})".format(service, status)
)
def action_focus_filter(self) -> None:
self.query_one("#filter", Input).focus()
def action_reload_data(self) -> None:
self.query_one("#filter", Input).value = ""
self.load_rows(ROWS)
def main() -> None:
{{CLASS_NAME}}().run()
if __name__ == "__main__":
main()
Screen {
layout: vertical;
}
#filter {
margin: 0 1 1 1;
}
#body {
height: 1fr;
}
#table {
width: 2fr;
}
#side {
width: 1fr;
padding: 0 1;
}
#details {
border: round $accent;
padding: 1;
min-height: 7;
}
#events {
border: round $primary;
margin-top: 1;
height: 1fr;
}
from __future__ import annotations
import pytest
from {{MODULE}} import {{CLASS_NAME}}
@pytest.mark.asyncio
async def test_dashboard_filter_flow() -> None:
app = {{CLASS_NAME}}()
async with app.run_test(size=(100, 30)) as pilot:
await pilot.click("#filter")
await pilot.press("q", "u", "e", "u", "e")
await pilot.pause()
table = app.query_one("#table")
assert table.row_count == 1
from __future__ import annotations
from textual.app import App, ComposeResult
from textual.containers import Horizontal, Vertical
from textual.widgets import DataTable, Footer, Header, Input, Static
RECORDS = [
{"service": "api", "environment": "prod", "requests": "13.2M", "error_rate": "0.12%"},
{"service": "api", "environment": "staging", "requests": "1.9M", "error_rate": "0.40%"},
{"service": "search", "environment": "prod", "requests": "8.8M", "error_rate": "0.09%"},
{"service": "billing", "environment": "prod", "requests": "3.1M", "error_rate": "0.31%"},
{"service": "worker", "environment": "prod", "requests": "27.0M", "error_rate": "0.03%"},
]
class {{CLASS_NAME}}(App):
CSS_PATH = "{{MODULE}}.tcss"
TITLE = "{{APP_TITLE}}"
SUB_TITLE = "Responsive data explorer"
HORIZONTAL_BREAKPOINTS = [
(0, "-narrow"),
(100, "-wide"),
]
def compose(self) -> ComposeResult:
yield Header()
yield Input(placeholder="Filter records…", id="filter")
with Horizontal(id="body"):
yield DataTable(id="table")
with Vertical(id="detail-pane"):
yield Static("Select a record for details.", id="details")
yield Static("Resize the terminal to see the breakpoint class switch.", id="hint")
yield Footer()
def on_mount(self) -> None:
table = self.query_one("#table", DataTable)
table.cursor_type = "row"
table.add_columns("Service", "Environment", "Requests", "Error rate")
self.load_rows(RECORDS)
def rows_for_query(self, query: str):
query = query.lower().strip()
if not query:
return RECORDS
return [
row
for row in RECORDS
if query in " ".join(row.values()).lower()
]
def load_rows(self, rows) -> None:
table = self.query_one("#table", DataTable)
table.clear(columns=False)
table.add_rows(
[
(row["service"], row["environment"], row["requests"], row["error_rate"])
for row in rows
]
)
self.sub_title = "{0} visible rows".format(len(rows))
def on_input_changed(self, event: Input.Changed) -> None:
if event.input.id == "filter":
self.load_rows(self.rows_for_query(event.value))
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
table = self.query_one("#table", DataTable)
service, environment, requests, error_rate = table.get_row(event.row_key)
self.query_one("#details", Static).update(
"\n".join(
[
"Service: {0}".format(service),
"Environment: {0}".format(environment),
"Requests/day: {0}".format(requests),
"Error rate: {0}".format(error_rate),
]
)
)
def main() -> None:
{{CLASS_NAME}}().run()
if __name__ == "__main__":
main()
Screen {
layout: vertical;
}
#filter {
margin: 0 1 1 1;
}
#body {
height: 1fr;
}
#table {
width: 1fr;
}
#detail-pane {
width: 40;
min-width: 28;
padding: 0 1;
}
#details,
#hint {
border: round $primary;
padding: 1;
margin-bottom: 1;
}
Screen.-narrow #body {
layout: vertical;
}
Screen.-wide #body {
layout: horizontal;
}
from __future__ import annotations
import pytest
from {{MODULE}} import {{CLASS_NAME}}
@pytest.mark.asyncio
async def test_data_explorer_filters_rows() -> None:
app = {{CLASS_NAME}}()
async with app.run_test(size=(120, 30)) as pilot:
await pilot.click("#filter")
await pilot.press("b", "i", "l", "l")
await pilot.pause()
table = app.query_one("#table")
assert table.row_count == 1
from __future__ import annotations
import json
from pathlib import Path
from textual import events
from textual.app import App, ComposeResult
from textual.containers import Horizontal
from textual.reactive import var
from textual.widgets import Button, Footer, Header, Static
class {{CLASS_NAME}}(App):
CSS_PATH = "{{MODULE}}.tcss"
TITLE = "{{APP_TITLE}}"
SUB_TITLE = "Browser / terminal delivery demo"
last_requested_delivery = var("")
def compose(self) -> ComposeResult:
yield Header()
yield Static(
"\n".join(
[
"This starter demonstrates browser-safe delivery APIs.",
"Use deliver_text / deliver_binary / deliver_screenshot",
"instead of assuming local file-system access.",
]
),
id="copy",
)
with Horizontal(id="actions"):
yield Button("Open docs", id="docs")
yield Button("Download report", id="export-text", variant="primary")
yield Button("Download JSON", id="export-json")
yield Button("Screenshot", id="screenshot")
yield Footer()
def export_dir(self) -> Path:
path = Path.cwd() / ".textual_exports"
path.mkdir(parents=True, exist_ok=True)
return path
def build_report(self) -> Path:
path = self.export_dir() / "report.md"
path.write_text("# Report\n\nGenerated by {{CLASS_NAME}}.\n", encoding="utf-8")
return path
def build_json(self) -> Path:
path = self.export_dir() / "report.json"
path.write_text(
json.dumps({"app": "{{CLASS_NAME}}", "kind": "example"}, indent=2),
encoding="utf-8",
)
return path
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "docs":
self.open_url("https://textual.textualize.io/")
elif event.button.id == "export-text":
self.last_requested_delivery = "report"
self.deliver_text(
self.build_report(),
save_filename="report.md",
name="report",
)
elif event.button.id == "export-json":
self.last_requested_delivery = "json"
self.deliver_binary(
self.build_json(),
save_filename="report.json",
mime_type="application/json",
name="json",
)
elif event.button.id == "screenshot":
self.last_requested_delivery = "screenshot"
self.deliver_screenshot(filename="app.svg")
def on_delivery_complete(self, event: events.DeliveryComplete) -> None:
label = event.name or "download"
self.notify("Delivered {0}".format(label), title="Export")
def on_delivery_failed(self, event: events.DeliveryFailed) -> None:
label = event.name or "download"
self.notify(
"Delivery failed for {0}: {1}".format(label, event.exception),
title="Export",
severity="error",
)
def main() -> None:
{{CLASS_NAME}}().run()
if __name__ == "__main__":
main()
#copy {
border: round $primary;
padding: 1;
margin: 0 1 1 1;
}
#actions {
height: auto;
padding: 0 1 1 1;
align: left middle;
}
#actions Button {
margin-right: 1;
}
from __future__ import annotations
import pytest
from {{MODULE}} import {{CLASS_NAME}}
@pytest.mark.asyncio
async def test_download_demo_requests_text_delivery() -> None:
app = {{CLASS_NAME}}()
calls = []
def fake_deliver_text(*args, **kwargs):
calls.append((args, kwargs))
return "fake-key"
app.deliver_text = fake_deliver_text # type: ignore[assignment]
async with app.run_test(size=(90, 24)) as pilot:
await pilot.click("#export-text")
await pilot.pause()
assert app.last_requested_delivery == "report"
assert len(calls) == 1
from __future__ import annotations
from pathlib import Path
from textual.app import App, ComposeResult
from textual.containers import Horizontal
from textual.reactive import var
from textual.widgets import Button, Footer, Header, Input, TextArea
SAMPLE_TEXT = """\
def greet(name: str) -> str:
return f"Hello, {name}!"
"""
class {{CLASS_NAME}}(App):
CSS_PATH = "{{MODULE}}.tcss"
TITLE = "{{APP_TITLE}}"
SUB_TITLE = "Text editor"
BINDINGS = [
("ctrl+s", "save", "Save"),
("f6", "toggle_line_numbers", "Line Numbers"),
]
saved_path = var("")
def __init__(self, default_path: str = "notes.py") -> None:
super().__init__()
self.default_path = default_path
def compose(self) -> ComposeResult:
yield Header()
with Horizontal(id="toolbar"):
yield Input(value=self.default_path, id="path")
yield Button("Save", id="save", variant="primary")
yield Button("Toggle line numbers", id="toggle-lines")
yield TextArea.code_editor(SAMPLE_TEXT, language="python", id="editor")
yield Footer()
def on_mount(self) -> None:
self.query_one("#editor", TextArea).focus()
def save_to_disk(self) -> Path:
path = Path(self.query_one("#path", Input).value or self.default_path)
path.write_text(self.query_one("#editor", TextArea).text, encoding="utf-8")
self.saved_path = str(path)
self.notify("Saved {0}".format(path), title="Editor")
return path
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "save":
self.action_save()
elif event.button.id == "toggle-lines":
self.action_toggle_line_numbers()
def action_save(self) -> None:
self.save_to_disk()
def action_toggle_line_numbers(self) -> None:
editor = self.query_one("#editor", TextArea)
editor.show_line_numbers = not editor.show_line_numbers
def main() -> None:
{{CLASS_NAME}}().run()
if __name__ == "__main__":
main()
#toolbar {
height: auto;
padding: 0 1 1 1;
align: left middle;
}
#path {
width: 1fr;
margin-right: 1;
}
#toolbar Button {
margin-right: 1;
}
#editor {
height: 1fr;
margin: 0 1 1 1;
border: round $primary;
}
from __future__ import annotations
from pathlib import Path
import pytest
from {{MODULE}} import {{CLASS_NAME}}
@pytest.mark.asyncio
async def test_editor_save(tmp_path: Path) -> None:
target = tmp_path / "demo.py"
app = {{CLASS_NAME}}(default_path=str(target))
async with app.run_test(size=(100, 30)) as pilot:
app.query_one("#path").value = str(target)
app.query_one("#editor").text = "print('ok')\n"
app.action_save()
await pilot.pause()
assert target.exists()
assert app.saved_path == str(target)
from __future__ import annotations
from pathlib import Path
from textual.app import App, ComposeResult
from textual.containers import Horizontal
from textual.reactive import var
from textual.widgets import DirectoryTree, Footer, Header, Static
class {{CLASS_NAME}}(App):
CSS_PATH = "{{MODULE}}.tcss"
TITLE = "{{APP_TITLE}}"
SUB_TITLE = "Filesystem browser"
BINDINGS = [
("f", "focus_tree", "Tree"),
]
preview_text = var("")
def __init__(self, root_path: str = ".") -> None:
super().__init__()
self.root_path = root_path
def compose(self) -> ComposeResult:
yield Header()
with Horizontal(id="body"):
yield DirectoryTree(self.root_path, id="tree")
yield Static("Select a file to preview it.", id="preview")
yield Footer()
def on_mount(self) -> None:
self.query_one("#tree", DirectoryTree).focus()
def show_file(self, path: Path) -> None:
try:
text = path.read_text(encoding="utf-8", errors="replace")
except Exception as error:
self.preview_text = "Could not open {0}: {1}".format(path, error)
else:
self.preview_text = text[:4000] or "(empty file)"
self.query_one("#preview", Static).update(self.preview_text)
self.sub_title = str(path)
def on_directory_tree_file_selected(self, event: DirectoryTree.FileSelected) -> None:
event.stop()
self.show_file(event.path)
def action_focus_tree(self) -> None:
self.query_one("#tree", DirectoryTree).focus()
def main() -> None:
{{CLASS_NAME}}().run()
if __name__ == "__main__":
main()
#body {
height: 1fr;
}
#tree {
width: 38;
min-width: 24;
}
#preview {
width: 1fr;
border: round $accent;
padding: 1;
}
from __future__ import annotations
from pathlib import Path
import pytest
from {{MODULE}} import {{CLASS_NAME}}
@pytest.mark.asyncio
async def test_file_browser_show_file(tmp_path: Path) -> None:
target = tmp_path / "notes.txt"
target.write_text("hello from textual", encoding="utf-8")
app = {{CLASS_NAME}}(root_path=str(tmp_path))
async with app.run_test(size=(100, 30)) as pilot:
app.show_file(target)
await pilot.pause()
assert "hello from textual" in app.preview_text
from __future__ import annotations
from textual.app import App, ComposeResult
from textual.containers import Horizontal, Vertical
from textual.reactive import var
from textual.widgets import Button, Footer, Header, Input, Static, Switch
class {{CLASS_NAME}}(App):
CSS_PATH = "{{MODULE}}.tcss"
TITLE = "{{APP_TITLE}}"
SUB_TITLE = "Profile form"
BINDINGS = [
("ctrl+s", "save", "Save"),
("ctrl+r", "reset", "Reset"),
]
dirty = var(False)
saved = var(False)
def compose(self) -> ComposeResult:
yield Header()
with Vertical(id="form"):
yield Input(placeholder="Name", id="name")
yield Input(placeholder="Email", id="email")
with Horizontal(id="toggle-row"):
yield Static("Enable account", classes="label")
yield Switch(id="enabled")
with Horizontal(id="actions"):
yield Button("Save", id="save", variant="primary")
yield Button("Reset", id="reset")
yield Static(id="preview")
yield Footer()
def on_mount(self) -> None:
self.refresh_preview()
self.query_one("#name", Input).focus()
def payload(self) -> dict:
return {
"name": self.query_one("#name", Input).value,
"email": self.query_one("#email", Input).value,
"enabled": self.query_one("#enabled", Switch).value,
}
def refresh_preview(self) -> None:
payload = self.payload()
self.query_one("#preview", Static).update(
"\n".join(
[
"Name: {0}".format(payload["name"] or "—"),
"Email: {0}".format(payload["email"] or "—"),
"Enabled: {0}".format("yes" if payload["enabled"] else "no"),
"Dirty: {0}".format("yes" if self.dirty else "no"),
"Saved: {0}".format("yes" if self.saved else "no"),
]
)
)
def on_input_changed(self, _event: Input.Changed) -> None:
self.dirty = True
self.saved = False
self.refresh_preview()
def on_switch_changed(self, _event: Switch.Changed) -> None:
self.dirty = True
self.saved = False
self.refresh_preview()
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "save":
self.action_save()
elif event.button.id == "reset":
self.action_reset()
def action_save(self) -> None:
self.saved = True
self.dirty = False
self.refresh_preview()
self.notify("Saved form data.", title="Profile")
def action_reset(self) -> None:
self.query_one("#name", Input).value = ""
self.query_one("#email", Input).value = ""
self.query_one("#enabled", Switch).value = False
self.saved = False
self.dirty = False
self.refresh_preview()
def main() -> None:
{{CLASS_NAME}}().run()
if __name__ == "__main__":
main()
Screen {
layout: vertical;
}
#form {
width: 70;
max-width: 100%;
padding: 1 2;
}
#toggle-row,
#actions {
height: auto;
align: left middle;
}
.label {
width: 1fr;
}
#preview {
border: round $primary;
padding: 1;
margin-top: 1;
min-height: 6;
}
from __future__ import annotations
import pytest
from {{MODULE}} import {{CLASS_NAME}}
@pytest.mark.asyncio
async def test_form_save() -> None:
app = {{CLASS_NAME}}()
async with app.run_test(size=(80, 24)) as pilot:
await pilot.click("#name")
await pilot.press("A", "n", "a")
await pilot.click("#save")
await pilot.pause()
assert app.saved is True
assert app.dirty is False
from __future__ import annotations
import sys
from datetime import datetime
from textual import events
from textual.app import App, ComposeResult
from textual.containers import Horizontal
from textual.reactive import var
from textual.widgets import Button, Footer, Header, Input, Log
class {{CLASS_NAME}}(App):
CSS_PATH = "{{MODULE}}.tcss"
TITLE = "{{APP_TITLE}}"
SUB_TITLE = "Log / print capture"
BINDINGS = [
("c", "clear_log", "Clear"),
]
line_count = var(0)
def compose(self) -> ComposeResult:
yield Header()
with Horizontal(id="controls"):
yield Button("Emit stdout", id="emit-out", variant="primary")
yield Button("Emit stderr", id="emit-err")
yield Button("Clear", id="clear")
yield Input(placeholder="Filter text", id="filter")
yield Log(id="log")
yield Footer()
def on_mount(self) -> None:
self.begin_capture_print(self)
def on_print(self, event: events.Print) -> None:
query = self.query_one("#filter", Input).value.strip().lower()
text = event.text.rstrip()
if query and query not in text.lower():
return
prefix = "ERR" if event.stderr else "OUT"
self.query_one("#log", Log).write_line(
"{0} {1} {2}".format(prefix, datetime.now().strftime("%H:%M:%S"), text)
)
self.query_one("#log", Log).anchor()
self.line_count += 1
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "emit-out":
print("heartbeat ok")
elif event.button.id == "emit-err":
print("simulated failure", file=sys.stderr)
elif event.button.id == "clear":
self.action_clear_log()
def action_clear_log(self) -> None:
self.query_one("#log", Log).clear()
self.line_count = 0
def main() -> None:
{{CLASS_NAME}}().run()
if __name__ == "__main__":
main()
#controls {
height: auto;
padding: 0 1 1 1;
align: left middle;
}
#controls Button {
margin-right: 1;
}
#filter {
width: 1fr;
}
#log {
height: 1fr;
border: round $accent;
margin: 0 1 1 1;
}
from __future__ import annotations
import pytest
from {{MODULE}} import {{CLASS_NAME}}
@pytest.mark.asyncio
async def test_log_monitor_captures_stdout() -> None:
app = {{CLASS_NAME}}()
async with app.run_test(size=(100, 30)) as pilot:
await pilot.click("#emit-out")
await pilot.pause()
assert app.line_count >= 1
from __future__ import annotations
from textual.app import App, ComposeResult
from textual.containers import Horizontal
from textual.reactive import var
from textual.widgets import Button, Footer, Header, Input, Static, Switch, TabbedContent, TabPane
class {{CLASS_NAME}}(App):
CSS_PATH = "{{MODULE}}.tcss"
TITLE = "{{APP_TITLE}}"
SUB_TITLE = "Settings editor"
BINDINGS = [
("ctrl+s", "save", "Save"),
]
saved = var(False)
def compose(self) -> ComposeResult:
yield Header()
with TabbedContent(initial="general"):
with TabPane("General", id="general"):
yield Input(placeholder="Project name", id="project-name")
with Horizontal(classes="switch-row"):
yield Static("Enable autosave")
yield Switch(id="autosave")
with TabPane("Appearance", id="appearance"):
yield Input(placeholder="Theme name", id="theme-name")
with Horizontal(classes="switch-row"):
yield Static("Dense layout")
yield Switch(id="dense-layout")
with TabPane("Advanced", id="advanced"):
with Horizontal(classes="switch-row"):
yield Static("Enable telemetry")
yield Switch(id="telemetry")
with Horizontal(id="actions"):
yield Button("Save", id="save", variant="primary")
yield Static(id="summary")
yield Footer()
def on_mount(self) -> None:
self.refresh_summary()
self.query_one("#project-name", Input).focus()
def summary_text(self) -> str:
return "\n".join(
[
"Project: {0}".format(self.query_one("#project-name", Input).value or "—"),
"Theme: {0}".format(self.query_one("#theme-name", Input).value or "textual-dark"),
"Autosave: {0}".format(self.query_one("#autosave", Switch).value),
"Dense: {0}".format(self.query_one("#dense-layout", Switch).value),
"Telemetry: {0}".format(self.query_one("#telemetry", Switch).value),
"Saved: {0}".format(self.saved),
]
)
def refresh_summary(self) -> None:
self.query_one("#summary", Static).update(self.summary_text())
def on_input_changed(self, _event: Input.Changed) -> None:
self.saved = False
self.refresh_summary()
def on_switch_changed(self, _event: Switch.Changed) -> None:
self.saved = False
self.refresh_summary()
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "save":
self.action_save()
def action_save(self) -> None:
self.saved = True
self.refresh_summary()
self.notify("Saved settings.", title="Configuration")
def main() -> None:
{{CLASS_NAME}}().run()
if __name__ == "__main__":
main()
TabbedContent {
height: auto;
}
.switch-row {
height: auto;
align: left middle;
padding: 0 0 1 0;
}
#actions {
height: auto;
padding: 0 1 1 1;
}
#summary {
border: round $primary;
padding: 1;
margin: 0 1 1 1;
min-height: 7;
}
from __future__ import annotations
import pytest
from {{MODULE}} import {{CLASS_NAME}}
@pytest.mark.asyncio
async def test_settings_save() -> None:
app = {{CLASS_NAME}}()
async with app.run_test(size=(100, 30)) as pilot:
await pilot.click("#project-name")
await pilot.press("D", "e", "m", "o")
await pilot.click("#save")
await pilot.pause()
assert app.saved is True
from __future__ import annotations
from textual.app import App, ComposeResult
from textual.containers import Horizontal, Vertical
from textual.reactive import reactive, var
from textual.widgets import Button, ContentSwitcher, Footer, Header, Input, Static
class {{CLASS_NAME}}(App):
CSS_PATH = "{{MODULE}}.tcss"
TITLE = "{{APP_TITLE}}"
SUB_TITLE = "Step-by-step workflow"
step_index = reactive(0)
finished = var(False)
def compose(self) -> ComposeResult:
yield Header()
yield Static(id="progress")
with ContentSwitcher(initial="step-1", id="steps"):
with Vertical(id="step-1"):
yield Static("Step 1 — basic details")
yield Input(placeholder="Project name", id="project-name")
with Vertical(id="step-2"):
yield Static("Step 2 — environment")
yield Input(placeholder="Environment", id="environment")
with Vertical(id="step-3"):
yield Static("Step 3 — review")
yield Static(id="review")
with Horizontal(id="actions"):
yield Button("Back", id="back")
yield Button("Next", id="next", variant="primary")
yield Button("Finish", id="finish")
yield Footer()
def on_mount(self) -> None:
self.watch_step_index(self.step_index)
def watch_step_index(self, step_index: int) -> None:
switcher = self.query_one("#steps", ContentSwitcher)
switcher.current = "step-{0}".format(step_index + 1)
self.query_one("#progress", Static).update("Step {0} of 3".format(step_index + 1))
self.query_one("#back", Button).disabled = step_index == 0
self.query_one("#next", Button).disabled = step_index == 2
self.query_one("#finish", Button).disabled = step_index != 2
if step_index == 2:
self.query_one("#review", Static).update(
"\n".join(
[
"Project: {0}".format(self.query_one("#project-name", Input).value or "—"),
"Environment: {0}".format(self.query_one("#environment", Input).value or "—"),
]
)
)
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "back":
self.action_back_step()
elif event.button.id == "next":
self.action_next_step()
elif event.button.id == "finish":
self.action_finish()
def action_back_step(self) -> None:
self.step_index = max(0, self.step_index - 1)
def action_next_step(self) -> None:
self.step_index = min(2, self.step_index + 1)
def action_finish(self) -> None:
self.finished = True
self.notify("Wizard complete.", title="Workflow")
def main() -> None:
{{CLASS_NAME}}().run()
if __name__ == "__main__":
main()
Screen {
layout: vertical;
}
#progress {
margin: 0 1 1 1;
}
#steps {
height: 1fr;
padding: 0 1;
}
#actions {
height: auto;
align: right middle;
padding: 0 1 1 1;
}
#actions Button {
margin-left: 1;
}
from __future__ import annotations
import pytest
from {{MODULE}} import {{CLASS_NAME}}
@pytest.mark.asyncio
async def test_wizard_step_navigation() -> None:
app = {{CLASS_NAME}}()
async with app.run_test(size=(90, 24)) as pilot:
await pilot.click("#next")
await pilot.click("#next")
await pilot.pause()
assert app.step_index == 2
{
"skill_name": "textual-tui-v2",
"evals": [
{
"id": "build-data-explorer",
"prompt": "Create a Textual data explorer app for a terminal-first admin tool. I want a filter box, a DataTable of fake customer records, a detail pane, responsive behaviour on narrow terminals, TCSS in a separate file, and pytest Pilot tests. Package it like a small project.",
"expected_output": "A runnable Textual project with a Python app, separate TCSS, tests, and packaging files. The design should use DataTable rather than a manual Rich table and include a responsive layout strategy.",
"assertions": [
"The output includes a Textual app file",
"The output includes a separate .tcss file",
"The app uses DataTable",
"The app includes a detail pane or equivalent secondary view",
"The project includes at least one pytest Pilot test",
"The layout includes a breakpoint strategy or explicit narrow-terminal handling",
"The project includes pyproject.toml or equivalent packaging metadata"
]
},
{
"id": "refactor-broken-search",
"prompt": "Audit and refactor the Textual app in evals/files/broken-search so it behaves like a responsive live-search tool. Preserve the core intent but fix architecture and performance issues, move styling out of Python if warranted, and add tests.",
"expected_output": "An improved version of the sample project that removes blocking search work from handlers, prefers built-in Textual widgets where appropriate, introduces more maintainable styling, and leaves behind meaningful tests.",
"files": [
"evals/files/broken-search/search_app.py"
],
"assertions": [
"The response identifies that the original handler performs blocking work",
"The refactor moves search work into a worker or equivalent non-blocking pattern",
"The refactor uses DataTable or another stronger built-in list or table widget than the original manual Rich table",
"Styling is moved to a separate .tcss file or clearly reduced in complexity",
"The output includes at least one pytest Pilot test"
]
},
{
"id": "browser-friendly-export",
"prompt": "Improve the export flow in evals/files/report-export so the app works well when served to a browser. Keep it as a Textual app, but make the export and feedback flow browser-aware and add a test scaffold.",
"expected_output": "A revised Textual app that uses browser-friendly delivery APIs for export instead of only writing to local paths, includes user feedback for success or failure, and leaves behind at least a smoke test.",
"files": [
"evals/files/report-export/report_export.py"
],
"assertions": [
"The response recognises that the original app is export-like and browser-hostile",
"The revised implementation uses deliver_text, deliver_binary, or deliver_screenshot",
"The revised implementation includes feedback for success or failure",
"The output includes a pytest test file"
]
},
{
"id": "design-admin-shell",
"prompt": "Build a keyboard-first Textual admin shell with durable top-level sections for dashboard, jobs, settings, and logs. Use modes or screens appropriately, make actions discoverable, and include a testing strategy.",
"expected_output": "A structured Textual app that chooses an appropriate navigation model for durable top-level sections, exposes discoverable actions, and includes tests or clearly scaffolded tests.",
"assertions": [
"The app uses modes or screen-based architecture rather than a single giant App class",
"The design includes discoverable actions such as a command palette or equivalent command provider setup",
"The output includes at least one test or a generated test scaffold",
"The response explains the navigation choice"
]
},
{
"id": "editor-with-preview",
"prompt": "Make me a Textual editor app for markdown notes with a text editor, a rendered preview, save support, and sensible key bindings. Keep the app maintainable and testable.",
"expected_output": "A Textual editor-style project using suitable built-in widgets such as TextArea and Markdown/MarkdownViewer, plus styling and tests.",
"assertions": [
"The output includes TextArea or another appropriate editing widget",
"The output includes a rendered preview or document pane",
"The app includes save support",
"The project includes tests",
"The structure is split into Python and styling rather than one giant inline blob"
]
}
]
}
from __future__ import annotations
import time
from rich.table import Table
from textual.app import App, ComposeResult
from textual.reactive import reactive
from textual.widgets import Footer, Header, Input, Static
FAKE_DOCS = [
("Guide", "Getting started with Textual"),
("Workers", "How to move slow work off the UI thread"),
("Screens", "Using screens and modes for navigation"),
("DataTable", "A better fit than a static Rich table for interactive results"),
("Testing", "Pilot and snapshot testing basics"),
]
class SearchApp(App):
TITLE = "Broken Search"
BINDINGS = [
("ctrl+f", "focus_search", "Search"),
("f1", "show_help", "Help"),
("f2", "toggle_theme", "Theme"),
("f3", "refresh_now", "Refresh"),
("f4", "export_report", "Export"),
("f5", "clear_search", "Clear"),
("f6", "show_about", "About"),
]
CSS = """
Screen {
layout: vertical;
}
#search {
margin: 1 2;
}
#results {
margin: 1 2;
height: 1fr;
border: round green;
}
"""
query = reactive("")
results = reactive(list)
def compose(self) -> ComposeResult:
yield Header()
yield Input(placeholder="Type to search docs", id="search")
yield Static("No results yet", id="results")
yield Footer()
def on_input_changed(self, event: Input.Changed) -> None:
self.query = event.value
time.sleep(0.5)
lowered = event.value.lower()
self.results = [
(title, summary)
for title, summary in FAKE_DOCS
if lowered in title.lower() or lowered in summary.lower()
]
self.refresh_results()
def refresh_results(self) -> None:
table = Table(title="Search results")
table.add_column("Title")
table.add_column("Summary")
for title, summary in self.results:
table.add_row(title, summary)
self.query_one("#results", Static).update(table)
def action_focus_search(self) -> None:
self.query_one("#search", Input).focus()
def action_show_help(self) -> None:
self.notify("No help yet")
def action_toggle_theme(self) -> None:
self.notify("Theme toggle not implemented")
def action_refresh_now(self) -> None:
self.refresh_results()
def action_export_report(self) -> None:
self.notify("Export not implemented")
def action_clear_search(self) -> None:
self.query_one("#search", Input).value = ""
self.results = []
self.refresh_results()
def action_show_about(self) -> None:
self.notify("Broken Search demo")
if __name__ == "__main__":
SearchApp().run()
from __future__ import annotations
from pathlib import Path
from textual.app import App, ComposeResult
from textual.widgets import Button, Footer, Header, Static
class ReportExportApp(App):
TITLE = "Local Report Export"
def compose(self) -> ComposeResult:
yield Header()
yield Button("Export report", id="export")
yield Static("Nothing exported yet", id="status")
yield Footer()
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "export":
self.action_export_report()
def action_export_report(self) -> None:
output = Path("report.csv")
output.write_text("name,value\nalpha,1\nbeta,2\n", encoding="utf-8")
self.query_one("#status", Static).update(f"Wrote {output.resolve()}")
if __name__ == "__main__":
ReportExportApp().run()
Evals
These evals are meant for iterative skill testing.
Suggested workflow: 1. run the prompt with this skill activated 2. save outputs per eval case 3. grade against the assertions in evals/evals.json 4. compare against a baseline run without the skill or against an older skill snapshot
Included sample projects:
files/broken-search/— blocking handler, inline CSS, manual Rich table, no testsfiles/report-export/— export-like app that writes local files but has no delivery API path
Anti-patterns
Use this file when reviewing generated code or planning a refactor.
Architecture smells
Giant App class
Symptoms:
- hundreds of lines
- many
on_*andaction_*methods - screen-specific logic everywhere
Fix:
- extract
Screenclasses - extract composite widgets
- keep
Appfor orchestration
Inline CSS sprawl
Symptoms:
- long
CSS = """..."""blocks - styling mixed with business logic
- hard-to-test selectors
Fix:
- move to
.tcss - keep classes semantic
- keep styling separate from control flow
Performance smells
Blocking handlers
Symptoms:
time.sleep(...)- network calls in
on_* - subprocess execution in button handlers
Fix:
- workers, messages, notifications
Heavy compute_*
Symptoms:
- I/O
- parsing
- long loops
- side effects
Fix:
- keep computes pure and cheap
- move work elsewhere
Widget misuse
Manual Rich tables inside Static
Use DataTable when the user needs focus, selection, cursor movement, or row interactions.
Ad-hoc tab systems
Use TabbedContent / TabPane.
Ad-hoc file tree widgets
Use DirectoryTree.
Home-grown markdown panes
Use MarkdownViewer or Markdown.
Testing smells
- no
run_test()coverage - brittle selectors tied to literal text
- behaviour changes with only manual testing
- layout-heavy app with no size-based test or snapshot coverage
Browser parity smells
- writing files directly to the local filesystem with no browser-aware path
- assuming terminal width
- export/report flows without delivery APIs
Architecture decision tree
Use this file when the user has described an app but not its internal shape.
First cut
Does the user mainly need one working surface?
Use a single Screen with containers and built-in widgets.
Best for:
- dashboards
- search tools
- editors
- settings panes
- monitors
Default structure:
App- one root screen or default screen
- composite widgets for repeated chunks
- external
.tcss
Does the user move between distinct contexts?
Use Screen classes.
Signals:
- drill-down views
- home -> detail -> edit
- separate flows with different headers/footers
- modal confirmation steps
Rule of thumb:
- if going “back” should restore the previous view, that usually wants a
Screen
Are there durable top-level areas with their own navigation stacks?
Use named MODES.
Signals:
- dashboard / jobs / settings / logs
- inbox / projects / account
- an app-level sidebar or command palette that jumps between sections
Rule of thumb:
- use modes for persistent product areas
- use screens for within-area navigation
Is the flow linear and local to one screen?
Use ContentSwitcher or a small state machine.
Best for:
- multi-step forms
- onboarding
- short wizards
- alternate detail panes
Is the interruption brief and task-specific?
Use ModalScreen.
Best for:
- confirmation dialogs
- pickers
- destructive actions
- tiny forms
Layout choices
Data-heavy tools
Prefer:
DataTable- filter/search input
- details pane
- breakpoint-driven collapse on narrow terminals
Avoid:
- rendering static Rich tables inside
Staticwhen the table needs focus or cursoring
Filesystem/document tools
Prefer:
DirectoryTree- preview/detail pane
MarkdownViewerorTextArea- delivery APIs for export/download
Chat and live task tools
Prefer:
- transcript/log widget
- input dock
- worker-driven background tasks
- anchored scrolling
- clear status/notification path
Extraction rules
Split code when any of these happen:
Appgrows beyond a few hundred lines- the same query selectors appear everywhere
- a screen has its own state and actions
- widget event handling dominates the app class
- styling is hard to reason about without opening Python
Good extraction targets:
Screenfor route-level behaviour- composite
Widgetfor reusable panes - helper module for domain logic
.tcssfor all layout and theme work
Stable defaults
When unsure: 1. start with one screen 2. compose built-in widgets 3. externalise styling 4. add IDs/classes deliberately 5. move long-running work into workers 6. add at least one Pilot test before expanding scope
Browser and delivery
Textual can run in the terminal and can also be served to a browser. Design export and handoff flows accordingly.
Dev loop
textual run --dev
Use for day-to-day iteration:
- live CSS updates
- faster feedback while shaping layouts
- easier debugging during development
textual console
Use when:
- event flow is unclear
- style or layout behaviour is confusing
- you need extra introspection while the app runs
textual serve
Use when:
- the app should run in a browser
- download/export behaviour matters
- layout parity between terminal and browser matters
Delivery APIs
Prefer delivery APIs over assuming local filesystem access when the app may run in a browser.
deliver_text(...)
Use for:
- CSV
- JSON
- markdown exports
- generated reports
deliver_binary(...)
Use for:
- images
- zip files
- binary artefacts
- generated documents
deliver_screenshot(...)
Use when you want to hand the user an image of the current app state.
Listen for delivery completion/failure if the flow needs confirmation or fallback messaging.
open_url(...)
Use when the app should hand off to a browser or external page:
- documentation
- issue tracker
- help site
- user-facing links
Browser-friendly design rules
- avoid flows that only make sense with a local working directory
- provide explicit download/export actions
- make narrow layouts work well
- do not bury key actions in hover-only affordances
- test both terminal and browser scenarios for apps that promise both
Export checklist
Before you ship an export flow, ask: 1. does this need to work in a browser? 2. should the user download a file instead of writing to a local path? 3. do I need success/failure notifications? 4. do I need a screenshot or text artefact for bug reports?
Packaging and CI
Use this file when the user wants something that can be installed, run, or tested like a normal Python project.
Baseline project shape
my_app.pyor a package module- matching
.tcss tests/pyproject.toml- optional
.github/workflows/textual-tests.yml
Recommended dependencies
Runtime:
textual
Useful dev dependencies:
pytestpytest-asynciotextual-devpytest-textual-snapshot
Optional:
- syntax extras when the app needs
TextAreasyntax support
Console entry points
Prefer a main() function and a package/script entry in pyproject.toml.
CI guidance
A good default workflow should:
- test multiple Python versions where sensible
- install the project with dev extras
- run pytest
- upload snapshot artefacts if present
Use the bundled generators:
python scripts/emit_textual_pyproject.pypython scripts/emit_github_actions_ci.py
Packaging checklist
Before finishing:
- entry point works
- tests are runnable with
pytest - the README or handoff notes mention
textual run --dev - the project structure is understandable without reading a long explanation
Reactivity and workers
This file is the default playbook for keeping Textual apps responsive.
State choices
reactive
Use when a state change should participate in automatic refresh/watcher behaviour.
Good fits:
- current filter text
- selected row key
- current step index
- UI-visible derived state inputs
var
Use when you want stored state without the heavier reactive machinery.
Good fits:
- caches
- internal flags
- values that do not need automatic redraw behaviour
Derived state
compute_*
Use for cheap, deterministic derived values.
Rules:
- keep it fast
- no I/O
- no sleeps
- no subprocesses
- no network
- no UI mutation side effects
Bad smell:
- a compute method opening files, calling APIs, or rebuilding large structures repeatedly
watch_*
Use to react to a state change with UI updates or local orchestration.
Good fits:
- updating a detail pane
- toggling classes
- switching visible content
- adjusting focus after state changes
Do not put long-running work here.
set_reactive
Use when you need to set reactive state before mount without firing watchers too early.
Typical case:
- initial state restored from config or CLI args
Workers
Use a worker whenever the task might block input or repaint.
Move these off the main event path:
- network calls
- subprocess work
- parsing large files
- sleeps / retries / polling
- expensive search
- filesystem scans
- model inference or streaming wrappers
@work
Best for app/widget methods that naturally launch background work.
Patterns:
- search as the user types
- background refresh
- async fetch with result-handling in one place
run_worker(...)
Good when launching work dynamically or from helper functions.
exclusive=True
Use for “latest request wins”.
Typical cases:
- live search
- debounced filtering
- preview generation
- background refresh keyed by the current selection
If an older request finishes later than a newer request, it should not overwrite the UI.
Thread workers
If you use thread workers:
- do not mutate widgets directly from the worker thread
- route updates back via
post_message(...)orcall_from_thread(...)
Small but valuable APIs
notify(...)
Use for lightweight global feedback:
- saved
- exported
- failed
- cancelled
batch_update()
Use when a single user action causes many widget changes. It reduces flicker and intermediate layouts.
anchor()
Use on scrollable transcript/log/chat views to keep the viewport pinned to the latest content.
begin_capture_print()
Useful when you want print(...) output to land inside the app for debugging or monitoring.
Default pattern for live search
1. input changes 2. store the current query reactively 3. launch a worker with exclusive=True 4. when results return, update the table/list 5. update the detail pane and status 6. notify on recoverable failures
Red flags
time.sleep(...)inon_*oraction_*requests.get(...)inside watchers or handlers- expensive loops in
compute_* - repeated
query_one(...)calls all over the app because state and composition are muddled - UI updates from a thread worker without marshalling back to the app thread
Repository map
This summary was generated from the bundled Textual repository snapshot used to build this skill.
High-value directories
src/textual/— framework source.src/textual/widgets/— built-in widgets (45widget implementation files detected).examples/— standalone example apps worth mining for architecture and styling patterns.docs/examples/— guide/tutorial examples, usually minimal and focused on one concept.tests/— excellent source of behaviour contracts and edge-case handling.
docs/examples hot spots
guide— 158 filesstyles— 145 fileswidgets— 89 fileshow-to— 29 filesapp— 15 filestutorial— 11 filesevents— 7 filesthemes— 3 filesgetting_started— 1 files
tests hot spots
snapshot_tests— 606 filescss— 18 filesinput— 12 filestext_area— 12 filestree— 12 filescommand_palette— 10 filesoption_list— 10 filesanimations— 7 filesselect— 6 filesdocument— 5 fileslayouts— 5 filesrenderables— 5 filesselection_list— 5 fileslistview— 4 filesnotifications— 4 filestoggles— 4 filessuggester— 3 filesworkers— 3 filesdirectory_tree— 2 files__init__.py— 1 files
Practical search order
1. Look in examples/ for full app structure. 2. Look in docs/examples/guide/ for the smallest focused example of a feature. 3. Look in tests/ when behaviour, edge cases, or event names are unclear.
Screens, modes, and command palette
These three features solve different navigation problems.
Screens
Use Screen when the user changes working context.
Examples:
- dashboard -> job detail
- repository browser -> file editor
- home -> settings
- list -> edit form
Use screens when:
- back-navigation matters
- focus should restore naturally
- the layout meaningfully changes
- a section has its own state and event handling
Modal screens
Use ModalScreen for short interruptions:
- confirmation prompts
- delete dialogs
- small pickers
- quick forms
Do not use a modal to replace a full screen just because it is faster to code.
Modes
Modes are named screen stacks for durable top-level areas.
Use modes when:
- the app has persistent product areas
- each area may drill down internally
- users need to switch between major sections without losing where they were
Good examples:
- dashboard / jobs / settings / logs
- inbox / projects / account
Bad example:
- a simple two-step wizard
Command palette
Add command palette support when:
- bindings are numerous
- actions need discoverability
- the app has screens or modes
- power users benefit from searchable commands
Use app-level providers for global actions and screen-level providers for local actions.
Typical palette commands:
- switch mode
- open help
- focus search
- export/download
- toggle theme
- jump to important screens
Practical defaults
One-screen app
- no modes
- command palette optional
ContentSwitcheronly for local step flows
Multi-screen workflow
- screens yes
- modes maybe
- command palette usually worthwhile
Admin shell
- modes yes
- screens inside modes often yes
- command palette strongly recommended
Common mistakes
- using modes when plain screens would do
- hiding all major actions behind key bindings with no palette support
- creating ad-hoc screen stacks by manually swapping many widgets
- keeping giant screen-specific methods inside the
Appinstead of extracting aScreen
Testing matrix
Treat tests as part of the deliverable, not optional polish.
Default minimum
For any non-trivial feature, leave behind:
1. Smoke test App launches under run_test().
2. Behaviour test The user interaction that changed actually works.
3. Layout or size test Use an alternate terminal size when the layout matters.
4. Snapshot test Add one when the feature is visually structured and regressions are likely.
Pilot-first testing
Use run_test() and Pilot for:
press(...)click(...)- filling inputs
- pausing for async work
- asserting widget state
Good behaviour tests:
- typing into a filter updates results
- selecting a row updates a detail pane
- pressing a binding changes mode/screen
- a save action updates status and notifies
Snapshot tests
Use snapshot tests when:
- TCSS and layout are important
- the app has multiple panes
- narrow vs wide layouts matter
- a refactor might silently change structure
Useful variations:
- alternate terminal sizes
- initial key presses
- a small setup callback before capture
Layout-sensitive features
Always consider a second size when the app has:
- side panes
- breakpoints
- content switchers
- long forms
- data tables
- browser support
Test-writing guidance
- give important widgets stable IDs
- avoid selectors based on display text when IDs or classes are better
- test through user actions rather than internal methods when possible
- pause for worker completion in the smallest reliable way
- keep one smoke test even if snapshot coverage exists
Good output for a skill-driven change
- a passing smoke test
- a focused behaviour test
- a comment or docstring only where behaviour is subtle
- snapshot coverage only where it earns its keep
Upstream pattern atlas
This file was generated from the bundled Textual repository snapshot.
Most-imported widgets in examples
Static— 96 importsLabel— 93 importsFooter— 45 importsHeader— 35 importsPlaceholder— 34 importsButton— 34 importsInput— 25 importsDigits— 18 importsDataTable— 9 importsMarkdown— 8 importsProgressBar— 8 importsRichLog— 7 importsSwitch— 6 importsTextArea— 6 importsSelectionList— 5 importsCollapsible— 4 importsOptionList— 4 importsRadioSet— 4 importsSelect— 4 importsWelcome— 4 imports
Example paths by feature
Breakpoints
examples/breakpoints.pytests/snapshot_tests/test_snapshots.py
Command Palette
examples/color_command.pydocs/examples/guide/command_palette/command02.py
Data Table
examples/theme_sandbox.pydocs/examples/guide/widgets/loading01.pydocs/examples/widgets/content_switcher.pydocs/examples/widgets/data_table.pydocs/examples/widgets/data_table_cursors.pydocs/examples/widgets/data_table_fixed.pydocs/examples/widgets/data_table_labels.pydocs/examples/widgets/data_table_renderables.py
Delivery
- No matching examples found in this snapshot.
Directory Tree
examples/code_browser.pydocs/examples/widgets/directory_tree.pydocs/examples/widgets/directory_tree_filtered.pytests/test_disabled.pytests/directory_tree/test_change_path.pytests/directory_tree/test_early_show_root.pytests/snapshot_tests/snapshot_apps/directory_tree_reload.pytests/tree/test_directory_tree.py
Modes
docs/examples/guide/screens/modes01.pytests/test_app.pytests/test_screen_modes.pytests/test_screens.pytests/css/test_screen_css.pytests/snapshot_tests/snapshot_apps/notification_through_modes.py
Text Area
examples/theme_sandbox.pydocs/examples/widgets/text_area_custom_language.pydocs/examples/widgets/text_area_custom_theme.pydocs/examples/widgets/text_area_example.pydocs/examples/widgets/text_area_extended.pydocs/examples/widgets/text_area_selection.pytests/test_query.pytests/test_widget.py
Workers
examples/dictionary.pyexamples/mother.pydocs/examples/guide/screens/questions01.pydocs/examples/guide/widgets/loading01.pydocs/examples/guide/workers/weather03.pydocs/examples/guide/workers/weather04.pydocs/examples/guide/workers/weather05.pytests/test_logger.py
How to mine upstream effectively
- Prefer
examples/when you want a complete app shell or a realistic layout. - Prefer
docs/examples/guide/when you want the smallest reproducible example of a feature. - Prefer
tests/when you need event names, edge cases, or confirmation that a behaviour is supported.
Widget selection atlas
Prefer the built-in widget that already matches the interaction model.
High-value widgets
DataTable
Use for:
- searchable result sets
- keyboard navigation over rows
- dashboards with metrics or records
- master/detail layouts
Prefer over:
- manual Rich
Tablerendered intoStatic - custom cursor logic for rows
Pair with:
- filter
Input - detail
Static/Markdown - responsive pane layout
DirectoryTree
Use for:
- file browsers
- repository explorers
- import pickers
- project navigation
Pair with:
- preview pane
TextAreaorMarkdownViewer- path/status footer
MarkdownViewer
Use for:
- rendered notes, help, changelogs, docs, AI output
- long-form content needing browser-like navigation
Prefer over:
- hand-rendering markdown into many small widgets
TextArea
Use for:
- text/code editing
- configuration editors
- scratch pads
- prompt builders
Pair with:
- save action
- status line
- optional preview or lint output
TabbedContent / TabPane
Use for:
- grouped settings
- alternate detail views
- inspector panes
Prefer over:
- hand-written tab state unless the behaviour is highly custom
Log / RichLog
Use for:
- streaming output
- task history
- captured stdout/stderr
- diagnostics
Prefer Log for simple text streams and RichLog when rich renderables matter.
Tree
Use for:
- hierarchical data that is not the filesystem
- settings groups
- expandable summaries
SelectionList, OptionList, ListView, Select
Quick guide:
SelectionListfor multi-select checklistsOptionListfor keyboard-first option menusListViewfor custom row widgetsSelectfor compact form dropdowns
Switch, Checkbox, Input, Button, Label, Static
These cover most form and control needs. Reach for them before custom controls.
Widget pairings that work well
DataTable+ detailStaticDirectoryTree+TextAreaInput+DataTable+ statusLabelMarkdownViewer+FooterTabbedContent+ per-tab formsLog+ filterInput
Signals you are missing a built-in
Stop and reconsider if you are about to:
- hand-roll tabs
- hand-roll file trees
- write your own editable multiline text widget
- render a Rich table into
Staticand then emulate selection - manage long-form markdown as many individual
Staticwidgets
Styling advice
Make widget choice do most of the work. Use TCSS for:
- layout
- spacing
- responsive pane arrangement
- semantic states via classes
Do not create custom widgets only to solve a styling problem.
#!/usr/bin/env python3
"""Utilities shared by the textual-tui-v2 helper scripts."""
from __future__ import annotations
import ast
import json
import os
import re
from pathlib import Path
from typing import Dict, Iterable, List, Optional, Sequence, Set, Tuple
SKILL_ROOT = Path(__file__).resolve().parents[1]
IGNORE_DIRS = {
".git",
".hg",
".svn",
".mypy_cache",
".pytest_cache",
".ruff_cache",
".tox",
".venv",
"venv",
"env",
"__pycache__",
"node_modules",
"dist",
"build",
}
TEXTUAL_IMPORT_PREFIX = "textual"
BINDING_VERBS_TO_SKIP = {
"quit",
"exit",
"back",
"command_palette",
}
NETWORK_CALL_PREFIXES = {
"requests.get",
"requests.post",
"requests.put",
"requests.delete",
"requests.request",
"httpx.get",
"httpx.post",
"httpx.put",
"httpx.delete",
"httpx.request",
"urllib.request.urlopen",
}
BLOCKING_CALL_PREFIXES = {
"time.sleep",
"subprocess.run",
"subprocess.check_output",
"subprocess.call",
"os.system",
} | NETWORK_CALL_PREFIXES
TABLE_LIKE_IMPORTS = {"Table", "Column"}
BUILTIN_WIDGETS = {
"Button",
"Checkbox",
"Collapsible",
"ContentSwitcher",
"DataTable",
"Digits",
"DirectoryTree",
"Footer",
"Header",
"HelpPanel",
"Input",
"KeyPanel",
"Label",
"Link",
"ListItem",
"ListView",
"LoadingIndicator",
"Log",
"Markdown",
"MarkdownViewer",
"MaskedInput",
"OptionList",
"Placeholder",
"Pretty",
"ProgressBar",
"RadioButton",
"RadioSet",
"RichLog",
"Rule",
"Select",
"SelectionList",
"Sparkline",
"Static",
"Switch",
"Tab",
"TabbedContent",
"TabPane",
"Tabs",
"TextArea",
"Tooltip",
"Tree",
"Welcome",
}
def to_posix(path: Path) -> str:
return path.as_posix()
def iter_files(root: Path, suffixes: Sequence[str]) -> Iterable[Path]:
suffixes = tuple(suffixes)
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = [name for name in dirnames if name not in IGNORE_DIRS]
for filename in filenames:
if filename.endswith(suffixes):
yield Path(dirpath) / filename
def read_text(path: Path) -> str:
return path.read_text(encoding="utf-8")
def safe_parse(path: Path) -> Optional[ast.AST]:
try:
return ast.parse(read_text(path), filename=str(path))
except SyntaxError:
return None
def full_name(node: ast.AST) -> Optional[str]:
if isinstance(node, ast.Name):
return node.id
if isinstance(node, ast.Attribute):
value = full_name(node.value)
if value is None:
return node.attr
return f"{value}.{node.attr}"
if isinstance(node, ast.Subscript):
return full_name(node.value)
if isinstance(node, ast.Call):
return full_name(node.func)
if isinstance(node, ast.Constant) and isinstance(node.value, str):
return node.value
return None
def call_name(node: ast.Call) -> Optional[str]:
return full_name(node.func)
def string_value(node: ast.AST) -> Optional[str]:
if isinstance(node, ast.Constant) and isinstance(node.value, str):
return node.value
return None
def int_value(node: ast.AST) -> Optional[int]:
if isinstance(node, ast.Constant) and isinstance(node.value, int):
return node.value
return None
def bool_value(node: ast.AST) -> Optional[bool]:
if isinstance(node, ast.Constant) and isinstance(node.value, bool):
return node.value
return None
def literal_structure(node: ast.AST):
try:
return ast.literal_eval(node)
except Exception:
return None
def class_base_names(class_def: ast.ClassDef) -> List[str]:
return [full_name(base) or ast.unparse(base) for base in class_def.bases]
def class_kind(base_names: Sequence[str]) -> Optional[str]:
simple_bases = {base.split(".")[-1] for base in base_names}
if "App" in simple_bases:
return "app"
if "ModalScreen" in simple_bases or "Screen" in simple_bases:
return "screen"
if simple_bases & {
"Widget",
"Static",
"Markdown",
"MarkdownViewer",
"Container",
"VerticalScroll",
"Horizontal",
"Vertical",
"ScrollableContainer",
"DataTable",
"DirectoryTree",
"TextArea",
"Log",
"RichLog",
"Pretty",
"Tree",
"ListView",
"Input",
"Button",
}:
return "widget"
return None
def class_span(class_def: ast.ClassDef) -> int:
if hasattr(class_def, "end_lineno") and class_def.end_lineno:
return int(class_def.end_lineno) - int(class_def.lineno) + 1
return 0
def decorator_names(function_def: ast.AST) -> List[str]:
names: List[str] = []
for decorator in getattr(function_def, "decorator_list", []):
if isinstance(decorator, ast.Call):
name = full_name(decorator.func)
else:
name = full_name(decorator)
if name:
names.append(name)
return names
def method_names(class_def: ast.ClassDef, prefix: str) -> List[str]:
names: List[str] = []
for node in class_def.body:
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name.startswith(prefix):
names.append(node.name)
return names
def assignment_value(class_def: ast.ClassDef, target_name: str):
for node in class_def.body:
if isinstance(node, ast.Assign):
for target in node.targets:
if isinstance(target, ast.Name) and target.id == target_name:
return node.value
elif isinstance(node, ast.AnnAssign):
if isinstance(node.target, ast.Name) and node.target.id == target_name:
return node.value
return None
def extract_bindings(class_def: ast.ClassDef) -> List[Dict[str, str]]:
bindings: List[Dict[str, str]] = []
value = assignment_value(class_def, "BINDINGS")
if value is None:
return bindings
literal = literal_structure(value)
if isinstance(literal, (list, tuple)):
for entry in literal:
key = action = description = None
if isinstance(entry, (list, tuple)) and len(entry) >= 2:
key = str(entry[0])
action = str(entry[1])
description = str(entry[2]) if len(entry) >= 3 else ""
elif isinstance(entry, dict):
key = str(entry.get("key", ""))
action = str(entry.get("action", ""))
description = str(entry.get("description", ""))
if key and action:
bindings.append(
{"key": key, "action": action, "description": description}
)
return bindings
def extract_modes(class_def: ast.ClassDef) -> Dict[str, str]:
value = assignment_value(class_def, "MODES")
literal = literal_structure(value) if value is not None else None
modes: Dict[str, str] = {}
if isinstance(literal, dict):
for key, mode_value in literal.items():
modes[str(key)] = str(mode_value)
return modes
def extract_screens(class_def: ast.ClassDef) -> Dict[str, str]:
value = assignment_value(class_def, "SCREENS")
literal = literal_structure(value) if value is not None else None
screens: Dict[str, str] = {}
if isinstance(literal, dict):
for key, screen_value in literal.items():
screens[str(key)] = str(screen_value)
return screens
def extract_css_paths(class_def: ast.ClassDef) -> List[str]:
value = assignment_value(class_def, "CSS_PATH")
literal = literal_structure(value) if value is not None else None
if isinstance(literal, str):
return [literal]
if isinstance(literal, (list, tuple)):
return [str(item) for item in literal if isinstance(item, str)]
return []
def extract_breakpoints(class_def: ast.ClassDef) -> Dict[str, int]:
out: Dict[str, int] = {}
for attr in ("HORIZONTAL_BREAKPOINTS", "VERTICAL_BREAKPOINTS"):
value = assignment_value(class_def, attr)
literal = literal_structure(value) if value is not None else None
if isinstance(literal, (list, tuple)):
out[attr.lower()] = len(literal)
return out
def extract_template_calls(tree: ast.AST) -> List[Dict[str, str]]:
results: List[Dict[str, str]] = []
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
func = full_name(node.func)
if not func:
continue
widget_name = func.split(".")[-1]
record: Dict[str, str] = {"widget": widget_name}
for keyword in node.keywords:
if keyword.arg == "id":
value = string_value(keyword.value)
if value:
record["id"] = value
if keyword.arg == "placeholder":
value = string_value(keyword.value)
if value:
record["placeholder"] = value
if "id" in record or widget_name in BUILTIN_WIDGETS:
results.append(record)
return results
def collect_imports(tree: ast.AST) -> Dict[str, Set[str]]:
imports: Dict[str, Set[str]] = {"modules": set(), "widgets": set(), "other_textual": set(), "rich": set()}
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for alias in node.names:
if alias.name.startswith(TEXTUAL_IMPORT_PREFIX):
imports["modules"].add(alias.name)
elif alias.name.startswith("rich"):
imports["rich"].add(alias.name)
elif isinstance(node, ast.ImportFrom):
module = node.module or ""
if module.startswith(TEXTUAL_IMPORT_PREFIX):
imports["modules"].add(module)
names = {alias.name for alias in node.names}
if module == "textual.widgets":
imports["widgets"].update(names)
else:
imports["other_textual"].update(names)
elif module.startswith("rich"):
imports["rich"].update(alias.name for alias in node.names)
return imports
def find_calls(tree: ast.AST) -> Set[str]:
calls: Set[str] = set()
for node in ast.walk(tree):
if isinstance(node, ast.Call):
name = call_name(node)
if name:
calls.add(name)
return calls
def handler_blocking_calls(function_def: ast.AST) -> List[str]:
found: List[str] = []
for node in ast.walk(function_def):
if isinstance(node, ast.Call):
name = call_name(node)
if name and any(name.startswith(prefix) for prefix in BLOCKING_CALL_PREFIXES):
found.append(name)
return sorted(set(found))
def scan_textual_python_file(path: Path, root_dir: Optional[Path] = None) -> Dict[str, object]:
tree = safe_parse(path)
text = read_text(path)
relative = to_posix(path.relative_to(root_dir or path.parent))
if tree is None:
return {
"path": relative,
"parse_error": True,
"textual": "textual" in text,
}
imports = collect_imports(tree)
calls = find_calls(tree)
class_records: List[Dict[str, object]] = []
app_classes: List[Dict[str, object]] = []
screen_classes: List[Dict[str, object]] = []
widget_classes: List[Dict[str, object]] = []
for node in tree.body:
if not isinstance(node, ast.ClassDef):
continue
bases = class_base_names(node)
kind = class_kind(bases)
record: Dict[str, object] = {
"name": node.name,
"bases": bases,
"kind": kind,
"span": class_span(node),
"methods": {
"actions": method_names(node, "action_"),
"handlers": method_names(node, "on_"),
"watchers": method_names(node, "watch_"),
"validators": method_names(node, "validate_"),
"computes": method_names(node, "compute_"),
},
"decorators": [],
}
if kind == "app":
record["bindings"] = extract_bindings(node)
record["modes"] = extract_modes(node)
record["screens"] = extract_screens(node)
record["css_paths"] = extract_css_paths(node)
record["breakpoints"] = extract_breakpoints(node)
app_classes.append(record)
elif kind == "screen":
record["bindings"] = extract_bindings(node)
record["css_paths"] = extract_css_paths(node)
record["breakpoints"] = extract_breakpoints(node)
screen_classes.append(record)
elif kind == "widget":
widget_classes.append(record)
class_records.append(record)
handler_calls: Dict[str, List[str]] = {}
worker_decorators: Set[str] = set()
for node in ast.walk(tree):
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
decorators = decorator_names(node)
if any(name.endswith("work") or name == "work" for name in decorators):
worker_decorators.update(decorators)
if node.name.startswith("on_") or node.name.startswith("action_") or node.name.startswith("compute_"):
blocking = handler_blocking_calls(node)
if blocking:
handler_calls[node.name] = blocking
template_calls = extract_template_calls(tree)
ids = sorted({record["id"] for record in template_calls if "id" in record})
placeholders = sorted(
{
record["placeholder"]
for record in template_calls
if "placeholder" in record
}
)
textual_detected = bool(
imports["modules"]
or imports["widgets"]
or app_classes
or screen_classes
or widget_classes
or ".tcss" in text
)
return {
"path": relative,
"parse_error": False,
"textual": textual_detected,
"imports": {
key: sorted(value) for key, value in imports.items()
},
"calls": sorted(calls),
"classes": class_records,
"app_classes": app_classes,
"screen_classes": screen_classes,
"widget_classes": widget_classes,
"ids": ids,
"placeholders": placeholders,
"handler_blocking_calls": handler_calls,
"uses": {
"reactive": "reactive" in text,
"var": "var(" in text or " var" in text,
"workers": "run_worker" in text or "work(" in text or "@work" in text,
"run_test": "run_test(" in text,
"pilot": "pilot." in text or "Pilot" in text,
"deliver": "deliver_text(" in text or "deliver_binary(" in text or "deliver_screenshot(" in text,
"command_palette": "COMMANDS" in text or "command_palette" in text,
"modes": "MODES" in text,
"screens": "SCREENS" in text or "install_screen(" in text or "push_screen(" in text,
"breakpoints": "HORIZONTAL_BREAKPOINTS" in text or "VERTICAL_BREAKPOINTS" in text,
"notify": "notify(" in text,
"anchor": ".anchor(" in text,
"batch_update": "batch_update(" in text,
"capture_print": "begin_capture_print(" in text,
},
"template_calls": template_calls,
}
def scan_project(project_root: Path) -> Dict[str, object]:
py_files = sorted(iter_files(project_root, [".py"]))
tcss_files = sorted(iter_files(project_root, [".tcss"]))
scans: List[Dict[str, object]] = []
for path in py_files:
scan = scan_textual_python_file(path, root_dir=project_root)
if scan.get("textual"):
scans.append(scan)
widgets_counter: Dict[str, int] = {}
all_ids: Set[str] = set()
all_placeholders: Set[str] = set()
for scan in scans:
for widget in scan["imports"]["widgets"]:
widgets_counter[widget] = widgets_counter.get(widget, 0) + 1
for template_call in scan["template_calls"]:
widget_name = template_call["widget"]
widgets_counter[widget_name] = widgets_counter.get(widget_name, 0) + 1
all_ids.update(scan["ids"])
all_placeholders.update(scan["placeholders"])
tests = [
scan for scan in scans if "/tests/" in f"/{scan['path']}/" or scan["path"].startswith("tests/")
]
apps = []
for scan in scans:
apps.extend(
[
{"path": scan["path"], "class_name": record["name"]}
for record in scan["app_classes"]
]
)
pyproject = project_root / "pyproject.toml"
requirements = project_root / "requirements.txt"
dependency_hints: List[str] = []
if pyproject.exists():
text = read_text(pyproject)
for line in text.splitlines():
if "textual" in line.lower():
dependency_hints.append(line.strip())
if requirements.exists():
dependency_hints.extend(
[line.strip() for line in read_text(requirements).splitlines() if "textual" in line.lower()]
)
return {
"project_root": str(project_root.resolve()),
"textual_detected": bool(scans or tcss_files),
"python_file_count": len(py_files),
"textual_python_file_count": len(scans),
"stylesheet_count": len(tcss_files),
"stylesheets": [to_posix(path.relative_to(project_root)) for path in tcss_files],
"apps": apps,
"tests": [{"path": scan["path"], "uses": scan["uses"]} for scan in tests],
"widget_usage": sorted(
[{"widget": widget, "count": count} for widget, count in widgets_counter.items()],
key=lambda item: (-item["count"], item["widget"]),
),
"ids": sorted(all_ids),
"placeholders": sorted(all_placeholders),
"dependency_hints": dependency_hints,
"files": scans,
}
def safe_key_for_test(binding: Dict[str, str]) -> bool:
key = binding["key"]
action = binding["action"]
if action.split("(")[0] in BINDING_VERBS_TO_SKIP:
return False
if key.lower() in {"q", "ctrl+c", "escape"}:
return False
return True
def render_template(text: str, replacements: Dict[str, str]) -> str:
pattern = re.compile(r"{{([A-Z0-9_]+)}}")
def replace(match: re.Match[str]) -> str:
key = match.group(1)
if key not in replacements:
raise KeyError("Unknown template placeholder: {0}".format(key))
return replacements[key]
return pattern.sub(replace, text)
def load_asset(*parts: str) -> str:
return (SKILL_ROOT / "assets" / Path(*parts)).read_text(encoding="utf-8")
def write_text_file(path: Path, content: str, force: bool = False) -> None:
if path.exists() and not force:
raise FileExistsError("Refusing to overwrite existing file without --force: {0}".format(path))
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
def json_dumps(data: object) -> str:
return json.dumps(data, indent=2, sort_keys=False)
#!/usr/bin/env python3
"""Audit a Textual project for architecture, performance, and testing issues."""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from typing import Dict, List
from _textual_skill_utils import json_dumps, scan_project
def add_finding(findings: List[Dict[str, object]], severity: str, title: str, evidence: List[str], recommendation: str) -> None:
findings.append(
{
"severity": severity,
"title": title,
"evidence": evidence,
"recommendation": recommendation,
}
)
def audit(project_root: Path) -> Dict[str, object]:
project = scan_project(project_root)
findings: List[Dict[str, object]] = []
files = project["files"]
apps = project["apps"]
widget_usage = {item["widget"]: item["count"] for item in project["widget_usage"]}
if not project["textual_detected"]:
add_finding(
findings,
"error",
"No Textual project detected",
["No Textual app classes, widget imports, or `.tcss` files were found."],
"Confirm the project root is correct or add Textual-specific files before using this audit.",
)
return {
"project_root": project["project_root"],
"textual_detected": False,
"findings": findings,
"summary": {"errors": 1, "warnings": 0, "info": 0},
}
if not project["tests"]:
add_finding(
findings,
"warning",
"No Textual tests detected",
["No files under `tests/` appeared to use `run_test()` or `Pilot`."],
"Add at least a smoke test and one interaction test using `run_test()` and `Pilot`.",
)
if not project["stylesheets"]:
inline_css_apps = []
for file_scan in files:
for app_class in file_scan["app_classes"]:
if app_class["css_paths"]:
continue
for class_record in file_scan["classes"]:
if class_record["name"] == app_class["name"]:
inline_css_apps.append("{0}:{1}".format(file_scan["path"], app_class["name"]))
if inline_css_apps:
add_finding(
findings,
"info",
"No external TCSS files detected",
inline_css_apps[:5],
"Keep inline `CSS` for toy apps only. Move styling into `.tcss` once layout or theming grows beyond a handful of rules.",
)
for file_scan in files:
for app_class in file_scan["app_classes"]:
actions = app_class["methods"]["actions"]
handlers = app_class["methods"]["handlers"]
total_methods = len(actions) + len(handlers)
if app_class["span"] >= 250 or total_methods >= 12:
add_finding(
findings,
"warning",
"Large App class likely needs extraction",
[
"{0}:{1} spans {2} lines with {3} actions/handlers.".format(
file_scan["path"], app_class["name"], app_class["span"], total_methods
)
],
"Split screen-specific logic into `Screen` classes and reusable widgets. Keep the `App` focused on orchestration.",
)
if app_class["bindings"] and len(app_class["bindings"]) >= 7 and not file_scan["uses"]["command_palette"]:
add_finding(
findings,
"info",
"Many bindings but no command palette integration",
[
"{0}:{1} defines {2} bindings.".format(
file_scan["path"], app_class["name"], len(app_class["bindings"])
)
],
"Add app-level or screen-level `COMMANDS` providers so features remain discoverable beyond the footer.",
)
if app_class["modes"] and not app_class["screens"]:
add_finding(
findings,
"info",
"Modes configured without named screens",
[
"{0}:{1} defines MODES={2}.".format(
file_scan["path"], app_class["name"], sorted(app_class["modes"].keys())
)
],
"This can be valid, but named screens in `SCREENS` often make navigation and testing easier when the mode stack grows.",
)
if (
(widget_usage.get("DataTable", 0) or widget_usage.get("DirectoryTree", 0))
and not app_class["breakpoints"]
and not file_scan["uses"]["breakpoints"]
):
add_finding(
findings,
"info",
"Explorer-style layout has no responsive breakpoints",
[
"{0}:{1} uses data/browser widgets without `HORIZONTAL_BREAKPOINTS` or `VERTICAL_BREAKPOINTS`.".format(
file_scan["path"], app_class["name"]
)
],
"Use breakpoint classes so the layout degrades gracefully on narrow terminals and in the browser.",
)
for file_scan in files:
blocking = file_scan["handler_blocking_calls"]
if blocking:
evidence = []
for handler_name, calls in sorted(blocking.items()):
evidence.append("{0}:{1} calls {2}".format(file_scan["path"], handler_name, ", ".join(calls)))
add_finding(
findings,
"warning",
"Potentially blocking work in handlers",
evidence[:10],
"Move network, subprocess, or sleep-heavy logic out of `on_*` / `action_*` handlers into `@work` or `run_worker(...)`.",
)
for class_record in file_scan["classes"]:
computes = class_record["methods"]["computes"]
if computes:
add_finding(
findings,
"info",
"Compute methods present",
[
"{0}:{1} defines {2}".format(file_scan["path"], class_record["name"], ", ".join(computes))
],
"Keep `compute_*` methods fast and side-effect free; move I/O or heavy work elsewhere.",
)
manual_table_files = []
for file_scan in files:
rich_imports = set(file_scan["imports"]["rich"])
if rich_imports and not file_scan["imports"]["widgets"]:
continue
if "Table" in rich_imports and widget_usage.get("DataTable", 0) == 0:
manual_table_files.append(file_scan["path"])
if manual_table_files:
add_finding(
findings,
"info",
"Manual Rich tables may be a missed DataTable opportunity",
manual_table_files[:5],
"If the table needs focus, sorting, cursoring, or row selection, prefer `DataTable` over manual Rich tables inside `Static`.",
)
export_like_files = []
for file_scan in files:
path_lower = file_scan["path"].lower()
text_flags = file_scan["uses"]
if any(token in path_lower for token in ("export", "report", "download", "share")) and not text_flags["deliver"]:
export_like_files.append(file_scan["path"])
if export_like_files:
add_finding(
findings,
"info",
"Export-like flows without delivery APIs",
export_like_files[:5],
"If the app may be served in a browser, prefer `deliver_text`, `deliver_binary`, or `deliver_screenshot` instead of assuming local file-system access.",
)
info_count = sum(1 for finding in findings if finding["severity"] == "info")
warning_count = sum(1 for finding in findings if finding["severity"] == "warning")
error_count = sum(1 for finding in findings if finding["severity"] == "error")
next_steps = [
"Run `python scripts/inspect_textual_project.py .` to see the raw project inventory.",
"Refactor any blocking handlers into workers before adding polish.",
"Leave behind Pilot tests whenever behaviour changes.",
]
return {
"project_root": project["project_root"],
"textual_detected": True,
"apps": apps,
"findings": findings,
"summary": {
"errors": error_count,
"warnings": warning_count,
"info": info_count,
"total": len(findings),
},
"next_steps": next_steps,
}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Audit a Textual project for likely architecture, performance, and testing issues."
)
parser.add_argument(
"project_root",
nargs="?",
default=".",
help="Project directory to audit (default: current directory).",
)
parser.add_argument(
"--json",
action="store_true",
help="Emit machine-readable JSON only.",
)
return parser.parse_args()
def main() -> int:
args = parse_args()
project_root = Path(args.project_root).expanduser().resolve()
if not project_root.exists():
print("Error: project root does not exist: {0}".format(project_root), file=sys.stderr)
return 2
report = audit(project_root)
if args.json:
print(json_dumps(report))
return 0
print("# Textual project audit")
print()
print("Project: {0}".format(report["project_root"]))
print("Apps: {0}".format(len(report["apps"])))
print(
"Findings: {0} total ({1} errors, {2} warnings, {3} info)".format(
report["summary"]["total"],
report["summary"]["errors"],
report["summary"]["warnings"],
report["summary"]["info"],
)
)
print()
if not report["findings"]:
print("No issues detected by the heuristic audit.")
return 0
for finding in report["findings"]:
print("## [{0}] {1}".format(finding["severity"].upper(), finding["title"]))
for line in finding["evidence"]:
print("- {0}".format(line))
print("Recommendation: {0}".format(finding["recommendation"]))
print()
return 0
if __name__ == "__main__":
raise SystemExit(main())
#!/usr/bin/env python3
"""Build markdown summaries from a local clone or snapshot of the Textual repository."""
from __future__ import annotations
import argparse
import ast
import collections
import os
from pathlib import Path
from typing import Counter, Dict, Iterable, List, Tuple
WIDGET_MODULE = "textual.widgets"
IGNORE_DIRS = {".git", "__pycache__", ".mypy_cache", ".pytest_cache"}
def iter_py_files(root: Path) -> Iterable[Path]:
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = [name for name in dirnames if name not in IGNORE_DIRS]
for filename in filenames:
if filename.endswith(".py"):
yield Path(dirpath) / filename
def safe_parse(path: Path):
try:
return ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
except Exception:
return None
def count_subdirs(root: Path, relative_subdir: str) -> List[Tuple[str, int]]:
subdir = root / relative_subdir
counts: Counter[str] = collections.Counter()
if not subdir.exists():
return []
for path in subdir.rglob("*"):
if path.is_file():
rel = path.relative_to(subdir)
top = rel.parts[0] if rel.parts else path.name
counts[top] += 1
return counts.most_common(20)
def widget_import_counts(paths: Iterable[Path]) -> List[Tuple[str, int]]:
counts: Counter[str] = collections.Counter()
for path in paths:
tree = safe_parse(path)
if tree is None:
continue
for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom) and (node.module or "") == WIDGET_MODULE:
for alias in node.names:
counts[alias.name] += 1
return counts.most_common(30)
def feature_example_paths(repo_root: Path) -> Dict[str, List[str]]:
feature_terms = {
"breakpoints": "HORIZONTAL_BREAKPOINTS",
"command_palette": "COMMANDS = App.COMMANDS",
"modes": "MODES =",
"workers": "@work",
"delivery": "deliver_text(",
"text_area": "TextArea",
"directory_tree": "DirectoryTree",
"data_table": "DataTable",
}
results: Dict[str, List[str]] = {key: [] for key in feature_terms}
search_roots = [
repo_root / "examples",
repo_root / "docs" / "examples",
repo_root / "tests",
]
for search_root in search_roots:
if not search_root.exists():
continue
for path in iter_py_files(search_root):
try:
text = path.read_text(encoding="utf-8")
except Exception:
continue
relative = path.relative_to(repo_root).as_posix()
for feature, token in feature_terms.items():
if token in text and len(results[feature]) < 8:
results[feature].append(relative)
return results
def widget_file_count(repo_root: Path) -> int:
widgets_dir = repo_root / "src" / "textual" / "widgets"
return len(list(widgets_dir.glob("_*.py"))) if widgets_dir.exists() else 0
def build_repo_map(repo_root: Path) -> str:
doc_example_areas = count_subdirs(repo_root, "docs/examples")
test_areas = count_subdirs(repo_root, "tests")
widgets_count = widget_file_count(repo_root)
lines = [
"# Repository map",
"",
"This summary was generated from the bundled Textual repository snapshot used to build this skill.",
"",
"## High-value directories",
"",
"- `src/textual/` — framework source.",
"- `src/textual/widgets/` — built-in widgets (`{0}` widget implementation files detected).".format(widgets_count),
"- `examples/` — standalone example apps worth mining for architecture and styling patterns.",
"- `docs/examples/` — guide/tutorial examples, usually minimal and focused on one concept.",
"- `tests/` — excellent source of behaviour contracts and edge-case handling.",
"",
"## docs/examples hot spots",
"",
]
for name, count in doc_example_areas:
lines.append("- `{0}` — {1} files".format(name, count))
lines.extend(
[
"",
"## tests hot spots",
"",
]
)
for name, count in test_areas:
lines.append("- `{0}` — {1} files".format(name, count))
lines.extend(
[
"",
"## Practical search order",
"",
"1. Look in `examples/` for full app structure.",
"2. Look in `docs/examples/guide/` for the smallest focused example of a feature.",
"3. Look in `tests/` when behaviour, edge cases, or event names are unclear.",
"",
]
)
return "\n".join(lines) + "\n"
def build_pattern_atlas(repo_root: Path) -> str:
example_paths = list(iter_py_files(repo_root / "examples")) if (repo_root / "examples").exists() else []
docs_example_paths = list(iter_py_files(repo_root / "docs" / "examples")) if (repo_root / "docs" / "examples").exists() else []
widget_counts = widget_import_counts(example_paths + docs_example_paths)
features = feature_example_paths(repo_root)
lines = [
"# Upstream pattern atlas",
"",
"This file was generated from the bundled Textual repository snapshot.",
"",
"## Most-imported widgets in examples",
"",
]
for widget, count in widget_counts[:20]:
lines.append("- `{0}` — {1} imports".format(widget, count))
lines.extend(
[
"",
"## Example paths by feature",
"",
]
)
for feature, paths in sorted(features.items()):
lines.append("### {0}".format(feature.replace("_", " ").title()))
if paths:
for path in paths:
lines.append("- `{0}`".format(path))
else:
lines.append("- No matching examples found in this snapshot.")
lines.append("")
lines.extend(
[
"## How to mine upstream effectively",
"",
"- Prefer `examples/` when you want a complete app shell or a realistic layout.",
"- Prefer `docs/examples/guide/` when you want the smallest reproducible example of a feature.",
"- Prefer `tests/` when you need event names, edge cases, or confirmation that a behaviour is supported.",
"",
]
)
return "\n".join(lines) + "\n"
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Generate markdown summaries from a local Textual repository snapshot."
)
parser.add_argument("repo_root", help="Path to the Textual repository root.")
parser.add_argument("--repo-map-out", default=None, help="Optional output path for the repository map markdown.")
parser.add_argument("--atlas-out", default=None, help="Optional output path for the pattern atlas markdown.")
return parser.parse_args()
def main() -> int:
args = parse_args()
repo_root = Path(args.repo_root).expanduser().resolve()
repo_map = build_repo_map(repo_root)
atlas = build_pattern_atlas(repo_root)
if args.repo_map_out:
Path(args.repo_map_out).write_text(repo_map, encoding="utf-8")
else:
print(repo_map)
if args.atlas_out:
Path(args.atlas_out).write_text(atlas, encoding="utf-8")
elif args.repo_map_out:
print(atlas)
return 0
if __name__ == "__main__":
raise SystemExit(main())
#!/usr/bin/env python3
"""Load a Textual app and dump its DOM tree, modes, screens, and active bindings as JSON."""
from __future__ import annotations
import argparse
import asyncio
import importlib
import importlib.util
import json
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Run a Textual app headlessly and dump its DOM/bindings as JSON."
)
parser.add_argument(
"target",
help="Either a Python file path or an import target like package.module[:AppClass].",
)
parser.add_argument(
"--class-name",
default=None,
help="Optional app class name when loading from a file or module.",
)
parser.add_argument(
"--size",
nargs=2,
type=int,
default=[100, 30],
metavar=("WIDTH", "HEIGHT"),
help="Headless terminal size to use (default: 100 30).",
)
return parser.parse_args()
def load_module_from_target(target: str):
path = Path(target)
if path.exists():
spec = importlib.util.spec_from_file_location(path.stem, path)
if spec is None or spec.loader is None:
raise RuntimeError("Could not load module from file: {0}".format(path))
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
module_name, _, class_name = target.partition(":")
module = importlib.import_module(module_name)
if class_name:
setattr(module, "__requested_app_class__", class_name)
return module
def find_app_class(module, explicit_class_name: Optional[str]):
class_name = explicit_class_name or getattr(module, "__requested_app_class__", None)
if class_name:
try:
return getattr(module, class_name)
except AttributeError as error:
raise RuntimeError("Module does not define app class {0!r}".format(class_name)) from error
try:
from textual.app import App
except Exception as error:
raise RuntimeError("Textual is not importable: {0}".format(error)) from error
matches = []
for value in module.__dict__.values():
if isinstance(value, type) and issubclass(value, App) and value is not App:
matches.append(value)
if not matches:
raise RuntimeError("Could not locate a Textual App subclass in the target module.")
return matches[0]
def node_record(node) -> Dict[str, Any]:
classes = []
if hasattr(node, "classes"):
try:
classes = sorted(str(name) for name in node.classes)
except Exception:
classes = []
return {
"type": node.__class__.__name__,
"id": getattr(node, "id", None),
"classes": classes,
"disabled": getattr(node, "disabled", None),
"can_focus": getattr(node, "can_focus", None),
}
async def run_dump(app_class, size):
app = app_class()
async with app.run_test(size=tuple(size)) as pilot:
await pilot.pause()
nodes = []
for node in app.screen.walk_children(with_self=True):
nodes.append(node_record(node))
bindings = {}
for key, active in app.active_bindings.items():
bindings[key] = {
"action": getattr(active.binding, "action", ""),
"description": getattr(active.binding, "description", ""),
}
app_info = {
"app_class": app_class.__name__,
"title": getattr(app, "title", ""),
"sub_title": getattr(app, "sub_title", ""),
"current_mode": getattr(app, "current_mode", None),
"modes": sorted(getattr(app_class, "MODES", {}).keys()) if hasattr(app_class, "MODES") else [],
"screens": sorted(getattr(app_class, "SCREENS", {}).keys()) if hasattr(app_class, "SCREENS") else [],
"bindings": bindings,
"dom": nodes,
}
return app_info
def main() -> int:
args = parse_args()
try:
import textual # noqa: F401
except Exception as error:
print("Error: Textual is not installed or importable in this environment: {0}".format(error), file=sys.stderr)
return 2
try:
module = load_module_from_target(args.target)
app_class = find_app_class(module, args.class_name)
payload = asyncio.run(run_dump(app_class, args.size))
except Exception as error:
print("Error: {0}".format(error), file=sys.stderr)
return 1
print(json.dumps(payload, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
#!/usr/bin/env python3
"""Generate a GitHub Actions workflow for a Textual project."""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
from _textual_skill_utils import load_asset, render_template, write_text_file, json_dumps
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Generate a GitHub Actions workflow for Textual tests."
)
parser.add_argument(
"--python-versions",
nargs="+",
default=["3.10", "3.11", "3.12"],
help="Python versions for the CI matrix (default: 3.10 3.11 3.12).",
)
parser.add_argument(
"--install-command",
default="python -m pip install -e .[dev]",
help="Command used to install project dependencies in CI.",
)
parser.add_argument(
"--pytest-command",
default="pytest",
help="Command used to execute the test suite in CI.",
)
parser.add_argument(
"--output",
default=".github/workflows/textual-tests.yml",
help="Output workflow path.",
)
parser.add_argument(
"--force",
action="store_true",
help="Overwrite the workflow if it already exists.",
)
return parser.parse_args()
def main() -> int:
args = parse_args()
replacements = {
"PYTHON_VERSIONS_JSON": "[" + ", ".join('"{0}"'.format(item) for item in args.python_versions) + "]",
"INSTALL_COMMAND": args.install_command,
"PYTEST_COMMAND": args.pytest_command,
}
content = render_template(
load_asset("project", "github-actions-ci.yml.tmpl"),
replacements,
)
output = Path(args.output).expanduser().resolve()
try:
write_text_file(output, content, force=args.force)
except FileExistsError as error:
print("Error: {0}".format(error), file=sys.stderr)
return 2
print(
json_dumps(
{
"written_file": str(output),
"python_versions": args.python_versions,
"install_command": args.install_command,
"pytest_command": args.pytest_command,
}
)
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
#!/usr/bin/env python3
"""Generate a Hatch-based pyproject.toml for a Textual app."""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
from _textual_skill_utils import load_asset, render_template, write_text_file, json_dumps
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Generate a pyproject.toml for a Textual application."
)
parser.add_argument("--project-name", required=True, help="Distribution name, e.g. 'ops-dashboard'.")
parser.add_argument("--module", required=True, help="Importable module containing the app entry point, e.g. 'ops_dashboard'.")
parser.add_argument(
"--command-name",
default=None,
help="Console script name (default: project-name).",
)
parser.add_argument(
"--entry-function",
default="main",
help="Function exposed as the console entry point (default: main).",
)
parser.add_argument(
"--python-min",
default="3.9",
help="Minimum Python version, e.g. 3.9 (default).",
)
parser.add_argument(
"--output",
default="pyproject.toml",
help="Output path (default: ./pyproject.toml).",
)
parser.add_argument(
"--force",
action="store_true",
help="Overwrite the output file if it already exists.",
)
return parser.parse_args()
def validate_identifier(text: str, label: str) -> None:
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", text):
raise ValueError("{0} must be a valid Python identifier.".format(label))
def main() -> int:
args = parse_args()
try:
validate_identifier(args.module, "--module")
validate_identifier(args.entry_function, "--entry-function")
except ValueError as error:
print("Error: {0}".format(error), file=sys.stderr)
return 2
command_name = args.command_name or args.project_name
replacements = {
"PROJECT_NAME": args.project_name,
"MODULE": args.module,
"COMMAND_NAME": command_name,
"ENTRY_FUNCTION": args.entry_function,
"PYTHON_MIN": args.python_min,
}
content = render_template(
load_asset("project", "pyproject.toml.tmpl"),
replacements,
)
output = Path(args.output).expanduser().resolve()
try:
write_text_file(output, content, force=args.force)
except FileExistsError as error:
print("Error: {0}".format(error), file=sys.stderr)
return 2
print(
json_dumps(
{
"written_file": str(output),
"project_name": args.project_name,
"command_name": command_name,
"module": args.module,
"entry_function": args.entry_function,
}
)
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
#!/usr/bin/env python3
"""Inspect a Python project and summarise Textual-related structure as JSON."""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
from _textual_skill_utils import json_dumps, scan_project
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Inspect a Python project and summarise Textual apps, widgets, TCSS files, tests, and dependencies as JSON."
)
parser.add_argument(
"project_root",
nargs="?",
default=".",
help="Project directory to inspect (default: current directory).",
)
return parser.parse_args()
def main() -> int:
args = parse_args()
project_root = Path(args.project_root).expanduser().resolve()
if not project_root.exists():
print("Error: project root does not exist: {0}".format(project_root), file=sys.stderr)
return 2
if not project_root.is_dir():
print("Error: project root is not a directory: {0}".format(project_root), file=sys.stderr)
return 2
summary = scan_project(project_root)
print(json_dumps(summary))
return 0
if __name__ == "__main__":
raise SystemExit(main())
#!/usr/bin/env python3
"""Validate the skill package by scaffolding templates and compiling generated code."""
from __future__ import annotations
import py_compile
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
TEMPLATES = [
"dashboard",
"form",
"chat",
"data-explorer",
"file-browser",
"settings",
"wizard",
"log-monitor",
"editor",
"admin-modes",
"download-demo",
]
def compile_python_files(paths):
for path in paths:
py_compile.compile(str(path), doraise=True)
def main() -> int:
skill_root = Path(__file__).resolve().parents[1]
scripts_dir = skill_root / "scripts"
try:
compile_python_files(path for path in scripts_dir.glob("*.py"))
except Exception as error:
print("Script compilation failed: {0}".format(error), file=sys.stderr)
return 1
temp_root = Path(tempfile.mkdtemp(prefix="textual-skill-check-"))
try:
for template in TEMPLATES:
module = template.replace("-", "_")
class_name = "".join(part.title() for part in module.split("_")) + "App"
output_dir = temp_root / template
command = [
sys.executable,
str(scripts_dir / "scaffold_textual_app.py"),
"--template",
template,
"--module",
module,
"--class-name",
class_name,
"--app-title",
template.replace("-", " ").title(),
"--output-dir",
str(output_dir),
"--force",
"--with-pyproject",
"--with-ci",
]
subprocess.run(command, check=True, cwd=str(skill_root))
compile_python_files(output_dir.rglob("*.py"))
except Exception as error:
print("Self-check failed: {0}".format(error), file=sys.stderr)
return 1
finally:
shutil.rmtree(temp_root, ignore_errors=True)
print("Self-check passed for scripts and all templates.")
return 0
if __name__ == "__main__":
raise SystemExit(main())