Coverage for src / documint_mcp / cli_ui.py: 0%

228 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-03-29 10:41 -0400

1"""CLI visual toolkit — branded output, spinners, dither, and formatting. 

2 

3All terminal rendering lives here so cli.py stays focused on logic. 

4Zero external dependencies — uses only ANSI escape codes + stdlib. 

5""" 

6from __future__ import annotations 

7 

8import itertools 

9import sys 

10import threading 

11import time 

12 

13# ── ANSI palette ───────────────────────────────────────────────────────────── 

14 

15_RESET = "\033[0m" 

16_BOLD = "\033[1m" 

17_DIM = "\033[2m" 

18_ITALIC = "\033[3m" 

19_UNDERLINE = "\033[4m" 

20 

21# Semantic colors 

22GREEN = "\033[38;5;114m" 

23RED = "\033[38;5;203m" 

24YELLOW = "\033[38;5;221m" 

25CYAN = "\033[38;5;117m" 

26BLUE = "\033[38;5;75m" 

27MAGENTA = "\033[38;5;176m" 

28WHITE = "\033[38;5;255m" 

29GRAY = "\033[38;5;245m" 

30DARK_GRAY = "\033[38;5;240m" 

31 

32# Brand gradient (mint/teal tones matching the leaf) 

33MINT_1 = "\033[38;5;48m" 

34MINT_2 = "\033[38;5;42m" 

35MINT_3 = "\033[38;5;36m" 

36MINT_4 = "\033[38;5;30m" 

37 

38# Background colors 

39BG_GREEN = "\033[48;5;22m" 

40BG_RED = "\033[48;5;52m" 

41BG_YELLOW = "\033[48;5;58m" 

42 

43 

44def _is_tty() -> bool: 

45 return hasattr(sys.stdout, "isatty") and sys.stdout.isatty() 

46 

47 

48def c(color: str, text: str) -> str: 

49 """Colorize text if stdout is a TTY.""" 

50 if not _is_tty(): 

51 return text 

52 return f"{color}{text}{_RESET}" 

53 

54 

55def bold(text: str) -> str: 

56 return c(_BOLD, text) 

57 

58 

59def dim(text: str) -> str: 

60 return c(_DIM, text) 

61 

62 

63# ── Banner ─────────────────────────────────────────────────────────────────── 

64 

65_VERSION = "0.3.0" 

66 

67 

68def print_banner(version: str = _VERSION) -> None: 

69 """Print the compact branded banner.""" 

70 if not _is_tty(): 

71 return 

72 print(f""" 

73{c(MINT_2, ' .')} {c(MINT_3, '. :')} {c(_BOLD, 'documint')} {c(DARK_GRAY, f'v{version}')} 

74{c(MINT_3, ' .:')}{c(MINT_4, '*:.')}{c(_RESET, '')} {c(GRAY, 'AST-powered doc drift detection')} 

75{c(MINT_4, ' :.')} 

76""") 

77 

78 

79def print_banner_full(version: str = _VERSION) -> None: 

80 """Print the full ASCII art banner.""" 

81 if not _is_tty(): 

82 return 

83 print(f""" 

84{c(MINT_1, ' ____ _ _ ')} 

85{c(MINT_2, '| _ \\ ___ ___ _ _ _ __ ___ (_)_ __ | |_ ')} 

86{c(MINT_3, '| | | |/ _ \\ / __| | | | _ ` _ \\| | _ \\| __|')} 

87{c(MINT_3, '| |_| | (_) | (__| |_| | | | | | | | | | | |_ ')} 

88{c(MINT_4, '|____/ \\___/ \\___|\\__,_|_| |_| |_|_|_| |_|\\__|')} 

89 

90 {c(GRAY, 'AST-powered doc drift detection')} {dim(f'v{version}')} 

91""") 

92 

93 

94# ── Dither ─────────────────────────────────────────────────────────────────── 

95 

96_DITHER_PATTERN = "+ x * + x * + x * +" 

97_DITHER_LIGHT = "* + x + * + x + * +" 

98 

99 

100def dither_divider(width: int = 60) -> str: 

101 """Generate a dither divider line.""" 

102 segment = _DITHER_PATTERN + " " 

