Add a comprehensive load verifier and fix the two issues it found:
- scripts/verify_skills.py: validates every loadable unit (flat root, suite sub-skill,
plugin skill) with real YAML parsing, name regex + global uniqueness, frontmatter
<=1024, description sanity, plugin.json JSON validity, and orphan detection. Read-only.
- 92-tui-design-template (root + code/SKILL.md): fix invalid YAML `triggers:` block
(`- "a", "b"` multi-scalar list items) -> one phrase per list item.
- 17-seo-schema-generator: remove ">" from description ("generate -> validate" ->
"generate then validate"); angle brackets are disallowed in descriptions.
Result: 75/75 loadable skills valid — 0 failures, 0 name collisions, 0 orphans,
0 plugin-manifest errors (65 flat + 3 plugins + 7 suite sub-skills).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
11 KiB
11 KiB
name, description, version, triggers, tools
| name | description | version | triggers | tools | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| tui-design-template | 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. | 1.0.0 |
|
|
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
@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
# 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
# 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:
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():
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
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:
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
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 annotationsin all TUI files_is_interactive()guard before entering TUI mode--no-tuiand--legacyCLI flags for fallback- Non-TTY detection falls back to plain text
NO_COLOR/FORCE_COLORenv var support- F-key alphanumeric fallbacks defined
KeyboardInterrupthandled 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