Coverage for little_loops / cli / output.py: 31%
135 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-08 15:34 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-08 15:34 -0500
1"""Shared CLI output utilities: terminal width, text wrapping, and ANSI color."""
3from __future__ import annotations
5import json
6import os
7import re
8import shutil
9import sys
10import textwrap
11from typing import TYPE_CHECKING, Any, Literal
13if TYPE_CHECKING:
14 from little_loops.config import CliConfig
17def terminal_size(default_cols: int = 80, default_rows: int = 24) -> tuple[int, int]:
18 """Return ``(cols, rows)`` from ``shutil.get_terminal_size``.
20 Use this when layout needs both dimensions (e.g. pinned-pane decisions in
21 alt-screen mode). For column-only needs, prefer :func:`terminal_width`.
22 """
23 size = shutil.get_terminal_size((default_cols, default_rows))
24 return size.columns, size.lines
27def terminal_width(default: int = 80) -> int:
28 """Return the current terminal column width, falling back to *default*."""
29 return terminal_size(default_cols=default)[0]
32def wrap_text(text: str, indent: str = " ", width: int | None = None) -> str:
33 """Wrap *text* at terminal width with consistent *indent* on every line."""
34 w = width or terminal_width()
35 return textwrap.fill(text, width=w, initial_indent=indent, subsequent_indent=indent)
38# ---------------------------------------------------------------------------
39# ANSI escape-sequence stripping
40# ---------------------------------------------------------------------------
42_ANSI_RE = re.compile(r"\x1b\[[0-9;]*[a-zA-Z]")
45def strip_ansi(text: str) -> str:
46 """Strip ANSI escape sequences from *text* and return plain text."""
47 return _ANSI_RE.sub("", text)
50# ---------------------------------------------------------------------------
51# Box-drawing character constants (Unicode box-drawing set)
52# ---------------------------------------------------------------------------
54BOX_H = "─" # ─
55BOX_V = "│" # │
56BOX_TL = "┌" # ┌
57BOX_TR = "┐" # ┐
58BOX_BL = "└" # └
59BOX_BR = "┘" # ┘
60BOX_ML = "├" # ├
61BOX_MR = "┤" # ┤
64# ---------------------------------------------------------------------------
65# ANSI color helpers — suppressed when NO_COLOR=1 or stdout is not a TTY
66# ---------------------------------------------------------------------------
68_USE_COLOR: bool = os.environ.get("FORCE_COLOR", "") == "1" or (
69 sys.stdout.isatty() and os.environ.get("NO_COLOR", "") == ""
70)
72PRIORITY_COLOR: dict[str, str] = {
73 "P0": "38;5;208;1",
74 "P1": "38;5;208",
75 "P2": "33",
76 "P3": "0",
77 "P4": "2",
78 "P5": "2",
79}
80TYPE_COLOR: dict[str, str] = {
81 "BUG": "38;5;208",
82 "FEAT": "32",
83 "ENH": "34",
84 "EPIC": "35",
85}
88def configure_output(config: CliConfig | None = None) -> None:
89 """Apply CLI color configuration to module-level color state.
91 Call this once at startup after loading BRConfig. Updates _USE_COLOR,
92 PRIORITY_COLOR, and TYPE_COLOR based on config and NO_COLOR env var.
94 Args:
95 config: CliConfig from BRConfig.cli, or None for defaults.
96 """
97 global _USE_COLOR, PRIORITY_COLOR, TYPE_COLOR
99 # NO_COLOR env var always takes precedence (industry convention)
100 no_color_env = os.environ.get("NO_COLOR", "") != ""
102 # FORCE_COLOR=1 forces color even when stdout is not a TTY
103 force_color = os.environ.get("FORCE_COLOR", "") == "1"
105 if config is None:
106 _USE_COLOR = not no_color_env and (force_color or sys.stdout.isatty())
107 return
109 _USE_COLOR = config.color and not no_color_env and (force_color or sys.stdout.isatty())
111 # Merge custom priority colors
112 PRIORITY_COLOR.update(
113 {
114 "P0": config.colors.priority.P0,
115 "P1": config.colors.priority.P1,
116 "P2": config.colors.priority.P2,
117 "P3": config.colors.priority.P3,
118 "P4": config.colors.priority.P4,
119 "P5": config.colors.priority.P5,
120 }
121 )
123 # Merge custom type colors
124 TYPE_COLOR.update(
125 {
126 "BUG": config.colors.type.BUG,
127 "FEAT": config.colors.type.FEAT,
128 "ENH": config.colors.type.ENH,
129 "EPIC": config.colors.type.EPIC,
130 }
131 )
134def use_color_enabled() -> bool:
135 """Return the current module-level color state set by configure_output()."""
136 return _USE_COLOR
139def colorize(text: str, code: str) -> str:
140 """Wrap *text* in the given ANSI escape *code*, or return it unchanged."""
141 if not _USE_COLOR:
142 return text
143 return f"\033[{code}m{text}\033[0m"
146def print_json(data: Any) -> None:
147 """Print *data* as formatted JSON to stdout."""
148 print(json.dumps(data, indent=2))
151def format_relative_time(seconds: float) -> str:
152 """Format seconds as a human-readable relative time string (e.g., '3m ago')."""
153 total = int(seconds)
154 if total < 60:
155 return f"{total}s ago"
156 if total < 3600:
157 m, s = divmod(total, 60)
158 return f"{m}m ago" if s == 0 else f"{m}m {s}s ago"
159 if total < 86400:
160 h, rem = divmod(total, 3600)
161 m = rem // 60
162 return f"{h}h ago" if m == 0 else f"{h}h {m}m ago"
163 d, rem = divmod(total, 86400)
164 h = rem // 3600
165 return f"{d}d ago" if h == 0 else f"{d}d {h}h ago"
168# ---------------------------------------------------------------------------
169# User-facing message helpers — simple, untimestamped, icon-prefixed.
170# Distinguished from Logger (logger.py) by: no timestamps, direct stdout/stderr,
171# icons only when color is enabled.
172# ---------------------------------------------------------------------------
174_ICONS: dict[str, str] = {
175 "success": "✓", # ✓
176 "error": "✗", # ✗
177 "warning": "⚠", # ⚠
178 "info": "ℹ", # ℹ
179 "hint": "›", # ›
180}
183def success(msg: str) -> None:
184 """Print a success message to stdout."""
185 icon = f"{_ICONS['success']} " if _USE_COLOR else ""
186 print(f"{colorize(icon + msg, '32')}", flush=True)
189def error(msg: str) -> None:
190 """Print an error message to stderr."""
191 icon = f"{_ICONS['error']} " if _USE_COLOR else ""
192 print(f"{colorize(icon + msg, '38;5;208')}", file=sys.stderr, flush=True)
195def warning(msg: str) -> None:
196 """Print a warning message to stdout."""
197 icon = f"{_ICONS['warning']} " if _USE_COLOR else ""
198 print(f"{colorize(icon + msg, '33')}", flush=True)
201def info(msg: str) -> None:
202 """Print an informational message to stdout."""
203 icon = f"{_ICONS['info']} " if _USE_COLOR else ""
204 print(f"{colorize(icon + msg, '36')}", flush=True)
207def hint(msg: str) -> None:
208 """Print a hint / dim message to stdout."""
209 icon = f"{_ICONS['hint']} " if _USE_COLOR else ""
210 print(f"{colorize(icon + msg, '2')}", flush=True)
213# ---------------------------------------------------------------------------
214# Structured formatters — pure string-returning helpers
215# ---------------------------------------------------------------------------
218def table(headers: list[str], rows: list[list[str]], max_col_width: int = 40) -> str:
219 """Return an auto-width box-drawn table string.
221 Column widths are the lesser of *max_col_width* and the longest value
222 in each column. Values exceeding *max_col_width* are truncated.
223 """
224 if not headers:
225 return ""
227 ncols = len(headers)
228 all_cells = [headers] + rows
230 col_widths: list[int] = [0] * ncols
231 for row in all_cells:
232 for i, cell in enumerate(row):
233 if i < ncols:
234 col_widths[i] = max(col_widths[i], len(cell))
236 col_widths = [max(3, min(w, max_col_width)) for w in col_widths]
238 def _cell(text: str, width: int) -> str:
239 if len(text) <= width:
240 return text.ljust(width)
241 return text[: width - 1] + "…"
243 def _sep(left: str, mid: str, right: str) -> str:
244 parts = [BOX_H * w for w in col_widths]
245 return left + mid.join(parts) + right
247 lines: list[str] = []
248 lines.append(_sep(BOX_TL, "┬", BOX_TR))
249 lines.append(
250 BOX_V + BOX_V.join(_cell(h, w) for h, w in zip(headers, col_widths, strict=True)) + BOX_V
251 )
252 lines.append(_sep(BOX_ML, "┼", BOX_MR))
254 for row in rows:
255 padded = []
256 for i in range(ncols):
257 val = row[i] if i < len(row) else ""
258 padded.append(_cell(val, col_widths[i]))
259 lines.append(BOX_V + BOX_V.join(padded) + BOX_V)
261 lines.append(_sep(BOX_BL, "┴", BOX_BR))
263 return "\n".join(lines)
266def status_block(items: dict[str, str]) -> str:
267 """Return aligned key-value pairs.
269 Keys are right-padded so values align. Returns empty string for an empty dict.
270 """
271 if not items:
272 return ""
274 max_key = max(len(k) for k in items)
275 lines: list[str] = []
276 for key, value in items.items():
277 lines.append(f"{key.ljust(max_key)}: {value}")
278 return "\n".join(lines)
281def progress(current: int, total: int, width: int = 20) -> str:
282 """Return a ``|####`` |`` progress bar of *width* columns."""
283 if width < 3:
284 width = 3
285 inner = width - 2
287 if total <= 0:
288 filled = 0
289 else:
290 filled = max(0, min(inner, round(inner * current / total)))
292 return "|" + "#" * filled + " " * (inner - filled) + "|"
295def sparkline(current: int, total: int, width: int = 16) -> str:
296 """Return a Unicode block-character progress bar of *width* characters."""
297 if width < 1:
298 width = 1
299 if total <= 0:
300 filled = 0
301 else:
302 filled = max(0, min(width, round(width * current / total)))
303 return "█" * filled + "░" * (width - filled)
306# ---------------------------------------------------------------------------
307# Output mode toggling
308# ---------------------------------------------------------------------------
310_OUTPUT_MODE: Literal["human", "json", "plain"] = "human"
313def set_output_mode(mode: Literal["human", "json", "plain"]) -> None:
314 """Set the global output mode for all formatters."""
315 global _OUTPUT_MODE
316 _OUTPUT_MODE = mode
319def get_output_mode() -> Literal["human", "json", "plain"]:
320 """Return the current global output mode."""
321 return _OUTPUT_MODE