# Ape CLI Terminal Experience

> Designs the terminal experience of a CLI app the way Claude Code does it -- palette, status glyphs, spinners, progress narration, prompts, confirmations, listings, key hints, and full-screen TUI views. Trigger on "ape cli terminal experience", or whenever the task involves printing to a terminal from a CLI: adding or restyling output, adding a spinner or progress indicator, writing a prompt or confirmation, building a TUI, choosing exit codes, or deciding stdout vs stderr. Also trigger on casual phrasings like "make the output nicer", "add colour", "this CLI looks ugly". Read this before writing the first line of output code.

- Skill: `arpitbbhayani/ape-cli-terminal-experience` (Agent Skill)
- Install (CLI): `npx skillmds@latest add arpitbbhayani/ape-cli-terminal-experience`
- Raw SKILL.md: https://api.skillmd.com/api/skills/arpitbbhayani/ape-cli-terminal-experience/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: AI & ML
- Author: arpitbbhayani (https://skillmd.com/u/arpitbbhayani)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/arpitbbhayani/ape-cli-terminal-experience

---


# CLI Terminal Experience

A terminal UI that reads like Claude Code: mostly grey, a few deliberate accents, one
line per fact, animation only while something is actually happening, and a plain-text
fallback the moment the output stops being a terminal.

Every rule below is enforceable and testable. Follow them exactly rather than
approximating the vibe.

---

## 1. The two laws

**Subtle by default.** Colour marks meaning -- a failure, a value the user will act on
-- and nothing else. Labels, units, paths, and chrome are dim; values are plain. A full
screen of output should read as mostly grey with a handful of accents, never as a
colour test page. If you cannot say what a colour *means*, remove it.

**Plain when not a terminal.** Pipe the program anywhere and every escape sequence
disappears, glyphs fall back to bracketed ASCII, rules vanish, and the spinner goes
silent. Output stays greppable; a script parsing it never sees a byte of styling.

Three consequences that people get wrong:

- Colour detection and terminal detection are **different questions**. `FORCE_COLOR`
  should add colour to piped output, but carriage-return redraws still make no sense
  there. Gate colour on `color_enabled()`, gate animation on `is_tty()`.
- **Only colour is a colour question.** Glyph shape, rules, and the spinner are terminal
  questions -- they gate on `is_tty()`. Conflating the two is the single most common bug
  in this module: `NO_COLOR=1` in a real terminal then degrades `✓` to `[OK]`, and
  `FORCE_COLOR=1 | cat` draws a horizontal rule into a pipe. `--no-color` on a terminal
  must keep `✓` and keep the rule, and just drop the escapes.
- `--json` output is data. Turn colour off for it globally, never decorate it.

---

## 2. One voice module

All user-facing output goes through a single presentation module (`ui.py`, `ui.go`,
`ui.ts` -- one file). Logic modules never call `print`. They raise typed errors and
return data; the CLI layer decides how it looks.

```
cli.py        argument parsing, flag wiring, orchestration, the exit-code map
ui.py         colours, glyphs, line shapes, listings, prompts, spinner, run()
everything    pure functions and typed exceptions -- zero output calls
else
```

The payoff is real: the whole program has one voice, `--no-color` is one switch, and
the logic is testable without capturing stdout. Grep your logic modules for `print` --
a hit is a bug.

---

## 3. Palette

256-colour codes, deliberately narrow: one accent, four semantics, two greys. Do not
add a colour without deleting one.

| Role     | Code  | Meaning                                                   |
|----------|-------|-----------------------------------------------------------|
| accent   | `208` | amber -- the program's own voice: prompts, commands to copy, selected row, values worth acting on |
| ok       | `71`  | muted green -- something passed or was created            |
| err      | `167` | muted red -- a failure                                    |
| warn     | `179` | muted amber-yellow -- worth knowing, not a failure        |
| info     | `110` | muted blue -- neutral narration                           |
| dim      | `245` | labels, units, secondary text, anything the eye skips     |
| faint    | `240` | chrome: rules, separators, the least important thing      |

Muted variants, not the terminal's default bright ANSI 1-7. Bright green on a
successful check is shouting.

Emit as `\033[38;5;<code>m ... \033[0m`, with `\033[1m` prepended for bold. Bold with no
colour (`strong`) is the right emphasis inside an otherwise plain block -- section
titles use it, so they survive `NO_COLOR` intact.

**Never use emoji** in CLI output or error messages. Glyphs below carry the semantics.

---

## 4. Glyphs

| Role | TTY | Piped    | Colour |
|------|-----|----------|--------|
| ok   | `✓` | `[OK]`   | ok     |
| err  | `✗` | `[FAIL]` | err    |
| warn | `!` | `[WARN]` | warn   |
| info | `·` | `[INFO]` | info   |
| step | `›` | `>`      | accent |

The ASCII fallback is bracketed on purpose: `[OK] credentials` is greppable,
`✓ credentials` is not reliably so.

The two columns are independent. **Shape follows the terminal, colour follows the
palette** -- `glyph()` picks `✓` vs `[OK]` on `is_tty()` and paints it on
`color_enabled()`. So `--no-color` in a terminal gives a plain `✓`, and
`FORCE_COLOR=1 | cat` gives a coloured `[OK]`. Both are correct.

---

## 5. Line grammar

Exactly seven shapes. Compose screens out of these; do not invent an eighth without a
reason you can state.

**Status line** -- `<glyph> <message>[  <dim detail>]`

```
✓ credentials       mode 0o600
✗ connections       gmail is INITIATED
! npx not found on PATH   Node.js is required for `skills` -- https://nodejs.org
```

The optional `width` argument pads *the message only*, so a batch of checks aligns
their details into a column. Compute the width once from the whole batch
(`max(len(name) for name in checks)`) -- never format a row in isolation, or the
columns jitter.

**Heading** -- one blank line above, bold text, no box, no banner, no rule underneath.

```

tools selected (3)
```

Counts belong in the heading, parenthesised and **dim while the title stays bold** --
`heading("workflows", 7)`, not `heading("workflows (7)")`. Pass the count as an argument
so it can be dimmed separately; interpolating it into the title bolds it too.

**Rule** -- `─` repeated to `min(terminal_width, 80)`, faint. Skipped entirely when not
a terminal -- a terminal question, so `FORCE_COLOR` does not resurrect it in a pipe. Use
it for full-screen TUI chrome; almost never in linear output.

**Key/value** -- two-space indent, dim `label:` (padded to a shared width), plain value.
The label is dim so the value reads first.

```
  harness:  claude -p
  store:    /Users/x/.px0
```

**Bullet** -- two-space indent, faint `·`, then text.

**Hint** -- a blank line, then dim text at column 0, describing what to do next. Always
the last thing in a block.

**Command** -- two-space indent, accent colour, nothing else on the line. This is the
one thing on screen the user is expected to copy, so it gets the accent and stands
alone.

```

try next:
  px0 doctor
  px0 new "describe what you want"
```

`hint` + `command` is the standard closing couplet for any command that finishes
successfully. Every terminal state should answer "and now what?".

---

## 6. Spinner

The single animation. Everything about it is deliberate.

- Frames: `⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏`, cycled at **0.08s**. Braille, because it occupies one cell and
  does not jitter the line width.
- Rendered as `\r<accent frame> <message><dim timer>` -- carriage return, no newline.
- **The elapsed timer is held back for the first second.** `(0s)` reads as broken. After
  1s show ` (3s)`, dim, integer seconds.
- Always writes to **stderr**, so a spinner never lands in output the user is capturing.
- **A no-op unless stderr is a terminal.** Piped, it prints exactly one plain line --
  its own message followed by `...` (`Verifying key...`) -- at the start and nothing
  else, so logs stay readable. In quiet mode (`--quiet`, `--json`) it prints nothing at
  all.
- **Truncated to the terminal width**, with `…` when it does not fit. A line that wraps
  cannot be erased: `\r` returns to the start of the *last* row, so the first row's text
  stays on screen forever. Recompute the width every frame -- terminals get resized
  mid-run.
- Runs on a daemon thread; joins with a timeout on stop.
- On stop it **erases its own line** (`\r` + spaces to terminal width + `\r`) before
  anything else prints. On an exception it erases *before the exception propagates*, so
  a traceback never lands on top of a half-drawn spinner.
- `stop(final)` optionally replaces the line with a status line, appending `(1.4s)` as
  the dim detail when the operation took a second or more.

Use it as a context manager so the erase is unconditional:

```python
with ui.spinner("Verifying Composio API key"):
    setup_composio(home, key)
ui.ok("Composio API key stored")
```

**Message style.** Spinner labels are capitalised present participles naming the work,
with a count when there is one:

```
Checking the request for gaps
Searching Composio's catalogue (3 queries)
Choosing from 40 candidates
Writing the workflow plan
```

Resolved status lines are lowercase and past tense: `✓ reindexed  412 passages`. The
contrast between the two is what makes a long flow legible -- capitalised means "in
flight", lowercase means "settled".

**Never nest spinners.** One at a time; update the message instead
(`sp.update("Now doing the next thing")`).

---

## 7. Progress narration

A multi-step flow is a sequence of spinner-then-result, with headings between phases.
The screen grows downward and never redraws what has scrolled past. This is what makes
it feel like Claude Code: the transcript of what happened stays on screen, and only the
live line animates.

```
⠹ Checking the request for gaps (2s)          <- transient, erased
✓ the request is clear   nothing to clarify   <- permanent

  · github: list pull requests
  · slack: send message

⠼ Searching Composio's catalogue (2 queries) (4s)

tools selected (3)
  1.  read         github.pulls.list      List pull requests for a repository
  2.  write        slack.messages.send    Post a message to a channel
! this workflow could change things outside px0   slack.messages.send

Enter accepts all; list numbers to drop (e.g. 2,3); n aborts
› keep all?
```

Rules:

- One spinner per unit of work the user would name. Not per HTTP call.
- Resolve every spinner with a status line or a heading -- never let one vanish silently.
- A skipped step still prints: `✓ no external service needed   this runs on its input alone`.
- Report the shape of what came back (`3 queries`, `40 candidates`), not raw dumps.

---

## 8. Prompts and interaction

Prompt prefix is the `step` glyph -- accent `›` in a terminal, `>` when piped --
followed by the question. Returns the input stripped.

```
› workflow id [new-workflow]:
```

**Defaults are shown dim in square brackets** and Enter takes them.

**Yes/no** capitalises the default: `[Y/n]` when Enter means yes, `[y/N]` when Enter
means no. Accept `y`/`yes`/`n`/`no`, case-insensitive, and re-ask on anything else --
never read garbage as a no. Anything destructive or outward-facing defaults to no.

**Secrets** echo masked with an explicit keep affordance:

```
› Composio API key [sk-1...9fa2, Enter to keep]:
```

Mask as `first4...last4`, or all asterisks when the value is 8 characters or shorter.
The *stored* value is what echoes masked; what the user types does not echo at all
(`getpass`), so it never lands in a screen recording or a scrollback buffer.

**Numbered menus** accent the number so a follow-up answer can refer to it:

```
harnesses
  1. claude     claude -p          installed
  2. gemini     gemini -p          not on PATH
  3. custom command

› pick [1-3]:
```

**Multi-select by exception.** When the program has already chosen well, do not make
the user re-pick. Show the choice, let Enter accept all of it, and take numbers to
*drop*: `Enter accepts all; list numbers to drop (e.g. 2,3); n aborts`. Parse digits out
of whatever they type. If they drop everything, that is an error, not an empty run.

**Multi-line entry** is terminated by a blank line, and you say so first:
`type the replacement body; a blank line finishes`.

**Cancelling is not an error.** `ui.info("cancelled")` and exit 0. Model it as a typed
`Cancelled` exception the top-level handler turns into that line, so a prompt buried
four calls deep can decline without threading a sentinel back up.

**Confirmation gates.** Anything that writes outside the program's own store, grants
write access, or costs money gets an explicit confirmation *after* a warning naming
exactly what is at stake:

```
! this workflow could change things outside px0   slack.messages.send
```

Route those warnings to **stdout** when they are part of an interactive review the user
is reading top-to-bottom, even though `warn` defaults to stderr -- otherwise they
interleave wrongly against the prompt.

**`--yes` skips every prompt.** Open-ended prompts take their default; **confirmations
answer yes.** Those are two different rules and the distinction matters: `--yes` exists
to unblock CI and cron, so if a destructive gate defaulted to no and `--yes` "took the
default", the flag would abort exactly the runs it was added to enable. A gate too
dangerous to pass under `--yes` needs its own flag (`--force`), not a default of no.

Implement `--yes` inside the prompt helpers, not at the call sites. One
`ui.set_yes(True)` at startup then covers every flow, including the ones added later.
Every interactive flow must have this path, and it must be tested with stdin closed.

---

## 9. Streams, buffering, exit codes

- **stdout** is the program's output -- the answer, the JSON, the generated text. Anything
  a pipe consumer wants.
- **stderr** is narration -- spinners, errors, warnings, progress. `err` and `warn`
  default there.
- Ambiguous case: a run summary like `✓ summarize success  run-1a2b` is narration. Send
  it to stderr and let the actual output own stdout.
- **Line-buffer stdout at startup** (`sys.stdout.reconfigure(line_buffering=True)`).
  Without it, stdout is block-buffered when piped while stderr is not, and the two
  interleave out of order. This is a one-line fix for a bug that looks like chaos.
- Flush every print.

Exit codes are a stable API. Give each failure category its own:

```
0  success (including a user-cancelled prompt)
1  user error -- bad input, missing store, failed precondition
2  connector / external service error
3  model or backend error
4  integrity error -- checks failed
130 interrupted
```

Map exceptions to codes in one place, at the top-level `main`, not scattered through
handlers -- `sys.exit(ui.run(main, {StoreMissing: 1, ConnectorError: 2}))`. That wrapper
owns Ctrl-C, `Cancelled`, and EOF-on-a-prompt as well, so those three never need a
handler anywhere else.

**An unmapped exception should still traceback.** It means a bug in your code, not a
condition the user can fix, and a swallowed stack trace costs you the only evidence you
had. Catch categories you named; let the rest through.

---

## 10. Failure modes to handle explicitly

**Ctrl-C.** Catch `KeyboardInterrupt` at the top level, print a newline to stderr (the
spinner has already cleared its own line), then `! interrupted`. Never a traceback.

**EOF on a prompt.** Piped stdin, CI, cron, `curl | sh`. This is the most-missed case in
the whole document, because every prompt helper is one `input()` call that raises
`EOFError` by default -- and an `EOFError` traceback is the exact failure this section
exists to prevent. **Catch it inside the prompt helper**, not at the call sites, and
convert it to a typed `NoInput`. Then there are two correct responses:

- If the command can still finish usefully, warn and continue:
  `! no terminal to prompt on; skipping Composio setup` + hint + command to do it later.
  A prompt with a default does this for free -- it takes the default on EOF.
- If it cannot, fail with the fix:
  `✗ this command needs an answer and stdin is exhausted` /
  `run it interactively, or pass --yes to accept the defaults`.

**Every error message names the fix.** The message is what went wrong; the dim detail is
the specifics; the hint and command are what to do. Three lines, no traceback.

```
✗ no px0 store at /Users/x/.px0
create one with:
  px0 init
```

**Never abort over something recoverable.** A pending OAuth consent should not throw away
four model passes of work -- finish, record what is pending, and tell the user what
completes it.

---

## 11. Tables and listings

Two-space indent, two-space column separators, `ljust` to widths computed over the whole
batch. No box-drawing, no borders, no headers unless there are more than three columns.

```
  read   github.pulls.list      List pull requests for a repository   ready
  write  slack.messages.send    Post a message to a channel           not authorized
```

One column may carry colour -- pick the one that changes behaviour (write access,
authorization state, outcome). Descriptions are dim. Ids are plain.

**Share the row formatter between the plain listing and any TUI** that shows the same
records, so both render identically and stay in sync. That means `row()` returns a
*string* and does not print -- the listing prints it, the TUI hands it to `addstr`. Give
it an explicit `stream`: colour depends on where the text is going, and a formatter that
silently asks about `sys.stdout` will emit plain text into a coloured destination.

Close a listing with a hint that summarises the risk or the gap:

```

3 of 12 tools can change things outside px0
```

Diffs get pager colours: `+` in ok, `-` in err, `@@` dim, `+++`/`---` bold.
Log timestamps get faint-ed so the message reads first.

---

## 12. Full-screen TUI (curses)

When a listing needs filtering and drill-down, go full-screen -- but keep the same
palette, so the TUI and the plain commands read as one program. Initialise colour pairs
with the same 256-colour codes and `use_default_colors()` so the terminal's own
background shows through. Fall back to `A_DIM`/`A_BOLD` when the terminal has no colour.

Layout:

```
 px0 runs · 12 of 47                     <- row 0: accent bold title, count after a dim ·
 outcome=failed  writes only             <- row 1: dim active-filter summary, or "no filters"
─────────────────────────────────────    <- row 2: faint rule
                                          
 › run-1a2b  summarize  manual  failed   <- rows: accent pointer + row text
   run-3c4d  digest     cron    success
                                          
─────────────────────────────────────    <- height-2: faint rule
 ↑↓ move  enter detail  / workflow  q quit  <- height-1: accent key, dim label
```

Rules:

- **The selection is a pointer (`›`), not a highlight bar.** Less flicker, and the row's
  own semantic colour (failures red, in-flight dim) stays readable.
- Key hints live on the last row: accent the key, dim the label, two spaces between
  pairs, truncate rather than wrap.
- Accept both arrows and `j`/`k`. `q` and `esc` both leave.
- Empty state is a dim sentence in the list area: `no runs match these filters`.
- Filters are single keystrokes that cycle or prompt; show the active set in the header
  and give one key (`c`) to clear everything.
- **Suspend curses for anything that writes to the real terminal** (a pager, a rerun, a
  provenance dump): `endwin()`, run it, print `Press any key to resume...`, read a key,
  `initscr().refresh()`. Do this in a context manager with a `finally`, so an exception
  in a keystroke handler can never leave the terminal in raw mode with no cursor.
- Swallow and display errors from keystroke handlers -- a failed pager returns you to the
  list, it does not tear the TUI down.
- Hide the cursor (`curs_set(0)`); show it only while a prompt is accepting text.
- Clamp every `addstr` to the window width and wrap it in a `try/except curses.error`;
  writing to the last cell of the last line raises.

---

## 13. Global flags every CLI gets

| Flag | Effect |
|------|--------|
| `--no-color` | force colour off, overriding detection. Glyphs stay Unicode, rules stay drawn -- only the escapes go |
| `--json` | machine-readable output on stdout, colour forced off, spinners quiet |
| `--quiet` | suppress narration, keep the actual output. Errors and warnings still print -- a silent failure is worse than a noisy one |
| `--yes` | defaults for prompts, yes for confirmations, never block |

Environment, honoured in this order: forced setting (`--no-color`) > `NO_COLOR` >
`FORCE_COLOR` > `TERM=dumb` (disables) > `isatty()`.

Get the two variables' semantics exactly right, because both are widely mis-implemented:

- **`NO_COLOR` disables when it is present *and non-empty*** -- that is what
  no-color.org specifies, and the empty case is the whole point of the wording. Users set
  `NO_COLOR=` to *undo* an inherited `NO_COLOR=1` for one command. Testing
  `is not None` breaks that escape hatch and there is no other way out of it.
- **`FORCE_COLOR=0` and `FORCE_COLOR=false` disable colour**, even on a terminal; any
  other value (including empty) enables it, even in a pipe. This is the Node/chalk
  convention the variable comes from, and `FORCE_COLOR=0` is how CI systems ask for
  clean logs. Treating any value as "on" turns that request into its opposite.

Wrap `isatty()` in `try/except (AttributeError, ValueError)` -- it raises on a closed
stream, and detection must absorb that rather than crash the program.

A root-level flag must survive subcommand parsing: `prog --json cmd` and
`prog cmd --json` must both work. If each subparser declares its own `--json`, the
subparser's default resets the root value. Test this.

---

## 14. Reference implementation

Copy this, adjust names, delete nothing. It is ~400 lines, it has no dependencies,
and it is the whole style -- palette, glyphs, the seven line shapes, the listing
formatter, every prompt in section 8, the spinner, and the exit-code mapper. Section
15 is its test suite.

```python
"""Terminal presentation: colours, glyphs, prompts, listings, and the spinner.

Python 3.10+ (PEP 604 unions). No dependencies.
"""

import getpass, itertools, os, re, shutil, sys, threading, time
from contextlib import contextmanager

_ACCENT, _OK, _ERR, _WARN, _INFO, _DIM, _FAINT = "208", "71", "167", "179", "110", "245", "240"
_forced: bool | None = None
_quiet = False
_assume_yes = False


class Cancelled(Exception):
    """The user declined. Not an error -- exit 0."""


class NoInput(Exception):
    """stdin is exhausted and the question has no safe default."""


def init() -> None:
    """Line-buffer stdout so it interleaves in order with unbuffered stderr."""
    try:
        sys.stdout.reconfigure(line_buffering=True)
    except (AttributeError, ValueError):
        pass


def set_color(enabled: bool | None) -> None:
    global _forced
    _forced = enabled


def set_quiet(enabled: bool) -> None:
    """--quiet: suppress narration. Errors and warnings still print."""
    global _quiet
    _quiet = enabled


def set_yes(enabled: bool) -> None:
    """--yes: never block on a prompt."""
    global _assume_yes
    _assume_yes = enabled


# --- detection: colour and terminal are different questions ------------------

def is_tty(stream=None) -> bool:
    """True when `stream` is a real terminal, regardless of colour settings."""
    stream = stream or sys.stdout
    try:
        return bool(stream.isatty())
    except (AttributeError, ValueError):
        return False


def color_enabled(stream=None) -> bool:
    if _forced is not None:
        return _forced
    if os.environ.get("NO_COLOR"):            # present and non-empty -- the spec
        return False
    force = os.environ.get("FORCE_COLOR")
    if force is not None:                     # 0/false disable, anything else enables
        return force not in ("0", "false")
    if os.environ.get("TERM") == "dumb":
        return False
    return is_tty(stream)


def paint(text: str, code: str, *, bold: bool = False, stream=None) -> str:
    if not text or not color_enabled(stream):
        return text
    prefix = "\033[1m" if bold else ""
    return f"{prefix}\033[38;5;{code}m{text}\033[0m"


def dim(t, **kw):    return paint(t, _DIM, **kw)
def faint(t, **kw):  return paint(t, _FAINT, **kw)
def accent(t, **kw): return paint(t, _ACCENT, **kw)
def strong(t, **kw):
    return f"\033[1m{t}\033[0m" if color_enabled(kw.get("stream")) else t


# --- lines ------------------------------------------------------------------

_GLYPHS = {"ok": ("✓", "[OK]", _OK), "err": ("✗", "[FAIL]", _ERR),
           "warn": ("!", "[WARN]", _WARN), "info": ("·", "[INFO]", _INFO),
           "step": ("›", ">", _ACCENT)}


def glyph(role, stream=None):
    """Shape follows the terminal; colour follows the palette. Two questions."""
    mark, fallback, code = _GLYPHS[role]
    return paint(mark if is_tty(stream) else fallback, code, stream=stream)


def _status(role, message, detail="", *, width=0, stream=None):
    stream = stream or sys.stdout
    line = f"{glyph(role, stream)} {message.ljust(width) if width else message}"
    if detail:
        line += f"  {dim(detail, stream=stream)}"
    print(line, file=stream, flush=True)


def ok(m, d="", **kw):
    if not _quiet: _status("ok", m, d, **kw)
def info(m, d="", **kw):
    if not _quiet: _status("info", m, d, **kw)
def step(m, d="", **kw):
    if not _quiet: _status("step", m, d, **kw)
def err(m, d="", **kw):  kw.setdefault("stream", sys.stderr); _status("err", m, d, **kw)
def warn(m, d="", **kw): kw.setdefault("stream", sys.stderr); _status("warn", m, d, **kw)


def heading(text, count=None, *, stream=None):
    """Bold title, blank line above. The count is dim and parenthesised."""
    stream = stream or sys.stdout
    if _quiet:
        return
    line = strong(text, stream=stream)
    if count is not None:
        line += f" {dim(f'({count})', stream=stream)}"
    print(file=stream)
    print(line, file=stream, flush=True)


def rule(stream=None):
    stream = stream or sys.stdout
    if _quiet or not is_tty(stream):          # a terminal question, not a colour one
        return
    width = min(shutil.get_terminal_size((80, 24)).columns, 80)
    print(faint("─" * width, stream=stream), file=stream, flush=True)


def kv(label, value, *, width=0, stream=None):
    stream = stream or sys.stdout
    if _quiet:
        return
    text = f"{label}:".ljust(width) if width else f"{label}:"
    print(f"  {dim(text, stream=stream)} {value}", file=stream, flush=True)


def bullet(text, stream=None):
    stream = stream or sys.stdout
    if _quiet:
        return
    print(f"  {faint('·', stream=stream)} {text}", file=stream, flush=True)


def hint(text, stream=None):
    stream = stream or sys.stdout
    if _quiet:
        return
    print(file=stream)
    print(dim(text, stream=stream), file=stream, flush=True)


def command(text, stream=None):
    stream = stream or sys.stdout
    if _quiet:
        return
    print(f"  {accent(text, stream=stream)}", file=stream, flush=True)


# --- listings: one formatter, shared with the TUI ---------------------------

def widths(rows) -> list[int]:
    """Column widths over the whole batch -- never format a row in isolation."""
    return [max(len(str(c)) for c in col) for col in zip(*rows)] if rows else []


def row(cells, widths, *, color=None, dims=(), stream=None) -> str:
    """One listing row as a string, so the plain listing and the TUI stay in sync.

    `color` is (index, fn) for the single column that carries colour, where fn(cell)
    returns a palette code or None. `dims` are the indices to dim.
    """
    out = []
    last = len(cells) - 1
    for i, cell in enumerate(cells):
        text = str(cell)
        if i != last and i < len(widths):
            text = text.ljust(widths[i])
        if color and i == color[0]:
            code = color[1](str(cell))
            text = paint(text, code, stream=stream) if code else text
        elif i in dims:
            text = dim(text, stream=stream)
        out.append(text)
    return "  " + "  ".join(out)


def table(rows, *, color=None, dims=(), stream=None):
    stream = stream or sys.stdout
    w = widths(rows)
    for cells in rows:
        print(row(cells, w, color=color, dims=dims, stream=stream), file=stream, flush=True)


# --- prompts ----------------------------------------------------------------

def prompt(text, default="") -> str:
    """Ask a question. Returns the answer stripped, or `default` on Enter.

    Never raises EOFError: with a default it takes it, without one it raises NoInput.
    """
    if _assume_yes:
        return default
    suffix = f" {dim(f'[{default}]')}" if default else ""
    try:
        answer = input(f"{glyph('step')} {text}{suffix}: ").strip()
    except EOFError:
        print(file=sys.stderr)
        if default:
            return default
        raise NoInput(text) from None
    return answer or default


def confirm(question, *, default=False) -> bool:
    """Yes/no. Anything destructive or outward-facing passes default=False.

    `--yes` answers yes -- it exists to unblock automation. A gate too dangerous for
    `--yes` needs its own explicit flag, not a default of no.
    """
    if _assume_yes:
        return True
    marks = "[Y/n]" if default else "[y/N]"
    while True:
        try:
            answer = input(f"{glyph('step')} {question} {dim(marks)} ").strip().lower()
        except EOFError:
            print(file=sys.stderr)
            raise NoInput(question) from None
        if not answer:
            return default
        if answer in ("y", "yes"):
            return True
        if answer in ("n", "no"):
            return False
        warn("answer y or n")


def mask(value: str) -> str:
    if not value:
        return ""
    return "*" * len(value) if len(value) <= 8 else f"{value[:4]}...{value[-4:]}"


def secret(label, current="") -> str:
    """Prompt for a secret. The existing value echoes masked, with a keep affordance."""
    if _assume_yes:
        return current
    suffix = f" {dim(f'[{mask(current)}, Enter to keep]')}" if current else ""
    try:
        answer = getpass.getpass(f"{glyph('step')} {label}{suffix}: ").strip()
    except EOFError:
        print(file=sys.stderr)
        if current:
            return current
        raise NoInput(label) from None
    return answer or current


def menu(title, rows, *, default=1) -> int:
    """Numbered menu over (label, detail) rows. Returns the 1-based choice."""
    heading(title)
    w = max((len(label) for label, _ in rows), default=0)
    for i, (label, detail) in enumerate(rows, 1):
        line = f"  {accent(f'{i}.')} {label.ljust(w)}"
        if detail:
            line += f"  {dim(detail)}"
        print(line, flush=True)
    while True:
        answer = prompt(f"pick [1-{len(rows)}]", str(default))
        if answer.isdigit() and 1 <= int(answer) <= len(rows):
            return int(answer)
        warn(f"pick a number between 1 and {len(rows)}")


def keep_all_but(count, *, noun="items") -> list[int]:
    """Multi-select by exception. Enter keeps all, digits drop rows, n aborts.

    Returns the 1-based indices to keep. Raises Cancelled on abort or an empty result.
    """
    hint("Enter accepts all; list numbers to drop (e.g. 2,3); n aborts")
    answer = prompt("keep all?")
    if answer.lower() in ("n", "no"):
        raise Cancelled("cancelled")
    dropped = {int(d) for d in re.findall(r"\d+", answer)}
    kept = [i for i in range(1, count + 1) if i not in dropped]
    if not kept:
        raise Cancelled(f"all {count} {noun} were dropped -- nothing left to run")
    return kept


# --- spinner ----------------------------------------------------------------

_FRAMES, _INTERVAL = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏", 0.08


class Spinner:
    def __init__(self, message, *, quiet=None, stream=None):
        self.message, self.stream = message, stream or sys.stderr
        self.quiet = _quiet if quiet is None else quiet
        self.animated = not self.quiet and is_tty(self.stream)   # a TTY, not colour
        self._stop = threading.Event()
        self._thread, self._started = None, 0.0

    def _spin(self):
        for frame in itertools.cycle(_FRAMES):
            if self._stop.is_set():
                return
            elapsed = time.monotonic() - self._started
            timer = f" ({elapsed:.0f}s)" if elapsed >= 1 else ""
            # Truncate: a wrapped line cannot be erased with one \r.
            room = max(8, shutil.get_terminal_size((80, 24)).columns - len(timer) - 3)
            message = self.message
            if len(message) > room:
                message = message[:room - 1] + "…"
            self.stream.write(f"\r{accent(frame, stream=self.stream)} {message}"
                              f"{dim(timer, stream=self.stream)}")
            self.stream.flush()
            self._stop.wait(_INTERVAL)

    def start(self):
        self._started = time.monotonic()
        if self.animated:
            self._thread = threading.Thread(target=self._spin, daemon=True)
            self._thread.start()
        elif not self.quiet:
            print(f"{self.message}...", file=self.stream, flush=True)
        return self

    def _erase(self):
        if not self.animated:
            return
        width = shutil.get_terminal_size((80, 24)).columns
        self.stream.write("\r" + " " * (width - 1) + "\r")
        self.stream.flush()

    def stop(self, final=None, role="ok"):
        self._stop.set()
        if self._thread is not None:
            self._thread.join(timeout=1.0)
        self._erase()
        if final and not self.quiet:
            elapsed = time.monotonic() - self._started
            _status(role, final, f"({elapsed:.1f}s)" if elapsed >= 1.0 else "",
                    stream=self.stream)

    def update(self, message):
        if self.animated:
            self._erase()
        self.message = message


@contextmanager
def spinner(message, *, done=None, quiet=None, stream=None):
    sp = Spinner(message, quiet=quiet, stream=stream).start()
    try:
        yield sp
    except BaseException:
        sp.stop()      # erase before the traceback lands
        raise
    else:
        sp.stop(done)


# --- exit codes: mapped in one place ----------------------------------------

def run(main, codes=None) -> int:
    """Wrap the top-level entry point. `codes` maps exception types to exit codes.

    Usage: sys.exit(ui.run(main, {StoreMissing: 1, ConnectorError: 2}))
    """
    try:
        return main() or 0
    except KeyboardInterrupt:
        print(file=sys.stderr)          # the spinner cleared its own line already
        warn("interrupted")
        return 130
    except Cancelled as e:
        info(str(e) or "cancelled")
        return 0
    except NoInput as e:
        err("this command needs an answer and stdin is exhausted", str(e))
        hint("run it interactively, or pass --yes to accept the defaults")
        return 1
    except Exception as e:
        for kind, code in (codes or {}).items():
            if isinstance(e, kind):
                err(str(e))
                return code
        raise                            # unmapped means a bug: let it traceback
```

**Porting.** Go: `fatih/color` or raw SGR strings, `golang.org/x/term.IsTerminal`,
`bubbletea` only for the full-screen view. Node: raw escape strings or `picocolors`,
`process.stdout.isTTY`, no `ora`/`chalk` stacks -- the whole spinner is 30 lines.
Rust: `anstyle` + a hand-rolled spinner. Do not pull in a heavyweight TUI framework for
linear output.

---

## 15. Tests to write

These are the invariants that keep the style from rotting. Write them alongside the
module; all 45 run in under a second with no terminal, using a `StringIO` subclass whose
`isatty()` you control.

**Detection**

- Piped output contains no `\x1b` and uses `[OK]` / `[FAIL]`.
- TTY output contains `\x1b[38;5;` and `✓`, and never `[OK]`.
- `NO_COLOR=1` disables colour; **`NO_COLOR=""` does not** (present *and non-empty*).
- `FORCE_COLOR=1` colours a pipe, leaves `is_tty()` false, keeps glyphs bracketed, and
  leaves the spinner inert.
- `FORCE_COLOR=0` disables colour even on a TTY.
- `TERM=dumb` disables colour on a TTY.
- `--no-color` on a TTY drops every escape but keeps `✓` and still draws the rule.
- `isatty()` on a closed stream does not raise.

**Lines and listings**

- `width` pads the message only: two status lines with the same width start their
  details at the same column.
- `kv` dims the label and leaves the value unstyled.
- `heading` bolds the title and dims the count separately.
- `err`/`warn` go to stderr, `ok` goes to stdout.
- `row()` with shared widths aligns every column across a batch.
- `table()` colours exactly one column, honouring the target stream.

**Prompts**

- A prompt on exhausted stdin raises the typed `NoInput`, never `EOFError`.
- A prompt with a default takes it on both EOF and Enter.
- `confirm` takes the capitalised default on Enter, accepts `YES`/`No` case-insensitively,
  re-asks on garbage, and raises `NoInput` on EOF.
- `mask` gives all asterisks at 8 characters or fewer, `first4...last4` above that.
- Multi-select by exception: Enter keeps all, `2,3` drops those two, `n` raises
  `Cancelled`, and dropping every row raises rather than returning an empty list.
- With `--yes` set, every prompt and confirmation returns **without reading stdin at
  all** -- assert this with stdin closed, or the test passes for the wrong reason.
- With `--quiet` set, narration writes nothing and errors still write.

**Spinner**

- On a pipe it writes exactly one line, `<message>...\n`, and no `\r`.
- In quiet mode it writes nothing.
- On a TTY it writes `\r`, at least one frame character, and ends with the `done` text.
- No rendered line exceeds the terminal width (set `COLUMNS` narrow and assert on the
  escape-stripped length).
- It erases before an exception propagates (last write ends in `\r`).
- The thread is dead when the block exits.
- `(0s)` never appears.
- `rule()` writes nothing when not a terminal, *even with* `FORCE_COLOR=1`.

**Exit codes**

- Success is 0; a mapped exception returns its own code with no traceback.
- Ctrl-C is 130 and prints one `! interrupted`.
- `Cancelled` is 0, `NoInput` is 1 with a hint naming `--yes`.
- An unmapped exception still tracebacks.
- `prog --json cmd` and `prog cmd --json` both parse to `json=True`.

---

## 16. Anti-patterns

| Do not | Because |
|--------|---------|
| Emoji anywhere in output | The glyph set carries the semantics; emoji break alignment and read as unserious |
| Boxes, banners, ASCII-art headers, `Panel.fit` | Chrome without information; a bold line does the same job |
| Bright default ANSI colours | Shouting; use the muted 256-colour codes |
| Progress bars for unknown-duration work | A spinner with an elapsed counter is honest; a fake bar is not |
| Nested or concurrent spinners | Two carriage returns fighting over one line |
| Spinners on stdout | They land in the user's captured output |
| Printing from logic modules | Breaks the single voice and makes the logic untestable |
| Colour on a value with no meaning attached | Turns the screen into a colour test page |
| A traceback as an error message | Say what broke and what fixes it |
| `print("Done!")` with no next step | Every terminal state answers "and now what?" |
| Re-drawing scrolled output | Only the live line animates; the transcript is permanent |
| A prompt with no `--yes` path | Breaks CI, cron, and `curl \| sh` |
| Silent truncation of a list | Say `showing 20 of 143` |
| Gating glyph shape or the rule on `color_enabled()` | Those are terminal questions; `--no-color` should not turn `✓` into `[OK]` |
| `NO_COLOR is not None`, or `FORCE_COLOR=0` meaning on | Breaks the two escape hatches users actually reach for |
| Bare `input()` in a prompt helper | Raises `EOFError` in CI -- the traceback this whole document forbids |
| `--yes` answering no to a destructive confirm | Aborts exactly the automated runs the flag was added to unblock |
| Handling Ctrl-C or EOF at each call site | It belongs once, in the top-level wrapper |
| Catching every exception to avoid a traceback | Unmapped means a bug; swallowing it destroys the only evidence |
| A spinner message longer than the terminal | It wraps, and a wrapped line can never be erased |
| A row formatter that prints, or that reads `sys.stdout` | The TUI cannot reuse it, and colour follows the wrong stream |

---

## 17. Checklist before shipping a command

1. Does every long operation have a spinner, and does every spinner resolve into a
   status line?
2. Does the command end with a hint and a copyab

…(truncated)
