mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-27 11:22:03 +00:00
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.
This commit is contained in:
parent
e7dbfdaad7
commit
83aa84ae3b
7 changed files with 1064 additions and 2 deletions
262
cli.py
262
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
|
||||
|
|
|
|||
|
|
@ -1039,6 +1039,64 @@ class CLICommandsMixin:
|
|||
print(" Usage: /personality <name>")
|
||||
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 <n>`` → resize the pet everywhere (e.g. 0.5)
|
||||
``/pet <slug>`` → 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 <factor> (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
|
||||
|
|
|
|||
|
|
@ -176,6 +176,8 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
|||
subcommands=("pending", "approve", "reject", "approval")),
|
||||
CommandDef("bundles", "List skill bundles (aliases /<name> for multiple skills)",
|
||||
"Tools & Skills"),
|
||||
CommandDef("pet", "Toggle or adopt a petdex mascot (/pet, /pet list, /pet <slug>)", "Tools & Skills",
|
||||
cli_only=True, args_hint="[toggle|list|scale <n>|<slug>]", 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")),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
# =========================================================================
|
||||
|
|
|
|||
482
hermes_cli/pets.py
Normal file
482
hermes_cli/pets.py
Normal file
|
|
@ -0,0 +1,482 @@
|
|||
"""CLI subcommand: ``hermes pets <subcommand>``.
|
||||
|
||||
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 <slug>")
|
||||
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 <slug>")
|
||||
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 <slug> 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 <slug> 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
|
||||
)
|
||||
136
tests/cli/test_cli_pet_pane.py
Normal file
136
tests/cli/test_cli_pet_pane.py
Normal file
|
|
@ -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
|
||||
104
tests/hermes_cli/test_pet_toggle.py
Normal file
104
tests/hermes_cli/test_pet_toggle.py
Normal file
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue