Claude Skill

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 printi

LLM Mart · 0 points · 7 views 32 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download arpitbbhayani-ape-skills-ape-cli-terminal-experience-ed1d8d7.zip · 15 KB
Part of arpitbbhayani/ape-skills — 10 skills

Install

skills CLI npx skills add https://github.com/arpitbbhayani/ape-skills/tree/master/ape-cli-terminal-experience
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install arpitbbhayani-ape-skills@llmmart
Git git clone https://github.com/arpitbbhayani/ape-skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole arpitbbhayani/ape-skills collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

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:

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.

"""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 copyable command?
  3. Piped through cat: no escapes, no \r, ASCII glyphs, parseable?
  4. Under --json: pure data on stdout, nothing else?
  5. Under --yes: no prompt blocks?
  6. With stdin closed: a clear message, not an EOFError traceback?
  7. Ctrl-C mid-run: one clean ! interrupted line?
  8. On failure: the right exit code, and a message naming the fix?
  9. Are errors and warnings on stderr, and the actual output on stdout?
  10. Does anything outward-facing or destructive get a named warning and an explicit confirmation first?
  11. Under --no-color in a real terminal: escapes gone, but ✓ and the rules still there?
  12. In a 40-column terminal: does the spinner line still erase cleanly?
  13. Under --quiet: narration gone, errors still visible?
Files (ape-skills)
  • SKILL.md 40.8 KB
    ---
    name: ape-cli-terminal-experience
    description: >
      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.
    ---
    
    # 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 copyable command?
    3. Piped through `cat`: no escapes, no `\r`, ASCII glyphs, parseable?
    4. Under `--json`: pure data on stdout, nothing else?
    5. Under `--yes`: no prompt blocks?
    6. With stdin closed: a clear message, not an `EOFError` traceback?
    7. Ctrl-C mid-run: one clean `! interrupted` line?
    8. On failure: the right exit code, and a message naming the fix?
    9. Are errors and warnings on stderr, and the actual output on stdout?
    10. Does anything outward-facing or destructive get a named warning and an explicit
        confirmation first?
    11. Under `--no-color` in a real terminal: escapes gone, but `✓` and the rules still
        there?
    12. In a 40-column terminal: does the spinner line still erase cleanly?
    13. Under `--quiet`: narration gone, errors still visible?
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related