| name | tui-engineering |
| description | Build production-grade, phone-friendly terminal UIs (full-screen TUI dashboards) with zero terminal libraries — pure-core architecture, ANSI-safe layout math, line-diffed painting, SGR mouse + touch gestures, hit-testing, narrow-width responsive layouts, and a headless test harness. Distilled from the axfleet fleet-monitoring TUI. MUST trigger when the user says: "build a TUI", "terminal dashboard", "terminal UI", "full-screen CLI", "ncurses-style", "做个 TUI", "终端面板", "终端界面", "命令行 dashboard", "TUI 适配手机/Moshi", "mouse support in terminal", or asks to add tabs/mouse/touch/responsive behavior to an existing terminal program. Cross-platform: works on Claude Code, OpenAI Codex, OpenCode, and OpenClaw.
|
TUI Engineering — phone-friendly terminal dashboards from scratch
Cross-platform Agent Skill — Claude Code · OpenAI Codex · OpenCode · OpenClaw 通用。
Build a full-screen terminal UI the way axfleet is built: no ncurses, no
blessed, no ink — the terminal layer is ~500 lines you own and can test.
Every rule in this skill states the failure it prevents; every mechanism has a
runnable reference implementation at
xingfanxia/axfleet-ui-public
(fixture-driven demo — bun install && bun run tui). The reference stack is
Bun + TypeScript; the architecture is stack-agnostic.
When to use / not use
- ✅ Full-screen dashboards, monitors, pickers, multi-tab tools; anything that
must stay readable at phone width (Moshi ≈45 cols) over mosh/SSH.
- ✅ Retrofitting mouse/touch/narrow-mode onto an existing raw-ANSI TUI.
- ❌ Line-oriented CLI output (just print), one-shot prompts (use readline),
or apps already committed to a TUI framework (follow that framework).
Architecture (non-negotiable shape)
contracts/ typed data model — the single shape everything renders from
state.ts pure transitions: (state, event) → new state (never mutate)
render/ pure renderers: (state, cols, rows) → string[] (one per tab)
term.ts THE ONLY module touching stdin/stdout
api.ts data source behind fetch/stream fns returning {ok,data}|{ok,error}
index.ts impure event loop wiring term → state → render → paint
Everything except term.ts/index.ts is a pure function — that's what makes
layout, input decoding, and gestures unit-testable without a TTY. Transitions
return the same reference when nothing changed; the loop skips redraws on
identity. Detail: references/architecture.md.
Build order (follow this sequence)
contracts/ — the typed data model. Fixtures and renderers both
compile against it, so tsc proves demo data is shape-complete.
ansi.ts + tests — visibleWidth / truncate / padEnd / wrapPlain.
All layout math flows through here. CJK/emoji = 2 cells. Never
String.length. → architecture.md §2
term.ts — alt screen, raw mode, line-diffed paints, crash restore,
pure decodeEvents with split-chunk carry-over + escape timeout.
→ architecture.md §3, input.md
state.ts + tests — tabs, selection, scroll, data application.
render/frame.ts — chrome + first tab; add the layout-contract test
NOW (every tab × sizes: exactly rows lines, each ≤ cols). Future tabs
inherit the guarantee by joining the loop. → testing.md
- Remaining tabs — one pure renderer each.
- Fixtures + demo api — deterministic noise, pure fn of (key, time). The
TUI now runs headless; every later change is eyeball-verifiable.
→ testing.md §fixtures
- Mouse/touch — SGR decode → direction-locked gesture machine → hit
ranges. Tests before wiring. → input.md
- Narrow-mode pass — render every tab at ~45 cols with fixtures, catalog
information loss, fix by stacking/compressing, pin with survival tests.
→ responsive.md
- Footer hints + README — document keys; keep single-key next/prev
aliases bindable from phone-terminal shortcut slots.
Invariants checklist (verify before calling it done)
Phone terminals — what actually arrives (hard-won facts)
| Phone action | What the TUI receives (Moshi, measured 2026-07) |
|---|
| tap | SGR press + release |
| vertical pan | wheel-up/down burst |
| horizontal swipe | nothing by default — armed only by moshi-hook's live env read of $TMUX_PANE/$ZELLIJ/$HERDR_ENV (that precedence); on detection swipe sends the mux's prefix chord (Ctrl-B n/p). Two verified recipes: tmux conditional binding forwards the chord into a pane titled via OSC 2 (input.md §6.5 — the default-setup path), or bare-shell HERDR_ENV=1 impersonation + speak the chord (§6.6) |
| Mouse-Mode drag | press/drag/release forwarded (gesture recognizer applies) |
Consequences: tap-the-tab-label is primary phone navigation, never make swipe
the only path, map D-pad ←/→ to next/prev, keep single-key aliases
(n/p) for custom shortcut slots. Full table + gesture state machine:
references/input.md.
References