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