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
- references/architecture.md — layers, ANSI width math, diffed painting, frame contract
- references/input.md — key/mouse decoding (with code), gestures, hit-testing, phone-terminal channels
- references/responsive.md — narrow-mode methodology (stack-don't-truncate) + audit workflow
- references/testing.md — layered test strategy, deterministic fixtures, verify harness
- Runnable reference: https://github.com/xingfanxia/axfleet-ui-public (code +
docs/tui-methodology.md)
1---2name: tui-engineering3description: 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.4---56# TUI Engineering — phone-friendly terminal dashboards from scratch78> **Cross-platform Agent Skill** — Claude Code · OpenAI Codex · OpenCode · OpenClaw 通用。910Build a full-screen terminal UI the way axfleet is built: **no ncurses, no11blessed, no ink** — the terminal layer is ~500 lines you own and can test.12Every rule in this skill states the failure it prevents; every mechanism has a13runnable reference implementation at14**[xingfanxia/axfleet-ui-public](https://github.com/xingfanxia/axfleet-ui-public)**15(fixture-driven demo — `bun install && bun run tui`). The reference stack is16Bun + TypeScript; the architecture is stack-agnostic.1718## When to use / not use1920- ✅ Full-screen dashboards, monitors, pickers, multi-tab tools; anything that21 must stay readable at phone width (Moshi ≈45 cols) over mosh/SSH.22- ✅ Retrofitting mouse/touch/narrow-mode onto an existing raw-ANSI TUI.23- ❌ Line-oriented CLI output (just print), one-shot prompts (use readline),24 or apps already committed to a TUI framework (follow that framework).2526## Architecture (non-negotiable shape)2728```29contracts/ typed data model — the single shape everything renders from30state.ts pure transitions: (state, event) → new state (never mutate)31render/ pure renderers: (state, cols, rows) → string[] (one per tab)32term.ts THE ONLY module touching stdin/stdout33api.ts data source behind fetch/stream fns returning {ok,data}|{ok,error}34index.ts impure event loop wiring term → state → render → paint35```3637Everything except `term.ts`/`index.ts` is a pure function — that's what makes38layout, input decoding, and gestures unit-testable without a TTY. Transitions39return the **same reference** when nothing changed; the loop skips redraws on40identity. Detail: [references/architecture.md](references/architecture.md).4142## Build order (follow this sequence)43441. **`contracts/`** — the typed data model. Fixtures and renderers both45 compile against it, so `tsc` proves demo data is shape-complete.462. **`ansi.ts` + tests** — `visibleWidth` / `truncate` / `padEnd` / `wrapPlain`.47 All layout math flows through here. CJK/emoji = 2 cells. Never48 `String.length`. → [architecture.md §2](references/architecture.md)493. **`term.ts`** — alt screen, raw mode, line-diffed paints, crash restore,50 pure `decodeEvents` with split-chunk carry-over + escape timeout.51 → [architecture.md §3](references/architecture.md), [input.md](references/input.md)524. **`state.ts` + tests** — tabs, selection, scroll, data application.535. **`render/frame.ts`** — chrome + first tab; add the layout-contract test54 NOW (every tab × sizes: exactly `rows` lines, each ≤ `cols`). Future tabs55 inherit the guarantee by joining the loop. → [testing.md](references/testing.md)566. **Remaining tabs** — one pure renderer each.577. **Fixtures + demo api** — deterministic noise, pure fn of (key, time). The58 TUI now runs headless; every later change is eyeball-verifiable.59 → [testing.md §fixtures](references/testing.md)608. **Mouse/touch** — SGR decode → direction-locked gesture machine → hit61 ranges. Tests before wiring. → [input.md](references/input.md)629. **Narrow-mode pass** — render every tab at ~45 cols with fixtures, catalog63 information loss, fix by stacking/compressing, pin with survival tests.64 → [responsive.md](references/responsive.md)6510. **Footer hints + README** — document keys; keep single-key next/prev66 aliases bindable from phone-terminal shortcut slots.6768## Invariants checklist (verify before calling it done)6970- [ ] Frame is exactly `rows` lines, every line `visibleWidth ≤ cols`, at ALL71 sizes (45×30 / 76×24 / 120×40 minimum) — soft-wrap corrupts diff paints72- [ ] Crash path restores the terminal (`uncaughtException` → `term.exit()`)73- [ ] Unknown CSI consumed through final byte (F5 must not leak "1","5")74- [ ] Trailing escape fragments carried across chunks + escape timeout75 (~40ms); NEVER eagerly treat chunk-final ESC as the ESC key if ESC quits76- [ ] Mouse modes disabled on exit in reverse order of enable77- [ ] Selectable list rows are ONE line each, list at top of pane78 (pane line == item index — hit-testing and follow-selection depend on it)79- [ ] Narrow mode stacks/wraps instead of truncating; survival tests assert80 full message text present at 45 cols81- [ ] Fixtures typed against `contracts/`; day-spaced noise periods don't82 divide a day evenly (aliasing)83- [ ] One command runs typecheck + all tests headless (`bun run verify`)8485## Phone terminals — what actually arrives (hard-won facts)8687| Phone action | What the TUI receives (Moshi, measured 2026-07) |88|---|---|89| tap | SGR press + release |90| vertical pan | wheel-up/down burst |91| 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) |92| Mouse-Mode drag | press/drag/release forwarded (gesture recognizer applies) |9394Consequences: tap-the-tab-label is primary phone navigation, never make swipe95the only path, map D-pad `←`/`→` to next/prev, keep single-key aliases96(`n`/`p`) for custom shortcut slots. Full table + gesture state machine:97[references/input.md](references/input.md).9899## References100101- [references/architecture.md](references/architecture.md) — layers, ANSI width math, diffed painting, frame contract102- [references/input.md](references/input.md) — key/mouse decoding (with code), gestures, hit-testing, phone-terminal channels103- [references/responsive.md](references/responsive.md) — narrow-mode methodology (stack-don't-truncate) + audit workflow104- [references/testing.md](references/testing.md) — layered test strategy, deterministic fixtures, verify harness105- Runnable reference: https://github.com/xingfanxia/axfleet-ui-public (code + `docs/tui-methodology.md`)