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