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

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
149    @property
150    def reset(self) -> str:
151        return self._palette["reset"]
bold: str
153    @property
154    def bold(self) -> str:
155        return self._palette["bold"]
italic: str
157    @property
158    def italic(self) -> str:
159        return self._palette["italic"]
chrome: str
161    @property
162    def chrome(self) -> str:
163        return self._palette["chrome"]
body: str
165    @property
166    def body(self) -> str:
167        return self._palette["body"]
dim: str
169    @property
170    def dim(self) -> str:
171        return self._palette["dim"]
accent: str
173    @property
174    def accent(self) -> str:
175        return self._palette["accent"]
highlight: str
177    @property
178    def highlight(self) -> str:
179        return self._palette["highlight"]
running: str
181    @property
182    def running(self) -> str:
183        return self._palette["running"]
success: str
185    @property
186    def success(self) -> str:
187        return self._palette["success"]
error: str
189    @property
190    def error(self) -> str:
191        return self._palette["error"]
spinner_colors: list[str]
193    @property
194    def spinner_colors(self) -> list[str]:
195        return self._palette["spinner_colors"]
def apply(self, name: str) -> None:
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.

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

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:
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)
def start_with_verbs( self, name: str, verbs: list[str] | None = None, interval: float = 10.0, spinner: list[str] | None = None) -> None:
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: " 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:
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
class NullSpinner:
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 CliSpinner that suppresses all terminal output. Used automatically by BaseResponder when an AgentDashboard is active, so the spinner never corrupts the alternate screen buffer.

Notes:

def start( self, message: str | None = None, spinner: list[str] | None = None) -> None:
464    def start(
465        self, message: str | None = None, spinner: list[str] | None = None
466    ) -> None:
467        pass
def start_with_verbs( self, name: str, verbs: list[str] | None = None, interval: float = 10.0, spinner: list[str] | None = None) -> None:
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
def stop(self, final_message: str | None = None) -> bool:
478    def stop(self, final_message: str | None = None) -> bool:
479        return False
class CliPrinter:
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        )
@staticmethod
def divider(char: str = '─', width: int = 60, color: str | None = None) -> None:
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}")
@staticmethod
def empty_line() -> None:
496    @staticmethod
497    def empty_line() -> None:
498        """Create an empty line for spacing."""
499        print()

Create an empty line for spacing.

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

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

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

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