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