Coverage for little_loops / cli / output.py: 0%
48 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-05-28 13:07 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-05-28 13:07 -0500
1"""Shared CLI output utilities: terminal width, text wrapping, and ANSI color."""
3from __future__ import annotations
5import json
6import os
7import shutil
8import sys
9import textwrap
10from typing import TYPE_CHECKING, Any
12if TYPE_CHECKING:
13 from little_loops.config import CliConfig
16def terminal_size(default_cols: int = 80, default_rows: int = 24) -> tuple[int, int]:
17 """Return ``(cols, rows)`` from ``shutil.get_terminal_size``.
19 Use this when layout needs both dimensions (e.g. pinned-pane decisions in
20 alt-screen mode). For column-only needs, prefer :func:`terminal_width`.
21 """
22 size = shutil.get_terminal_size((default_cols, default_rows))
23 return size.columns, size.lines
26def terminal_width(default: int = 80) -> int:
27 """Return the current terminal column width, falling back to *default*."""
28 return terminal_size(default_cols=default)[0]
31def wrap_text(text: str, indent: str = " ", width: int | None = None) -> str:
32 """Wrap *text* at terminal width with consistent *indent* on every line."""
33 w = width or terminal_width()
34 return textwrap.fill(text, width=w, initial_indent=indent, subsequent_indent=indent)
37# ---------------------------------------------------------------------------
38# ANSI color helpers — suppressed when NO_COLOR=1 or stdout is not a TTY
39# ---------------------------------------------------------------------------
41_USE_COLOR: bool = sys.stdout.isatty() and os.environ.get("NO_COLOR", "") == ""
43PRIORITY_COLOR: dict[str, str] = {
44 "P0": "38;5;208;1",
45 "P1": "38;5;208",
46 "P2": "33",
47 "P3": "0",
48 "P4": "2",
49 "P5": "2",
50}
51TYPE_COLOR: dict[str, str] = {
52 "BUG": "38;5;208",
53 "FEAT": "32",
54 "ENH": "34",
55 "EPIC": "35",
56}
59def configure_output(config: CliConfig | None = None) -> None:
60 """Apply CLI color configuration to module-level color state.
62 Call this once at startup after loading BRConfig. Updates _USE_COLOR,
63 PRIORITY_COLOR, and TYPE_COLOR based on config and NO_COLOR env var.
65 Args:
66 config: CliConfig from BRConfig.cli, or None for defaults.
67 """
68 global _USE_COLOR, PRIORITY_COLOR, TYPE_COLOR
70 # NO_COLOR env var always takes precedence (industry convention)
71 no_color_env = os.environ.get("NO_COLOR", "") != ""
73 if config is None:
74 _USE_COLOR = sys.stdout.isatty() and not no_color_env
75 return
77 _USE_COLOR = config.color and sys.stdout.isatty() and not no_color_env
79 # Merge custom priority colors
80 PRIORITY_COLOR.update(
81 {
82 "P0": config.colors.priority.P0,
83 "P1": config.colors.priority.P1,
84 "P2": config.colors.priority.P2,
85 "P3": config.colors.priority.P3,
86 "P4": config.colors.priority.P4,
87 "P5": config.colors.priority.P5,
88 }
89 )
91 # Merge custom type colors
92 TYPE_COLOR.update(
93 {
94 "BUG": config.colors.type.BUG,
95 "FEAT": config.colors.type.FEAT,
96 "ENH": config.colors.type.ENH,
97 "EPIC": config.colors.type.EPIC,
98 }
99 )
102def use_color_enabled() -> bool:
103 """Return the current module-level color state set by configure_output()."""
104 return _USE_COLOR
107def colorize(text: str, code: str) -> str:
108 """Wrap *text* in the given ANSI escape *code*, or return it unchanged."""
109 if not _USE_COLOR:
110 return text
111 return f"\033[{code}m{text}\033[0m"
114def print_json(data: Any) -> None:
115 """Print *data* as formatted JSON to stdout."""
116 print(json.dumps(data, indent=2))
119def format_relative_time(seconds: float) -> str:
120 """Format seconds as a human-readable relative time string (e.g., '3m ago')."""
121 total = int(seconds)
122 if total < 60:
123 return f"{total}s ago"
124 if total < 3600:
125 m, s = divmod(total, 60)
126 return f"{m}m ago" if s == 0 else f"{m}m {s}s ago"
127 if total < 86400:
128 h, rem = divmod(total, 3600)
129 m = rem // 60
130 return f"{h}h ago" if m == 0 else f"{h}h {m}m ago"
131 d, rem = divmod(total, 86400)
132 h = rem // 3600
133 return f"{d}d ago" if h == 0 else f"{d}d {h}h ago"