Run the additive migration pass from SKILL-MIGRATION-GUIDE: generate a root SKILL.md for every skill that lacked one, copied from its desktop/SKILL.md (or code/SKILL.md), with name set to the directory name and description + body preserved verbatim. - scripts/migrate_skill_root.py: the reusable, non-destructive migrator (dry-run default). - 61 new root SKILL.md (desktop source for most; code/SKILL.md for 61/62/92). - Untouched: 16/17/95 (already had root); desktop/ and code/ packaging left intact. - All 64 root SKILL.md validate: frontmatter <=1024, kebab name, description present. Still MANUAL (no SKILL.md source — commands/README only), need hand-authored root SKILL.md: 81-mac-optimizer, 90-reference-curator, 91-multi-agent-guide, 94-dintel-bootstrap. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
304 lines
11 KiB
Markdown
304 lines
11 KiB
Markdown
---
|
|
name: 92-tui-design-template
|
|
description: Build Norton Commander / Gopher style TUI wizard interfaces for CLI tools using Python Rich. Covers architecture, components, keyboard input, bilingual i18n, and battle-tested gotchas.
|
|
version: 1.0.0
|
|
triggers:
|
|
- "build TUI", "TUI wizard", "terminal UI", "CLI wizard"
|
|
- "Norton Commander style", "Gopher style", "retro TUI"
|
|
- "Rich TUI", "interactive CLI", "keyboard navigation"
|
|
- "dual panel interface", "terminal wizard"
|
|
tools:
|
|
- Read
|
|
- Write
|
|
- Edit
|
|
- Bash
|
|
- Grep
|
|
- Glob
|
|
---
|
|
|
|
# TUI Designer
|
|
|
|
Build retro-style terminal wizard interfaces (Norton Commander + Gopher) for any Python CLI tool using the Rich library. No new dependencies — Rich + stdlib only.
|
|
|
|
## When to Use
|
|
|
|
- Building an interactive CLI wizard or setup flow
|
|
- Adding keyboard-driven navigation to an existing CLI tool
|
|
- Creating dual-panel terminal interfaces with status + menus
|
|
- Building bilingual (or multi-language) terminal interfaces
|
|
|
|
## Architecture Blueprint
|
|
|
|
### Component Structure
|
|
|
|
```
|
|
src/{project}/tui/
|
|
__init__.py # Public API: launch_tui()
|
|
i18n.py # String registry with runtime language toggle
|
|
input.py # Raw tty/termios keypress reader + escape parser
|
|
themes.py # Color scheme + NO_COLOR support
|
|
widgets.py # Shared primitives (status icons, mini-tables)
|
|
breadcrumb.py # Top navigation path bar
|
|
function_bar.py # Bottom F-key shortcut bar
|
|
status_panel.py # Left panel: system health / status
|
|
menu.py # Right panel: numbered menu items
|
|
dialog.py # Modal overlays (confirm, input, error)
|
|
core.py # Layout engine (assembles frame)
|
|
runner.py # Main event loop + screen stack
|
|
selector.py # Arrow-key list selector for long lists
|
|
renderers.py # Content renderers for leaf screens
|
|
screens/
|
|
__init__.py # Screen/MenuItem/Action dataclasses + registry
|
|
home.py # Root screen
|
|
{category}.py # One file per menu category
|
|
```
|
|
|
|
### Data Flow
|
|
|
|
```
|
|
User keypress
|
|
-> input.py (parse_key_bytes -> normalize_key)
|
|
-> runner.py (_handle_key dispatches)
|
|
-> Navigation: ScreenStack push/pop
|
|
-> F-keys: toggle language, refresh, help, quit
|
|
-> Digits: jump cursor or push screen
|
|
-> Arrows: ListSelector cursor movement
|
|
-> Enter: confirm selection via key_handler callback
|
|
-> core.py (render_frame assembles panels)
|
|
-> Console output
|
|
```
|
|
|
|
## Screen System
|
|
|
|
### Screen Dataclass
|
|
|
|
```python
|
|
@dataclass
|
|
class Screen:
|
|
id: str # "setup.credentials"
|
|
title: str # i18n key
|
|
breadcrumb: list[str] # i18n key path
|
|
menu_items: list[MenuItem] # For menu screens
|
|
content_renderer: Callable | None # For leaf screens: (console, status_data) -> str
|
|
key_handler: Callable | None # For interactive leaves: (app, key) -> bool
|
|
on_enter: Callable | None # Runs when screen is pushed
|
|
```
|
|
|
|
### Navigation Model (Gopher-style stack)
|
|
|
|
```
|
|
ScreenStack (LIFO):
|
|
[1-9] = push screen (menu) or jump cursor (leaf)
|
|
[B] = pop (back)
|
|
[H] = clear to root
|
|
[Q] = quit with confirm
|
|
Enter = confirm selection on leaf screens
|
|
Up/Down = navigate ListSelector items
|
|
```
|
|
|
|
### Screen Registration Pattern
|
|
|
|
```python
|
|
# screens/setup.py
|
|
from myapp.tui.screens import Screen, MenuItem, register_screen
|
|
from myapp.tui.renderers import render_credentials, handle_credential_key
|
|
|
|
register_screen(Screen(
|
|
id="setup.credentials",
|
|
title="setup.credentials",
|
|
breadcrumb=["nav.home", "home.setup", "setup.credentials"],
|
|
content_renderer=render_credentials,
|
|
key_handler=handle_credential_key, # For interactive leaves
|
|
))
|
|
```
|
|
|
|
## Keyboard Input
|
|
|
|
### Escape Sequence Parser
|
|
|
|
```python
|
|
# input.py — stdlib only, no dependencies
|
|
import tty, termios, select, sys, os
|
|
|
|
def read_key() -> str:
|
|
fd = sys.stdin.fileno()
|
|
old = termios.tcgetattr(fd)
|
|
try:
|
|
tty.setraw(fd)
|
|
first = os.read(fd, 1)
|
|
if first == b"\x1b":
|
|
ready, _, _ = select.select([fd], [], [], 0.05) # 50ms timeout
|
|
if ready:
|
|
rest = os.read(fd, 5)
|
|
return parse_escape(first + rest)
|
|
return "escape"
|
|
return parse_single(first)
|
|
finally:
|
|
termios.tcsetattr(fd, termios.TCSADRAIN, old)
|
|
```
|
|
|
|
### F-key Alphanumeric Fallbacks
|
|
|
|
Always provide fallbacks — terminals intercept F-keys unpredictably:
|
|
|
|
| F-key | Fallback | Action |
|
|
|-------|----------|--------|
|
|
| F1 | ? | Help |
|
|
| F3 | l | Language toggle |
|
|
| F5 | r | Refresh |
|
|
| F10 | q | Quit |
|
|
|
|
## ListSelector Component
|
|
|
|
For screens with 10+ selectable items, use `ListSelector` instead of number-only input:
|
|
|
|
```python
|
|
selector = ListSelector(
|
|
items=[SelectableItem(label="model-name", status="ready", data={...})],
|
|
on_select=my_callback,
|
|
header="Select model:", # str or Callable[[], str] for dynamic headers
|
|
footer="Hint text",
|
|
)
|
|
# In content_renderer: return selector.render()
|
|
# In key_handler: return selector.handle_key(app, key)
|
|
```
|
|
|
|
- Arrow Up/Down moves visible `>` cursor
|
|
- Enter/Space confirms selection
|
|
- 1-9 jumps cursor (does NOT auto-select — user must press Enter)
|
|
- Sections headers group items visually
|
|
- Callable headers re-evaluate on each render (for dynamic "Active:" display)
|
|
|
|
## Line Input Mode
|
|
|
|
For screens requiring text input (file paths, names, search), use `read_line()`:
|
|
|
|
```python
|
|
from myapp.tui.input import read_line
|
|
|
|
# In a key_handler:
|
|
def handle_my_key(app, key):
|
|
if key == "p":
|
|
app.console.print("Enter file path: ", end="")
|
|
path = read_line()
|
|
if path is None: # Escape pressed
|
|
return True
|
|
# Use the path...
|
|
return True
|
|
```
|
|
|
|
### Implementation pattern
|
|
|
|
```python
|
|
def read_line(prompt: str = "") -> str | None:
|
|
"""Read a full line in raw mode. Returns None on Escape/Ctrl-C."""
|
|
# Uses tty.setraw() same as read_key()
|
|
# Accumulates chars in buffer, prints each as typed
|
|
# Handles: Enter (return string), Escape (return None),
|
|
# Ctrl-C (return None), Backspace (delete last)
|
|
# Escape sequences (arrow keys pressed during input) are consumed and ignored
|
|
```
|
|
|
|
### Line Input Gotchas
|
|
|
|
| Gotcha | Problem | Fix |
|
|
|--------|---------|-----|
|
|
| Pasted paths with quotes | Users paste `'/path/to/file'` with quotes | Strip outer quotes: `path.strip("'\"")` |
|
|
| Tilde expansion | `~/.config/...` not expanded | Use `Path(path).expanduser()` |
|
|
| Raw mode echo | Characters not visible while typing | Manually `sys.stdout.write(ch)` each keystroke |
|
|
| Escape during input | Arrow keys produce garbage in buffer | Detect `0x1B`, consume rest of sequence, ignore |
|
|
| Module-level imports for testability | `import select` inside function can't be patched | Import `select`, `termios`, `tty` at module level (in `try/except`) |
|
|
|
|
## Bilingual i18n
|
|
|
|
Simple dict registry — no framework needed:
|
|
|
|
```python
|
|
STRINGS = {
|
|
"nav.back": {"en": "Back", "ko": "뒤로"},
|
|
...
|
|
}
|
|
_lang = "en"
|
|
|
|
def t(key: str) -> str:
|
|
return STRINGS.get(key, {}).get(_lang, key)
|
|
|
|
def toggle_language():
|
|
global _lang
|
|
_lang = "ko" if _lang == "en" else "en"
|
|
```
|
|
|
|
On toggle, clear ALL cached screen content so every screen re-renders.
|
|
|
|
## Three-Tier Responsive Layout
|
|
|
|
```python
|
|
if cols >= 100: # Full dual-panel
|
|
Columns([status_panel, content_panel], padding=(0, 1))
|
|
elif cols >= 80: # Narrow status
|
|
Columns([narrow_status, content_panel], padding=(0, 1))
|
|
else: # Single panel (status line + content stacked)
|
|
console.print(f"Status: {health}")
|
|
console.print(content)
|
|
```
|
|
|
|
Use `rich.columns.Columns` (NOT `rich.layout.Layout`) — Columns auto-fits content height.
|
|
|
|
---
|
|
|
|
## Gotchas (Battle-Tested)
|
|
|
|
### Rich Library
|
|
|
|
| Gotcha | Problem | Fix |
|
|
|--------|---------|-----|
|
|
| `Panel(box=None)` | `AttributeError: NoneType has no attribute substitute` | Use `box=SIMPLE` from `rich.box` |
|
|
| `Layout` stretches panels | Panels expand to fill available height with empty space | Use `Columns` with `expand=False` instead |
|
|
| `Status` spinner nesting | "Only one live display may be active at once" crash | Never nest `Status()` inside other Rich output. Use plain `console.print("Loading...")` |
|
|
| `[1]` in markup | Rich interprets `[1]` as a potential style tag | Escape: `\[1]` |
|
|
| Rich + raw stdin | `tty.setraw()` conflicts with Rich's terminal state | Always restore termios in `finally` block |
|
|
| `NO_COLOR` env var | Must respect `NO_COLOR` and `DTM_NO_COLOR` | Check env vars, return plain theme dict |
|
|
|
|
### Keyboard Input
|
|
|
|
| Gotcha | Problem | Fix |
|
|
|--------|---------|-----|
|
|
| F-key escape sequences | Vary between Terminal.app, iTerm2, VS Code | Map multiple sequences per key + provide alphanumeric fallbacks |
|
|
| Items 10+ unreachable | Pressing `1` then `0` selects item 1 immediately | Digits jump cursor only, Enter confirms. Items 10+ use arrows |
|
|
| `Ctrl+C` in raw mode | Crashes without terminal restoration | Wrap main loop in `try/except KeyboardInterrupt` |
|
|
| Non-interactive detection | Piped input hangs on `read_key()` | Check `sys.stdin.isatty()` before entering TUI mode |
|
|
|
|
### Content & Caching
|
|
|
|
| Gotcha | Problem | Fix |
|
|
|--------|---------|-----|
|
|
| API calls per render | `content_renderer` called on every keystroke | Cache rendered content in `_cached_content[screen.id]`; clear on F5 |
|
|
| Stale cached content | Model/account change doesn't update display | `invalidate_screen()` clears cache; use callable headers for dynamic data |
|
|
| Config has null names | `set_active_account(id)` saves id but not name | Resolve names from API as fallback; backfill to config on success |
|
|
| Korean char alignment | `{label:<12}` misaligns CJK double-width chars | Calculate display width: `sum(2 if ord(c) > 0x7F else 1 for c in s)` |
|
|
| Language toggle stale | Screens show old language after F3 | Clear ALL `_cached_content` on language toggle, not just current screen |
|
|
|
|
### UX Patterns
|
|
|
|
| Pattern | Why |
|
|
|---------|-----|
|
|
| Confirmation panels (0.8s delay) | Users need visual feedback that their action took effect |
|
|
| `<< active` markers | Users must see which item is currently selected at a glance |
|
|
| Section headers in lists | Group related items (Recommended / Installed / LM Studio) |
|
|
| Guidance hints at bottom | Tell users how to reach features that require other steps first |
|
|
| Breadcrumb path always visible | Users always know where they are in the hierarchy |
|
|
|
|
## Checklist for New TUI Projects
|
|
|
|
- [ ] `from __future__ import annotations` in all TUI files
|
|
- [ ] `_is_interactive()` guard before entering TUI mode
|
|
- [ ] `--no-tui` and `--legacy` CLI flags for fallback
|
|
- [ ] Non-TTY detection falls back to plain text
|
|
- [ ] `NO_COLOR` / `FORCE_COLOR` env var support
|
|
- [ ] F-key alphanumeric fallbacks defined
|
|
- [ ] `KeyboardInterrupt` handled in main loop
|
|
- [ ] Escape sequences for F1-F10, arrows mapped
|
|
- [ ] Content cached, invalidated on refresh/change
|
|
- [ ] All user-facing strings through `t()` i18n function
|
|
- [ ] Tests for: i18n, input parser, screen registry, navigation stack
|