103 repeated = (segment * ((width // len(segment)) + 1))[:width] 

104 return c(DARK_GRAY, repeated) 

105 

106 

107def dither_divider_mint(width: int = 60) -> str: 

108 """Dither divider with mint color gradient.""" 

109 if not _is_tty(): 

110 return dither_divider(width) 

111 segment_w = max(1, width // 7) 

112 parts = [ 

113 f"{MINT_4}{'+ ' * (segment_w // 2)}", 

114 f"{MINT_3}{'x ' * (segment_w // 2)}", 

115 f"{MINT_2}{'* ' * (segment_w // 2)}", 

116 f"{MINT_1}{'x ' * (segment_w // 2)}", 

117 f"{MINT_2}{'* ' * (segment_w // 2)}", 

118 f"{MINT_3}{'x ' * (segment_w // 2)}", 

119 f"{MINT_4}{'+ ' * (segment_w // 2)}", 

120 ] 

121 return "".join(parts) + _RESET 

122 

123 

124def dither_light() -> str: 

125 """Subtle dither for between items.""" 

126 return c(DARK_GRAY, " " + _DITHER_LIGHT) 

127 

128 

129# ── Box drawing ────────────────────────────────────────────────────────────── 

130 

131BOX_TL = "\u250c" 

132BOX_TR = "\u2510" 

133BOX_BL = "\u2514" 

134BOX_BR = "\u2518" 

135BOX_H = "\u2500" 

136BOX_V = "\u2502" 

137BOX_LT = "\u251c" 

138BOX_RT = "\u2524" 

139 

140 

141def box(title: str, content: str, width: int = 62, color: str = CYAN) -> str: 

142 """Wrap content in a box-drawn frame.""" 

143 title_display = f" {title} " 

144 fill = max(0, width - len(title_display) - 2) 

145 top = c(color, f"{BOX_TL}{BOX_H}{title_display}{BOX_H * fill}{BOX_TR}") 

146 bottom = c(color, f"{BOX_BL}{BOX_H * (width)}{BOX_BR}") 

147 lines = content.split("\n") 

148 body_lines = [] 

149 for line in lines: 

150 pad = max(0, width - 2 - len(line)) 

151 body_lines.append(f"{c(color, BOX_V)} {line}{' ' * pad}{c(color, BOX_V)}") 

152 return f"{top}\n" + "\n".join(body_lines) + f"\n{bottom}" 

153 

154 

155def dither_box(title: str, content: str) -> str: 

156 """Render content with dithered borders.""" 

157 border = c(DARK_GRAY, "+ x * + " + "x " * 20 + "* + x +") 

158 title_line = f"{c(DARK_GRAY, ':')} {bold(title)}" 

159 lines = content.split("\n") 

160 body = "\n".join(f"{c(DARK_GRAY, ':')} {line}" for line in lines) 

161 return f"{border}\n{title_line}\n{c(DARK_GRAY, ':')}\n{body}\n{border}" 

162 

163 

164# ── Table ──────────────────────────────────────────────────────────────────── 

165 

166def table(headers: list[str], rows: list[list[str]], col_colors: list[str] | None = None) -> str: 

167 """Render a simple aligned table.""" 

168 if not rows: 

169 return "" 

170 widths = [len(h) for h in headers] 

171 for row in rows: 

172 for i, cell in enumerate(row): 

173 if i < len(widths): 

174 # Strip ANSI for width calculation 

175 clean = cell 

176 for code in [GREEN, RED, YELLOW, CYAN, BLUE, MAGENTA, WHITE, GRAY, DARK_GRAY, 

177 MINT_1, MINT_2, MINT_3, MINT_4, _BOLD, _DIM, _ITALIC, _RESET]: 

178 clean = clean.replace(code, "") 

179 widths[i] = max(widths[i], len(clean)) 

180 

181 header_line = " ".join(f"{c(_BOLD, h):<{widths[i] + (len(c(_BOLD, '')) - len(''))}}" if _is_tty() 

182 else f"{h:<{widths[i]}}" 

183 for i, h in enumerate(headers)) 

184 sep = " ".join(BOX_H * w for w in widths) 

185 

186 lines = [f" {header_line}", f" {c(DARK_GRAY, sep)}"] 

187 for row in rows: 

188 cells = [] 

189 for i, cell in enumerate(row): 

190 cells.append(cell) 

191 lines.append(" " + " ".join(cells)) 

192 return "\n".join(lines) 

193 

194 

195# ── Spinner ────────────────────────────────────────────────────────────────── 

196 

197class Spinner: 

198 """Minimal terminal spinner for long-running operations.""" 

199 

200 # Dither-themed frames 

201 _FRAMES = [" ", "+ ", "+x ", "+x*", " x*", " *", " "] 

202 

203 def __init__(self, message: str, color: str = CYAN) -> None: 

204 self._message = message 

205 self._color = color 

206 self._running = False 

207 self._thread: threading.Thread | None = None 

208 

209 def __enter__(self) -> Spinner: 

210 self.start() 

211 return self 

212 

213 def __exit__(self, *_: object) -> None: 

214 self.stop() 

215 

216 def start(self) -> None: 

217 if not _is_tty(): 

218 print(f" {self._message}...") 

219 return 

220 self._running = True 

221 self._thread = threading.Thread(target=self._spin, daemon=True) 

222 self._thread.start() 

223 

224 def _spin(self) -> None: 

225 for frame in itertools.cycle(self._FRAMES): 

226 if not self._running: 

227 break 

228 sys.stdout.write(f"\r {self._color}{frame}{_RESET} {self._message}") 

229 sys.stdout.flush() 

230 time.sleep(0.12) 

231 

232 def stop(self, result: str = "") -> None: 

233 self._running = False 

234 if self._thread: 

235 self._thread.join(timeout=1) 

236 if _is_tty(): 

237 sys.stdout.write(f"\r {GREEN}*{_RESET} {self._message} {result}\n") 

238 sys.stdout.flush() 

239 elif result: 

240 print(f" * {self._message} {result}") 

241 

242 

243# ── Dither progress bar ───────────────────────────────────────────────────── 

244 

245_DITHER_CHARS = " +x*x+*x+" 

246 

247 

248def progress_bar(current: int, total: int, width: int = 30) -> str: 

249 """Render a dither-style progress bar.""" 

250 if total == 0: 

251 return "" 

252 ratio = min(current / total, 1.0) 

253 filled = int(ratio * width) 

254 bar_chars = "" 

255 for i in range(width): 

256 if i < filled: 

257 density = min(len(_DITHER_CHARS) - 1, int((i / width) * len(_DITHER_CHARS))) 

258 bar_chars += _DITHER_CHARS[density] 

259 else: 

260 bar_chars += " " 

261 pct = f"{ratio * 100:5.1f}%" 

262 return f" {c(MINT_2, f'[{bar_chars}]')} {pct}" 

263 

264 

265# ── Status indicators ─────────────────────────────────────────────────────── 

266 

267def ok(msg: str) -> str: 

268 return f" {c(GREEN, '*')} {msg}" 

269 

270 

271def warn(msg: str) -> str: 

272 return f" {c(YELLOW, '!')} {msg}" 

273 

274 

275def err(msg: str) -> str: 

276 return f" {c(RED, 'x')} {msg}" 

277 

278 

279def info(msg: str) -> str: 

280 return f" {c(CYAN, '~')} {msg}" 

281 

282 

283def step(n: int, total: int, msg: str) -> str: 

284 return f" {dim(f'Step {n}/{total}')} {c(CYAN, msg)}" 

285 

286 

287# ── Severity badges ───────────────────────────────────────────────────────── 

288 

289def severity_badge(level: str) -> str: 

290 """Colored severity indicator.""" 

291 level_up = level.upper() 

292 if level_up == "HIGH": 

293 return c(RED, "HIGH") 

294 elif level_up == "MEDIUM": 

295 return c(YELLOW, "MEDIUM") 

296 elif level_up == "LOW": 

297 return c(GRAY, "LOW") 

298 elif level_up in ("FRESH", "PASS", "OK"): 

299 return c(GREEN, level_up) 

300 return level_up 

301 

302 

303# ── Diff rendering ────────────────────────────────────────────────────────── 

304 

305def render_diff(old_line: str, new_line: str) -> str: 

306 """Render a colored diff pair.""" 

307 return f" {c(RED, f'- {old_line}')}\n {c(GREEN, f'+ {new_line}')}" 

308 

309 

310# ── Suggestions ───────────────────────────────────────────────────────────── 

311 

312_NEXT_STEPS: dict[str, list[tuple[str, str]]] = { 

313 "scan": [ 

314 ("Run drift detection", "documint drift"), 

315 ("Check doc coverage", "documint coverage"), 

316 ], 

317 "drift": [ 

318 ("Draft a patch for a finding", "documint propose --finding-id <id>"), 

319 ("Start watching for changes", "documint watch"), 

320 ], 

321 "propose": [ 

322 ("Publish updated docs", "documint publish"), 

323 ("Run full drift check", "documint drift"), 

324 ], 

325 "coverage": [ 

326 ("Fix gaps with AI patches", "documint propose --artifact-id <id>"), 

327 ("Start watching", "documint watch"), 

328 ], 

329 "watch": [], 

330 "ci": [], 

331} 

332 

333 

334def suggest_next(command: str) -> None: 

335 """Print next-step suggestions for a command.""" 

336 steps = _NEXT_STEPS.get(command, []) 

337 if not steps or not _is_tty(): 

338 return 

339 print(f"\n {c(GRAY, 'Next steps:')}") 

340 for desc, cmd in steps: 

341 print(f" {c(CYAN, '->')} {desc}: {c(BLUE, cmd)}") 

342 print() 

343 

344 

345# ── Friendly errors ───────────────────────────────────────────────────────── 

346 

347def friendly_error(message: str, hint: str = "") -> None: 

348 """Print a conversational error message.""" 

349 print(f"\n{err(message)}") 

350 if hint: 

351 print(f" {c(GRAY, 'Hint:')} {hint}") 

352 print() 

353 

354 

355def api_unreachable(url: str) -> None: 

356 """Print a friendly API connection error.""" 

357 friendly_error( 

358 f"Could not reach the Documint API at {url}", 

359 hint="Is the server running? Try: documint-server", 

360 ) 

361 

362 

363# ── Slash commands ─────────────────────────────────────────────────────────── 

364 

365SLASH_COMMANDS: dict[str, str] = { 

366 "/drift": "Run drift detection on the current project", 

367 "/patch": "Generate a doc patch for a finding", 

368 "/watch": "Start watching for file changes", 

369 "/status": "Show project status and recent activity", 

370 "/coverage": "Show documentation coverage report", 

371 "/scan": "Snapshot the current project state", 

372 "/help": "Show available commands", 

373 "/quit": "Exit interactive mode", 

374} 

375 

376 

377def print_slash_help() -> None: 

378 """Print slash command reference.""" 

379 print(f"\n {bold('Available commands:')}\n") 

380 for cmd, desc in SLASH_COMMANDS.items(): 

381 print(f" {c(CYAN, f'{cmd:<12}')} {desc}") 

382 print(f"\n {c(GRAY, 'Or type a question in natural language:')}") 

383 print(f' {c(MAGENTA, '"what docs are stale?"')}') 

384 print(f' {c(MAGENTA, '"show me the drift for auth.py"')}') 

385 print() 

386 

387 

388# ── Natural language intent routing ────────────────────────────────────────── 

389 

390def detect_intent(query: str) -> str | None: 

391 """Detect the user's intent from a natural language query. 

392 

393 Returns a command name or None if unrecognized. 

394 """ 

395 q = query.lower().strip() 

396 

397 # Drift / staleness 

398 if any(w in q for w in ["stale", "drift", "outdated", "out of date", "lying", "wrong"]): 

399 return "drift" 

400 

401 # Coverage 

402 if any(w in q for w in ["coverage", "documented", "how much", "undocumented", "missing"]): 

403 return "coverage" 

404 

405 # Patch / fix 

406 if any(w in q for w in ["patch", "fix", "update docs", "propose", "draft"]): 

407 return "propose" 

408 

409 # Watch 

410 if any(w in q for w in ["watch", "monitor", "live", "real-time", "realtime"]): 

411 return "watch" 

412 

413 # Scan / snapshot 

414 if any(w in q for w in ["scan", "snapshot", "check", "analyze"]): 

415 return "scan" 

416 

417 # Status 

418 if any(w in q for w in ["status", "health", "how are", "overview", "summary"]): 

419 return "scan" 

420 

421 return None 

422 

423 

424def print_intent_help() -> None: 

425 """Print what the CLI understands.""" 

426 print(f" {c(GRAY, 'I understand questions about:')}") 

427 print(f" {c(CYAN, '*')} documentation drift and staleness") 

428 print(f" {c(CYAN, '*')} coverage and undocumented symbols") 

429 print(f" {c(CYAN, '*')} patching and fixing stale docs") 

430 print(f" {c(CYAN, '*')} watching for real-time changes") 

431 print(f" {c(CYAN, '*')} project health and status") 

432 example1 = '"what docs are stale?"' 

433 example2 = '"show me coverage"' 

434 print(f"\n {c(GRAY, 'Try:')} documint {c(MAGENTA, example1)}") 

435 print(f" documint {c(MAGENTA, example2)}") 

436 print()