From 1fe6f717514471d8499e2ad539618a041559ead3 Mon Sep 17 00:00:00 2001 From: Andrew Yim Date: Fri, 20 Mar 2026 18:59:45 +0900 Subject: [PATCH] feat: add 93-tui-designer skill for CLI wizard interfaces Reusable patterns for building Norton Commander / Gopher style TUI wizards with Python Rich. Covers architecture, components, keyboard input, bilingual i18n, ListSelector, and 21 battle-tested gotchas. Reference implementation: DTM Agent TUI (dintel-gtm-agent/src/dtm/tui/) Co-Authored-By: Claude Opus 4.6 (1M context) --- custom-skills/93-tui-designer/README.md | 28 ++ custom-skills/93-tui-designer/code/SKILL.md | 263 ++++++++++++++++++ .../shared/references/dtm-wizard-reference.md | 84 ++++++ 3 files changed, 375 insertions(+) create mode 100644 custom-skills/93-tui-designer/README.md create mode 100644 custom-skills/93-tui-designer/code/SKILL.md create mode 100644 custom-skills/93-tui-designer/shared/references/dtm-wizard-reference.md diff --git a/custom-skills/93-tui-designer/README.md b/custom-skills/93-tui-designer/README.md new file mode 100644 index 0000000..7e6867e --- /dev/null +++ b/custom-skills/93-tui-designer/README.md @@ -0,0 +1,28 @@ +# 93-tui-designer + +Build Norton Commander / Gopher style TUI wizard interfaces for any Python CLI tool using Rich. + +## Purpose + +Reusable patterns and battle-tested gotchas for building retro-style terminal wizards with: +- Dual-panel layout (status + content) +- Stack-based Gopher navigation (numbered menus, Back, Home) +- Arrow-key list selector for long option lists +- Bilingual i18n with runtime toggle +- Keyboard-driven input (F-keys with alphanumeric fallbacks) +- Three-tier responsive layout (wide/narrow/single panel) + +## Structure + +``` +93-tui-designer/ + code/SKILL.md # Main skill (architecture, patterns, gotchas) + shared/references/ + dtm-wizard-reference.md # DTM Agent implementation reference + docs/ # Future: logs, lessons learned +``` + +## First Implementation + +DTM Agent TUI Wizard — 20 files, 21 screens, 29 tests. +See `shared/references/dtm-wizard-reference.md` for details. diff --git a/custom-skills/93-tui-designer/code/SKILL.md b/custom-skills/93-tui-designer/code/SKILL.md new file mode 100644 index 0000000..6d563cf --- /dev/null +++ b/custom-skills/93-tui-designer/code/SKILL.md @@ -0,0 +1,263 @@ +--- +name: tui-designer +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) + +## 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 diff --git a/custom-skills/93-tui-designer/shared/references/dtm-wizard-reference.md b/custom-skills/93-tui-designer/shared/references/dtm-wizard-reference.md new file mode 100644 index 0000000..9fc4cf2 --- /dev/null +++ b/custom-skills/93-tui-designer/shared/references/dtm-wizard-reference.md @@ -0,0 +1,84 @@ +# DTM Wizard — Reference Implementation + +The DTM Agent TUI wizard is the first project built with this skill's patterns. + +## Repository +- **Project**: D.intelligence Tag Manager Agent +- **Location**: `github.com/D-intelligence/dintel-gtm-agent` +- **TUI code**: `src/dtm/tui/` (20 files, ~2500 lines) +- **Tests**: `tools/tests/unit/test_tui_*.py` (29 tests) + +## File Inventory + +| File | Lines | Purpose | +|------|-------|---------| +| `__init__.py` | 13 | Public API | +| `i18n.py` | 93 | 60+ bilingual EN/KR strings | +| `input.py` | 93 | Escape sequence parser | +| `themes.py` | 48 | Norton Commander color scheme | +| `widgets.py` | 35 | Status icons, mini-tables | +| `breadcrumb.py` | 22 | Navigation path bar | +| `function_bar.py` | 33 | F-key shortcut bar | +| `status_panel.py` | 85 | Left panel health display | +| `menu.py` | 35 | Gopher-style numbered menu | +| `dialog.py` | 70 | Modal overlays | +| `core.py` | 100 | Three-tier layout engine | +| `runner.py` | 170 | Event loop + ScreenStack | +| `selector.py` | 156 | Arrow-key list selector | +| `renderers.py` | ~1100 | 15 leaf screen content renderers | +| `screens/*.py` | ~250 | 21 screen definitions | + +## Screen Hierarchy (21 screens) + +``` +home + setup + setup.credentials + setup.account + setup.account.containers (dynamic sub-screen) + setup.ai + setup.connectivity + operations + ops.workflow + ops.container_analysis + ops.ai_analysis + ops.performance + configuration + config.review + config.change_account + config.change_container + config.export + diagnostics + diag.health + diag.test + diag.recommendations +``` + +## PRs and Evolution + +| PR | Title | Key Changes | +|----|-------|-------------| +| #10 | Core TUI redesign | 19 modules, 28 tests | +| #11 | 7 usability fixes | Panel height, no-color, bilingual icons | +| #12 | Interactive selection | Account/container by number, help screen | +| #13 | Name resolution | Resolve IDs to names from API | +| #14 | Loading spinners | Visual feedback during API calls | +| #15 | AI model selector | Ollama + LM Studio detection | +| #16 | Batch UX fixes | ListSelector, export formats, crash fixes | + +## External Service Detection Pattern + +```python +def _detect_lm_studio() -> dict: + """Detect LM Studio on localhost:1234.""" + import requests + try: + resp = requests.get("http://localhost:1234/v1/models", timeout=3) + if resp.status_code == 200: + return {"available": True, "models": [m["id"] for m in resp.json().get("data", [])]} + except Exception: + pass + return {"available": False, "models": []} +``` + +This pattern works for any OpenAI-compatible local LLM server.