Chewy TUI
Platform Conventions ran the catalog: Apple HIG, Material, Fluent, WCAG, clig.dev. Each platform with its written-down expectations. One row was missing.
A terminal UI is a grid of cells. Each cell holds one glyph in one color on one background. That is the entire substrate, and it has no HIG. A CLI emits a line, and the conventions a line-oriented tool inherits do not extend to a thing that draws into a grid, accepts mouse events, switches buffers, and competes with the terminal emulator for the cursor. Apple has Liquid Glass. Google has Material 3. The terminal has Bram Moolenaar’s :help, Thomas Dickey’s xterm FAQ, and a few dozen blog posts by the people who built the libraries.
The terminal is these designers’ canvas. Their blog posts and READMEs are the palette. Charm is the de facto authority: they ship the libraries most modern TUIs are built on (Bubble Tea, Bubbles, Lip Gloss, Glamour, Glow, Wish, Soft Serve, VHS), and The Next Generation of the Command Line is the closest thing they have to a manifesto. Where Textualize and the xterm FAQ fill gaps Charm has not written down, the post reaches for those. Where they contradict, defer to Charm.
Three stacks, same shape:
| Ecosystem | Framework | Styling | Layout model | Render granularity | Authority |
|---|---|---|---|---|---|
| Go | Bubble Tea | Lip Gloss | Box DSL | per-line diff | Charm |
| Python | Textual | Rich | CSS subset | per-cell diff | Textualize |
| TypeScript | Ink | props + chalk | Yoga flexbox | React reconciler | Vadim Demedes / Vercel |
| Rust | ratatui | inline | flexbox | immediate-mode | community fork |
Each is one framework, one styling library, one maintainer with a published opinion. The frameworks differ on layout (DSL vs CSS vs JSX) and on render granularity, but they agree on the substrate. The cell is the unit everywhere.
Inheritance
The pre-computer constraints carry the typography.
Typewriter pitch (1874). The Sholes & Glidden, marketed by Remington from July 1874, fixed letter width because the mechanical carriage demanded it. Monospace became the typographic substrate for code, ledgers, and terminals. A TUI does not choose monospace; monospace comes with the medium.
Telegram compression (1840s). Drop articles, prefer codes, pay per word. A TUI status line is a telegram. cpu 0% · mem 47% · 0 agents is not a sentence. Every character earns its place because the line is one row tall.
Ledger columns (1494). Pacioli’s Summa de Arithmetica described double-entry bookkeeping with debits and credits in fixed columns so a clerk could scan a thousand entries by running the eye down. Most TUIs are ledgers. A kanban with one PR per row and one station per column is a ledger. Alignment is enforced by the font, not by CSS.
Broadcast color (1970s). ANSI gave terminals 8 colors, then 16, then 256, then truecolor. Every TUI still has to render on the 8-color tier. The constraint is downward, not upward.
The physics
The principles in GUI Before Computers carry over without translation.
Gestalt grouping still groups by proximity and similarity. Two columns one space apart read as related; two spaces apart, distinct.
Fitts’s Law has no pointer. Distance becomes keystroke count. The fastest target is one key away. Every long-running TUI eventually grows a q for quit and a ? for help.
Hick’s Law governs the keybind footer. Six keys is fine; sixteen is a problem. The constraint is harsh enough that authors invent chord systems (g g to jump to top, vim-style), which is Hick’s Law with extra steps.
Miller’s Law sets the column count. Seven plus or minus two, same as 1956.
The opinions
These are folk-canon. They have no committee, no spec, just thirty years of “every serious TUI does it this way.”
Persistent status line. Bill Joy’s vi, written at Berkeley in 1976 on a Lear-Siegler ADM-3A, echoed commands on the last line of the terminal. The convention answers “what state is this app in” without forcing the user to remember.
Modal editing. Vi modes were inherited and durably advocated by Bram Moolenaar’s vim (first public release, Amiga, November 1991). Holding a modifier across every keystroke fights the keyboard’s geometry; the keyboard switches modes instead.
Keybind footer. q quit r refresh d toggle along the bottom. Mutt, midnight commander, htop made it expected. The affordance and the signifier in one row. Without it, the app is a door without a handle.
The palette
Here is what the practitioners actually wrote down. Charm first, then the gaps.
Auto-downsample color profiles. termenv detects truecolor → 256 → 16 → monochrome and converts at render time. Hardcoding truecolor breaks on tmux without RGB; hardcoding ANSI 16 looks like 1995 on a modern terminal. (Charm.)
Adaptive colors over hardcoded ANSI. Lip Gloss ships AdaptiveColor{Light, Dark} for exactly this. A color that reads well on a dark terminal can disappear on a light one. (Charm.)
Separate structure from style. Lip Gloss treats layout and styling as independent concerns the way CSS separates from HTML. Layout boxes are composable; styles cascade. The Elm Architecture in Bubble Tea carries the state half of the same split: pure update functions, commands for side effects. (The Next Generation of the Command Line.)
SSH is just io.ReadWriter. Charm’s Wish treats every SSH session as a Bubble Tea program; the network is another transport. (Do You Even SSH?)
Alt screen via ?1049, not ?47. ?1049 clears the alt screen on entry and restores cursor on exit; ?47 leaks state. (Thomas Dickey, xterm FAQ.)
Bracketed paste, ?2004. Without it, a pasted newline is indistinguishable from a typed Enter. With it, the paste arrives wrapped in ESC[200~ … ESC[201~. Treat the wrapped block as one paste, not as keystrokes.
Mouse mode ?1006, not ?1000. Mode 1000 encodes coordinates in single bytes and breaks past column 223. Mode 1006 (SGR) handles arbitrarily wide terminals. (Dickey, xterm ctlseqs.)
Detect background via OSC 11. Query the terminal with \e]11;?\a and it answers with its background color. That is how adaptive themes know which side they are on. (wezterm docs.)
Overwrite, don’t clear. Clearing the screen before drawing risks a visible blank frame; overwrite in place to avoid tearing. (Will McGugan, Textualize.)
Coalesce a frame into one write(). Multiple writes during an update can flash a partial frame. Emit each frame as a single buffered write. (McGugan, same post.)
Synchronized Output, DEC mode ?2026. Bracket each frame with begin/end so supporting emulators deliver it atomically. (Spec by Christian Parpart, Contour.)
Diff and patch, do not redraw. Rewrite only the cells that changed. The cheap, flickering TUIs you have used were 60-fps paint-the-world apps; the ones that feel native compute a render diff and write the delta. Bubble Tea’s renderer does this per-line; Textual does it per-cell.
isatty() extends past color. A CLI drops styling when piped (Platform Conventions); a TUI has more to drop. Skip the alt screen, skip mouse capture, skip bracketed paste. Emit the markdown a pager would have rendered.
NO_COLOR is a contract. If the environment variable is set, drop all color. Proposed in 2017 and adopted across the ecosystem before “accessibility mode” was a thing in any GUI framework.
The pitfalls
The recurring traps in the same posts.
Emoji break alignment. ZWJ sequences (👨👩👧) are one grapheme, multiple codepoints, and terminal emulators disagree about their width. Pad defensively in column-aligned contexts, or skip emoji where alignment matters. (McGugan, Real World Terminal Emojis.)
len(s) is a bug. East Asian Wide characters take two cells. Width has to be computed per grapheme via Unicode TR#11 / TR#51 tables, not codepoint count. A naive printf("%-20s") on a string with one CJK character renders one cell short.
Escape vs Alt is a timing problem. A bare ESC and ESC followed by a key are indistinguishable except by delay. Vim’s ttimeoutlen documents the standard workaround at 25-50ms. Too short and legitimate Alt combos break; too long and pressing Escape feels sluggish.
Hardcoded ANSI fails on the other theme. A color tuned for dark terminals washes out on light ones. Adaptive colors or OSC 11 detection are the fix.
Redrawing on every keystroke flickers. Most TUIs that feel cheap are paint-the-world apps. Diff-and-patch is what separates production-grade from prototype.
Raw mode hijacks Ctrl-C. In line-buffered mode the kernel turns ^C into SIGINT for free; in raw mode the TUI receives the byte 0x03 as a keypress and the signal never fires. If the app does not route it back to SIGINT (or at least to quit), the user’s reflex hits a wall. CLIs inherit ^C from the shell. TUIs have to wire it.
The filter
If you can render it on a VT100, the design will probably hold up. If you need 24-bit color, smooth scrolling, or sub-cell positioning, you are building a GUI that boots inside a terminal, which is fine but no longer inherits.
The reason TUIs are returning is not nostalgia. The cell grid forces every decision that GUI frameworks made optional. Layout aligns because the font enforces it. Color is semantic because there is not enough of it to be decorative. Conveyance is explicit because there is no hover. Discoverability lives in the footer because there is no menu bar. The constraints write the design.
A real HIG for this substrate would lower the cost of every new TUI by a useful constant. None exists. The blogs are the palette; this post is one attempt to organize them.
Reference implementations
- tig (GPL-2.0+) — snack-sized. A git browser in ~17k LOC of C. Persistent status line, keybind footer, modes for log/diff/blame/refs, mouse optional. The whole pattern fits on one weekend’s reading.
- htop (GPL-2.0+) — three decades of cell-by-cell rendering done right.
- helix (MPL-2.0) — modern modal editor with capability detection done right.
- Bubble Tea (MIT) — Charm’s Elm-style architecture, the canonical Go-side framework.
- Textual (MIT) — Python framework whose blog filled in the render-loop gaps.