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_COLORshould add colour to piped output, but carriage-return redraws still make no sense there. Gate colour oncolor_enabled(), gate animation onis_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=1in a real terminal then degrades✓to[OK], andFORCE_COLOR=1 | catdraws a horizontal rule into a pipe.--no-coloron a terminal must keep✓and keep the rule, and just drop the escapes. --jsonoutput 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:\rreturns 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.
errandwarndefault there. - Ambiguous case: a run summary like
✓ summarize success run-1a2bis 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.qandescboth 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, printPress any key to resume..., read a key,initscr().refresh(). Do this in a context manager with afinally, 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
addstrto the window width and wrap it in atry/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_COLORdisables 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 setNO_COLOR=to undo an inheritedNO_COLOR=1for one command. Testingis not Nonebreaks that escape hatch and there is no other way out of it.FORCE_COLOR=0andFORCE_COLOR=falsedisable 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, andFORCE_COLOR=0is 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
\x1band uses[OK]/[FAIL]. - TTY output contains
\x1b[38;5;and✓, and never[OK]. NO_COLOR=1disables colour;NO_COLOR=""does not (present and non-empty).FORCE_COLOR=1colours a pipe, leavesis_tty()false, keeps glyphs bracketed, and leaves the spinner inert.FORCE_COLOR=0disables colour even on a TTY.TERM=dumbdisables colour on a TTY.--no-coloron a TTY drops every escape but keeps✓and still draws the rule.isatty()on a closed stream does not raise.
Lines and listings
widthpads the message only: two status lines with the same width start their details at the same column.kvdims the label and leaves the value unstyled.headingbolds the title and dims the count separately.err/warngo to stderr,okgoes 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, neverEOFError. - A prompt with a default takes it on both EOF and Enter.
confirmtakes the capitalised default on Enter, acceptsYES/Nocase-insensitively, re-asks on garbage, and raisesNoInputon EOF.maskgives all asterisks at 8 characters or fewer,first4...last4above that.- Multi-select by exception: Enter keeps all,
2,3drops those two,nraisesCancelled, and dropping every row raises rather than returning an empty list. - With
--yesset, every prompt and confirmation returns without reading stdin at all -- assert this with stdin closed, or the test passes for the wrong reason. - With
--quietset, 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 thedonetext. - No rendered line exceeds the terminal width (set
COLUMNSnarrow 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 withFORCE_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. Cancelledis 0,NoInputis 1 with a hint naming--yes.- An unmapped exception still tracebacks.
prog --json cmdandprog cmd --jsonboth parse tojson=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
- Does every long operation have a spinner, and does every spinner resolve into a status line?
- Does the command end with a hint and a copyab
…(truncated)