CLI Terminal Experience

SkillProductivity

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.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the CLI Terminal Experience skill

What this skill tells your AI

The instructions your AI receives, as published by arpitbbhayani/ape-skills in ape-cli-terminal-experience/SKILL.md and read by ahel’s review.

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.

RoleCodeMeaning
accent208amber -- the program's own voice: prompts, commands to copy, selected row, values worth acting on
ok71muted green -- something passed or was created
err167muted red -- a failure
warn179muted amber-yellow -- worth knowing, not a failure
info110muted blue -- neutral narration
dim245labels, units, secondary text, anything the eye skips
faint240chrome: 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

RoleTTYPipedColour
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

FlagEffect
--no-colorforce colour off, overriding detection. Glyphs stay Unicode, rules stay drawn -- only the escapes go
--jsonmachine-readable output on stdout, colour forced off, spinners quiet
--quietsuppress narration, keep the actual output. Errors and warnings still print -- a silent failure is worse than a noisy one
--yesdefaults 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)}

Shortened here. Read the whole file on GitHub.

Signals

GitHub stars
40
Forks
3
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
ape-cli-terminal-experience
Source
github.com/arpitbbhayani/ape-skills