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        )
class Theme:
 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.

THEMES: dict[str, dict] = {'dark': {'reset': '\x1b[0m', 'bold': '\x1b[1m', 'italic': '\x1b[3m', 'chrome': '\x1b[90m', 'body': '\x1b[97m', 'dim': '\x1b[2m', 'accent': '\x1b[96m', 'highlight': '\x1b[95m', 'running': '\x1b[93m', 'success': '\x1b[92m', 'error': '\x1b[91m', 'spinner_colors': ['\x1b[96m', '\x1b[94m', '\x1b[95m', '\x1b[96m']}, 'light': {'reset': '\x1b[0m', 'bold': '\x1b[1m', 'italic': '\x1b[3m', 'chrome': '\x1b[90m', 'body': '\x1b[30m', 'dim': '\x1b[2m', 'accent': '\x1b[36m', 'highlight': '\x1b[35m', 'running': '\x1b[33m', 'success': '\x1b[32m', 'error': '\x1b[31m', 'spinner_colors': ['\x1b[36m', '\x1b[34m', '\x1b[35m', '\x1b[36m']}, 'solarized': {'reset': '\x1b[0m', 'bold': '\x1b[1m', 'italic': '\x1b[3m', 'chrome': '\x1b[38;5;240m', 'body': '\x1b[38;5;252m', 'dim': '\x1b[2m', 'accent': '\x1b[38;5;37m', 'highlight': '\x1b[38;5;125m', 'running': '\x1b[38;5;136m', 'success': '\x1b[38;5;64m', 'error': '\x1b[38;5;160m', 'spinner_colors': ['\x1b[38;5;37m', '\x1b[38;5;33m', '\x1b[38;5;61m', '\x1b[38;5;37m']}, 'mono': {'reset': '\x1b[0m', 'bold': '\x1b[1m', 'italic': '\x1b[3m', 'chrome': '\x1b[90m', 'body': '\x1b[97m', 'dim': '\x1b[2m', 'accent': '\x1b[1m', 'highlight': '\x1b[1m', 'running': '\x1b[97m', 'success': '\x1b[97m', 'error': '\x1b[1m', 'spinner_colors': ['\x1b[97m', '\x1b[37m', '\x1b[90m', '\x1b[97m']}}
VALID: tuple[str, ...] = ('dark', 'light', 'solarized', 'mono')
reset: str
142    @property
143    def reset(self) -> str:
144        return self._palette["reset"]
bold: str
146    @property
147    def bold(self) -> str:
148        return self._palette["bold"]
italic: str
150    @property
151    def italic(self) -> str:
152        return self._palette["italic"]
chrome: str
154    @property
155    def chrome(self) -> str:
156        return self._palette["chrome"]
body: str
158    @property
159    def body(self) -> str:
160        return self._palette["body"]
dim: str
162    @property
163    def dim(self) -> str:
164        return self._palette["dim"]
accent: str
166    @property
167    def accent(self) -> str:
168        return self._palette["accent"]
highlight: str
170    @property
171    def highlight(self) -> str:
172        return self._palette["highlight"]
running: str
174    @property
175    def running(self) -> str:
176        return self._palette["running"]
success: str
178    @property
179    def success(self) -> str:
180        return self._palette["success"]
error: str
182    @property
183    def error(self) -> str:
184        return self._palette["error"]
spinner_colors: list[str]
186    @property
187    def spinner_colors(self) -> list[str]:
188        return self._palette["spinner_colors"]
def apply(self, name: str) -> None:
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.

theme = <Theme object>
def set_theme(name: str) -> None:
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"
class CliSpinner:
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.

DEFAULT_VERBS = ['thinking', 'vibing', 'pontificating', 'contemplating', 'deliberating', 'cogitating', 'ruminating', 'musing', 'ideating', 'postulating', 'hypothesizing', 'extrapolating', 'philosophizing', 'noodling', 'percolating', 'marinating', 'stewing', 'scheming', 'conniving', 'divining', 'spelunking', 'ratiocinating', 'cerebrating', 'woolgathering', 'daydreaming', 'lucubrating', 'excogitating', 'thinkulating', 'brainwaving', 'cogitronning', 'synapsing', 'thoughtcrafting', 'mindweaving', 'intellectualizing', 'computating', 'ponderizing', 'mentalating', 'brainbrewing']
def start( self, message: str | None = None, spinner: list[str] | None = None) -> None:
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)
def start_with_verbs( self, name: str, verbs: list[str] | None = None, interval: float = 10.0, spinner: list[str] | None = None) -> None:
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: " is ". The verb rotates through verbs every interval seconds.

Requires:

  • name:
    • Type: str
    • What: The subject displayed before the verb (e.g. "Claude")

Optional:

  • verbs:

  • 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
def stop(self, final_message: str | None = None) -> 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
class CliPrinter:
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        )
@staticmethod
def divider(char: str = '─', width: int = 60, color: str | None = None) -> None:
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}")
@staticmethod
def empty_line() -> None:
443    @staticmethod
444    def empty_line() -> None:
445        """Create an empty line for spacing."""
446        print()

Create an empty line for spacing.

@staticmethod
def header(label: str) -> None:
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        )
@staticmethod
def kwarg_inputs(**kwargs) -> None:
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            )
@staticmethod
def label(tag: str, text: str, color: str | None = None) -> None:
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        )
@staticmethod
def tool_event( tool_name: str, status: str, is_running: bool = False, elapsed: float | None = None) -> None:
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        )
@staticmethod
def info(message: str, color: str | None = None) -> None:
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
@staticmethod
def typewrite(text: str, delay: float = 0.008) -> 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.

@staticmethod
def print_response(name: str, text: str) -> None:
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.

@staticmethod
def print_status(name: str, success: bool, elapsed: float) -> None:
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        )