Skip to main content

tui

Build, review, or debug OpenTUI terminal UIs — React JSX or core constructs, Yoga flexbox, gradient meters, colour screenshots. Any Bun/TypeScript TUI. Only Bun is tested; Node/Deno at own risk.

Source facts

Repository
MadAppGang/magus
Last source activity
September 18, 2026 at 15:37
Detected SKILL.md language
English
Stars
10
Forks
4

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

File Explorer
67 files

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
name
tui
description
Build, review, or debug OpenTUI terminal UIs — React JSX or core constructs, Yoga flexbox, gradient meters, colour screenshots. Any Bun/TypeScript TUI. Only Bun is tested; Node/Deno at own risk.
disable-model-invocation
true
# OpenTUI TUI Build Bun/TypeScript terminal UIs that look like **btop, gonzo, posting and lazygit** — colourful, dense, graph-and-badge-heavy — not like `cat` output. OpenTUI hands you a real layout engine (Yoga flexbox) and a React renderer, and **no** meter, chart, badge or colour maths, so under-baked is the *default* outcome — which the contract below and the shipped `assets/` prevent. You cannot judge a look from source: **build → screenshot in colour → look → fix**. ## The aesthetic this skill is for The bar is **more colour, more graphs, less text**; the failure mode is not ugliness but *under-baking* — printing `CPU 78%` where the row could carry a meter, a percentage and a sparkline. Reach for the rich form mechanically. | You have… | Default to… | Not… | |---|---|---| | a bounded value (a percentage — **`pct` is 0–100, never 0–1**) | a **gradient meter** — one colour per cell across the fill | a bare number, or a flat one-colour bar | | a time series / recent history | a **sparkline** (`▁▂▃▄▅▆▇█`) | the latest number alone | | a category distribution | a **bar chart** or stacked bar, coloured per category | a list of `name: count` lines | | counts over time × category | a **heat row** per category, brightness = magnitude | a table of numbers | | a discrete status / level / method | a **badge** — dark ink on a saturated fill | plain coloured text alone | | genuine free narrative (logs, detail) | text — **and only inside a log or detail panel** | text as the main surface | Three rules keep it from tipping into noise. **Colour is semantic, never decorative** — one colour means one thing app-wide; red as "error" here and "selected" there kills glanceability. **Make gradients smooth** — one blended colour per column at the widget's real width, never four `░▒▓█` glyphs sharing one. **Dim the chrome, saturate the signal.** **Negative control: if your first screenshot shows a single-colour bar or bare numbers, you failed** — as is a non-log panel over about half prose or numerals. Then run that count a **second time over the WHOLE frame, not per panel** (graphics body rows vs text rows across the capture): every panel can pass the table while the screen still reads as prose — one build's first wide capture was 28 of 38 rows text, every panel compliant. Under half, add a graphics panel. ## What OpenTUI does not ship — and how to get it No meter, gauge, progress bar, sparkline, chart, heatmap or badge exists in the library, nor the colour maths they need — no `lerp`/`mix`/`blend`/`gradient`/`darken`/`rgbToHsv` anywhere in `packages/core/src`, **no string-width helper at all**, and `setCellWithAlphaBlending` is *compositing*, not interpolation. That layer ships here as tested code. **Copy it in; never retype it** — the 0.80 heat floor, 24 ramp steps and `>128` ink threshold degrade silently. ```bash SKILL="${CLAUDE_PLUGIN_ROOT:+$CLAUDE_PLUGIN_ROOT/skills/tui}"; SKILL="${SKILL:-PASTE_THE_DIR_THIS_SKILL_MD_WAS_READ_FROM}" # that var is UNSET in a Bash tool (MEASURED) — so paste. `:?` would ABORT the block and copy nothing bun init -y # FROM EMPTY this is step 1: package.json, typescript + @types/bun — `tsc` and `"types": ["bun"]` below need both. Creates NO src/. It ALSO writes a tsconfig.json you MUST overwrite with the block below — its defaults carry no `jsxImportSource`, so nothing renders bun add @opentui/core @opentui/react react && bun add -d @types/react # the shims import from core. UNPINNED, so you get `latest` — 0.5.1 as of 2026-08-06, not the 0.4.5 this skill's own package.json pins. Both run clean; only the `--compile` row cares (versions-and-builds.md) mkdir -p src; if [ -d "$SKILL/assets" ]; then cp -r "$SKILL/assets/theme" src/theme && cp -r "$SKILL/assets/runtime" src/runtime # `cp -r` needs src/; copy BOTH — theme = tokens/color/text/widgets + 2 tests, runtime = shutdown + test + env.d.ts else echo "SKILL is still the placeholder — paste the real dir above, re-run these 2 lines; the install already ran"; fi ``` Three test files ship — two under `theme/`, `shutdown.test.ts` under `runtime/` — so copying only `theme/` leaves the teardown suite behind; `color.ts` imports `./tokens`, `widgets.tsx` imports both, the tests all four, and `opentui-env.d.ts` must fall inside your `tsconfig.json` `include`. **Leave the relative imports as they are** — in `src/theme/` they resolve untouched (MEASURED in a bare `bun init`: `diff -qr` identical, `tsc` clean, 119/119 pass); `@/theme/…` is 9×TS2307. ## Two API surfaces — never blend them > **INVARIANT.** Never mix the core **construct DSL** (`Text({…})`, `Box({…}, child)`, `new XRenderable(ctx, opts)`) > with **React intrinsic rendering** (`<text>`, `<box>`, `<span>`) in the same snippet, file or fenced block — such > code compiles under neither. **Importing from `@opentui/core` is not a violation.** Calling its DSL beside JSX is. **React JSX is the canonical surface**: lowercase intrinsics, flat props, children as children — **zero** capitalised renderables across three shipping apps. Capitalised in JSX means *your own* component; multi-word intrinsics are **kebab-case** (`<tab-select>`, `<ascii-font>`, `<line-number>`), `scrollbox` is one word, and Solid's underscore spellings **do not compile in React.** An invented kebab tag typechecks clean against a string index signature, so the compiler cannot catch a surface error — run the linter (**Acceptance**). ## Bootstrap: zero to first frame `bun create tui --template react` scaffolds this; by hand it is two files, meeting in `src/index.tsx` as function calls, never constructs. Call `process.exit()` nowhere else — a crash that skipped `destroy()` leaves the terminal in raw mode inside the alternate screen, recovered with `reset` or `stty sane`. ```tsx import { createCliRenderer } from "@opentui/core"; import { createRoot } from "@opentui/react" import { installShutdown } from "./runtime/shutdown"; import { App } from "./App" // never `OWNED_SIGNALS` const renderer = await createCliRenderer({ screenMode: "alternate-screen", // full-screen app; "main-screen" stays in the scrollback exitSignals: [], // BOTH, so the renderer registers NOTHING and installShutdown is the sole owner. exitOnCtrlC: false, // MEASURED under a real SIGTERM — `[]`: disposer→unmount→destroy, ONE destroy; }) // `["SIGTERM"]`: destroy→disposer→unmount→destroy, TWO, the tree torn down last const root = createRoot(renderer); const shutdown = installShutdown(renderer, root) // disposers → unmount → destroy → stderr → 50ms flush → exit(128+signum); 0 only on a clean quit // Ctrl+C IS NOT A SIGNAL HERE: raw mode clears ISIG, so the tty hands you 0x03 and SIGINT is never raised. Deleting renderer.keyInput.on("keypress", (k) => { if (k.ctrl && k.name === "c") void shutdown() }) // this makes Ctrl+C INERT, not redundant root.render(<App onQuit={shutdown} />) // installShutdown's own SIGINT handler still catches `kill -INT` from outside the tty ``` ```jsonc // tsconfig.json — complete and copy-pasteable, the keys the tested skill root uses. BOTH jsx keys or nothing renders; // "target"+"module" are REQUIRED beside moduleResolution "bundler" (omit either → TS5095, not a UI); no "DOM" in lib // (span/b/i/u/a collide with HTML tags); skipLibCheck else 16x bun:ffi TS2307. { "compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "@opentui/react", "target": "ESNext", "module": "ESNext", "moduleResolution": "bundler", "lib": ["ESNext"], "types": ["bun"], "strict": true, "noEmit": true, "skipLibCheck": true }, "include": ["src/**/*"] } ``` ## Filling the terminal: header / body / footer The React root does **not** inherit the terminal's height — omit `height` and the tree collapses to content, floating the footer. Pin it from `useTerminalDimensions()`; one region claims the leftover with `flexGrow={1} minHeight={0}`: ```tsx const { width, height } = useTerminalDimensions(); return ( <box flexDirection="column" height={height}> {/* root: pin height; nothing else grows */} <box height={1} paddingLeft={1}><text>{`svcmon ${width}x${height}`}</text></box> <box flexDirection="row" flexGrow={1} minHeight={0}> {/* body: every row the chrome left over */} <box flexDirection="column" flexGrow={1} minWidth={0}> <Panel title="Load" flexShrink={0}>{/* content-sized: flexShrink={0} or the grower below starves it */}</Panel> <Panel title="Errors/min" flexGrow={1} flexBasis={0} minHeight={0}>{/* eats the leftover; scrollbox inside */}</Panel> </box> <box width={22}><Panel title="Services" flexShrink={0}>{/* fixed-width side column */}</Panel></box> </box> <box height={1} paddingLeft={1}><text>q quit · tab focus</text></box> </box> ) ``` **Sizing is a call-site concern, chrome is not.** `<Panel>` accepts every layout prop a parent legitimately owns (`PanelLayout`: growth, size, `min`/`max`, `alignSelf`, `margin*`) and nothing that changes appearance — a shipped test asserts a call site cannot override `borderStyle` or the colours. MEASURED at 80×24: `flexGrow={1} minHeight={0}` paints 18 rows and the footer holds row 24 — **but only while the grower's own child is content-sized.** **A grower holding a `<scrollbox>` starves its SIBLINGS**, because a scrollbox's intrinsic height is its entire content: the panel asks for 40 rows of a 22-row body, and Yoga's default `flexShrink: 1` spreads that shortfall across *every* sibling. MEASURED on this snippet at 80×24, 40 lines in `Errors/min`: `Load` collapsed from five rows to one, its five `<text>` children overprinting into `iot` — which reads as a broken widget, not a layout mistake. Cure it with props `PanelLayout` already exposes: **`flexShrink={0}` on every content-sized panel, `flexBasis={0}` on the grower** (either alone fixed the measured case; write both). This is `react-patterns.md`'s height starvation one level up — the same last-wins collapse, inflicted by a sibling rather than by the box's own `height`. **The leftover-eating panel must be a `<scrollbox>` stocked deeper than the tallest viewport it will ever occupy.** `flexGrow={1}` grants rows, it does not fill them — a grower holding five rows paints five and leaves the rest black, the unpainted hole a screenshot fails you for. MEASURED: 30 rows filled that side panel at 80×24 and still left a gap at 145×45, so stock for the WIDE capture — which is also what keeps the screen-wide density count passing at 40 rows. ## Core workflow 1. **New project → the bootstrap above; existing project → follow its pin** — read the installed `.d.ts`, not the docs site (which names no version), and do not migrate 0.1.x ↔ 0.4.x unless asked. 2. **Choose one surface and stay on it**: React unless the app has none (`core-api.md` owns the imperative surface). 3. **Flexbox for boxes, arithmetic for widget widths.** Yoga accounts for border and padding, so *heights*, panel placement and growth need no size budget — `gap`, `minWidth`, `flexWrap`, `flexGrow`, `flexBasis` all work (undocumented). But the data widgets take a **numeric `width`** that cannot `flexGrow`, so a full-width visual IS a column budget: **`Panel` costs 4 columns of chrome, a `<scrollbox>` inside it 1 more** (both MEASURED; the probe and the resample rule: `react-patterns.md`). Narrower than the Bubble Tea reflex, not a return to it — heights are flexbox, widths of data widgets are arithmetic. 4. **Build each visual as one row of styled text**, imported rather than retyped with hex literals: `Meter`, `Sparkline`, `HeatRow`, `StackedBar`, `Panel`, `Badge` and `BadgeSpan` (the span form, for a badge sharing a `<text>`) all ship in `assets/theme/widgets.tsx`. Never reimplement them; 2D only → `components-and-charts.md`. 5. **Screenshot in colour and look** (below), then **test behaviour and appearance** with `testRender`: `captureCharFrame()` for text, `captureSpans()` for colour — no args, and `renderOnce()` first or every cell is filler. ## Shipping it: pick your pin by artifact **Run from source, or a plain npm package → 0.4.x or 0.5.x.** An unpinned `bun add` gives **0.5.1** today (2026-08-06); 0.4.5 and 0.5.1 both run clean, so take the default unless you need the compile row. **A standalone `bun build --compile` binary → 0.1.107, and no `--external`** — it leaves the binary unable to resolve the module. The costliest mistake is `bun add @opentui/core@latest` then `--compile`, so **re-test 0.4.x `--compile` before accepting that downgrade**: MEASURED 2026-07-30, 0.4.5 compiles fine and the *binary* exits 1 resolving its native library inside `/$bunfs/root/` — upstream bug, not design, and 0.1.x is abandoned. Nothing cross-compiles. **Only Bun is tested**; Node/Deno, the matrix and the `--external` trap: `versions-and-builds.md`. ## Seeing your UI: the screenshot loop A plain text capture strips the colour that *is* the point; this turns a **running app into a colour-accurate PNG** you can `Read`. **Prerequisites: `aha` (`brew install aha` / `apt-get install aha`), a Chromium-family browser and tmux.** ```bash OUT=$(mktemp -d); SOCK=otui-$$; SESS=tui-$$ # unique per run: a fixed dir, socket or session collides with a parallel capture SKILL="${CLAUDE_PLUGIN_ROOT:+$CLAUDE_PLUGIN_ROOT/skills/tui}"; SKILL="${SKILL:-PASTE_THE_DIR_THIS_SKILL_MD_WAS_READ_FROM}" # as above shot() { local A="$OUT/${1}x${2}.ansi" # ${1}x${2} cells → a ${3}-pixel PNG. `-f /dev/null` on EVERY invocation, never just tmux -f /dev/null -L "$SOCK" new-session -d -s "$SESS" -x "${1}" -y "${2}" "bun run src/index.tsx" # new-session: a .tmux.conf for _ in $(seq 40); do sleep 0.25; tmux -f /dev/null -L "$SOCK" capture-pane -p -e -t "$SESS" >"$A" 2>/dev/null && grep -q $'\x1b' "$A" && break; done # that auto-creates sessions spawns ALL of them, tmux -f /dev/null -L "$SOCK" kill-window -t "$SESS" 2>/dev/null # and a client whose server died starts one that reads it. Poll for a COLOURED frame — a fixed sleep races the recreate grep -q $'\x1b' "$A" || { echo "NO ESC BYTES in $A — no -e, or it never drew. STOP: render nothing, conclude nothing"; return 1; } # a gate EXITS; an echo does not bun run "$SKILL/scripts/ansi-to-png.ts" "$A" "$OUT/${1}x${2}.png" "${3}" # colour census (`grep -o … | sort -u`): screenshot-workflow.md } shot 80 24 720x480 && shot 145 45 1300x900 && ls -l "$OUT"/*.png # BOTH sizes in one command — the wide one is not optional ```
View on GitHub
This SKILL.md is very large, so SkillsMP previews the first section here. View on GitHub