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
« 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.
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
8import itertools
9import sys
10import threading
11import time
13# ── ANSI palette ─────────────────────────────────────────────────────────────
15_RESET = "\033[0m"
16_BOLD = "\033[1m"
17_DIM = "\033[2m"
18_ITALIC = "\033[3m"
19_UNDERLINE = "\033[4m"
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"
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"
38# Background colors
39BG_GREEN = "\033[48;5;22m"
40BG_RED = "\033[48;5;52m"
41BG_YELLOW = "\033[48;5;58m"
44def _is_tty() -> bool:
45 return hasattr(sys.stdout, "isatty") and sys.stdout.isatty()
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}"
55def bold(text: str) -> str:
56 return c(_BOLD, text)
59def dim(text: str) -> str:
60 return c(_DIM, text)
63# ── Banner ───────────────────────────────────────────────────────────────────
65_VERSION = "0.3.0"
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""")
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, '|____/ \\___/ \\___|\\__,_|_| |_| |_|_|_| |_|\\__|')}
90 {c(GRAY, 'AST-powered doc drift detection')} {dim(f'v{version}')}
91""")
94# ── Dither ───────────────────────────────────────────────────────────────────
96_DITHER_PATTERN = "+ x * + x * + x * +"
97_DITHER_LIGHT = "* + x + * + x + * +"
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)
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
124def dither_light() -> str:
125 """Subtle dither for between items."""
126 return c(DARK_GRAY, " " + _DITHER_LIGHT)
129# ── Box drawing ──────────────────────────────────────────────────────────────
131BOX_TL = "\u250c"
132BOX_TR = "\u2510"
133BOX_BL = "\u2514"
134BOX_BR = "\u2518"
135BOX_H = "\u2500"
136BOX_V = "\u2502"
137BOX_LT = "\u251c"
138BOX_RT = "\u2524"
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}"
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}"
164# ── Table ────────────────────────────────────────────────────────────────────
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))
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)
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)
195# ── Spinner ──────────────────────────────────────────────────────────────────
197class Spinner:
198 """Minimal terminal spinner for long-running operations."""
200 # Dither-themed frames
201 _FRAMES = [" ", "+ ", "+x ", "+x*", " x*", " *", " "]
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
209 def __enter__(self) -> Spinner:
210 self.start()
211 return self
213 def __exit__(self, *_: object) -> None:
214 self.stop()
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()
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)
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}")
243# ── Dither progress bar ─────────────────────────────────────────────────────
245_DITHER_CHARS = " +x*x+*x+"
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}"
265# ── Status indicators ───────────────────────────────────────────────────────
267def ok(msg: str) -> str:
268 return f" {c(GREEN, '*')} {msg}"
271def warn(msg: str) -> str:
272 return f" {c(YELLOW, '!')} {msg}"
275def err(msg: str) -> str:
276 return f" {c(RED, 'x')} {msg}"
279def info(msg: str) -> str:
280 return f" {c(CYAN, '~')} {msg}"
283def step(n: int, total: int, msg: str) -> str:
284 return f" {dim(f'Step {n}/{total}')} {c(CYAN, msg)}"
287# ── Severity badges ─────────────────────────────────────────────────────────
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
303# ── Diff rendering ──────────────────────────────────────────────────────────
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}')}"
310# ── Suggestions ─────────────────────────────────────────────────────────────
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}
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()
345# ── Friendly errors ─────────────────────────────────────────────────────────
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()
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 )
363# ── Slash commands ───────────────────────────────────────────────────────────
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}
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()
388# ── Natural language intent routing ──────────────────────────────────────────
390def detect_intent(query: str) -> str | None:
391 """Detect the user's intent from a natural language query.
393 Returns a command name or None if unrecognized.
394 """
395 q = query.lower().strip()
397 # Drift / staleness
398 if any(w in q for w in ["stale", "drift", "outdated", "out of date", "lying", "wrong"]):
399 return "drift"
401 # Coverage
402 if any(w in q for w in ["coverage", "documented", "how much", "undocumented", "missing"]):
403 return "coverage"
405 # Patch / fix
406 if any(w in q for w in ["patch", "fix", "update docs", "propose", "draft"]):
407 return "propose"
409 # Watch
410 if any(w in q for w in ["watch", "monitor", "live", "real-time", "realtime"]):
411 return "watch"
413 # Scan / snapshot
414 if any(w in q for w in ["scan", "snapshot", "check", "analyze"]):
415 return "scan"
417 # Status
418 if any(w in q for w in ["status", "health", "how are", "overview", "summary"]):
419 return "scan"
421 return None
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()