CLI Output Styling Reference¶
This document covers all CLI output styling, formatting, and rendering code in little-loops.
Design philosophy: No third-party styling libraries. All formatting uses Python stdlib (textwrap, shutil) plus manual ANSI escape codes. NO_COLOR env var is respected everywhere. Terminal width is always queried dynamically.
Core Module: scripts/little_loops/cli/output.py¶
The central styling utility imported by most CLI commands.
Terminal width and size¶
from little_loops.cli.output import terminal_size, terminal_width, wrap_text
w = terminal_width() # int, falls back to 80
cols, rows = terminal_size() # (int, int), falls back to (80, 24)
text = wrap_text(text, indent=" ", width=None) # wraps at terminal width
terminal_width() is a thin wrapper around terminal_size() (which calls
shutil.get_terminal_size((default_cols, default_rows)) and returns
(columns, lines)). Prefer terminal_size() whenever a layout decision
needs the rows dimension — e.g. the pinned-pane fallback ladder in
ll-loop run --show-diagrams --clear. Use terminal_width() for the
column-only case (the vast majority of callers).
ANSI color¶
from little_loops.cli.output import colorize, PRIORITY_COLOR, TYPE_COLOR
colored = colorize("P1", PRIORITY_COLOR["P1"]) # ANSI-wrapped or passthrough
colorize(text, code) wraps text in \033[{code}m...\033[0m. Returns text unchanged when color is disabled.
Color is enabled when sys.stdout.isatty() is True and NO_COLOR env var is absent or empty. Setting FORCE_COLOR=1 bypasses the TTY check and forces color on. This is evaluated at import time into _USE_COLOR.
Default color codes¶
| Key | ANSI Code | Appearance |
|---|---|---|
P0 |
38;5;208;1 |
Bold orange |
P1 |
38;5;208 |
Orange |
P2 |
33 |
Yellow |
P3 |
0 |
Default |
P4 |
2 |
Dim |
P5 |
2 |
Dim |
BUG |
38;5;208 |
Orange |
FEAT |
32 |
Green |
ENH |
34 |
Blue |
EPIC |
35 |
Purple-magenta |
Category colors for ll-loop list headers (key is the lowercase slug used in CATEGORY_COLOR; keys are matched against each loop's category: field). v2 polish removes the FEAT-green duplication — code-quality and quality no longer share the issues-list FEAT green:
| Slug | ANSI Code | Appearance |
|---|---|---|
apo |
38;5;141 |
Purple |
code-quality |
38;5;75 |
256-blue (v2: was 32 green) |
data |
34 |
Blue |
evaluation |
38;5;208 |
Orange |
gate |
38;5;160 |
Red |
harness |
35 |
Magenta |
integration |
38;5;39 |
Sky |
issue-management |
36 |
Cyan |
meta |
38;5;220 |
256-yellow (v2: was 38;5;208 orange) |
planning |
38;5;39 |
Sky |
quality |
38;5;178 |
256-gold (v2: was 32 green) |
research |
36 |
Cyan |
rl |
38;5;160 |
Red |
uncategorized |
0;2 |
Reset-dim |
Label colors for ll-loop list rows (resolved via LABEL_COLOR.get(label, "2"); unknown labels default to dim 2):
| Label | ANSI Code | Appearance |
|---|---|---|
hitl |
36 |
Cyan |
comparison |
35 |
Magenta |
generated |
33 |
Yellow |
meta |
38;5;208 |
Orange |
The ACRONYMS frozenset ({'APO', 'HITL', 'LLM', 'SVG', 'FSM', 'RLHF', 'API'}) governs
acronym-aware title casing via _smart_title() — apo renders as APO in
ll-loop list headers rather than the Apo that plain .title() would produce.
A second helper, _all_caps(slug), uppercases every word (e.g.
"issue-management" → "ISSUE MANAGEMENT") for section-marker labels:
category headers, subgroup subheads, top/closing summary lines. ll-loop list
uses _all_caps for those sites; body content (name, kind, labels,
description) stays mixed case.
Edge colors (used in FSM diagrams — applied to both label text and connector line characters):
| Label | ANSI Code | Appearance |
|---|---|---|
yes |
32 |
Green |
no |
38;5;208 |
Orange |
error |
31 |
Red |
blocked |
31 |
Red |
partial |
33 |
Yellow |
retry_exhausted |
38;5;208 |
Orange |
rate_limit_exhausted |
38;5;214 |
Amber |
throttle_hard |
38;5;196 |
Bold red (hardcoded — not user-configurable via cli.colors.fsm_edge_labels) |
next, _ |
2 |
Dim |
Startup configuration¶
Call once after loading config to apply user-defined colors:
from little_loops.cli.output import configure_output
configure_output(config.cli) # or configure_output(None) for defaults
configure_output merges config.cli.colors.priority and config.cli.colors.type into the module-level dicts. NO_COLOR env var always takes precedence over config.
Public API¶
All public functions in little_loops.cli.output that external callers (custom commands, extension authors, downstream ll- tools) are expected to use. The module ships no __all__; this list reflects the convention that underscore-prefixed symbols (e.g. _smart_title, _all_caps) are internal.
The size/text helpers (terminal_size, terminal_width, wrap_text, strip_ansi), the color entry points (configure_output, use_color_enabled, colorize), and the JSON/text helpers (print_json, format_relative_time) are documented in their owning subsections above. The four families below complete the public surface.
Status channels¶
Five untimestamped, icon-prefixed message helpers. success, info, warning, and hint write to stdout; error writes to stderr. All use flush=True. The icons render only when color is enabled (see use_color_enabled()).
from little_loops.cli.output import success, error, warning, info, hint
success("Built 3 issues")
error("Failed to parse config") # → stderr
warning("Issue IDs are deprecated")
info("Sprint created")
hint("Run /ll:sprint-show for details")
| Function | Stream | Icon | Color code |
|---|---|---|---|
success(msg) |
stdout | ✓ |
32 (green) |
error(msg) |
stderr | ✗ |
38;5;208 (orange) |
warning(msg) |
stdout | ⚠ |
33 (yellow) |
info(msg) |
stdout | ℹ |
36 (cyan) |
hint(msg) |
stdout | › |
2 (dim) |
Text helpers¶
strip_ansi, format_relative_time, and print_json complete the text-surface trio. strip_ansi is documented at the top of this section (under the ANSI helpers); the latter two are pure-string or pure-output helpers.
from little_loops.cli.output import strip_ansi, format_relative_time, print_json
plain = strip_ansi(colored_text) # drop \033[...m sequences
print(format_relative_time(180)) # "3m ago"
print_json({"issues": 3}) # dumps to stdout, indented
format_relative_time(seconds: float) -> str—"Ns ago"/"Nm ago"/"Nh Nm ago"/"Nd Nh ago"print_json(data: Any) -> None—json.dumps(data, indent=2)to stdout
Structural formatters (pure strings)¶
These return strings; print them yourself. Useful for building composite output (cards, status blocks, progress overlays) without committing to a specific stream.
from little_loops.cli.output import table, status_block, progress, sparkline
print(table(["ID", "Title"], [["B-1", "Crash"], ["B-2", "Slow"]]))
print(status_block({"status": "open", "priority": "P1"}))
print(progress(current=3, total=10))
print(sparkline(current=7, total=20))
| Function | Signature | Renders |
|---|---|---|
table |
(headers, rows, max_col_width=40) |
Box-drawn table; values exceeding max_col_width truncate with … (U+2026) |
status_block |
(items: dict[str, str]) |
Right-padded key/value pairs; empty dict returns "" |
progress |
(current: int, total: int, width: int = 20) |
ASCII bar \|####... \| |
sparkline |
(current: int, total: int, width: int = 16) |
Unicode block bar of █/░ |
Output-mode control¶
from little_loops.cli.output import set_output_mode, get_output_mode
set_output_mode("json") # callers gate rendering on this
mode = get_output_mode() # returns Literal["human", "json", "plain"]
set_output_mode(mode) toggles the module-global _OUTPUT_MODE; get_output_mode() reads it. Tools that support --json flip this once at startup and branch later formatters accordingly. The default is "human"; "plain" strips color regardless of _USE_COLOR at the formatter level.
Logo: scripts/little_loops/logo.py¶
Reads and prints ASCII art from scripts/little_loops/assets/ll-cli-logo.txt (in-package since FEAT-2274). Silent no-op if the file is missing.
from little_loops.logo import print_logo, get_logo
print_logo() # prints logo with surrounding blank lines
logo = get_logo() # returns str | None
Issue Card: scripts/little_loops/cli/issues/show.py¶
_render_card(fields) renders a Unicode box-drawing character summary card for ll-issues show.
Box-drawing characters used¶
| Char | Unicode | Role |
|---|---|---|
─ |
U+2500 | Horizontal border |
│ |
U+2502 | Vertical border |
┌ |
U+250C | Top-left corner |
┐ |
U+2510 | Top-right corner |
└ |
U+2514 | Bottom-left corner |
┘ |
U+2518 | Bottom-right corner |
├ |
U+251C | Left mid-border |
┤ |
U+2524 | Right mid-border |
Layout¶
┌──────────────────────────────────────────────────────┐
│ FEAT-518: Issue title │
├──────────────────────────────────────────────────────┤
│ Priority: P3 · Status: Open │
│ Confidence: 85 · Outcome: 78 │
│ Cmplx: 22 · Tcov: 24 · Ambig: 25 · Chsrf: 22 │
├──────────────────────────────────────────────────────┤
│ Summary text wrapped to content width, paragraph- │
│ reflowed so hard line breaks don't survive as orphans.│
├──────────────────────────────────────────────────────┤
│ Integration: 4 files · Labels: cli, ll-issues │
│ Discovered: 2026-06-15 │
│ Completed at: 2026-05-01 │
│ History: /ll:capture-issue, /ll:refine-issue │
├──────────────────────────────────────────────────────┤
│ Path: .issues/features/... │
└──────────────────────────────────────────────────────┘
Title is bold; borders, field labels, and the Path line are dimmed; status is
colored per state (In Progress yellow, Blocked red, Deferred/Cancelled
dim, Completed green, Open default). The inter-field separator within a
joined row is · (middot), not the │ border glyph, so it doesn't read as an
accidental column line (ENH-2574).
Detail line fields:
| Field | Source | Display |
|---|---|---|
Source |
discovered_by frontmatter |
Short alias (capture, scan, audit, format) or first 7 chars; omitted if absent or if the value is manual (the default case is low-signal — ENH-2574) |
Needs: formatting |
Required sections check | Renders only when the file is missing required template sections; omitted entirely when formatting is already correct (collapses the old always-on Norm/Fmt pair — ENH-2574) |
Captured at |
captured_at frontmatter |
Date-only (YYYY-MM-DD, time component dropped); omitted when it equals Discovered on the same calendar date (ENH-2574) |
Discovered |
discovered_date frontmatter |
YYYY-MM-DD date when the bug/feature was observed; distinct from captured_at (ENH-2535) |
Discovered commit |
discovered_commit frontmatter |
First 7 chars of the git SHA (short-form to avoid right-border bleed); omitted if absent (ENH-2535) |
Discovered branch / Discovered source / Upstream |
discovered_branch / discovered_source / discovered_external_repo frontmatter |
BUGs benefit most (gives the git-bisect anchor); omitted if absent (ENH-2535) |
Completed at |
completed_at frontmatter |
Date-only (time component dropped, ENH-2574); omitted if absent |
Decision needed / Decision ref |
decision_needed + decision_ref frontmatter |
Coupled form: Decision needed → <decision_ref> when both set; explicit no when decision_needed: false; standalone Decision ref: when only decision_ref is set (ENH-2535) |
Parent |
parent frontmatter |
EPIC-NNN (Title) when epic is resolvable; ID-only when not (ENH-2535) |
Blocks / Blocked by / Depends on / Relates to / Supersedes / Decomposed into / Affects / Focus area |
relationship edge frontmatter | Comma-joined IDs (ENH-2535) |
History |
## Session Log body section |
Distinct /ll:* commands with occurrence counts; omitted if absent |
Closing note / Cancellation reason / Deferral reason / Closed by / Closed at / Deferred at |
closure context frontmatter | Rendered only when status is done / cancelled / deferred; Closed at/Deferred at are date-only (ENH-2535, dates per ENH-2574) |
The relationships / capture / discovery / completion / history / closure rows above form a single column-aligned block: once it has 4 or more rows, labels right-pad to the widest label so every row's value starts at the same column (ENH-2574 item 6).
Width targets min(terminal_width() - 4, 100) — the card widens on wide
terminals instead of staying pinned to the longest structural (metadata) line,
and never exceeds terminal_width() - 4. The summary is reflowed
paragraph-first (blank lines split paragraphs; hard breaks within a paragraph
are joined) before wrapping with textwrap.wrap(). An unbreakable token wider
than the content area is truncated with … rather than bleeding past the
right border (ENH-2574).
Issue List: scripts/little_loops/cli/issues/list_cmd.py¶
cmd_list groups issues by type (BUG/FEAT/ENH/EPIC) and colorizes each row:
- Type group headers: bold + type color
- Issue IDs: type color
- Priority labels: priority color
FSM Diagram: scripts/little_loops/cli/loop/layout.py¶
_render_fsm_diagram(fsm, verbose) produces a 2D box-drawing ASCII diagram for ll-loop show.
Diagram structure¶
The renderer produces three sections:
- Main flow — the primary (happy-path) traversal rendered as a horizontal row of boxes connected by labeled arrows
- Branches — alternate forward transitions rendered below with vertical connectors
- Back-edges — transitions to earlier states (cycles) rendered below with U-routing
State box format¶
→prefix marks the initial state◉suffix marks terminal states[type]badge appears in the top border row (not the content area) — e.g.,──[prompt]──- The state name in the first content row is rendered bold for visual hierarchy
- In non-verbose mode, action is truncated to the first non-empty line with
… - Box widths are computed per-state, capped by
max_box_innerderived from terminal width
Edge arrows¶
Main-path arrows between boxes:
U-route for back-edges (main-to-main):
Vertical connectors for off-path states use │ and ▼, with separate label rows for down/up directions to prevent overlap. (▲ U+25B2 is used only in the windowed-crop overflow banner at layout.py:945.)
Self-loops render as ↺ label below the box row.
Centering¶
The entire diagram is center-indented: indent = (terminal_width - total_diagram_width) // 2
Edge colorization¶
Transition edges are colored by semantic type — both connector line characters (│, ─, ▼, ▶, corner chars) and label text:
- Color is applied at draw time: each grid character (pipe, dash, arrowhead, corner) is wrapped in
colorize(ch, code)via the_edge_line_color(label)helper. _colorize_diagram_labels(diagram)additionally post-processes the rendered string to colorize label words when bounded by box-drawing or whitespace characters._collect_edges()includeson_blocked("blocked"),on_retry_exhausted("retry_exhausted"),on_rate_limit_exhausted("rate_limit_exhausted"), andon_throttle_hard("throttle_hard") transitions in addition to the standard fields.
Default edge color mapping (see Output Color Reference > Edge colors above for ANSI codes):
| Label keyword | Color |
|---|---|
yes |
Green |
no |
Orange |
error |
Red |
blocked |
Red |
partial |
Yellow |
retry_exhausted |
Orange |
rate_limit_exhausted |
Amber |
throttle_hard |
Bold red |
next / _ (default) |
Dim |
Edge label colors are user-configurable via cli.colors.fsm_edge_labels in ll-config.json. See CONFIGURATION.md → cli.colors.fsm_edge_labels.
Note:
rate_limit_waitingis a heartbeat event emitted during rate-limit backoff, not an FSM transition edge label. It does not appear in the_EDGE_LABEL_COLORSdict and is not colorized in diagrams. Rate-limit activity is visually indicated by therate_limit_exhaustededge (Amber) when the retry budget runs out.Note:
cli.colors.fsm_edge_labelsgoverns more than diagram arrows. As of ENH-1050, the same config key also controls: - The✓/✗verdict symbol colors inStateFeedRenderer.handle_event()(theyes,no, anderrorkeys map to checkmark and x-mark colors during evaluate events) - The[TERMINAL]marker color inprint_execution_plan()(uses theyeskey, defaulting to green)
The active state highlight color is configurable via cli.colors.fsm_active_state (default: green 32). The same value drives both border coloring and interior background fill: the fg color code is automatically converted to its bg equivalent (e.g. "32" → bg "42") so all interior cells are filled with the highlight color. Border glyphs (┌ ─ ┐ │ └ ┘) also carry the bg fill (e.g. \033[32;42m│) so the colored region runs edge-to-edge with no visible gap between the fill and the border. The state name and content lines render with a bright white foreground (97) over the colored background for legibility across light and dark terminal themes. Compound ANSI codes (e.g. "38;5;208") cannot be auto-converted and fall back to border-only coloring. See CONFIGURATION.md → cli.colors.fsm_active_state.
State overview table¶
_print_state_overview_table(fsm) (cli/loop/info.py) renders a compact aligned table below the diagram:
State Type Action Preview Transitions
───────────── ────── ─────────────────────────────── ──────────────────
→ run prompt Analyze the current issue and… success──→ verify
verify shell python -m pytest scripts/... success/fail──→ ...
Column widths adapt to terminal width. Long values are truncated with … (U+2026).
Sprint Visualization: scripts/little_loops/cli/sprint/show.py¶
_render_dependency_graph(waves, dep_graph) renders an ASCII dependency graph:
_render_health_summary(...) produces a single-line sprint health status:
OK -- 8 issues in 3 waves, overlap serialized
BLOCKED -- dependency cycles detected
REVIEW -- 2 potential dependency(ies) to review
Dependency Map: scripts/little_loops/dependency_mapper/formatting.py¶
format_report(report) renders a Markdown dependency analysis report with tables.
format_text_graph(issues, proposals) renders an ASCII dependency graph with three arrow styles:
| Arrow | Meaning |
|---|---|
──→ |
Existing blocked_by dependency |
--> |
Soft depends_on prerequisite |
-.→ |
Proposed dependency |
format_epic_tree(root_id, root_info, child_map, graph, use_color) renders an EPIC child hierarchy as a Unicode box-drawing tree. Children appear with ├──/└── connectors, status badges ([done], [blocked]), and ⮡ blocks ISSUE-NNN annotations under each blocker. Ordered via DependencyGraph.topological_sort().
Issue History: scripts/little_loops/issue_history/formatting.py¶
Multiple output format functions for ll-history:
| Function | Output format |
|---|---|
format_summary_text(summary) |
Plain text with = headers (section titles) and aligned column rows |
format_summary_json(summary) |
JSON |
format_analysis_text(analysis) |
Plain text with unicode trend arrows (↑↓→) |
format_analysis_json(analysis) |
JSON |
format_analysis_yaml(analysis) |
YAML (falls back to JSON if pyyaml not installed) |
format_analysis_markdown(analysis) |
Markdown with tables and emoji badges (🔥⚡🔴🟠🟡✓⚠️) |
The Markdown formatter uses emoji only for machine-rendered output (files/PRs), not for TTY display.
Adding New Styled Output¶
- Import from
little_loops.cli.output: - Use
terminal_width()for any layout calculations — never hardcode widths. - Call
colorize(text, code)for color — it automatically no-ops when color is disabled. - For new color mappings, add to
PRIORITY_COLOR,TYPE_COLOR,CATEGORY_COLOR, orLABEL_COLORinoutput.py, or define a local dict following the same ANSI code format._smart_title()is the canonical helper for acronym-aware category/subgroup title casing; consultACRONYMSbefore adding a new acronym. - Respect
NO_COLORby routing all color throughcolorize()rather than embedding raw ANSI escapes inline.