1"""Shared CLI output utilities: terminal width, text wrapping, and ANSI color."""

2

3 from __future__ import annotations

4

5 import json

6 import os

7 import re

8 import shutil

9 import sys

10 import textwrap

11 from typing import TYPE_CHECKING, Any, Literal

12

13 if TYPE_CHECKING:

14 from little_loops.config import CliConfig

15

16

17 def terminal_size(default_cols: int = 80, default_rows: int = 24) -> tuple[int, int]:

18 """Return ``(cols, rows)`` from ``shutil.get_terminal_size``.

19

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

25

26

27 def 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]

30

31

32 def 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)

36

37

38# ---------------------------------------------------------------------------

39# ANSI escape-sequence stripping

40# ---------------------------------------------------------------------------

41

42_ANSI_RE = re.compile(r"\x1b\[[0-9;]*[a-zA-Z]")

43

44

45 def strip_ansi(text: str) -> str:

46 """Strip ANSI escape sequences from *text* and return plain text."""

47 return _ANSI_RE.sub("", text)

48

49

50# ---------------------------------------------------------------------------

51# Box-drawing character constants (Unicode box-drawing set)

52# ---------------------------------------------------------------------------

53

54 BOX_H = "─" # ─

55 BOX_V = "│" # │

56 BOX_TL = "┌" # ┌

57 BOX_TR = "┐" # ┐

58 BOX_BL = "└" # └

59 BOX_BR = "┘" # ┘

60 BOX_ML = "├" # ├

61 BOX_MR = "┤" # ┤

62

63

64# ---------------------------------------------------------------------------

65# ANSI color helpers — suppressed when NO_COLOR=1 or stdout is not a TTY

66# ---------------------------------------------------------------------------

67

68_USE_COLOR: bool = os.environ.get("FORCE_COLOR", "") == "1" or (

69 sys.stdout.isatty() and os.environ.get("NO_COLOR", "") == ""

70)

71

72 PRIORITY_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}

80 TYPE_COLOR: dict[str, str] = {

81 "BUG": "38;5;208",

82 "FEAT": "32",

83 "ENH": "34",

84 "EPIC": "35",

85}

86

87

88 def configure_output(config: CliConfig | None = None) -> None:

89 """Apply CLI color configuration to module-level color state.

90

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.

93

94 Args:

95 config: CliConfig from BRConfig.cli, or None for defaults.

96 """

97 global _USE_COLOR, PRIORITY_COLOR, TYPE_COLOR

98

99 # NO_COLOR env var always takes precedence (industry convention)

100 no_color_env = os.environ.get("NO_COLOR", "") != ""

101

102 # FORCE_COLOR=1 forces color even when stdout is not a TTY

103 force_color = os.environ.get("FORCE_COLOR", "") == "1"

104

105 if config is None:

106 _USE_COLOR = not no_color_env and (force_color or sys.stdout.isatty())

107 return

108

109 _USE_COLOR = config.color and not no_color_env and (force_color or sys.stdout.isatty())

110

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 )

122

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 )

132

133

134 def use_color_enabled() -> bool:

135 """Return the current module-level color state set by configure_output()."""

136 return _USE_COLOR

137

138

139 def 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"

144

145

146 def print_json(data: Any) -> None:

147 """Print *data* as formatted JSON to stdout."""

148 print(json.dumps(data, indent=2))

149

150

151 def 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"

166

167

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# ---------------------------------------------------------------------------

173

174_ICONS: dict[str, str] = {

175 "success": "✓", # ✓

176 "error": "✗", # ✗

177 "warning": "⚠", # ⚠

178 "info": "ℹ", # ℹ

179 "hint": "›", # ›

180}

181

182

183 def 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)

187

188

189 def 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)

193

194

195 def 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)

199

200

201 def 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)

205

206

207 def 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)

211

212

213# ---------------------------------------------------------------------------

214# Structured formatters — pure string-returning helpers

215# ---------------------------------------------------------------------------

216

217

218 def table(headers: list[str], rows: list[list[str]], max_col_width: int = 40) -> str:

219 """Return an auto-width box-drawn table string.

220

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 ""

226

227 ncols = len(headers)

228 all_cells = [headers] + rows

229

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))

235

236 col_widths = [max(3, min(w, max_col_width)) for w in col_widths]

237

238 def _cell(text: str, width: int) -> str:

239 if len(text) <= width:

240 return text.ljust(width)

241 return text[: width - 1] + "…"

242

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

246

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))

253

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)

260

261 lines.append(_sep(BOX_BL, "┴", BOX_BR))

262

263 return "\n".join(lines)

264

265

266 def status_block(items: dict[str, str]) -> str:

267 """Return aligned key-value pairs.

268

269 Keys are right-padded so values align. Returns empty string for an empty dict.

270 """

271 if not items:

272 return ""

273

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)

279

280

281 def 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

286

287 if total <= 0:

288 filled = 0

289 else:

290 filled = max(0, min(inner, round(inner * current / total)))

291

292 return "|" + "#" * filled + " " * (inner - filled) + "|"

293

294

295 def 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)

304

305

306# ---------------------------------------------------------------------------

307# Output mode toggling

308# ---------------------------------------------------------------------------

309

310_OUTPUT_MODE: Literal["human", "json", "plain"] = "human"

311

312

313 def 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

317

318

319 def get_output_mode() -> Literal["human", "json", "plain"]:

320 """Return the current global output mode."""

321 return _OUTPUT_MODE