textual-tui-reactive
Skill0 starsChanged 7 months agoMITView on GitHub
Textual TUI with DataTables, reactive properties, @work async tasks, multi-tab layout, and keybindings. Use when building terminal UI dashboards, when user says "TUI", "terminal dashboard", "textual app", or needs a rich terminal interface.
In this repository37 other files it gives its agents.
- ablation-study-frameworkSkill
- async-http-websocket-patternsSkill
- brain-dumpSkill
- chaos-controlled-behavior-systemSkill
- daily-digestSkill
- database-reset-and-seedSkill
- duckdb-analytics-layerSkill
- event-driven-backtest-engineSkill
- event-study-full-pipelineSkill
- event-study-stats-suiteSkill
- failure-learning-systemSkill
- fastapi-sse-live-dashboardSkill
- goal-task-decompositionSkill
- high-information-gain-interviewerSkill
- idea-extraction-batch-pipelineSkill
- idea-grouping-taxonomySkill
- interview-belief-extractorSkill
- journal-captureSkill
- journal-reviewSkill
- keyword-filter-frameworkSkill
- langgraph-agentic-pipelineSkill
- llm-as-judge-eval-frameworkSkill
- multi-tab-dashboard-rendererSkill
- multi-tier-memory-systemSkill
- parquet-storage-with-walSkill
- project-insightsSkill
- pydantic-typed-config-loaderSkill
- quality-trend-detectionSkill
- research-gap-finderSkill
- safe-commitSkill
- simtom-knowledge-isolationSkill
- statistical-signal-detectionSkill
- streaming-dataset-pipelineSkill
- structured-data-extraction-pipelineSkill
- structured-logging-setupSkill
- system-health-monitoringSkill
- vector-db-ingestionSkill
The file
We split this skill into the rules it gives an agent
2 rules. About 0 are common, 1 is distinctive, and 1 is very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈1
Distinctive · estimate
This author's own choices. Worth a look.
1
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 333
call_from_thread: Always use this to update widgets from a worker thread. - Distinctive: line 337
Demo mode: Always have a demo mode. It makes development, testing, and demos trivial.
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Textual TUI Reactive Dashboard
Build rich terminal UI dashboards using Textual with DataTables, reactive properties, background async workers, multi-tab layouts, and keybindings.
When to Use
- Building a terminal-based monitoring dashboard
- Need a TUI that auto-refreshes with live data
- Want keyboard navigation, tabs, and interactive tables
- Need a local dashboard that works over SSH
Architecture
project/
monitor.py # Textual App + DataService + CSSThe Textual app has:
DataService— fetches data (demo or live)MonitorApp(App)— layout, keybindings, refresh intervals@workdecorated methods — background data fetchingcall_from_thread— safely update UI from worker threadsreactiveproperties — auto-trigger UI updates on change
Step-by-Step Implementation
Step 1: Create a DataService
Same pattern as the web dashboard — demo/live modes with a clean public API.
from pathlib import Path
class DataService:
def __init__(self, data_dir: Path, demo: bool = False) -> None:
self._data_dir = data_dir
self._demo = demo
def get_items(self) -> list[dict]:
if self._demo:
return self._demo_items()
return self._live_items()
def get_detail(self, item_id: str) -> dict | None:
if self._demo:
return self._demo_detail(item_id)
return self._live_detail(item_id)
def get_health(self) -> dict:
if self._demo:
return self._demo_health()
return self._live_health()
def get_item_ids(self) -> list[str]:
return [item["id"] for item in self.get_items()]
# ... demo and live implementationsStep 2: Define CSS for the app
Textual uses CSS (a subset of web CSS) for layout and styling.
CSS = """
Screen {
background: $surface;
}
#main-table {
height: 1fr;
}
#detail-container {
height: 1fr;
padding: 0 1;
}
#selector {
height: 3;
margin-bottom: 1;
}
#detail-body {
height: 1fr;
}
#health-content {
height: auto;
padding: 1;
border: solid $panel;
}
/* Color classes for status indicators */
.status-ok { color: $success; text-style: bold; }
.status-warn { color: $warning; text-style: bold; }
.status-error { color: $error; text-style: bold; }
"""Step 3: Build the App with TabbedContent
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal, Vertical
from textual.reactive import reactive
from textual.widgets import (
DataTable, Footer, Header, Select, Static, TabbedContent, TabPane,
)
class MonitorApp(App):
TITLE = "My Monitor"
CSS = CSS
BINDINGS = [
Binding("q", "quit", "Quit"),
Binding("r", "refresh_all", "Refresh"),
Binding("1", "switch_tab('tab-items')", "Items"),
Binding("2", "switch_tab('tab-detail')", "Detail"),
Binding("3", "switch_tab('tab-health')", "Health"),
]
# Reactive properties — changing these auto-triggers watchers
_selected_id: reactive[str] = reactive("")
def __init__(self, data_service: DataService) -> None:
super().__init__()
self._ds = data_service
def compose(self) -> ComposeResult:
yield Header(show_clock=False)
with TabbedContent(id="tabs"):
with TabPane("Items", id="tab-items"):
yield DataTable(id="items-table", cursor_type="row")
with TabPane("Detail", id="tab-detail"):
with Vertical(id="detail-container"):
yield Select(
options=[("Loading...", "__loading__")],
id="selector",
prompt="Select item",
allow_blank=False,
)
with Horizontal(id="detail-body"):
yield Static(id="detail-content")
yield DataTable(id="detail-table", cursor_type="none")
with TabPane("Health", id="tab-health"):
yield Static(id="health-content")
yield Footer()Step 4: Set up tables and intervals on mount
def on_mount(self) -> None:
# Configure table columns
table = self.query_one("#items-table", DataTable)
table.add_column("ID", key="id", width=16)
table.add_column("Name", key="name", width=30)
table.add_column("Value", key="value", width=10)
table.add_column("Status", key="status", width=10)
table.add_column("Updated", key="updated", width=10)
detail_table = self.query_one("#detail-table", DataTable)
detail_table.add_column("Time", key="ts", width=10)
detail_table.add_column("Event", key="event", width=20)
detail_table.add_column("Value", key="value", width=10)
# Initial loads
self.refresh_items()
self.refresh_health()
# Periodic refresh intervals
self.set_interval(1.0, self._tick_clock)
self.set_interval(2.0, self.refresh_items)
self.set_interval(1.0, self.refresh_detail)
self.set_interval(5.0, self.refresh_health)
def _tick_clock(self) -> None:
from datetime import datetime, timezone
now = datetime.now(timezone.utc).strftime("%H:%M:%S UTC")
mode = "DEMO" if self._ds._demo else "LIVE"
self.sub_title = f"{now} [{mode}]"Step 5: Use @work for background data fetching
The @work decorator runs the method in a thread. Use call_from_thread to safely update the UI.
from textual import work
@work(exclusive=True, thread=True)
def refresh_items(self) -> None:
"""Fetch items in background thread, update table on main thread."""
items = self._ds.get_items()
ids = [item["id"] for item in items]
self.call_from_thread(self._update_items_table, items)
self.call_from_thread(self._update_selector, ids)
def _update_items_table(self, items: list[dict]) -> None:
table = self.query_one("#items-table", DataTable)
table.clear()
for item in items:
table.add_row(
item["id"],
item["name"][:28],
f"{item['value']:.2f}",
item["status"],
f"{item.get('updated_ago', '?')}s",
key=item["id"],
)
def _update_selector(self, ids: list[str]) -> None:
selector = self.query_one("#selector", Select)
current = self._selected_id
options = [(item_id, item_id) for item_id in ids]
if not options:
return
selector.set_options(options)
if current and current in ids:
selector.value = current
else:
selector.value = ids[0]
self._selected_id = ids[0]
@work(exclusive=True, thread=True)
def refresh_detail(self) -> None:
item_id = self._selected_id
if not item_id:
return
detail = self._ds.get_detail(item_id)
self.call_from_thread(self._update_detail, detail)
@work(exclusive=True, thread=True)
def refresh_health(self) -> None:
health = self._ds.get_health()
self.call_from_thread(self._update_health, health)Step 6: Handle user interactions
def action_switch_tab(self, tab_id: str) -> None:
tabs = self.query_one("#tabs", TabbedContent)
tabs.active = tab_id
def action_refresh_all(self) -> None:
self.refresh_items()
self.refresh_detail()
self.refresh_health()
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
"""Click a row in the items table -> switch to detail tab."""
if event.data_table.id == "items-table":
item_id = str(event.row_key.value)
self._selected_id = item_id
tabs = self.query_one("#tabs", TabbedContent)
tabs.active = "tab-detail"
self.refresh_detail()
def on_select_changed(self, event: Select.Changed) -> None:
"""Dropdown selection changed -> refresh detail."""
if event.select.id == "selector":
val = event.value
if val and val != Select.BLANK:
self._selected_id = str(val)
self.refresh_detail()Step 7: Update health display with rich markup
def _update_health(self, h: dict) -> None:
status = h.get("status", "unknown")
if status == "demo":
status_line = "[yellow]DEMO[/yellow] (synthetic data)"
elif status == "running":
status_line = "[green]RUNNING[/green]"
elif status == "stale":
status_line = "[yellow]STALE[/yellow]"
else:
status_line = "[red]DOWN[/red]"
content = (
f"Status: {status_line}\n"
f"Items: {h.get('item_count', '?')}\n"
f"Events: {h.get('event_count', '?')}\n"
f"Uptime: {h.get('uptime_sec', '?')}s"
)
widget = self.query_one("#health-content", Static)
widget.update(content)Step 8: CLI entrypoint
import typer
cli = typer.Typer(add_completion=False)
@cli.command()
def main(
data_dir: Path = typer.Option(Path("data"), help="Data directory"),
demo: bool = typer.Option(False, "--demo", help="Use synthetic demo data"),
) -> None:
ds = DataService(data_dir, demo=demo)
app = MonitorApp(ds)
app.run()
if __name__ == "__main__":
cli()Key Principles
@work(exclusive=True, thread=True): Runs data fetching in a background thread so the UI stays responsive.exclusive=Truecancels any previous running instance of the same worker.call_from_thread: Always use this to update widgets from a worker thread. Direct widget access from threads will crash.reactiveproperties: Declare withreactive[str] = reactive(""). Changing the value auto-triggerswatch_methods if defined.set_interval: Set up periodic refreshes on mount. Different data gets different intervals (items: 2s, detail: 1s, health: 5s).- Keybindings: Number keys for tab switching (
1,2,3),rfor refresh,qfor quit. Users expect these. - Demo mode: Always have a demo mode. It makes development, testing, and demos trivial.
Dependencies
textual>=0.40
typerDoes it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

