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

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

@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 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