refactor(pets): unify the /pet slash command; drop /pets

/pet now toggles, browses (/pet list), adopts (/pet <slug>), and resizes
(/pet scale <n>) across CLI, TUI, and desktop; the separate /pets command is
removed. CLI pet-state derivation delegates to agent.pet.state.derive_pet_state
so the surfaces can't drift. Adds set_pet_scale/toggle/gallery helpers +
`hermes pets scale`.
This commit is contained in:
Brooklyn Nicholson 2026-06-16 18:35:09 -05:00
parent b6abd39ca5
commit 52134078e3
10 changed files with 273 additions and 79 deletions

View File

@ -52,6 +52,16 @@ describe('desktop slash command curation', () => {
expect(desktopSlashUnavailableMessage('/personality')).toBeNull()
})
it('routes /pet through the desktop action handler and drops /pets', () => {
expect(resolveDesktopCommand('/pet')?.surface).toEqual({ kind: 'action', action: 'pet' })
expect(resolveDesktopCommand('/pet')?.args).toBe(true)
expect(isDesktopSlashSuggestion('/pet')).toBe(true)
expect(isDesktopSlashCommand('/pet')).toBe(true)
expect(resolveDesktopCommand('/pets')?.surface).toEqual({ kind: 'unavailable', reason: 'settings' })
expect(isDesktopSlashSuggestion('/pets')).toBe(false)
expect(isDesktopSlashCommand('/pets')).toBe(false)
})
it('treats /browser as an executable action command (local-gateway connect)', () => {
// /browser used to be terminal-only; it now resolves to a desktop action
// handler that routes browser.manage RPC when the gateway is local.

View File

@ -34,6 +34,7 @@ export type DesktopActionId =
| 'handoff'
| 'help'
| 'new'
| 'pet'
| 'profile'
| 'skin'
| 'title'
@ -128,8 +129,7 @@ const DESKTOP_COMMAND_SPECS: readonly DesktopCommandSpec[] = [
{ name: '/debug', description: 'Create a debug report', surface: exec() },
{ name: '/goal', description: 'Manage the standing goal for this session', surface: exec() },
{ name: '/personality', description: 'Switch personality for this session', surface: exec(), args: true },
{ name: '/pet', description: 'Bring out a petdex mascot (/pet boba, /pet off)', surface: exec(), args: true },
{ name: '/pets', description: 'List your pets, or browse the petdex gallery', surface: exec(), args: true },
{ name: '/pet', description: 'Toggle or adopt a petdex mascot (/pet, /pet list, /pet boba)', surface: action('pet'), args: true },
{ name: '/queue', description: 'Queue a prompt for the next turn', aliases: ['/q'], surface: exec() },
{ name: '/retry', description: 'Retry the last user message', surface: exec() },
{ name: '/rollback', description: 'List or restore filesystem checkpoints', surface: exec() },
@ -157,7 +157,7 @@ const NO_DESKTOP_SURFACE: Record<DesktopUnavailableReason, readonly string[]> =
'/sb', '/set-home', '/sethome', '/snap', '/snapshot', '/statusbar', '/toolsets', '/update', '/verbose'
],
messaging: ['/approve', '/deny'],
settings: ['/skills'],
settings: ['/skills', '/pets'],
advanced: ['/curator', '/fast', '/insights', '/kanban', '/reasoning', '/voice']
}

19
cli.py
View File

@ -4222,17 +4222,22 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
self._pet_flash("jump" if done else "wave")
def _derive_pet_state(self) -> str:
"""Map current CLI activity to a pet animation state (mirrors the TUI).
"""Map current CLI activity to a pet animation state.
A transient reaction beat wins while it's live; otherwise reasoning →
``review``, an in-flight turn ``run``, idle at rest.
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 = ""
if getattr(self, "_agent_running", False):
return "review" if self._pet_reasoning else "run"
return "idle"
from agent.pet.state import derive_pet_state
return derive_pet_state(
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."""
@ -7650,8 +7655,6 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
self._handle_personality_command(cmd_original)
elif canonical == "pet":
self._handle_pet_command(cmd_original)
elif canonical == "pets":
self._handle_pets_command(cmd_original)
elif canonical == "retry":
retry_msg = self.retry_last()
if retry_msg and hasattr(self, '_pending_input'):

View File

@ -1040,29 +1040,47 @@ class CLICommandsMixin:
print()
def _handle_pet_command(self, cmd: str):
"""Bring out a petdex mascot (selection only — see ``/pets`` for the list).
"""Toggle, browse, or adopt a petdex mascot.
``/pet <slug>`` adopt (install if needed) + make active
``/pet off`` put the pet away
``/pet`` show the active pet + a pointer to ``/pets``
``/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 _pet_config, _set_active, _set_enabled
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:
cfg = _pet_config()
active = store.resolve_active_pet(str(cfg.get("slug", "") or ""))
state = "on" if cfg.get("enabled") else "off"
print(f"(^_^) Pet: {state} · active: {active.display_name if active else 'none'}")
print(" /pet <slug> to bring one out · /pet off · /pets for your collection")
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":
@ -1079,48 +1097,6 @@ class CLICommandsMixin:
_set_active(arg)
print(f"(^_^)b {pet.display_name} is out — it'll pop in shortly.")
def _handle_pets_command(self, cmd: str):
"""List your pets, or browse the petdex gallery.
``/pets`` your installed pets (active one marked)
``/pets gallery`` browse the petdex catalog (first 20)
"""
from agent.pet import store
from agent.pet.manifest import ManifestError, fetch_manifest
from hermes_cli.pets import _pet_config
parts = cmd.split(maxsplit=1)
arg = parts[1].strip().lower() if len(parts) > 1 else ""
if arg in ("gallery", "all", "browse"):
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()}
print(f"(^o^)/ petdex gallery — first 20 of {len(entries)}:")
for entry in entries[:20]:
mark = "" if entry.slug in installed else ""
print(f" {mark} {entry.slug:<24} {entry.display_name}")
print(" /pet <slug> to bring one out")
return
cfg = _pet_config()
active = store.resolve_active_pet(str(cfg.get("slug", "") or ""))
active_slug = active.slug if active else ""
installed = store.installed_pets()
state = "on" if cfg.get("enabled") else "off"
print(f"(^_^) Your pets ({state}):")
if not installed:
print(" none yet — /pet boba to adopt one · /pets gallery to browse")
return
for p in installed:
mark = "" if p.slug == active_slug else " "
print(f" {mark} {p.slug:<24} {p.display_name}")
print(" /pet <slug> to switch · /pet off · /pets gallery to browse")
def _handle_cron_command(self, cmd: str):
"""Handle the /cron command to manage scheduled tasks."""
from cli import get_job

View File

@ -176,10 +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", "Bring out a petdex mascot (/pet <slug>, /pet off)", "Tools & Skills",
cli_only=True, args_hint="<slug|off>", subcommands=("off",)),
CommandDef("pets", "List your pets, or browse the petdex gallery", "Tools & Skills",
cli_only=True, args_hint="[gallery]", subcommands=("gallery",)),
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")),

View File

@ -127,6 +127,16 @@ def _cmd_off(args) -> int:
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.
@ -314,6 +324,81 @@ def _set_enabled(enabled: bool) -> None:
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).
@ -384,6 +469,10 @@ def register_cli(parent: argparse.ArgumentParser) -> None:
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.13.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)

View File

@ -55,6 +55,10 @@ def _make_cli():
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
return cli_obj

View 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

View File

@ -208,17 +208,28 @@ describe('createSlashHandler', () => {
})
})
it('opens the pet picker locally for bare /pet and /pet list', () => {
it('opens the pet picker for /pet list only', () => {
const ctx = buildCtx()
expect(createSlashHandler(ctx)('/pet')).toBe(true)
expect(createSlashHandler(ctx)('/pet list')).toBe(true)
expect(getOverlayState().petPicker).toBe(true)
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
resetOverlayState()
expect(createSlashHandler(ctx)('/pet list')).toBe(true)
expect(getOverlayState().petPicker).toBe(true)
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
expect(createSlashHandler(ctx)('/pet')).toBe(true)
expect(getOverlayState().petPicker).toBe(false)
expect(ctx.gateway.gw.request).toHaveBeenCalledWith(
'slash.exec',
expect.objectContaining({ command: 'pet' })
)
resetOverlayState()
expect(createSlashHandler(ctx)('/pet toggle')).toBe(true)
expect(getOverlayState().petPicker).toBe(false)
expect(ctx.gateway.gw.request).toHaveBeenCalledWith(
'slash.exec',
expect.objectContaining({ command: 'pet toggle' })
)
})
it('routes /pet <slug> to the slash worker without opening the picker', () => {

View File

@ -342,19 +342,18 @@ export const sessionCommands: SlashCommand[] = [
},
{
help: 'pick / adopt an animated pet',
help: 'toggle / adopt / resize an animated pet',
name: 'pet',
usage: '/pet [list | <slug> | off]',
usage: '/pet [toggle | list | scale <n> | <slug>]',
run: (arg, ctx, cmd) => {
const sub = arg.trim().toLowerCase()
// No slug (or an explicit "list") → the interactive picker; the TUI can
// do this even though the text `/pet` path only prints. status/off/<slug>
// keep their text behaviour via the slash worker.
if (!sub || sub === 'list') {
// Gallery picker — the interactive browse surface.
if (sub === 'list') {
return patchOverlayState({ petPicker: true })
}
// Bare /pet and /pet toggle flip display.pet.enabled via the slash worker.
ctx.gateway.gw
.request<SlashExecResponse>('slash.exec', { command: cmd.slice(1), session_id: ctx.sid })
.then(