Skill v1.0.1
currentAutomated scan100/100+2 new
name: tui-design description: Design terminal user interfaces and interactive CLIs that stay usable, accessible, and scriptable. Use when building or reviewing a TUI, a full-screen terminal app, or an interactive command-line tool. user_invocable: true metadata: version: "1.0.1"
TUI Design
Design the command-line interface first. Add a full-screen TUI only when the task needs persistent context, fast navigation, selection, or live updates. A TUI owns the screen, the input, the scrollback, and the terminal state; do not take that cost without a reason.
Decide if a TUI is correct
- Use a plain command when the operation is one-shot, batchable, or scripted.
- Use the user's
$PAGERwhen the task is reading or searching long output.
Page only when stdout is a terminal, and provide --no-pager.
- Use a TUI when users repeatedly navigate, compare, filter, select, or watch
changing data. Process monitors, resource browsers, and Git interfaces are good examples.
- Do not build a TUI only to get color or animation. Styled output, a prompt,
or a single selector is usually enough.
- Never make the TUI the only way to do an important operation. Ship
equivalent non-interactive subcommands and structured output:
``bash app # optional interactive interface app list --json app delete ID --yes ``
- Share the domain logic below both interfaces. The TUI is an adapter.
Keep the two interfaces separate
- Write results to stdout and write progress, warnings, and errors to stderr.
- Test each stream for a terminal independently. stdout can be a pipe while
stderr is still a terminal.
- When output is not a terminal, change presentation only, never meaning.
Remove color, cursor motion, progress animation, and the alternate screen.
- Provide
--jsonfor structured data and one JSON document per line for
streaming events. Treat those schemas as a public interface.
- Prompt only when stdin is a terminal. Provide a flag for every prompt so a
script can supply the answer. Never block a pipeline on input.
- Support
-q/--quietand--verbose, with the same meaning everywhere.
Lay out for a character grid
- Make 80x24 the minimum target. Essential navigation, current state, primary
actions, and errors must stay usable there.
- Recompute the layout from the current size on every resize. Handle
SIGWINCH, then query the real dimensions; do not cache the launch size.
- Use explicit breakpoints. Drop decoration and secondary metadata before you
hide primary content. Below the minimum, print a plain "terminal too small".
- Clamp sizes before subtracting. Test 1x1, 20x5, and zero-size reports.
- Give headers, status bars, and key hints fixed heights; give the main view
the rest; set minimum sizes on interactive panes.
- Wrap prose. Truncate scan-oriented rows with an ellipsis and offer a detail
view. An ellipsis signals hidden content; silent clipping does not.
- Use the alternate screen for sustained applications, and inline rendering
for command-shaped work that should leave output in scrollback.
- Spend borders carefully: two bordered panes cost at least four columns at
- Prefer whitespace, alignment, and one shared divider.
Handle text as the terminal sees it
- Measure rendered cells, not bytes, code points, or string length. CJK is
usually two cells, combining marks are zero, and one emoji may be many code points.
- Cut and edit at grapheme-cluster boundaries, not code points.
- Do not treat East Asian Width as a complete width algorithm; ambiguous
characters vary by terminal, locale, and font.
- Keep emoji out of borders, fixed columns, progress tracks, and cursors.
Reported widths disagree across terminals and multiplexers.
- Treat Nerd Font and Powerline glyphs as optional. Keep an ASCII fallback and
never let an unlabeled icon be the only meaning.
- Normalize tabs to a fixed width before measuring.
Use color as an enhancement
- Design a readable monochrome hierarchy first, then add semantic color.
- Support the ladder: no color, 16 colors, 256 colors, true color. Detect the
capability; do not assume true color, and remember COLORTERM is lost through ssh and sudo.
- Define roles such as muted, surface, selected, success, warning, and error.
Map roles through themes; do not scatter literal colors in the code.
- Detect the terminal background before you choose fixed colors, and keep an
unknown state for terminals that do not answer.
- Never encode meaning in color alone. Add a label, symbol, border, or cursor.
- Honor
NO_COLORwhen it is present and not empty. An explicit flag or the
user config may still override it.
- Target 4.5:1 contrast for ordinary text. You do not control the user's font
size, theme, or rendering.
Bind keys that every terminal can send
- Support arrows and
hjkl. Show arrows in the footer and the vim aliases in
the full help.
- Make
Ctrl+Ca global, highest-priority exit that no focused widget can
shadow. It must restore the terminal before it exits.
- Bind
qto quit at the root,?to full help,/to filter,:to a
command palette, Enter to activate, Tab/Shift+Tab to move focus.
- Give
Escone consistent ladder: close a menu, cancel an edit or filter,
close the overlay, go back one level. At the root it does nothing.
- Leave
Ctrl+S,Ctrl+Q,Ctrl+D, andCtrl+Zalone. They belong to the
terminal and the shell.
- Do not require
Ctrl+Shift,Ctrl+Enter, or a Tab andCtrl+I
distinction. Legacy terminals cannot encode them. Use the Kitty keyboard protocol only for optional accelerators.
- Keep mouse support off by default unless direct manipulation is central.
Mouse reporting breaks ordinary text selection; document the Shift or Option override, and keep full keyboard equivalence.
Make the interface discoverable
- Keep a one-line footer with three to six actions for the current focus, for
example up/down move Enter open / filter ? help q quit.
- Derive the footer from the active keymap so it cannot drift from reality.
?opens a full help overlay grouped by global, navigation, current pane,
actions, and search.
- List aliases together, and never advertise a key that does nothing. Dim
disabled actions and give the reason.
- Show the active sort, filter, mode, and match count. Do not overload plain
typing silently.
- Treat empty states as content. Distinguish "nothing exists", "no match",
"loading", "failed", and "no access", and give the next action: No matches for "prod". Press Esc to clear the filter.
Report state and errors honestly
- Print something within 100ms. Silence during slow work looks like a hang.
- Use a spinner only for unknown durations; switch to counts or a progress
measure when totals are known. Always label what is happening.
- Stop animation on success, failure, cancellation, or non-interactive output,
and keep the frame rate low enough for ssh and slow terminals.
- Show loading inside the affected pane and keep the surrounding context.
- Every error answers three questions: what failed, why, and what to do next.
Rewrite low-level errors into domain language. Put the action last.
- Group repeated failures under one explanation instead of one paragraph each.
- Keep recoverable errors inside the TUI, preserve the user's input and
selection, and offer the retry key. Restore the terminal before a fatal error prints.
- Confirm irreversible, broad, or externally visible actions, and name the
target: Delete 12 pods in production? Default the focus to cancel.
- Do not confirm actions with reliable undo. Offer undo instead, and only
advertise it when recovery is guaranteed.
Structure the application
- Keep one source of truth for state, and one serialized path for every
visible state transition.
- Make the update step a deterministic transition: no clock, no filesystem, no
network, no globals inside it.
- Turn every occurrence into a typed event: keys, resize, ticks, completion,
failure, cancellation.
- Return descriptions of effects instead of performing them in the update
step. Keep rendering pure and cheap.
- Keep the event loop free. Anything slower than a few milliseconds is worker
work that sends its result back as an event.
- Cancel stale work and also reject stale results with a generation counter;
an old response must never overwrite a newer one.
- Tie worker lifetime to the screen or widget that started it.
- Keep the domain packages free of the TUI framework, of styling, and of
terminal APIs, so the same code serves --json, tests, and scripts.
- Reuse existing primitives for lists, tables, viewports, inputs, and help
before writing your own; they already solved Unicode, scrolling, and paste.
- Never log to the stream the TUI owns. Log to a file and
tail -fit from a
second terminal. Debug from a second process.
Own the terminal state
- Centralize acquisition and restoration. Track raw mode, alternate screen,
hidden cursor, mouse capture, focus reporting, and bracketed paste, and undo them in reverse order.
- Restore on normal exit, on error, on panic, on
SIGINT, and onSIGTERM.
Make cleanup idempotent and best effort.
- Treat pasted text as data, not as keybindings, and bound its size.
- Accept that
SIGKILLcannot be handled. Documentresetorstty sane. - Render on state change, not in a free loop. Build the next frame, diff it,
and write only the changed cells. Do not clear the whole screen every frame.
- Virtualize long lists, key the selection by item identity rather than row
position, and bound streaming queues so producers apply backpressure.
Configure with a clear precedence
- Order: flags, environment variables, project config, user config, system
config, built-in defaults. Provide a command that shows effective values and their source.
- Match the mechanism to the lifetime: flags vary per run, environment varies
per machine, project files are shared, user config is personal.
- Follow XDG:
XDG_CONFIG_HOME,XDG_STATE_HOME,XDG_DATA_HOME,
XDG_CACHE_HOME, XDG_RUNTIME_DIR.
- Ship a complete default keymap and theme. High configurability without a
good baseline is onboarding debt. Make reset easy.
- Map keys to semantic actions such as
item.open, not to functions, and
detect conflicts.
Design for screen readers
- Assume a screen reader sees a text buffer, not your widgets. Borders and
coordinates carry no meaning to it.
- Provide a first-class linear mode:
--plain,--no-interactive, or an
accessible prompt mode that echoes context and asks one question at a time.
- Do not assume a framework is accessible because it is popular. Verify with
NVDA, JAWS, and VoiceOver separately; snapshot tests prove nothing here.
- Keep a real cursor where the user's attention is.
- There is no portable accessibility environment variable.
NO_COLORis the
only established convention; anything else must be documented and paired with a flag.
Verify it
- Test state transitions without a terminal: send an event, assert the new
state and the requested effect.
- Snapshot the rendered buffer at fixed sizes. Cover narrow, wide, empty,
Unicode, selected, error, and loading states.
- Golden-test the plain contract with redirected streams,
TERM=dumb, and
NO_COLOR=1. Assert that no escape sequence, prompt, or timestamp appears.
- Add pseudo-terminal tests for lifecycle: raw mode, resize, paste,
Ctrl+C,
SIGTERM, and panic. After each failure path, assert the terminal was restored.
- Exercise the real thing through ssh and tmux, and on more than one emulator.
- Use recorded tapes for documentation, not as the regression gate.
Related skills
writing-testsfor deterministic tests, including the TUI cases above.my-code-reviewwhen reviewing someone else's terminal interface.go-conventionswhen the implementation is Go.