building-tui-apps
π―Skillfrom mhagrelius/dotfiles
building-tui-apps skill from mhagrelius/dotfiles
Installation
npx skills add https://github.com/mhagrelius/dotfiles --skill building-tui-appsSkill Details
Use when building interactive terminal dashboards or full-screen terminal applications; when implementing keyboard navigation, live data updates, or multi-panel layouts; when TUI is flickering, slow, or unresponsive; when handling terminal resize events
Overview
# Building TUI Applications
Overview
TUIs are reactive terminal interfaces. Unlike CLIs (single operation β exit), TUIs maintain state, handle events, and update displays continuously. Think of them as web apps for the terminal.
When to Use TUI
```dot
digraph decision {
rankdir=TB;
"Need persistent display?" [shape=diamond];
"Multiple views/panels?" [shape=diamond];
"Real-time updates?" [shape=diamond];
"CLI with progress" [shape=box, style=filled, fillcolor=lightblue];
"Full TUI" [shape=box, style=filled, fillcolor=lightgreen];
"CLI" [shape=box, style=filled, fillcolor=lightyellow];
"Need persistent display?" -> "CLI" [label="no"];
"Need persistent display?" -> "Multiple views/panels?" [label="yes"];
"Multiple views/panels?" -> "Full TUI" [label="yes"];
"Multiple views/panels?" -> "Real-time updates?" [label="no"];
"Real-time updates?" -> "Full TUI" [label="yes"];
"Real-time updates?" -> "CLI with progress" [label="no"];
}
```
TUI is right when: Dashboard monitoring, file browsers, log viewers, interactive data exploration, multi-step wizards with navigation
CLI is better when: Single operation, piping output, scripting, simple progress display
Quick Reference: Libraries by Language
| Language | Full TUI Framework | Simple Interactive |
|----------|-------------------|-------------------|
| Python | textual (modern, reactive) | rich (tables, progress, prompts) |
| TypeScript | ink (React-like) or blessed | inquirer (prompts only) |
| C# | Terminal.Gui (full widgets) | Spectre.Console (tables, prompts) |
Library Selection Flowchart
```dot
digraph library {
rankdir=TB;
"Need full-screen app?" [shape=diamond];
"Python or TS?" [shape=diamond];
"C#?" [shape=diamond];
"Modern reactive?" [shape=diamond];
"textual" [shape=box, style=filled, fillcolor=lightgreen];
"ink" [shape=box, style=filled, fillcolor=lightblue];
"blessed" [shape=box, style=filled, fillcolor=lightblue];
"Terminal.Gui" [shape=box, style=filled, fillcolor=lightyellow];
"rich/Spectre" [shape=box, style=filled, fillcolor=lightgray];
"Need full-screen app?" -> "Python or TS?" [label="yes"];
"Need full-screen app?" -> "rich/Spectre" [label="no, just prompts/tables"];
"Python or TS?" -> "textual" [label="Python"];
"Python or TS?" -> "Modern reactive?" [label="TypeScript"];
"Modern reactive?" -> "ink" [label="yes, React-like"];
"Modern reactive?" -> "blessed" [label="no, traditional"];
"Python or TS?" -> "C#?" [label="neither"];
"C#?" -> "Terminal.Gui" [label="yes"];
}
```
Core Architecture Pattern
```
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β App β
β βββββββββββββββ βββββββββββββββ βββββββββββββββββββ β
β β State ββ β Widgets ββ β Render β β
β β (reactive) β β (compose) β β (on change) β β
β βββββββββββββββ βββββββββββββββ βββββββββββββββββββ β
β β β β
β βββββββββββ Events βββββββββββββββββββ β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
```
All modern TUI frameworks use this reactive pattern:
- State changes β triggers re-render
- Events (keyboard, mouse, resize) β update state
- Widgets compose into layouts
Python: Textual
Basic Structure
```python
from textual.app import App, ComposeResult
from textual.widgets import Header, Footer, DataTable, Static
from textual.reactive import reactive
from textual.containers import Horizontal, Vertical
class DashboardApp(App):
"""Main TUI application."""
CSS = """
#sidebar { width: 30; }
#main { width: 1fr; }
"""
BINDINGS = [
("q", "quit", "Quit"),
("r", "refresh", "Refresh"),
("enter", "select", "Select"),
]
# Reactive state - changes trigger UI updates
selected_id: reactive[str | None] = reactive(None)
items: reactive[list] = reactive([])
def compose(self) -> ComposeResult:
"""Build the UI tree."""
yield Header()
with Horizontal():
yield DataTable(id="table")
yield Static(id="detail")
yield Footer()
def on_mount(self) -> None:
"""Called when app starts."""
self.load_data()
def watch_selected_id(self, new_id: str | None) -> None:
"""Called automatically when selected_id changes."""
self.update_detail_panel(new_id)
def action_refresh(self) -> None:
"""Handle 'r' key."""
self.load_data()
async def load_data(self) -> None:
"""Load data without blocking UI."""
self.items = await self.fetch_items()
```
Key Patterns
Workers for async operations:
```python
from textual.worker import Worker
class MyApp(App):
@work(exclusive=True)
async def fetch_data(self) -> None:
"""Run in background, won't block UI."""
result = await api.get_items()
self.items = result
def on_worker_state_changed(self, event: Worker.StateChanged) -> None:
"""Handle worker completion."""
if event.state == WorkerState.SUCCESS:
self.refresh_table()
```
Custom widgets:
```python
from textual.widget import Widget
from textual.message import Message
class NoticeCard(Widget):
"""Custom widget with message passing."""
class Selected(Message):
def __init__(self, notice_id: str) -> None:
self.notice_id = notice_id
super().__init__()
def on_click(self) -> None:
self.post_message(self.Selected(self.notice_id))
```
TypeScript: Ink
Basic Structure
```tsx
import React, { useState, useEffect } from 'react';
import { render, Box, Text, useInput, useApp } from 'ink';
const Dashboard = () => {
const [items, setItems] = useState
const [selectedIndex, setSelectedIndex] = useState(0);
const { exit } = useApp();
// Handle keyboard input
useInput((input, key) => {
if (input === 'q') exit();
if (key.upArrow) setSelectedIndex(i => Math.max(0, i - 1));
if (key.downArrow) setSelectedIndex(i => Math.min(items.length - 1, i + 1));
if (key.return) handleSelect(items[selectedIndex]);
});
// Load data on mount
useEffect(() => {
loadItems().then(setItems);
}, []);
return (
);
};
render(
```
Key Patterns
Reactive updates:
```tsx
import { useEffect, useState } from 'react';
const LiveStatus = () => {
const [status, setStatus] = useState('loading');
useEffect(() => {
const interval = setInterval(async () => {
const data = await fetchStatus();
setStatus(data);
}, 1000);
return () => clearInterval(interval);
}, []);
return
};
```
C#: Terminal.Gui
Basic Structure
```csharp
using Terminal.Gui;
class Program
{
static void Main()
{
Application.Init();
var top = Application.Top;
var win = new Window("Dashboard")
{
X = 0, Y = 1,
Width = Dim.Fill(),
Height = Dim.Fill()
};
var listView = new ListView(items)
{
X = 0, Y = 0,
Width = Dim.Percent(30),
Height = Dim.Fill()
};
var detailView = new TextView()
{
X = Pos.Right(listView) + 1,
Y = 0,
Width = Dim.Fill(),
Height = Dim.Fill()
};
listView.SelectedItemChanged += (args) => {
detailView.Text = GetDetails(items[listView.SelectedItem]);
};
win.Add(listView, detailView);
top.Add(win);
Application.Run();
Application.Shutdown();
}
}
```
Layout Patterns
Responsive Layout
Handle terminal resize gracefully:
```python
# Textual - automatic with CSS
CSS = """
#sidebar {
width: 30;
}
@media (width < 80) {
#sidebar { display: none; }
}
"""
```
```typescript
// Ink - useStdout hook
import { useStdout } from 'ink';
const ResponsiveLayout = () => {
const { stdout } = useStdout();
const width = stdout.columns;
return (
{width >= 80 &&
);
};
```
Common Layouts
```
ββββββββββββββββββββββββββββββββββ ββββββββββββββββββββββββββββββββββ
β Header β β Sidebar β Main β
ββββββββββββ¬ββββββββββββββββββββββ€ β β β
β Sidebar β Main β β ββββββ β β
β β β β Item 1 β Detail View β
β Nav β Content β β Item 2 β β
β β β β Item 3 β β
ββββββββββββ΄ββββββββββββββββββββββ€ β β β
β Footer β βββββββββββββββ΄βββββββββββββββββββ
ββββββββββββββββββββββββββββββββββ
Master-Detail Sidebar + Content
```
State Management
```dot
digraph state {
rankdir=LR;
"User Input" [shape=ellipse];
"Event Handler" [shape=box];
"State Update" [shape=box];
"Re-render" [shape=box];
"Display" [shape=ellipse];
"User Input" -> "Event Handler";
"Event Handler" -> "State Update";
"State Update" -> "Re-render";
"Re-render" -> "Display";
"Display" -> "User Input" [style=dashed, label="next input"];
}
```
Rules:
- Single source of truth - One place for each piece of state
- Unidirectional flow - Events β State β Render
- Reactive updates - Use reactive/useState, not manual refresh
Performance
Avoid Re-render Storms
```python
# Bad - triggers re-render per item
for item in items:
self.items.append(item) # Each append triggers render!
# Good - single update
self.items = new_items # One render
```
Virtualization for Large Lists
```python
# Textual DataTable handles this automatically
# For custom widgets, only render visible items
def render_visible(self):
viewport_start = self.scroll_offset
viewport_end = viewport_start + self.height
visible_items = self.items[viewport_start:viewport_end]
# Only render visible_items
```
Debounce Rapid Updates
```python
from textual.timer import Timer
class LiveDashboard(App):
def __init__(self):
self._pending_updates = []
self._update_timer: Timer | None = None
def queue_update(self, data):
self._pending_updates.append(data)
if not self._update_timer:
self._update_timer = self.set_timer(0.1, self._flush_updates)
def _flush_updates(self):
# Process all pending updates at once
self.process_batch(self._pending_updates)
self._pending_updates = []
self._update_timer = None
```
Keyboard Navigation
Standard Keybindings
| Key | Action |
|-----|--------|
| β/β or j/k | Navigate items |
| Enter | Select/confirm |
| Escape | Cancel/back |
| q | Quit |
| ? | Help |
| / | Search |
| Tab | Next panel |
Focus Management
```python
# Textual
class MyApp(App):
def action_next_panel(self) -> None:
self.screen.focus_next()
def action_prev_panel(self) -> None:
self.screen.focus_previous()
```
Async Operations: The Worker Pattern
Critical rule: Never block the main thread. TUIs freeze if you make synchronous network/file calls.
Python Textual Workers
```python
from textual.app import App
from textual.worker import Worker, WorkerState
class DashboardApp(App):
def on_mount(self) -> None:
# Start worker - doesn't block UI
self.run_worker(self.fetch_data())
async def fetch_data(self) -> None:
"""Runs in background thread."""
result = await api.get_items() # Network call
self.items = result # Update state when done
def on_worker_state_changed(self, event: Worker.StateChanged) -> None:
if event.state == WorkerState.ERROR:
self.show_error(str(event.worker.error))
```
TypeScript Ink
```tsx
const Dashboard = () => {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Async in useEffect - doesn't block render
(async () => {
const result = await fetchData();
setData(result);
setLoading(false);
})();
}, []);
if (loading) return
return
};
```
C# Terminal.Gui
```csharp
// Use Application.MainLoop.Invoke for thread-safe UI updates
Task.Run(async () => {
var data = await FetchDataAsync();
Application.MainLoop.Invoke(() => {
listView.SetSource(data); // Update UI on main thread
});
});
```
Accessibility
- High contrast by default - Don't rely only on color
- Screen reader text - Provide text alternatives
- Keyboard-only navigation - Everything accessible via keyboard
```python
# Textual - use semantic widgets
from textual.widgets import Button, Label
# Bad - visual only
yield Static("[bold red]Error![/]")
# Good - semantic + visual
yield Label("Error: File not found", id="error", classes="error")
```
Anti-Patterns
| Anti-Pattern | Problem | Fix |
|--------------|---------|-----|
| Blocking main thread | UI freezes | Use workers/async |
| Manual screen clear | Flicker | Use framework's render |
| Global state mutations | Race conditions | Use reactive state |
| Not handling resize | Broken layout | Test with small terminals |
| Hardcoded dimensions | Not portable | Use relative sizing (Dim.Fill, percentages) |
| No keyboard shortcuts | Mouse-dependent | Add BINDINGS/useInput |
| Polling in render | CPU spin | Use timers, events |
Testing TUI Apps
Python with Textual
```python
from textual.testing import AppTest
async def test_dashboard():
async with AppTest(DashboardApp()) as app:
# Wait for mount
await app.wait_for_loaded()
# Check initial state
table = app.query_one("#table", DataTable)
assert table.row_count > 0
# Simulate key press
await app.press("down")
await app.press("enter")
# Check result
detail = app.query_one("#detail", Static)
assert "selected" in detail.render()
```
Testing Strategies
- Snapshot tests - Compare rendered output
- Interaction tests - Simulate key presses, verify state
- State tests - Directly test state management logic
- Integration tests - Test with real backend (mocked API)
File Structure
```
my_tui/
βββ app.py # Main App class
βββ screens/ # Full-screen views
β βββ main.py
β βββ detail.py
βββ widgets/ # Reusable components
β βββ sidebar.py
β βββ status_bar.py
βββ state/ # State management
β βββ store.py
βββ api/ # Backend communication
β βββ client.py
βββ styles.css # Textual CSS (if using)
βββ tests/
βββ test_app.py
```