Files
Andrew Yim 6ac547e78f
Some checks failed
Verify Skills / verify-skills (push) Has been cancelled
refactor(skills): clean skill names (strip NN- prefix from name:) — convention change
Adopt: directory keeps its NN- ordering prefix; skill `name:` is the clean form
without it (dir 16-seo-schema-validator → name: seo-schema-validator). Nicer to
invoke, matches the original desktop/SKILL.md names, still globally unique.

- 71 root SKILL.md: name: NN-foo → name: foo (flat skills + reference-curator suite).
  Plugins (mac-optimizer/multi-agent-guide/dintel-bootstrap) already clean; 95 already clean.
- scripts/migrate_skill_root.py: derive name = dirname minus NN- prefix (skill_name()).
- CLAUDE.md + SKILL-MIGRATION-GUIDE.md: document the dir-prefix / clean-name convention.

verify_skills.py: 0 name collisions across all renamed skills. (The ~/.claude/skills
symlinks were re-pointed to the clean names separately — filesystem only.)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 02:11:01 +09:00

312 lines
11 KiB
Markdown

---
name: 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