refactor: reorganize skill numbering, remove obsolete skills, rename shared libs
- Rename: 00→80 claude-settings-optimizer, 88→79 dintel-skill-update, 92→81 mac-optimizer, 93→82 tui-design-template - Rename: dintel-shared → _dintel-shared (consistent with _ourdigital-shared) - Remove: 61-gtm-manager, 62-gtm-guardian (obsolete), 99_archive - Update all dintel-* skill refs (114 occurrences across 31 files) - Sync README.md, CLAUDE.md, AGENTS.md with new structure Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
28
custom-skills/82-tui-design-template/README.md
Normal file
28
custom-skills/82-tui-design-template/README.md
Normal file
@@ -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.
|
||||
303
custom-skills/82-tui-design-template/code/SKILL.md
Normal file
303
custom-skills/82-tui-design-template/code/SKILL.md
Normal file
@@ -0,0 +1,303 @@
|
||||
---
|
||||
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
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user