From 83aa84ae3b00ba5f2923dad2194a491f6bab6888 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 20 Jun 2026 14:18:33 -0500 Subject: [PATCH] feat(pets): CLI pet pane + /pet command Render the reactive pet pane in the classic CLI (steady redraw, right-aligned) and wire the /pet command to list and switch pets, plus an enable/disable toggle. Backed by hermes_cli/pets.py and the CLI commands mixin, registered in the central command registry. Covered by the CLI pet pane and toggle tests. --- cli.py | 262 ++++++++++++++- hermes_cli/cli_commands_mixin.py | 58 ++++ hermes_cli/commands.py | 2 + hermes_cli/main.py | 22 +- hermes_cli/pets.py | 482 ++++++++++++++++++++++++++++ tests/cli/test_cli_pet_pane.py | 136 ++++++++ tests/hermes_cli/test_pet_toggle.py | 104 ++++++ 7 files changed, 1064 insertions(+), 2 deletions(-) create mode 100644 hermes_cli/pets.py create mode 100644 tests/cli/test_cli_pet_pane.py create mode 100644 tests/hermes_cli/test_pet_toggle.py diff --git a/cli.py b/cli.py index b1c9a4bc8ef..4d5ac86994b 100644 --- a/cli.py +++ b/cli.py @@ -60,7 +60,7 @@ from prompt_toolkit.history import FileHistory from prompt_toolkit.styles import Style as PTStyle from prompt_toolkit.patch_stdout import patch_stdout from prompt_toolkit.application import Application -from prompt_toolkit.layout import Layout, HSplit, Window, FormattedTextControl, ConditionalContainer +from prompt_toolkit.layout import Layout, HSplit, Window, FormattedTextControl, ConditionalContainer, WindowAlign from prompt_toolkit.layout.processors import Processor, Transformation, PasswordProcessor, ConditionalProcessor from prompt_toolkit.filters import Condition from prompt_toolkit.layout.dimension import Dimension @@ -3590,6 +3590,25 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): self._last_scrollback_tool: str = "" # last tool name printed to scrollback (for "new" dedup) self._command_running = False self._command_status = "" + # Petdex mascot (opt-in via display.pet). The base CLI mirrors the TUI's + # PetPane: a half-block sprite above the prompt that reacts to agent + # activity. Lazily resolved; an invalidate timer drives the animation. + self._pet_renderer = None # agent.pet.render.PetRenderer | None + self._pet_slug: str = "" + self._pet_enabled: bool = False + self._pet_cols: int = 18 + self._pet_scale: float = 0.7 + self._pet_frames_cache: dict = {} # state -> list[grid] + self._pet_frame_idx: int = 0 + self._pet_lock = threading.Lock() + self._pet_cfg_checked: float = 0.0 + self._pet_anim_running: bool = False + self._pet_anim_thread = None + # Transient reaction beats (wave/jump/failed) + steady reasoning flag. + self._pet_event: str = "" + self._pet_event_until: float = 0.0 + self._pet_reasoning: bool = False + self._pet_turn_error: bool = False self._attached_images: list[Path] = [] self._image_counter = 0 self.preloaded_skills: list[str] = [] @@ -4130,6 +4149,218 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): return f" {txt} ({elapsed_str})" return f" {txt}" + # ── Petdex mascot (base-CLI pet pane) ─────────────────────────────── + # + # Parity with the TUI: a half-block sprite rendered as a prompt_toolkit + # window above the prompt, reacting to agent state and animated by a timer + # that calls ``app.invalidate()``. Half-blocks only — the crisp Kitty image + # protocol can't coexist with prompt_toolkit's patch_stdout output layer + # (raw image escapes get swallowed/mangled), so we use truecolor styled + # text, which prompt_toolkit renders natively in any 24-bit terminal. + + _PET_FRAME_INTERVAL = 0.16 + _PET_CFG_INTERVAL = 2.5 + + def _pet_resolve_config(self) -> None: + """(Re)resolve the active pet from config — picks up live enable/disable/ + + switch made via ``/pet`` or ``hermes pets`` without a restart, mirroring + the TUI's steady poll. Cheap and fail-open: any problem disables the pet. + """ + try: + from agent.pet import constants, store + from agent.pet.render import PetRenderer + from hermes_cli.config import load_config + + cfg = load_config() + display = cfg.get("display", {}) if isinstance(cfg.get("display"), dict) else {} + pet_cfg = display.get("pet", {}) if isinstance(display.get("pet"), dict) else {} + + enabled = bool(pet_cfg.get("enabled")) + slug = str(pet_cfg.get("slug", "") or "") + scale = float(pet_cfg.get("scale", constants.DEFAULT_SCALE) or constants.DEFAULT_SCALE) + cols = constants.resolve_cols(scale, pet_cfg.get("unicode_cols", 0)) + + if not enabled: + with self._pet_lock: + self._pet_enabled = False + self._pet_renderer = None + self._pet_frames_cache.clear() + return + + pet = store.resolve_active_pet(slug) + if pet is None or not pet.exists: + with self._pet_lock: + self._pet_enabled = False + self._pet_renderer = None + self._pet_frames_cache.clear() + return + + with self._pet_lock: + # Rebuild only when the resolved pet or geometry changes. + if ( + self._pet_renderer is None + or self._pet_slug != pet.slug + or self._pet_cols != cols + or self._pet_scale != scale + ): + self._pet_renderer = PetRenderer( + str(pet.spritesheet), mode="unicode", scale=scale, unicode_cols=cols + ) + self._pet_slug = pet.slug + self._pet_cols = cols + self._pet_scale = scale + self._pet_frames_cache.clear() + self._pet_frame_idx = 0 + self._pet_enabled = True + except Exception: + with self._pet_lock: + self._pet_enabled = False + self._pet_renderer = None + + def _pet_flash(self, state: str, secs: float = 1.6) -> None: + """Briefly force a transient reaction (wave/jump/failed) before resting.""" + self._pet_event = state + self._pet_event_until = time.monotonic() + secs + + def _pet_react_turn_end(self) -> None: + """Flash the end-of-turn beat: failed on error, jump on a finished plan, else wave.""" + if not self._pet_enabled: + return + from agent.pet.state import todos_all_done + + if self._pet_turn_error: + self._pet_flash("failed") + return + try: + store = getattr(self.agent, "_todo_store", None) + done = todos_all_done(store.read()) if store else False + except Exception: + done = False + self._pet_flash("jump" if done else "wave") + + def _derive_pet_state(self) -> str: + """Map current CLI activity to a pet animation state. + + A transient reaction beat (wave/jump/failed) wins while it's live; + otherwise the steady state comes from the shared + :func:`agent.pet.state.derive_pet_state` so the CLI can't drift from the + TUI/desktop priority order. + """ + if self._pet_event and time.monotonic() < self._pet_event_until: + return self._pet_event + self._pet_event = "" + from agent.pet.state import derive_pet_state + + # A live blocking modal (approval / clarify / sudo / secret / slash + # confirm) means the agent is paused on the user → the `waiting` pose, + # which outranks the in-flight signals in derive_pet_state. + awaiting_input = bool( + self._approval_state + or self._clarify_state + or self._sudo_state + or self._secret_state + or getattr(self, "_slash_confirm_state", None) + ) + + return derive_pet_state( + awaiting_input=awaiting_input, + busy=getattr(self, "_agent_running", False), + reasoning=self._pet_reasoning, + ).value + + def _pet_frames_for(self, state: str) -> list: + """Return (and cache) the half-block grids for one state.""" + cached = self._pet_frames_cache.get(state) + if cached is not None: + return cached + renderer = self._pet_renderer + if renderer is None: + return [] + try: + count = renderer.frame_count(state) or 1 + grids = [renderer.cells(state, i, cols=self._pet_cols) for i in range(count)] + except Exception: + grids = [] + self._pet_frames_cache[state] = grids + return grids + + def _pet_fragments(self): + """Return prompt_toolkit FormattedText for the current pet frame, or [].""" + with self._pet_lock: + if not self._pet_enabled or self._pet_renderer is None: + return [] + state = self._derive_pet_state() + grids = self._pet_frames_for(state) + if not grids: + return [] + grid = grids[self._pet_frame_idx % len(grids)] + + frags = [] + for y, row in enumerate(grid): + if y: + frags.append(("", "\n")) + for top, bottom in row: + tr, tg, tb, ta = top + br, bg, bb, ba = bottom + top_op = ta >= 32 + bot_op = ba >= 32 + if not top_op and not bot_op: + frags.append(("", " ")) + elif top_op and bot_op: + frags.append((f"fg:#{tr:02x}{tg:02x}{tb:02x} bg:#{br:02x}{bg:02x}{bb:02x}", "▀")) + elif top_op: + # Upper half only — leave the lower half the terminal's bg + # instead of painting it black (cleaner on light themes). + frags.append((f"fg:#{tr:02x}{tg:02x}{tb:02x}", "▀")) + else: + frags.append((f"fg:#{br:02x}{bg:02x}{bb:02x}", "▄")) + return frags + + def _pet_widget_height(self) -> int: + """Visible rows for the pet window — 0 collapses it when no pet shows.""" + with self._pet_lock: + if not self._pet_enabled or self._pet_renderer is None: + return 0 + grids = self._pet_frames_for(self._derive_pet_state()) + if not grids or not grids[0]: + return 0 + return len(grids[0]) + + def _pet_anim_loop(self) -> None: + """Advance the frame + invalidate on a timer while a pet is enabled.""" + while self._pet_anim_running: + time.sleep(self._PET_FRAME_INTERVAL) + now = time.monotonic() + if now - self._pet_cfg_checked >= self._PET_CFG_INTERVAL: + self._pet_cfg_checked = now + self._pet_resolve_config() + if not self._pet_enabled: + continue + with self._pet_lock: + self._pet_frame_idx += 1 + app = getattr(self, "_app", None) + if app is not None: + try: + app.invalidate() + except Exception: + pass + + def _pet_start_anim(self) -> None: + if self._pet_anim_running: + return + self._pet_resolve_config() + self._pet_anim_running = True + self._pet_anim_thread = threading.Thread(target=self._pet_anim_loop, daemon=True) + self._pet_anim_thread.start() + + def _pet_stop_anim(self) -> None: + self._pet_anim_running = False + thread = self._pet_anim_thread + if thread is not None: + thread.join(timeout=0.3) + self._pet_anim_thread = None + def _voice_record_key_label(self) -> str: """Return the configured voice push-to-talk key formatted for UI. @@ -7475,6 +7706,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): elif canonical == "personality": # Use original case (handler lowercases the personality name itself) self._handle_personality_command(cmd_original) + elif canonical == "pet": + self._handle_pet_command(cmd_original) elif canonical == "retry": retry_msg = self.retry_last() if retry_msg and hasattr(self, '_pending_input'): @@ -9638,6 +9871,15 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): stacked line to scrollback on tool.completed so users can see the full history of tool calls (not just the current one in the spinner). """ + # Feed the pet: tools mean "running" (not reasoning); a failed tool + # latches the turn so it ends on a sulk. + if event_type == "tool.started": + self._pet_reasoning = False + elif event_type == "tool.completed" and kwargs.get("is_error"): + self._pet_turn_error = True + elif event_type and event_type.startswith("reasoning"): + self._pet_reasoning = True + if event_type == "tool.completed": self._tool_start_time = 0.0 # Print stacked scrollback line for "all" / "new" modes @@ -11590,6 +11832,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): spinner_widget, spacer, *self._get_extra_tui_widgets(), + getattr(self, "_pet_widget", None), status_bar, input_rule_top, image_bar, @@ -12938,6 +13181,16 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): wrap_lines=True, ) + # Petdex mascot — right-aligned half-block sprite above the prompt, + # mirroring the TUI's PetPane. Collapses to height 0 when no pet is + # enabled, so it's a no-op for everyone else. The _pet_anim_loop thread + # advances frames + invalidates; align=RIGHT pins it to the edge. + self._pet_widget = Window( + content=FormattedTextControl(self._pet_fragments), + height=self._pet_widget_height, + align=WindowAlign.RIGHT, + ) + spacer = Window( content=FormattedTextControl(get_hint_text), height=get_hint_height, @@ -13708,6 +13961,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): # Regular chat - run agent self._agent_running = True + self._pet_turn_error = False + self._pet_reasoning = False app.invalidate() # Refresh status line try: @@ -13718,6 +13973,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): self._tool_start_time = 0.0 self._pending_tool_info.clear() self._last_scrollback_tool = "" + self._pet_reasoning = False + self._pet_react_turn_end() app.invalidate() # Refresh status line @@ -13950,6 +14207,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): # The app enables focus reporting + mouse tracking; record that # so _run_cleanup resets them on exit (#36823). _mark_tui_input_modes_active() + # Drive the petdex mascot animation (no-op when no pet enabled). + self._pet_start_anim() app.run() except (EOFError, KeyboardInterrupt, BrokenPipeError): pass @@ -13976,6 +14235,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): raise finally: self._should_exit = True + self._pet_stop_anim() # Interrupt the agent immediately so its daemon thread stops making # API calls and exits promptly (agent_thread is daemon, so the # process will exit once the main thread finishes, but interrupting diff --git a/hermes_cli/cli_commands_mixin.py b/hermes_cli/cli_commands_mixin.py index 499f8e9a1a5..a064321b4d1 100644 --- a/hermes_cli/cli_commands_mixin.py +++ b/hermes_cli/cli_commands_mixin.py @@ -1039,6 +1039,64 @@ class CLICommandsMixin: print(" Usage: /personality ") print() + def _handle_pet_command(self, cmd: str): + """Toggle, browse, or adopt a petdex mascot. + + ``/pet`` / ``/pet toggle`` → flip ``display.pet.enabled`` on/off + ``/pet list`` → browse the petdex gallery + ``/pet scale `` → resize the pet everywhere (e.g. 0.5) + ``/pet `` → adopt (install if needed) + make active + ``/pet off`` → disable (alias for toggle-off) + + Writes ``display.pet.*`` to config; the CLI/TUI/desktop pet surfaces + pick the change up on their next poll, so the pet appears shortly. + """ + from agent.pet import store + from agent.pet.manifest import ManifestError + from hermes_cli.pets import _set_active, _set_enabled, print_pet_gallery, set_pet_scale, toggle_pet_display + + parts = cmd.split(maxsplit=1) + arg = parts[1].strip() if len(parts) > 1 else "" + low = arg.lower() + + if not arg or low == "toggle": + enabled, name, err = toggle_pet_display() + if err: + print(f"(x_x) {err}") + return + if enabled: + print(f"(^_^)b {name} is out — it'll pop in shortly.") + else: + print(f"(-_-)zzZ {name} put away." if name else "(-_-)zzZ Pet put away.") + return + + if low in ("list", "gallery", "browse", "all"): + print_pet_gallery() + return + + if low == "scale" or low.startswith("scale "): + value = arg[len("scale"):].strip() + if not value: + print("(o_o) Usage: /pet scale (e.g. /pet scale 0.5)") + return + scale, err = set_pet_scale(value) + print(f"(x_x) {err}" if err else f"(^_^) Pet scale → {scale:g}.") + return + + if low == "off": + _set_enabled(False) + print("(-_-)zzZ Pet put away.") + return + + print(f"(o_o) Fetching '{arg}' from petdex…") + try: + pet = store.install_pet(arg) + except (store.PetStoreError, ManifestError) as exc: + print(f"(x_x) Couldn't adopt '{arg}': {exc}") + return + _set_active(arg) + print(f"(^_^)b {pet.display_name} is out — it'll pop in shortly.") + def _handle_cron_command(self, cmd: str): """Handle the /cron command to manage scheduled tasks.""" from cli import get_job diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 514e7f659b3..b7e19bdeebf 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -176,6 +176,8 @@ COMMAND_REGISTRY: list[CommandDef] = [ subcommands=("pending", "approve", "reject", "approval")), CommandDef("bundles", "List skill bundles (aliases / for multiple skills)", "Tools & Skills"), + CommandDef("pet", "Toggle or adopt a petdex mascot (/pet, /pet list, /pet )", "Tools & Skills", + cli_only=True, args_hint="[toggle|list|scale |]", subcommands=("toggle", "list", "scale", "off")), CommandDef("cron", "Manage scheduled tasks", "Tools & Skills", cli_only=True, args_hint="[subcommand]", subcommands=("list", "add", "create", "edit", "pause", "resume", "run", "remove")), diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 4508642d0cb..d29f92975c3 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -11083,7 +11083,7 @@ _BUILTIN_SUBCOMMANDS = frozenset( "config", "cron", "curator", "dashboard", "debug", "doctor", "dump", "fallback", "gateway", "hooks", "import", "insights", "gui", "desktop", "kanban", "login", "logout", "logs", "lsp", "mcp", "memory", "migrate", - "model", "pairing", "plugins", "portal", "postinstall", "profile", "proxy", + "model", "pairing", "pets", "plugins", "portal", "postinstall", "profile", "proxy", "prompt-size", "send", "sessions", "setup", "skills", "slack", "status", "tools", "uninstall", "update", @@ -11975,6 +11975,26 @@ def main(): except Exception as _exc: logging.getLogger(__name__).debug("curator CLI wiring failed: %s", _exc) + # ========================================================================= + # pets command — petdex animated mascots (CLI / TUI / desktop display) + # ========================================================================= + pets_parser = subparsers.add_parser( + "pets", + help="Browse, install, and select petdex animated pets", + description=( + "Petdex (https://github.com/crafter-station/petdex) is a public " + "gallery of animated sprite pets for coding agents. Install one " + "and Hermes shows it reacting to agent activity across the CLI, " + "TUI, and desktop app." + ), + ) + try: + from hermes_cli.pets import register_cli as _register_pets_cli + + _register_pets_cli(pets_parser) + except Exception as _exc: + logging.getLogger(__name__).debug("pets CLI wiring failed: %s", _exc) + # ========================================================================= # memory command (parser built in hermes_cli/subcommands/memory.py) # ========================================================================= diff --git a/hermes_cli/pets.py b/hermes_cli/pets.py new file mode 100644 index 00000000000..1cb74c63411 --- /dev/null +++ b/hermes_cli/pets.py @@ -0,0 +1,482 @@ +"""CLI subcommand: ``hermes pets ``. + +Thin shell around :mod:`agent.pet`. Browses the public petdex gallery, +installs pets into the profile's ``pets/`` directory, selects the active +mascot (writes ``display.pet.*`` to config.yaml), and runs a doctor check. + +No side effects at import time — ``main.py`` wires the argparse subparsers on +demand via :func:`register_cli`. +""" + +from __future__ import annotations + +import argparse +import sys + + +def _print(msg: str = "") -> None: + print(msg) + + +def _err(msg: str) -> None: + print(msg, file=sys.stderr) + + +def _cmd_list(args) -> int: + """List gallery pets (or only installed ones with ``--installed``).""" + from agent.pet import store + + if getattr(args, "installed", False): + pets = store.installed_pets() + if not pets: + _print("No pets installed. Try: hermes pets install boba") + return 0 + _print(f"Installed pets ({len(pets)}):") + for pet in pets: + _print(f" {pet.slug:<24} {pet.display_name}") + return 0 + + from agent.pet.manifest import ManifestError, fetch_manifest + + try: + entries = fetch_manifest() + except ManifestError as exc: + _err(f"✗ {exc}") + return 1 + + query = (getattr(args, "query", "") or "").strip().lower() + if query: + entries = [ + e + for e in entries + if query in e.slug.lower() or query in e.display_name.lower() + ] + + limit = getattr(args, "limit", 0) or 0 + shown = entries[:limit] if limit > 0 else entries + installed = {p.slug for p in store.installed_pets()} + + _print(f"petdex gallery — {len(entries)} pet(s){' matching ' + repr(query) if query else ''}:") + for entry in shown: + mark = "✓" if entry.slug in installed else " " + _print(f" {mark} {entry.slug:<28} {entry.display_name} ({entry.kind})") + if limit and len(entries) > limit: + _print(f" … {len(entries) - limit} more (use --limit 0 or --query to filter)") + _print("\nInstall one with: hermes pets install ") + return 0 + + +def _cmd_install(args) -> int: + from agent.pet import store + from agent.pet.manifest import ManifestError + + slug = args.slug.strip() + try: + pet = store.install_pet(slug, force=getattr(args, "force", False)) + except (store.PetStoreError, ManifestError) as exc: + _err(f"✗ install failed: {exc}") + return 1 + + _print(f"✓ installed {pet.display_name} → {pet.directory}") + + if getattr(args, "select", False) or not _has_active_pet(): + _set_active(slug) + _print(f"✓ {pet.display_name} is now the active pet (display.pet.slug={slug}, enabled)") + else: + _print(f" Make it active with: hermes pets select {slug}") + return 0 + + +def _cmd_remove(args) -> int: + from agent.pet import store + + slug = args.slug.strip() + if store.remove_pet(slug): + _print(f"✓ removed {slug}") + return 0 + _err(f"✗ '{slug}' is not installed") + return 1 + + +def _cmd_select(args) -> int: + from agent.pet import store + + slug = (getattr(args, "slug", "") or "").strip() + if not slug: + pets = store.installed_pets() + if not pets: + _err("✗ no pets installed — run: hermes pets install boba") + return 1 + slug = _interactive_pick(pets) + if not slug: + return 1 + + pet = store.load_pet(slug) + if pet is None or not pet.exists: + _err(f"✗ '{slug}' is not installed — run: hermes pets install {slug}") + return 1 + + _set_active(slug) + _print(f"✓ active pet set to {pet.display_name} (display.pet.slug={slug}, enabled)") + return 0 + + +def _cmd_off(args) -> int: + _set_enabled(False) + _print("✓ pet disabled (display.pet.enabled=false)") + return 0 + + +def _cmd_scale(args) -> int: + """Persist ``display.pet.scale`` — one knob resizes every surface.""" + scale, err = set_pet_scale(args.factor) + if err: + _err(f"✗ {err}") + return 1 + _print(f"✓ pet scale set to {scale:g} (display.pet.scale)") + return 0 + + +def _cmd_show(args) -> int: + """Animate the active (or named) pet in the terminal. + + Uses the shared :class:`~agent.pet.render.PetRenderer` — full graphics + protocol (kitty/iTerm2/sixel) when the terminal supports it, else a + truecolor Unicode half-block fallback. Ctrl+C to stop. + """ + import time + + from agent.pet import store + from agent.pet.constants import DEFAULT_SCALE, LOOP_MS, STATE_ROWS, PetState, resolve_cols + from agent.pet.render import build_renderer + + cfg = _pet_config() + slug = (getattr(args, "slug", "") or "").strip() or str(cfg.get("slug", "") or "") + pet = store.resolve_active_pet(slug) + if pet is None: + _err("✗ no pet to show — run: hermes pets install boba") + return 1 + + mode_cfg = getattr(args, "mode", None) or str(cfg.get("render_mode", "auto") or "auto") + scale = float(getattr(args, "scale", 0) or cfg.get("scale", DEFAULT_SCALE) or DEFAULT_SCALE) + cols = resolve_cols(scale, cfg.get("unicode_cols", 0)) + + renderer = build_renderer( + pet.spritesheet, + configured_mode=mode_cfg, + scale=scale, + unicode_cols=cols, + ) + if not renderer.available: + _err( + "✗ cannot render here (no TTY / graphics disabled). " + f"Effective mode: {renderer.mode}." + ) + return 1 + + # Which states to play: one named state, or cycle the driveable rows. + requested = (getattr(args, "state", "") or "").strip().lower() + if requested: + states = [requested] + elif getattr(args, "cycle", False): + states = [s for s in STATE_ROWS if s in {e.value for e in PetState}] + else: + states = [PetState.IDLE.value] + + is_unicode = renderer.mode == "unicode" + frame_delay = max(0.05, (LOOP_MS / 1000.0) / max(1, renderer.frame_count(states[0]) or 1)) + + # Right-align the sprite against the terminal's right edge — half-blocks by + # indenting each row, graphics protocols by padding the cursor to the right + # column before the image draws (kitty/iTerm/sixel all render at the cursor). + import shutil + + term_cols = shutil.get_terminal_size((80, 24)).columns + indent = "" + g_indent = "" + if is_unicode: + indent = " " * max(0, term_cols - cols - 1) + else: + cell_cols = max(1, int(renderer.frame_w * renderer.scale) // 8) + g_indent = " " * max(0, term_cols - cell_cols - 1) + + out = sys.stdout + out.write("\x1b[?25l") # hide cursor + out.flush() + prev_lines = 0 + try: + _print(f"{pet.display_name} — mode={renderer.mode} (Ctrl+C to stop)") + loops = 0 + while True: + for state in states: + count = renderer.frame_count(state) or 1 + for i in range(count): + encoded = renderer.frame(state, i) + if is_unicode: + if indent: + encoded = "\n".join(indent + ln for ln in encoded.split("\n")) + if prev_lines: + out.write(f"\x1b[{prev_lines}F") # cursor up to redraw in place + out.write(encoded) + out.write("\x1b[0m\n") + # Lines drawn = sprite rows + the trailing newline; move + # back up exactly that many so the next frame overwrites. + prev_lines = encoded.count("\n") + 1 + else: + out.write("\x1b[2J\x1b[3J\x1b[H") # clear for image protocols + out.write(f"{pet.display_name} [{state}]\n") + if g_indent: + out.write(g_indent) + out.write(encoded) + out.write("\n") + out.flush() + time.sleep(frame_delay) + loops += 1 + if getattr(args, "once", False) and loops >= len(states): + break + except KeyboardInterrupt: + pass + finally: + out.write("\x1b[?25h") # show cursor + out.write("\x1b[0m\n") + out.flush() + return 0 + + +def _cmd_doctor(args) -> int: + """Report install state, active pet, config, and terminal capability.""" + from agent.pet import store + from agent.pet.render import detect_terminal_graphics, resolve_mode + + cfg = _pet_config() + enabled = bool(cfg.get("enabled")) + configured_slug = str(cfg.get("slug", "") or "") + mode_cfg = str(cfg.get("render_mode", "auto") or "auto") + + pets = store.installed_pets() + active = store.resolve_active_pet(configured_slug) + + _print("petdex doctor") + _print(f" pets dir: {store.pets_dir()}") + _print(f" installed: {len(pets)} ({', '.join(p.slug for p in pets) or 'none'})") + _print(f" display.pet.enabled: {enabled}") + _print(f" display.pet.slug: {configured_slug or '(unset)'}") + _print(f" active (resolved): {active.slug if active else '(none)'}") + _print(f" display.pet.render_mode: {mode_cfg}") + _print(f" detected graphics: {detect_terminal_graphics()}") + _print(f" effective mode (TTY): {resolve_mode(mode_cfg)}") + + ok = True + if not pets: + _print(" → no pets installed. Run: hermes pets install boba") + ok = False + elif active is None: + _print(" → active pet unresolved. Run: hermes pets select ") + ok = False + elif not enabled: + _print(" → pet display is disabled. Run: hermes pets select " + active.slug) + + try: + import PIL # noqa: F401 + except ImportError: + _print(" ✗ Pillow not importable — sprite decoding will be unavailable") + ok = False + + _print(" ✓ ready" if ok and enabled else " (run the suggestions above to finish setup)") + return 0 + + +# ───────────────────────────────────────────────────────────────────────── +# config helpers +# ───────────────────────────────────────────────────────────────────────── + +def _pet_config() -> dict: + from hermes_cli.config import load_config + + cfg = load_config() + display = cfg.get("display", {}) if isinstance(cfg.get("display"), dict) else {} + pet = display.get("pet", {}) + return pet if isinstance(pet, dict) else {} + + +def _has_active_pet() -> bool: + return bool(_pet_config().get("enabled")) and bool(_pet_config().get("slug")) + + +def _set_active(slug: str) -> None: + from hermes_cli.config import load_config, save_config + + cfg = load_config() + display = cfg.setdefault("display", {}) + pet = display.setdefault("pet", {}) + pet["slug"] = slug + pet["enabled"] = True + save_config(cfg) + + +def _set_enabled(enabled: bool) -> None: + from hermes_cli.config import load_config, save_config + + cfg = load_config() + display = cfg.setdefault("display", {}) + pet = display.setdefault("pet", {}) + pet["enabled"] = enabled + save_config(cfg) + + +def _set_scale(scale: float) -> None: + from hermes_cli.config import load_config, save_config + + cfg = load_config() + display = cfg.setdefault("display", {}) + pet = display.setdefault("pet", {}) + pet["scale"] = scale + save_config(cfg) + + +def set_pet_scale(value: float | str) -> tuple[float, str | None]: + """Set ``display.pet.scale`` (clamped to bounds). Returns ``(applied, error)``. + + The single write path behind ``/pet scale`` and the desktop slider, so every + surface that resolves scale from config picks it up identically. *error* is + set (and nothing written) only when *value* isn't a number. + """ + from agent.pet.constants import clamp_scale + + try: + scale = clamp_scale(float(value)) + except (TypeError, ValueError): + return 0.0, f"not a number: {value!r} — try a value like 0.5" + + _set_scale(scale) + return scale, None + + +def toggle_pet_display() -> tuple[bool, str | None, str | None]: + """Toggle ``display.pet.enabled``. + + Returns ``(enabled, display_name, error_message)``. *error_message* is set + when turning on but nothing is installed to show. + """ + from agent.pet import store + + cfg = _pet_config() + slug = str(cfg.get("slug", "") or "") + pet = store.resolve_active_pet(slug) + + if bool(cfg.get("enabled")): + _set_enabled(False) + return False, pet.display_name if pet else None, None + + if pet is None: + installed = store.installed_pets() + if not installed: + return False, None, "no pets installed — /pet list to browse, or /pet to adopt" + pet = installed[0] + _set_active(pet.slug) + else: + _set_enabled(True) + return True, pet.display_name, None + + +def print_pet_gallery(*, limit: int = 20) -> None: + """Print a slice of the public petdex gallery (CLI/TUI text fallback).""" + from agent.pet import store + from agent.pet.manifest import ManifestError, fetch_manifest + + try: + entries = fetch_manifest() + except ManifestError as exc: + print(f"(._.) Couldn't reach the petdex gallery: {exc}") + return + + installed = {p.slug for p in store.installed_pets()} + shown = entries[:limit] if limit > 0 else entries + print(f"(^o^)/ petdex gallery — first {len(shown)} of {len(entries)}:") + for entry in shown: + mark = "●" if entry.slug in installed else "○" + print(f" {mark} {entry.slug:<24} {entry.display_name}") + print(" /pet to adopt · /pet to toggle") + + +def _clear_active_if(slug: str) -> bool: + """Disable + unset the active pet iff it's ``slug`` (e.g. after removal). + + Returns whether anything changed, so callers don't write config needlessly. + """ + from hermes_cli.config import load_config, save_config + + cfg = load_config() + pet = cfg.setdefault("display", {}).setdefault("pet", {}) + if not isinstance(pet, dict) or str(pet.get("slug", "") or "") != slug: + return False + pet["slug"] = "" + pet["enabled"] = False + save_config(cfg) + return True + + +def _interactive_pick(pets) -> str: + """Minimal numbered picker (avoids curses dep for a tiny list).""" + _print("Installed pets:") + for i, pet in enumerate(pets, 1): + _print(f" {i}. {pet.slug:<24} {pet.display_name}") + try: + choice = input("Select a pet [1]: ").strip() or "1" + idx = int(choice) - 1 + except (EOFError, KeyboardInterrupt, ValueError): + _err("✗ cancelled") + return "" + if 0 <= idx < len(pets): + return pets[idx].slug + _err("✗ invalid selection") + return "" + + +# ───────────────────────────────────────────────────────────────────────── +# argparse wiring +# ───────────────────────────────────────────────────────────────────────── + +def register_cli(parent: argparse.ArgumentParser) -> None: + """Attach ``pets`` subcommands to *parent* (called by main.py).""" + parent.set_defaults(func=lambda a: (parent.print_help(), 0)[1]) + subs = parent.add_subparsers(dest="pets_command") + + p_list = subs.add_parser("list", help="Browse the petdex gallery") + p_list.add_argument("query", nargs="?", default="", help="Filter by slug/name substring") + p_list.add_argument("--installed", action="store_true", help="Only show installed pets") + p_list.add_argument("--limit", type=int, default=40, help="Max rows (0 = all)") + p_list.set_defaults(func=_cmd_list) + + p_install = subs.add_parser("install", help="Install a pet from the gallery") + p_install.add_argument("slug", help="Pet slug (e.g. boba)") + p_install.add_argument("--force", action="store_true", help="Re-download even if present") + p_install.add_argument("--select", action="store_true", help="Make it the active pet") + p_install.set_defaults(func=_cmd_install) + + p_select = subs.add_parser("select", help="Set the active pet (writes display.pet.*)") + p_select.add_argument("slug", nargs="?", default="", help="Pet slug (omit for picker)") + p_select.set_defaults(func=_cmd_select) + + p_show = subs.add_parser("show", help="Animate the active pet in the terminal") + p_show.add_argument("slug", nargs="?", default="", help="Pet slug (default: active)") + p_show.add_argument("--state", default="", help="Single state: idle/run/review/failed/wave/jump") + p_show.add_argument("--cycle", action="store_true", help="Cycle through all states") + p_show.add_argument("--once", action="store_true", help="Play once instead of looping") + p_show.add_argument("--mode", default=None, help="Override render mode (kitty/iterm/sixel/unicode/auto)") + p_show.add_argument("--scale", type=float, default=0, help="Override scale (0 = config)") + p_show.set_defaults(func=_cmd_show) + + subs.add_parser("off", help="Disable the pet display").set_defaults(func=_cmd_off) + + p_scale = subs.add_parser("scale", help="Resize the pet everywhere (display.pet.scale)") + p_scale.add_argument("factor", help="Scale factor, e.g. 0.5 (clamped 0.1–3.0)") + p_scale.set_defaults(func=_cmd_scale) + + p_remove = subs.add_parser("remove", help="Delete an installed pet") + p_remove.add_argument("slug", help="Pet slug") + p_remove.set_defaults(func=_cmd_remove) + + subs.add_parser("doctor", help="Check pet setup + terminal graphics support").set_defaults( + func=_cmd_doctor + ) diff --git a/tests/cli/test_cli_pet_pane.py b/tests/cli/test_cli_pet_pane.py new file mode 100644 index 00000000000..f848971d85a --- /dev/null +++ b/tests/cli/test_cli_pet_pane.py @@ -0,0 +1,136 @@ +"""The base-CLI petdex pane: reactive half-block sprite above the prompt. + +Mirrors the TUI's PetPane. The methods are tested in isolation via __new__ so +we don't pay the full HermesCLI.__init__ cost; a synthetic spritesheet exercises +the real engine decode + half-block fragment building. +""" + +from __future__ import annotations + +import threading + +import pytest + +from agent.pet import store +from agent.pet.constants import FRAME_H, FRAME_W +from agent.pet.render import PetRenderer +from cli import HermesCLI + + +@pytest.fixture +def boba_like(tmp_path, monkeypatch): + """Install a synthetic pet into a temp HERMES_HOME and return its slug.""" + from PIL import Image + + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + + cols, rows = 8, 9 + sheet = Image.new("RGBA", (FRAME_W * cols, FRAME_H * rows), (0, 0, 0, 0)) + for r in range(rows): + color = (20 + r * 25, 60, 120, 255) + for c in range(cols): + block = Image.new("RGBA", (FRAME_W, FRAME_H), color) + sheet.paste(block, (c * FRAME_W, r * FRAME_H)) + + pet_dir = store.pets_dir() / "boba" + pet_dir.mkdir(parents=True, exist_ok=True) + sheet.save(pet_dir / "spritesheet.webp") + (pet_dir / "pet.json").write_text( + '{"id":"boba","displayName":"Boba","description":"d","spritesheetPath":"spritesheet.webp"}' + ) + return "boba" + + +def _make_cli(): + cli_obj = HermesCLI.__new__(HermesCLI) + cli_obj._app = None + cli_obj._pet_lock = threading.Lock() + cli_obj._pet_enabled = False + cli_obj._pet_renderer = None + cli_obj._pet_slug = "" + cli_obj._pet_cols = 18 + cli_obj._pet_scale = 0.7 + cli_obj._pet_frames_cache = {} + cli_obj._pet_frame_idx = 0 + cli_obj._agent_running = False + # Transient-beat + reasoning state (set by HermesCLI.__init__ in production). + cli_obj._pet_event = "" + cli_obj._pet_event_until = 0.0 + cli_obj._pet_reasoning = False + # Blocking-modal state — a live one maps the pet to `waiting`. + cli_obj._approval_state = None + cli_obj._clarify_state = None + cli_obj._sudo_state = None + cli_obj._secret_state = None + cli_obj._slash_confirm_state = None + return cli_obj + + +def test_pet_state_tracks_agent_running(): + cli_obj = _make_cli() + assert cli_obj._derive_pet_state() == "idle" + cli_obj._agent_running = True + assert cli_obj._derive_pet_state() == "run" + + +def test_pet_state_waits_on_a_blocking_modal(): + # A live clarify/approval pauses the agent on the user → `waiting`, even + # while the turn is technically still running. + cli_obj = _make_cli() + cli_obj._agent_running = True + cli_obj._clarify_state = {"question": "?"} + assert cli_obj._derive_pet_state() == "waiting" + + +def test_pet_pane_collapsed_when_disabled(): + # No renderer resolved → the window reports zero height and no fragments, + # so it's invisible for users without a pet. + cli_obj = _make_cli() + assert cli_obj._pet_widget_height() == 0 + assert cli_obj._pet_fragments() == [] + + +def test_pet_fragments_render_half_blocks(boba_like): + cli_obj = _make_cli() + cli_obj._pet_renderer = PetRenderer( + str(store.load_pet("boba").spritesheet), mode="unicode", scale=0.4, unicode_cols=14 + ) + cli_obj._pet_cols = 14 + cli_obj._pet_enabled = True + + height = cli_obj._pet_widget_height() + assert height > 0 + + frags = cli_obj._pet_fragments() + assert frags, "expected fragments for an enabled pet" + # Each fragment is a (style, text) pair; glyphs are half-blocks or blanks. + glyphs = {text for _, text in frags} + assert glyphs <= {"▀", "▄", " ", "\n"} + # Opaque cells carry a truecolor foreground style. + assert any(text == "▀" and "fg:#" in style for style, text in frags) + # Row count in the fragment stream matches the reported window height. + assert sum(1 for _, text in frags if text == "\n") == height - 1 + + +def test_pet_resolve_config_enables_and_disables(boba_like): + from hermes_cli.config import load_config, save_config + + cli_obj = _make_cli() + + cfg = load_config() + cfg.setdefault("display", {}).setdefault("pet", {}) + cfg["display"]["pet"].update({"enabled": True, "slug": "boba"}) + save_config(cfg) + + cli_obj._pet_resolve_config() + assert cli_obj._pet_enabled is True + assert cli_obj._pet_renderer is not None + assert cli_obj._pet_slug == "boba" + + cfg["display"]["pet"]["enabled"] = False + save_config(cfg) + cli_obj._pet_resolve_config() + assert cli_obj._pet_enabled is False + assert cli_obj._pet_renderer is None diff --git a/tests/hermes_cli/test_pet_toggle.py b/tests/hermes_cli/test_pet_toggle.py new file mode 100644 index 00000000000..b423e46fab0 --- /dev/null +++ b/tests/hermes_cli/test_pet_toggle.py @@ -0,0 +1,104 @@ +"""Tests for pet slash-command config helpers.""" + +from __future__ import annotations + +import pytest + +from agent.pet import store +from agent.pet.constants import FRAME_H, FRAME_W + + +@pytest.fixture +def boba_installed(tmp_path, monkeypatch): + from PIL import Image + + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + + sheet = Image.new("RGBA", (FRAME_W * 8, FRAME_H * 9), (0, 0, 0, 0)) + pet_dir = store.pets_dir() / "boba" + pet_dir.mkdir(parents=True, exist_ok=True) + sheet.save(pet_dir / "spritesheet.webp") + (pet_dir / "pet.json").write_text( + '{"id":"boba","displayName":"Boba","description":"d","spritesheetPath":"spritesheet.webp"}' + ) + return home + + +def _write_config(home, *, enabled: bool, slug: str = "") -> None: + import yaml + + cfg = {"display": {"pet": {"enabled": enabled, "slug": slug, "scale": 0.33}}} + (home / "config.yaml").write_text(yaml.dump(cfg), encoding="utf-8") + + +def test_toggle_pet_display_turns_off_when_enabled(boba_installed): + from hermes_cli.pets import _pet_config, toggle_pet_display + + _write_config(boba_installed, enabled=True, slug="boba") + + enabled, name, err = toggle_pet_display() + + assert err is None + assert enabled is False + assert name == "Boba" + assert _pet_config()["enabled"] is False + + +def test_toggle_pet_display_turns_on_resolved_pet(boba_installed): + from hermes_cli.pets import _pet_config, toggle_pet_display + + _write_config(boba_installed, enabled=False, slug="boba") + + enabled, name, err = toggle_pet_display() + + assert err is None + assert enabled is True + assert name == "Boba" + assert _pet_config()["enabled"] is True + + +def test_toggle_pet_display_errors_with_no_installed_pets(tmp_path, monkeypatch): + from hermes_cli.pets import toggle_pet_display + + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + _write_config(home, enabled=False, slug="") + + enabled, name, err = toggle_pet_display() + + assert enabled is False + assert name is None + assert err is not None + + +@pytest.fixture +def empty_home(tmp_path, monkeypatch): + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + return home + + +def test_set_pet_scale_writes_clamped_value(empty_home): + from agent.pet.constants import MAX_SCALE, MIN_SCALE + from hermes_cli.pets import _pet_config, set_pet_scale + + applied, err = set_pet_scale("0.5") + assert err is None + assert applied == 0.5 + assert _pet_config()["scale"] == 0.5 + + # Out-of-range values clamp to the bounds rather than erroring. + assert set_pet_scale(99) == (MAX_SCALE, None) + assert set_pet_scale(0) == (MIN_SCALE, None) + + +def test_set_pet_scale_rejects_non_numbers(empty_home): + from hermes_cli.pets import set_pet_scale + + applied, err = set_pet_scale("huge") + assert applied == 0.0 + assert err is not None