spych.cli_tools
1import sys, time, threading, re, random 2 3from spych.spinners import Spinner 4 5# Helper to strip ANSI escape codes before measuring string length, 6# so box-drawing alignment is based on visible characters only. 7_ANSI_ESCAPE_RE = re.compile(r"\033\[[0-9;]*m") 8 9 10def _visible_len(text: str) -> int: 11 return len(_ANSI_ESCAPE_RE.sub("", text)) 12 13 14# --------------------------------------------------------------------------- 15# Theme system 16# --------------------------------------------------------------------------- 17 18# Escape-code constants used only inside this module to build the palettes. 19_RESET = "\033[0m" 20_BOLD = "\033[1m" 21_DIM = "\033[2m" 22_ITALIC = "\033[3m" 23 24 25class Theme: 26 """ 27 Holds the active color palette. Four built-in themes are provided: 28 29 - ``"dark"`` (default) — bright colors on dark backgrounds. 30 - ``"light"`` — deep, high-contrast colors on light/white backgrounds. 31 - ``"solarized"`` — Solarized-Dark accent palette; comfortable for long sessions. 32 - ``"mono"`` — greyscale only; no color, just bold/dim contrast. 33 34 Every role needed by ``CliSpinner`` and ``CliPrinter`` lives here, 35 including the formatting escapes (``reset``, ``bold``, ``italic``) so 36 nothing outside this class references raw ANSI strings. 37 """ 38 39 THEMES: dict[str, dict] = { 40 # ------------------------------------------------------------------ 41 # dark — original palette, bright on dark 42 # ------------------------------------------------------------------ 43 "dark": { 44 # formatting 45 "reset": _RESET, 46 "bold": _BOLD, 47 "italic": _ITALIC, 48 # structural 49 "chrome": "\033[90m", # dark-gray — borders, dividers 50 "body": "\033[97m", # bright white — primary text 51 "dim": _DIM, # secondary / de-emphasised 52 # semantic 53 "accent": "\033[96m", # bright cyan — brand accent 54 "highlight": "\033[95m", # bright magenta — speaker label 55 "running": "\033[93m", # bright yellow — in-progress 56 "success": "\033[92m", # bright green — completed 57 "error": "\033[91m", # bright red — failures 58 # spinner 59 "spinner_colors": [ 60 "\033[96m", # bright cyan 61 "\033[94m", # bright blue 62 "\033[95m", # bright magenta 63 "\033[96m", 64 ], 65 }, 66 # ------------------------------------------------------------------ 67 # light — readable on white/light backgrounds 68 # ------------------------------------------------------------------ 69 "light": { 70 "reset": _RESET, 71 "bold": _BOLD, 72 "italic": _ITALIC, 73 "chrome": "\033[90m", # dark-gray borders 74 "body": "\033[30m", # black text 75 "dim": _DIM, 76 "accent": "\033[36m", # teal 77 "highlight": "\033[35m", # magenta/purple 78 "running": "\033[33m", # amber 79 "success": "\033[32m", # dark green 80 "error": "\033[31m", # dark red 81 "spinner_colors": [ 82 "\033[36m", # teal 83 "\033[34m", # blue 84 "\033[35m", # magenta 85 "\033[36m", 86 ], 87 }, 88 # ------------------------------------------------------------------ 89 # solarized — Solarized-Dark accent colors, muted base 90 # ------------------------------------------------------------------ 91 "solarized": { 92 "reset": _RESET, 93 "bold": _BOLD, 94 "italic": _ITALIC, 95 "chrome": "\033[38;5;240m", # base01 — subtle borders 96 "body": "\033[38;5;252m", # base2 — primary text 97 "dim": _DIM, 98 "accent": "\033[38;5;37m", # cyan (#2aa198) 99 "highlight": "\033[38;5;125m", # magenta (#d33682) 100 "running": "\033[38;5;136m", # yellow (#b58900) 101 "success": "\033[38;5;64m", # green (#859900) 102 "error": "\033[38;5;160m", # red (#dc322f) 103 "spinner_colors": [ 104 "\033[38;5;37m", # cyan 105 "\033[38;5;33m", # blue (#268bd2) 106 "\033[38;5;61m", # violet (#6c71c4) 107 "\033[38;5;37m", 108 ], 109 }, 110 # ------------------------------------------------------------------ 111 # mono — greyscale; bold/dim contrast only, no hue 112 # ------------------------------------------------------------------ 113 "mono": { 114 "reset": _RESET, 115 "bold": _BOLD, 116 "italic": _ITALIC, 117 "chrome": "\033[90m", # dark gray 118 "body": "\033[97m", # bright white 119 "dim": _DIM, 120 "accent": _BOLD, # bold, no hue 121 "highlight": _BOLD, 122 "running": "\033[97m", 123 "success": "\033[97m", 124 "error": _BOLD, 125 "spinner_colors": [ 126 "\033[97m", # bright white 127 "\033[37m", # light gray 128 "\033[90m", # dark gray 129 "\033[97m", 130 ], 131 }, 132 } 133 134 VALID: tuple[str, ...] = tuple(THEMES.keys()) 135 136 def __init__(self) -> None: 137 self._palette: dict = self.THEMES["dark"] 138 139 # Convenience accessors ------------------------------------------------- 140 141 @property 142 def reset(self) -> str: 143 return self._palette["reset"] 144 145 @property 146 def bold(self) -> str: 147 return self._palette["bold"] 148 149 @property 150 def italic(self) -> str: 151 return self._palette["italic"] 152 153 @property 154 def chrome(self) -> str: 155 return self._palette["chrome"] 156 157 @property 158 def body(self) -> str: 159 return self._palette["body"] 160 161 @property 162 def dim(self) -> str: 163 return self._palette["dim"] 164 165 @property 166 def accent(self) -> str: 167 return self._palette["accent"] 168 169 @property 170 def highlight(self) -> str: 171 return self._palette["highlight"] 172 173 @property 174 def running(self) -> str: 175 return self._palette["running"] 176 177 @property 178 def success(self) -> str: 179 return self._palette["success"] 180 181 @property 182 def error(self) -> str: 183 return self._palette["error"] 184 185 @property 186 def spinner_colors(self) -> list[str]: 187 return self._palette["spinner_colors"] 188 189 # Mutation -------------------------------------------------------------- 190 191 def apply(self, name: str) -> None: 192 """Switch the active theme by name.""" 193 if name not in self.THEMES: 194 raise ValueError( 195 f"Unknown theme {name!r}. Choose one of: {', '.join(self.VALID)}" 196 ) 197 self._palette = self.THEMES[name] 198 199 200# Module-level singleton consumed by CliSpinner and CliPrinter. 201theme = Theme() 202 203 204def set_theme(name: str) -> None: 205 """ 206 Switch the global CLI color theme. 207 208 Call this **once**, before any output is produced — typically right after 209 argument parsing in ``cli.py``. 210 211 Requires: 212 213 - ``name``: 214 - Type: ``str`` 215 - What: One of ``"dark"`` (default), ``"light"``, ``"solarized"``, 216 or ``"mono"`` 217 """ 218 theme.apply(name) 219 220 221# --------------------------------------------------------------------------- 222 223 224class CliSpinner: 225 """ 226 Animated terminal spinner that runs on a background thread. 227 Call .start(message) and .stop() around blocking work. 228 """ 229 230 DEFAULT_VERBS = [ 231 "thinking", 232 "vibing", 233 "pontificating", 234 "contemplating", 235 "deliberating", 236 "cogitating", 237 "ruminating", 238 "musing", 239 "ideating", 240 "postulating", 241 "hypothesizing", 242 "extrapolating", 243 "philosophizing", 244 "noodling", 245 "percolating", 246 "marinating", 247 "stewing", 248 "scheming", 249 "conniving", 250 "divining", 251 "spelunking", 252 "ratiocinating", 253 "cerebrating", 254 "woolgathering", 255 "daydreaming", 256 "lucubrating", 257 "excogitating", 258 "thinkulating", 259 "brainwaving", 260 "cogitronning", 261 "synapsing", 262 "thoughtcrafting", 263 "mindweaving", 264 "intellectualizing", 265 "computating", 266 "ponderizing", 267 "mentalating", 268 "brainbrewing", 269 ] 270 271 def __init__(self) -> None: 272 self._thread: threading.Thread | None = None 273 self._stop_event = threading.Event() 274 self._message = "" 275 self._verb_thread: threading.Thread | None = None 276 self._running = False 277 self._frames = Spinner.BRAILLE 278 279 # ------------------------------------------------------------------ 280 # Public API 281 # ------------------------------------------------------------------ 282 283 def start( 284 self, 285 message: str | None = None, 286 spinner: list[str] | None = None, 287 ) -> None: 288 """ 289 Start the spinner. 290 291 Optional: 292 293 - ``message``: 294 - Type: str | None 295 - What: Text displayed beside the spinner frame. 296 - Default: None (keeps previous message) 297 298 - ``spinner``: 299 - Type: list[str] | None 300 - What: Spinner Frames to use 301 - Default: None (Braille) 302 """ 303 if self._thread and self._thread.is_alive(): 304 self.stop() 305 self._running = True 306 self._stop_event.clear() 307 if spinner is not None: 308 self._frames = spinner 309 if message: 310 self._message = message 311 self._thread = threading.Thread(target=self._spin, daemon=True) 312 self._thread.start() 313 314 def start_with_verbs( 315 self, 316 name: str, 317 verbs: list[str] | None = None, 318 interval: float = 10.0, 319 spinner: list[str] | None = None, 320 ) -> None: 321 """ 322 Start the spinner with a cycling verb message: "<name> is <verb>". 323 The verb rotates through `verbs` every `interval` seconds. 324 325 Requires: 326 327 - `name`: 328 - Type: str 329 - What: The subject displayed before the verb (e.g. "Claude") 330 331 Optional: 332 333 - `verbs`: 334 - Type: list[str] | None 335 - What: Verbs to cycle through. Defaults to CliSpinner.DEFAULT_VERBS 336 - Default: None 337 338 - `interval`: 339 - Type: float 340 - What: Seconds between each verb swap 341 - Default: 10.0 342 343 - `spinner`: 344 - Type: list[str] | None 345 - What: Frame list or None to not change. 346 - Default: None 347 """ 348 verbs = verbs if verbs is not None else self.DEFAULT_VERBS 349 350 def _get_random_message(): 351 idx = random.randrange(len(verbs)) 352 return f"{name} is {verbs[idx]}" 353 354 def _get_random_spinner(): 355 options = [ 356 Spinner.ARC, 357 Spinner.CIRCLE_ARCS, 358 Spinner.CIRCLE_FILL, 359 Spinner.LINE, 360 Spinner.DOT_PULSE, 361 Spinner.BOUNCE, 362 Spinner.BLOCK, 363 ] 364 idx = random.randrange(len(options)) 365 return options[idx] 366 367 self.start(_get_random_message(), spinner=_get_random_spinner()) 368 369 def _verb_cycle() -> None: 370 while not self._stop_event.wait(timeout=interval): 371 # Update message and spinner frames directly instead of 372 # calling start(), which would call stop() and try to 373 # join this thread — causing "cannot join current thread". 374 self._message = _get_random_message() 375 self._frames = _get_random_spinner() 376 377 self._verb_thread = threading.Thread(target=_verb_cycle, daemon=True) 378 self._verb_thread.start() 379 380 def stop(self, final_message: str | None = None) -> None: 381 was_running = self._running 382 self._running = False 383 self._stop_event.set() 384 if self._thread and self._thread.is_alive(): 385 self._thread.join() 386 self._thread = None 387 # Don't join verb_thread if we're being called from within it — 388 # that would raise "cannot join current thread". 389 if ( 390 self._verb_thread 391 and self._verb_thread.is_alive() 392 and self._verb_thread is not threading.current_thread() 393 ): 394 self._verb_thread.join() 395 self._verb_thread = None 396 sys.stdout.write("\r\033[2K") 397 sys.stdout.flush() 398 if final_message: 399 print(final_message) 400 return was_running 401 402 def _spin(self) -> None: 403 frame_idx = 0 404 color_idx = 0 405 dot_count = 0 406 frames = self._frames # snapshot so swaps don't mid-spin 407 while not self._stop_event.is_set(): 408 frame = frames[frame_idx % len(frames)] 409 colors = theme.spinner_colors 410 color = colors[color_idx % len(colors)] 411 dots = "." * (dot_count % 4) 412 413 visible_content = f" {frame} {self._message}{dots:<3}" 414 padding = max(0, 60 - _visible_len(visible_content)) * " " 415 line = ( 416 f"\r {color}{theme.bold}{frame}{theme.reset} " 417 f"{theme.body}{self._message}{theme.chrome}{dots:<3}{theme.reset}" 418 f"{padding}" 419 ) 420 sys.stdout.write(line) 421 sys.stdout.flush() 422 423 time.sleep(0.12) 424 frame_idx += 1 425 if frame_idx % 5 == 0: 426 dot_count += 1 427 if frame_idx % 20 == 0: 428 color_idx += 1 429 430 # Re-read frames each tick so verb-cycle updates take effect 431 frames = self._frames 432 433 434class CliPrinter: 435 @staticmethod 436 def divider( 437 char: str = "─", width: int = 60, color: str | None = None 438 ) -> None: 439 color = color if color is not None else theme.accent 440 print(f"{color}{char * width}{theme.reset}") 441 442 @staticmethod 443 def empty_line() -> None: 444 """Create an empty line for spacing.""" 445 print() 446 447 @staticmethod 448 def header(label: str) -> None: 449 inner = ( 450 f" {theme.accent}{theme.bold}Spych{theme.reset}" 451 f": {theme.body}{label}{theme.reset}" 452 ) 453 pad = max(0, 58 - _visible_len(inner)) 454 print( 455 f"\n{theme.chrome}┌{'─' * 58}┐{theme.reset}\n" 456 f"{theme.chrome}│{theme.reset}{inner}{theme.reset}" 457 f"{' ' * pad}{theme.chrome}│{theme.reset}\n" 458 f"{theme.chrome}└{'─' * 58}┘{theme.reset}" 459 ) 460 461 @staticmethod 462 def kwarg_inputs(**kwargs) -> None: 463 for key, value in kwargs.items(): 464 print( 465 f" {theme.accent}{key}{theme.reset}: {theme.body}{value}{theme.reset}" 466 ) 467 468 @staticmethod 469 def label(tag: str, text: str, color: str | None = None) -> None: 470 color = color if color is not None else theme.accent 471 print( 472 f" {color}{theme.bold}{tag}{theme.reset} {theme.body}{text}{theme.reset}" 473 ) 474 475 @staticmethod 476 def tool_event( 477 tool_name: str, 478 status: str, 479 is_running: bool = False, 480 elapsed: float | None = None, 481 ) -> None: 482 icon = "⚙" if is_running else "✓" 483 color = theme.running if is_running else theme.success 484 elapsed_str = ( 485 f" {theme.chrome}({elapsed:.2f}s){theme.reset}" if elapsed else "" 486 ) 487 print( 488 f" {color}{icon}{theme.reset} {theme.dim}tool:{theme.reset} " 489 f"{theme.italic}{tool_name}{theme.reset} -> {theme.chrome}{status}{elapsed_str}" 490 ) 491 492 @staticmethod 493 def info(message: str, color: str | None = None) -> None: 494 """ 495 Usage: 496 497 - Print a single informational line. Useful from inside respond() to 498 surface status updates without touching the spinner directly. 499 500 Requires: 501 502 - `message`: 503 - Type: str 504 - What: The message to print 505 506 Optional: 507 508 - `color`: 509 - Type: str (ANSI escape code) 510 - What: ANSI color code for the message. Defaults to theme accent. 511 - Default: None 512 """ 513 color = color if color is not None else theme.accent 514 print( 515 f" {color}{theme.bold}i{theme.reset} {theme.body}{message}{theme.reset}" 516 ) 517 518 @staticmethod 519 def typewrite(text: str, delay: float = 0.008) -> None: 520 """Print text with a subtle typewriter effect.""" 521 for ch in text: 522 sys.stdout.write(ch) 523 sys.stdout.flush() 524 time.sleep(delay) 525 print() 526 527 @staticmethod 528 def print_response(name: str, text: str) -> None: 529 """Render the final response with light formatting.""" 530 print(f" {theme.highlight}{theme.bold}{name}:{theme.reset}") 531 print() 532 print(text) 533 534 @staticmethod 535 def print_status(name: str, success: bool, elapsed: float) -> None: 536 icon = "✓" if success else "✗" 537 color = theme.success if success else theme.error 538 print( 539 f"\n {color}{icon}{theme.reset} {theme.dim}{name} {elapsed:.2f}s{theme.reset}" 540 )
26class Theme: 27 """ 28 Holds the active color palette. Four built-in themes are provided: 29 30 - ``"dark"`` (default) — bright colors on dark backgrounds. 31 - ``"light"`` — deep, high-contrast colors on light/white backgrounds. 32 - ``"solarized"`` — Solarized-Dark accent palette; comfortable for long sessions. 33 - ``"mono"`` — greyscale only; no color, just bold/dim contrast. 34 35 Every role needed by ``CliSpinner`` and ``CliPrinter`` lives here, 36 including the formatting escapes (``reset``, ``bold``, ``italic``) so 37 nothing outside this class references raw ANSI strings. 38 """ 39 40 THEMES: dict[str, dict] = { 41 # ------------------------------------------------------------------ 42 # dark — original palette, bright on dark 43 # ------------------------------------------------------------------ 44 "dark": { 45 # formatting 46 "reset": _RESET, 47 "bold": _BOLD, 48 "italic": _ITALIC, 49 # structural 50 "chrome": "\033[90m", # dark-gray — borders, dividers 51 "body": "\033[97m", # bright white — primary text 52 "dim": _DIM, # secondary / de-emphasised 53 # semantic 54 "accent": "\033[96m", # bright cyan — brand accent 55 "highlight": "\033[95m", # bright magenta — speaker label 56 "running": "\033[93m", # bright yellow — in-progress 57 "success": "\033[92m", # bright green — completed 58 "error": "\033[91m", # bright red — failures 59 # spinner 60 "spinner_colors": [ 61 "\033[96m", # bright cyan 62 "\033[94m", # bright blue 63 "\033[95m", # bright magenta 64 "\033[96m", 65 ], 66 }, 67 # ------------------------------------------------------------------ 68 # light — readable on white/light backgrounds 69 # ------------------------------------------------------------------ 70 "light": { 71 "reset": _RESET, 72 "bold": _BOLD, 73 "italic": _ITALIC, 74 "chrome": "\033[90m", # dark-gray borders 75 "body": "\033[30m", # black text 76 "dim": _DIM, 77 "accent": "\033[36m", # teal 78 "highlight": "\033[35m", # magenta/purple 79 "running": "\033[33m", # amber 80 "success": "\033[32m", # dark green 81 "error": "\033[31m", # dark red 82 "spinner_colors": [ 83 "\033[36m", # teal 84 "\033[34m", # blue 85 "\033[35m", # magenta 86 "\033[36m", 87 ], 88 }, 89 # ------------------------------------------------------------------ 90 # solarized — Solarized-Dark accent colors, muted base 91 # ------------------------------------------------------------------ 92 "solarized": { 93 "reset": _RESET, 94 "bold": _BOLD, 95 "italic": _ITALIC, 96 "chrome": "\033[38;5;240m", # base01 — subtle borders 97 "body": "\033[38;5;252m", # base2 — primary text 98 "dim": _DIM, 99 "accent": "\033[38;5;37m", # cyan (#2aa198) 100 "highlight": "\033[38;5;125m", # magenta (#d33682) 101 "running": "\033[38;5;136m", # yellow (#b58900) 102 "success": "\033[38;5;64m", # green (#859900) 103 "error": "\033[38;5;160m", # red (#dc322f) 104 "spinner_colors": [ 105 "\033[38;5;37m", # cyan 106 "\033[38;5;33m", # blue (#268bd2) 107 "\033[38;5;61m", # violet (#6c71c4) 108 "\033[38;5;37m", 109 ], 110 }, 111 # ------------------------------------------------------------------ 112 # mono — greyscale; bold/dim contrast only, no hue 113 # ------------------------------------------------------------------ 114 "mono": { 115 "reset": _RESET, 116 "bold": _BOLD, 117 "italic": _ITALIC, 118 "chrome": "\033[90m", # dark gray 119 "body": "\033[97m", # bright white 120 "dim": _DIM, 121 "accent": _BOLD, # bold, no hue 122 "highlight": _BOLD, 123 "running": "\033[97m", 124 "success": "\033[97m", 125 "error": _BOLD, 126 "spinner_colors": [ 127 "\033[97m", # bright white 128 "\033[37m", # light gray 129 "\033[90m", # dark gray 130 "\033[97m", 131 ], 132 }, 133 } 134 135 VALID: tuple[str, ...] = tuple(THEMES.keys()) 136 137 def __init__(self) -> None: 138 self._palette: dict = self.THEMES["dark"] 139 140 # Convenience accessors ------------------------------------------------- 141 142 @property 143 def reset(self) -> str: 144 return self._palette["reset"] 145 146 @property 147 def bold(self) -> str: 148 return self._palette["bold"] 149 150 @property 151 def italic(self) -> str: 152 return self._palette["italic"] 153 154 @property 155 def chrome(self) -> str: 156 return self._palette["chrome"] 157 158 @property 159 def body(self) -> str: 160 return self._palette["body"] 161 162 @property 163 def dim(self) -> str: 164 return self._palette["dim"] 165 166 @property 167 def accent(self) -> str: 168 return self._palette["accent"] 169 170 @property 171 def highlight(self) -> str: 172 return self._palette["highlight"] 173 174 @property 175 def running(self) -> str: 176 return self._palette["running"] 177 178 @property 179 def success(self) -> str: 180 return self._palette["success"] 181 182 @property 183 def error(self) -> str: 184 return self._palette["error"] 185 186 @property 187 def spinner_colors(self) -> list[str]: 188 return self._palette["spinner_colors"] 189 190 # Mutation -------------------------------------------------------------- 191 192 def apply(self, name: str) -> None: 193 """Switch the active theme by name.""" 194 if name not in self.THEMES: 195 raise ValueError( 196 f"Unknown theme {name!r}. Choose one of: {', '.join(self.VALID)}" 197 ) 198 self._palette = self.THEMES[name]
Holds the active color palette. Four built-in themes are provided:
"dark"(default) — bright colors on dark backgrounds."light"— deep, high-contrast colors on light/white backgrounds."solarized"— Solarized-Dark accent palette; comfortable for long sessions."mono"— greyscale only; no color, just bold/dim contrast.
Every role needed by CliSpinner and CliPrinter lives here,
including the formatting escapes (reset, bold, italic) so
nothing outside this class references raw ANSI strings.
192 def apply(self, name: str) -> None: 193 """Switch the active theme by name.""" 194 if name not in self.THEMES: 195 raise ValueError( 196 f"Unknown theme {name!r}. Choose one of: {', '.join(self.VALID)}" 197 ) 198 self._palette = self.THEMES[name]
Switch the active theme by name.
205def set_theme(name: str) -> None: 206 """ 207 Switch the global CLI color theme. 208 209 Call this **once**, before any output is produced — typically right after 210 argument parsing in ``cli.py``. 211 212 Requires: 213 214 - ``name``: 215 - Type: ``str`` 216 - What: One of ``"dark"`` (default), ``"light"``, ``"solarized"``, 217 or ``"mono"`` 218 """ 219 theme.apply(name)
Switch the global CLI color theme.
Call this once, before any output is produced — typically right after
argument parsing in cli.py.
Requires:
name:- Type:
str - What: One of
"dark"(default),"light","solarized", or"mono"
- Type:
225class CliSpinner: 226 """ 227 Animated terminal spinner that runs on a background thread. 228 Call .start(message) and .stop() around blocking work. 229 """ 230 231 DEFAULT_VERBS = [ 232 "thinking", 233 "vibing", 234 "pontificating", 235 "contemplating", 236 "deliberating", 237 "cogitating", 238 "ruminating", 239 "musing", 240 "ideating", 241 "postulating", 242 "hypothesizing", 243 "extrapolating", 244 "philosophizing", 245 "noodling", 246 "percolating", 247 "marinating", 248 "stewing", 249 "scheming", 250 "conniving", 251 "divining", 252 "spelunking", 253 "ratiocinating", 254 "cerebrating", 255 "woolgathering", 256 "daydreaming", 257 "lucubrating", 258 "excogitating", 259 "thinkulating", 260 "brainwaving", 261 "cogitronning", 262 "synapsing", 263 "thoughtcrafting", 264 "mindweaving", 265 "intellectualizing", 266 "computating", 267 "ponderizing", 268 "mentalating", 269 "brainbrewing", 270 ] 271 272 def __init__(self) -> None: 273 self._thread: threading.Thread | None = None 274 self._stop_event = threading.Event() 275 self._message = "" 276 self._verb_thread: threading.Thread | None = None 277 self._running = False 278 self._frames = Spinner.BRAILLE 279 280 # ------------------------------------------------------------------ 281 # Public API 282 # ------------------------------------------------------------------ 283 284 def start( 285 self, 286 message: str | None = None, 287 spinner: list[str] | None = None, 288 ) -> None: 289 """ 290 Start the spinner. 291 292 Optional: 293 294 - ``message``: 295 - Type: str | None 296 - What: Text displayed beside the spinner frame. 297 - Default: None (keeps previous message) 298 299 - ``spinner``: 300 - Type: list[str] | None 301 - What: Spinner Frames to use 302 - Default: None (Braille) 303 """ 304 if self._thread and self._thread.is_alive(): 305 self.stop() 306 self._running = True 307 self._stop_event.clear() 308 if spinner is not None: 309 self._frames = spinner 310 if message: 311 self._message = message 312 self._thread = threading.Thread(target=self._spin, daemon=True) 313 self._thread.start() 314 315 def start_with_verbs( 316 self, 317 name: str, 318 verbs: list[str] | None = None, 319 interval: float = 10.0, 320 spinner: list[str] | None = None, 321 ) -> None: 322 """ 323 Start the spinner with a cycling verb message: "<name> is <verb>". 324 The verb rotates through `verbs` every `interval` seconds. 325 326 Requires: 327 328 - `name`: 329 - Type: str 330 - What: The subject displayed before the verb (e.g. "Claude") 331 332 Optional: 333 334 - `verbs`: 335 - Type: list[str] | None 336 - What: Verbs to cycle through. Defaults to CliSpinner.DEFAULT_VERBS 337 - Default: None 338 339 - `interval`: 340 - Type: float 341 - What: Seconds between each verb swap 342 - Default: 10.0 343 344 - `spinner`: 345 - Type: list[str] | None 346 - What: Frame list or None to not change. 347 - Default: None 348 """ 349 verbs = verbs if verbs is not None else self.DEFAULT_VERBS 350 351 def _get_random_message(): 352 idx = random.randrange(len(verbs)) 353 return f"{name} is {verbs[idx]}" 354 355 def _get_random_spinner(): 356 options = [ 357 Spinner.ARC, 358 Spinner.CIRCLE_ARCS, 359 Spinner.CIRCLE_FILL, 360 Spinner.LINE, 361 Spinner.DOT_PULSE, 362 Spinner.BOUNCE, 363 Spinner.BLOCK, 364 ] 365 idx = random.randrange(len(options)) 366 return options[idx] 367 368 self.start(_get_random_message(), spinner=_get_random_spinner()) 369 370 def _verb_cycle() -> None: 371 while not self._stop_event.wait(timeout=interval): 372 # Update message and spinner frames directly instead of 373 # calling start(), which would call stop() and try to 374 # join this thread — causing "cannot join current thread". 375 self._message = _get_random_message() 376 self._frames = _get_random_spinner() 377 378 self._verb_thread = threading.Thread(target=_verb_cycle, daemon=True) 379 self._verb_thread.start() 380 381 def stop(self, final_message: str | None = None) -> None: 382 was_running = self._running 383 self._running = False 384 self._stop_event.set() 385 if self._thread and self._thread.is_alive(): 386 self._thread.join() 387 self._thread = None 388 # Don't join verb_thread if we're being called from within it — 389 # that would raise "cannot join current thread". 390 if ( 391 self._verb_thread 392 and self._verb_thread.is_alive() 393 and self._verb_thread is not threading.current_thread() 394 ): 395 self._verb_thread.join() 396 self._verb_thread = None 397 sys.stdout.write("\r\033[2K") 398 sys.stdout.flush() 399 if final_message: 400 print(final_message) 401 return was_running 402 403 def _spin(self) -> None: 404 frame_idx = 0 405 color_idx = 0 406 dot_count = 0 407 frames = self._frames # snapshot so swaps don't mid-spin 408 while not self._stop_event.is_set(): 409 frame = frames[frame_idx % len(frames)] 410 colors = theme.spinner_colors 411 color = colors[color_idx % len(colors)] 412 dots = "." * (dot_count % 4) 413 414 visible_content = f" {frame} {self._message}{dots:<3}" 415 padding = max(0, 60 - _visible_len(visible_content)) * " " 416 line = ( 417 f"\r {color}{theme.bold}{frame}{theme.reset} " 418 f"{theme.body}{self._message}{theme.chrome}{dots:<3}{theme.reset}" 419 f"{padding}" 420 ) 421 sys.stdout.write(line) 422 sys.stdout.flush() 423 424 time.sleep(0.12) 425 frame_idx += 1 426 if frame_idx % 5 == 0: 427 dot_count += 1 428 if frame_idx % 20 == 0: 429 color_idx += 1 430 431 # Re-read frames each tick so verb-cycle updates take effect 432 frames = self._frames
Animated terminal spinner that runs on a background thread. Call .start(message) and .stop() around blocking work.
284 def start( 285 self, 286 message: str | None = None, 287 spinner: list[str] | None = None, 288 ) -> None: 289 """ 290 Start the spinner. 291 292 Optional: 293 294 - ``message``: 295 - Type: str | None 296 - What: Text displayed beside the spinner frame. 297 - Default: None (keeps previous message) 298 299 - ``spinner``: 300 - Type: list[str] | None 301 - What: Spinner Frames to use 302 - Default: None (Braille) 303 """ 304 if self._thread and self._thread.is_alive(): 305 self.stop() 306 self._running = True 307 self._stop_event.clear() 308 if spinner is not None: 309 self._frames = spinner 310 if message: 311 self._message = message 312 self._thread = threading.Thread(target=self._spin, daemon=True) 313 self._thread.start()
Start the spinner.
Optional:
message:- Type: str | None
- What: Text displayed beside the spinner frame.
- Default: None (keeps previous message)
spinner:- Type: list[str] | None
- What: Spinner Frames to use
- Default: None (Braille)
315 def start_with_verbs( 316 self, 317 name: str, 318 verbs: list[str] | None = None, 319 interval: float = 10.0, 320 spinner: list[str] | None = None, 321 ) -> None: 322 """ 323 Start the spinner with a cycling verb message: "<name> is <verb>". 324 The verb rotates through `verbs` every `interval` seconds. 325 326 Requires: 327 328 - `name`: 329 - Type: str 330 - What: The subject displayed before the verb (e.g. "Claude") 331 332 Optional: 333 334 - `verbs`: 335 - Type: list[str] | None 336 - What: Verbs to cycle through. Defaults to CliSpinner.DEFAULT_VERBS 337 - Default: None 338 339 - `interval`: 340 - Type: float 341 - What: Seconds between each verb swap 342 - Default: 10.0 343 344 - `spinner`: 345 - Type: list[str] | None 346 - What: Frame list or None to not change. 347 - Default: None 348 """ 349 verbs = verbs if verbs is not None else self.DEFAULT_VERBS 350 351 def _get_random_message(): 352 idx = random.randrange(len(verbs)) 353 return f"{name} is {verbs[idx]}" 354 355 def _get_random_spinner(): 356 options = [ 357 Spinner.ARC, 358 Spinner.CIRCLE_ARCS, 359 Spinner.CIRCLE_FILL, 360 Spinner.LINE, 361 Spinner.DOT_PULSE, 362 Spinner.BOUNCE, 363 Spinner.BLOCK, 364 ] 365 idx = random.randrange(len(options)) 366 return options[idx] 367 368 self.start(_get_random_message(), spinner=_get_random_spinner()) 369 370 def _verb_cycle() -> None: 371 while not self._stop_event.wait(timeout=interval): 372 # Update message and spinner frames directly instead of 373 # calling start(), which would call stop() and try to 374 # join this thread — causing "cannot join current thread". 375 self._message = _get_random_message() 376 self._frames = _get_random_spinner() 377 378 self._verb_thread = threading.Thread(target=_verb_cycle, daemon=True) 379 self._verb_thread.start()
Start the spinner with a cycling verb message: "verbs every interval seconds.
Requires:
name:- Type: str
- What: The subject displayed before the verb (e.g. "Claude")
Optional:
verbs:- Type: list[str] | None
- What: Verbs to cycle through. Defaults to CliSpinner.DEFAULT_VERBS
- Default: None
interval:- Type: float
- What: Seconds between each verb swap
- Default: 10.0
spinner:- Type: list[str] | None
- What: Frame list or None to not change.
- Default: None
381 def stop(self, final_message: str | None = None) -> None: 382 was_running = self._running 383 self._running = False 384 self._stop_event.set() 385 if self._thread and self._thread.is_alive(): 386 self._thread.join() 387 self._thread = None 388 # Don't join verb_thread if we're being called from within it — 389 # that would raise "cannot join current thread". 390 if ( 391 self._verb_thread 392 and self._verb_thread.is_alive() 393 and self._verb_thread is not threading.current_thread() 394 ): 395 self._verb_thread.join() 396 self._verb_thread = None 397 sys.stdout.write("\r\033[2K") 398 sys.stdout.flush() 399 if final_message: 400 print(final_message) 401 return was_running
435class CliPrinter: 436 @staticmethod 437 def divider( 438 char: str = "─", width: int = 60, color: str | None = None 439 ) -> None: 440 color = color if color is not None else theme.accent 441 print(f"{color}{char * width}{theme.reset}") 442 443 @staticmethod 444 def empty_line() -> None: 445 """Create an empty line for spacing.""" 446 print() 447 448 @staticmethod 449 def header(label: str) -> None: 450 inner = ( 451 f" {theme.accent}{theme.bold}Spych{theme.reset}" 452 f": {theme.body}{label}{theme.reset}" 453 ) 454 pad = max(0, 58 - _visible_len(inner)) 455 print( 456 f"\n{theme.chrome}┌{'─' * 58}┐{theme.reset}\n" 457 f"{theme.chrome}│{theme.reset}{inner}{theme.reset}" 458 f"{' ' * pad}{theme.chrome}│{theme.reset}\n" 459 f"{theme.chrome}└{'─' * 58}┘{theme.reset}" 460 ) 461 462 @staticmethod 463 def kwarg_inputs(**kwargs) -> None: 464 for key, value in kwargs.items(): 465 print( 466 f" {theme.accent}{key}{theme.reset}: {theme.body}{value}{theme.reset}" 467 ) 468 469 @staticmethod 470 def label(tag: str, text: str, color: str | None = None) -> None: 471 color = color if color is not None else theme.accent 472 print( 473 f" {color}{theme.bold}{tag}{theme.reset} {theme.body}{text}{theme.reset}" 474 ) 475 476 @staticmethod 477 def tool_event( 478 tool_name: str, 479 status: str, 480 is_running: bool = False, 481 elapsed: float | None = None, 482 ) -> None: 483 icon = "⚙" if is_running else "✓" 484 color = theme.running if is_running else theme.success 485 elapsed_str = ( 486 f" {theme.chrome}({elapsed:.2f}s){theme.reset}" if elapsed else "" 487 ) 488 print( 489 f" {color}{icon}{theme.reset} {theme.dim}tool:{theme.reset} " 490 f"{theme.italic}{tool_name}{theme.reset} -> {theme.chrome}{status}{elapsed_str}" 491 ) 492 493 @staticmethod 494 def info(message: str, color: str | None = None) -> None: 495 """ 496 Usage: 497 498 - Print a single informational line. Useful from inside respond() to 499 surface status updates without touching the spinner directly. 500 501 Requires: 502 503 - `message`: 504 - Type: str 505 - What: The message to print 506 507 Optional: 508 509 - `color`: 510 - Type: str (ANSI escape code) 511 - What: ANSI color code for the message. Defaults to theme accent. 512 - Default: None 513 """ 514 color = color if color is not None else theme.accent 515 print( 516 f" {color}{theme.bold}i{theme.reset} {theme.body}{message}{theme.reset}" 517 ) 518 519 @staticmethod 520 def typewrite(text: str, delay: float = 0.008) -> None: 521 """Print text with a subtle typewriter effect.""" 522 for ch in text: 523 sys.stdout.write(ch) 524 sys.stdout.flush() 525 time.sleep(delay) 526 print() 527 528 @staticmethod 529 def print_response(name: str, text: str) -> None: 530 """Render the final response with light formatting.""" 531 print(f" {theme.highlight}{theme.bold}{name}:{theme.reset}") 532 print() 533 print(text) 534 535 @staticmethod 536 def print_status(name: str, success: bool, elapsed: float) -> None: 537 icon = "✓" if success else "✗" 538 color = theme.success if success else theme.error 539 print( 540 f"\n {color}{icon}{theme.reset} {theme.dim}{name} {elapsed:.2f}s{theme.reset}" 541 )
443 @staticmethod 444 def empty_line() -> None: 445 """Create an empty line for spacing.""" 446 print()
Create an empty line for spacing.
448 @staticmethod 449 def header(label: str) -> None: 450 inner = ( 451 f" {theme.accent}{theme.bold}Spych{theme.reset}" 452 f": {theme.body}{label}{theme.reset}" 453 ) 454 pad = max(0, 58 - _visible_len(inner)) 455 print( 456 f"\n{theme.chrome}┌{'─' * 58}┐{theme.reset}\n" 457 f"{theme.chrome}│{theme.reset}{inner}{theme.reset}" 458 f"{' ' * pad}{theme.chrome}│{theme.reset}\n" 459 f"{theme.chrome}└{'─' * 58}┘{theme.reset}" 460 )
476 @staticmethod 477 def tool_event( 478 tool_name: str, 479 status: str, 480 is_running: bool = False, 481 elapsed: float | None = None, 482 ) -> None: 483 icon = "⚙" if is_running else "✓" 484 color = theme.running if is_running else theme.success 485 elapsed_str = ( 486 f" {theme.chrome}({elapsed:.2f}s){theme.reset}" if elapsed else "" 487 ) 488 print( 489 f" {color}{icon}{theme.reset} {theme.dim}tool:{theme.reset} " 490 f"{theme.italic}{tool_name}{theme.reset} -> {theme.chrome}{status}{elapsed_str}" 491 )
493 @staticmethod 494 def info(message: str, color: str | None = None) -> None: 495 """ 496 Usage: 497 498 - Print a single informational line. Useful from inside respond() to 499 surface status updates without touching the spinner directly. 500 501 Requires: 502 503 - `message`: 504 - Type: str 505 - What: The message to print 506 507 Optional: 508 509 - `color`: 510 - Type: str (ANSI escape code) 511 - What: ANSI color code for the message. Defaults to theme accent. 512 - Default: None 513 """ 514 color = color if color is not None else theme.accent 515 print( 516 f" {color}{theme.bold}i{theme.reset} {theme.body}{message}{theme.reset}" 517 )
Usage:
- Print a single informational line. Useful from inside respond() to surface status updates without touching the spinner directly.
Requires:
message:- Type: str
- What: The message to print
Optional:
color:- Type: str (ANSI escape code)
- What: ANSI color code for the message. Defaults to theme accent.
- Default: None
519 @staticmethod 520 def typewrite(text: str, delay: float = 0.008) -> None: 521 """Print text with a subtle typewriter effect.""" 522 for ch in text: 523 sys.stdout.write(ch) 524 sys.stdout.flush() 525 time.sleep(delay) 526 print()
Print text with a subtle typewriter effect.
528 @staticmethod 529 def print_response(name: str, text: str) -> None: 530 """Render the final response with light formatting.""" 531 print(f" {theme.highlight}{theme.bold}{name}:{theme.reset}") 532 print() 533 print(text)
Render the final response with light formatting.