From e7dbfdaad7b1bb45cd9eb9c09a03c0213c65320d Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 20 Jun 2026 14:18:30 -0500 Subject: [PATCH 1/6] feat(pets): pet engine + display.pet config Add the shared pet engine under agent/pet/: spritesheet manifest loading and in-process caching, six-state animation model, frame rendering, and the persistent pet store. Register the display.pet config block (pet, scale, enabled, etc.) that every surface reads from. Covered by tests/agent/test_pet_engine.py. --- agent/pet/__init__.py | 51 +++ agent/pet/constants.py | 167 +++++++++ agent/pet/manifest.py | 128 +++++++ agent/pet/render.py | 618 +++++++++++++++++++++++++++++++++ agent/pet/state.py | 81 +++++ agent/pet/store.py | 316 +++++++++++++++++ hermes_cli/config.py | 25 ++ tests/agent/test_pet_engine.py | 371 ++++++++++++++++++++ 8 files changed, 1757 insertions(+) create mode 100644 agent/pet/__init__.py create mode 100644 agent/pet/constants.py create mode 100644 agent/pet/manifest.py create mode 100644 agent/pet/render.py create mode 100644 agent/pet/state.py create mode 100644 agent/pet/store.py create mode 100644 tests/agent/test_pet_engine.py diff --git a/agent/pet/__init__.py b/agent/pet/__init__.py new file mode 100644 index 00000000000..b045598d2eb --- /dev/null +++ b/agent/pet/__init__.py @@ -0,0 +1,51 @@ +"""Petdex pet engine — shared core for the CLI, TUI, and desktop surfaces. + +Petdex (https://github.com/crafter-station/petdex) is a public gallery of +animated sprite "pets" for coding agents. Each pet is a ``pet.json`` plus a +``spritesheet.{webp,png}`` of 192×208 px cells. Current Codex/petdex sheets use +an 8-column × 9-row atlas; older Hermes/petdex sheets used an 8-row atlas. +Hermes infers the row taxonomy from the sheet and maps agent activity onto +idle/run/review/failed/wave/jump. + +This package is the **single source of truth** for the feature so the base +CLI (Python) and TUI (Ink, via ``tui_gateway``) never duplicate the hard +parts: + +- :mod:`agent.pet.constants` — frame geometry + the :class:`PetState` enum. +- :mod:`agent.pet.state` — map agent activity → a :class:`PetState`. +- :mod:`agent.pet.manifest` — fetch the public petdex manifest. +- :mod:`agent.pet.store` — install / list / resolve pets on disk + (profile-aware via ``get_hermes_home()``). +- :mod:`agent.pet.render` — decode a spritesheet and encode frames for a + terminal (kitty / iTerm2 / sixel graphics + protocols, with a Unicode half-block + fallback). + +Rendering in the Electron desktop is necessarily TypeScript (canvas), but it +reuses the same on-disk store and the same state semantics. + +The whole feature is a *display* concern: it adds no model tool, mutates no +system prompt or toolset, and therefore has zero effect on prompt caching. +""" + +from agent.pet.constants import ( + DEFAULT_SCALE, + FRAME_H, + FRAME_W, + FRAMES_PER_STATE, + LOOP_MS, + STATE_ROWS, + PetState, +) +from agent.pet.state import derive_pet_state + +__all__ = [ + "DEFAULT_SCALE", + "FRAME_H", + "FRAME_W", + "FRAMES_PER_STATE", + "LOOP_MS", + "STATE_ROWS", + "PetState", + "derive_pet_state", +] diff --git a/agent/pet/constants.py b/agent/pet/constants.py new file mode 100644 index 00000000000..a7e816c4012 --- /dev/null +++ b/agent/pet/constants.py @@ -0,0 +1,167 @@ +"""Pet sprite geometry + animation-state taxonomy. + +These values are the common petdex/Codex pet geometry. The real ``pet.json`` +usually only carries ``id``/``displayName``/``description``/``spritesheetPath``; +row taxonomy is inferred from the atlas shape so Hermes can render both legacy +8-row sheets and current 9-row Codex sheets. +""" + +from __future__ import annotations + +from enum import Enum + +# Frame geometry (pixels). Current Codex/petdex spritesheets are 8 columns x 9 +# rows (1536x1872), while older Hermes/petdex sheets used 9 columns x 8 rows +# (1728x1664). Renderers derive both row taxonomy and real column count from the +# concrete sheet, so either shape works. +FRAME_W = 192 +FRAME_H = 208 + +# Frames consumed per animation state (the petdex web app uses CSS +# ``steps(6)``). A sheet may physically contain more columns; we only step +# through the first ``FRAMES_PER_STATE``. +FRAMES_PER_STATE = 6 + +# Full-loop duration for one state, milliseconds (petdex default). +LOOP_MS = 1100 + +# Default on-screen scale relative to native frame size. ``display.pet.scale`` +# is the single master scalar: the desktop canvas multiplies its native pixels +# by it and every terminal surface derives its half-block/kitty column width +# from it (see :func:`cols_for_scale`), so one number shrinks all three +# interfaces together. (petdex's own clients render at 0.7; we default smaller +# so the kitty/GUI mascot stays a glanceable corner sprite. The half-block +# fallback can't shrink as far — see ``UNICODE_MIN_COLS`` — and clamps to its +# legibility floor instead.) +DEFAULT_SCALE = 0.33 + +# User-settable scale bounds (``/pet scale``, desktop slider). Floor keeps the +# pet clickable/visible; ceiling stops a fat-fingered value from filling the +# screen. The unicode fallback additionally clamps to ``UNICODE_MIN_COLS``. +MIN_SCALE = 0.1 +MAX_SCALE = 3.0 + + +def clamp_scale(scale: float) -> float: + """Clamp *scale* to ``[MIN_SCALE, MAX_SCALE]`` (the single validation point).""" + return max(MIN_SCALE, min(MAX_SCALE, scale)) + +# Terminal cells one native frame spans at ``scale == 1.0``. A cell is ~8px +# wide, a frame is ``FRAME_W`` (192) px → 24 cells. This mirrors the kitty +# graphics placement (``scaled_px // 8``) so at full scale every renderer agrees. +BASE_UNICODE_COLS = FRAME_W // 8 + +# Legibility floor for the half-block fallback. A half-block cell samples the +# sprite at only 1 horizontal + 2 vertical taps, so below this width a 192×208 +# pet collapses into an unreadable blob *regardless* of scale. kitty/GUI draw +# true pixels and have no such floor — that's why the same ``scale: 0.33`` is +# crisp there but mush in half-blocks. ``scale`` shrinks the unicode pet down +# TO this floor (and grows it above), instead of past it into noise. +UNICODE_MIN_COLS = 16 + + +def cols_for_scale(scale: float) -> int: + """Half-block width implied by *scale*, clamped to the legibility floor. + + Above the floor it tracks the kitty cell box (``scaled_px // 8``) so the two + renderers converge at larger sizes; below it the floor keeps the sprite + readable rather than letting it devolve into a blob. + """ + return max(UNICODE_MIN_COLS, round(BASE_UNICODE_COLS * (scale or DEFAULT_SCALE))) + + +def resolve_cols(scale: float, unicode_cols: int = 0) -> int: + """Resolve terminal width: explicit *unicode_cols* override, else from *scale*.""" + return int(unicode_cols) if unicode_cols and int(unicode_cols) > 0 else cols_for_scale(scale) + + +class PetState(str, Enum): + """Animation state a pet can be shown in. + + These are Hermes' activity state names. They are not always identical to the + source atlas row names: Codex-format pets use rows like ``jumping`` / + ``running`` while the UI keeps the shorter ``jump`` / ``run`` names. + """ + + IDLE = "idle" + WAVE = "wave" + RUN = "run" + FAILED = "failed" + REVIEW = "review" + JUMP = "jump" + WAITING = "waiting" + + +# Legacy Hermes/petdex row order (top -> bottom) used by the older 8-row, +# 9-column atlas shape. +LEGACY_STATE_ROWS: list[str] = [ + PetState.IDLE.value, + PetState.WAVE.value, + PetState.RUN.value, + PetState.FAILED.value, + PetState.REVIEW.value, + PetState.JUMP.value, + "extra1", + "extra2", +] + +# Current Petdex row order (top -> bottom) used by 1536x1872 atlases: +# 8 columns x 9 rows of 192x208 cells. +CODEX_STATE_ROWS: list[str] = [ + PetState.IDLE.value, + "running-right", + "running-left", + "waving", + "jumping", + PetState.FAILED.value, + PetState.WAITING.value, + "running", + PetState.REVIEW.value, +] + +# Default/fallback for callers without a sheet. Prefer the current 9-row Codex +# format because generated pets and the public Codex pet contract use it. +STATE_ROWS: list[str] = CODEX_STATE_ROWS + +# Canonical Hermes activity names -> accepted row-name aliases in descending +# preference. This keeps our internal state names stable (`wave`/`jump`/`run`) +# while matching Petdex's current `waving`/`jumping`/`running` taxonomy. +STATE_ALIASES: dict[str, tuple[str, ...]] = { + PetState.IDLE.value: (PetState.IDLE.value,), + PetState.WAVE.value: (PetState.WAVE.value, "waving"), + PetState.JUMP.value: (PetState.JUMP.value, "jumping"), + PetState.RUN.value: (PetState.RUN.value, "running"), + PetState.FAILED.value: (PetState.FAILED.value,), + PetState.REVIEW.value: (PetState.REVIEW.value,), + PetState.WAITING.value: (PetState.WAITING.value,), +} + + +def state_aliases_for(state: "PetState | str") -> tuple[str, ...]: + """Return accepted row-name aliases for *state* (always non-empty).""" + value = state.value if isinstance(state, PetState) else str(state) + aliases = STATE_ALIASES.get(value) + return aliases if aliases else (value,) + + +def state_rows_for_grid(row_count: int | None) -> list[str]: + """Return the row taxonomy for a spritesheet with *row_count* rows.""" + try: + rows = int(row_count or 0) + except (TypeError, ValueError): + rows = 0 + + if rows >= len(CODEX_STATE_ROWS): + return CODEX_STATE_ROWS + return LEGACY_STATE_ROWS + + +def state_row_index(state: "PetState | str", row_count: int | None = None) -> int: + """Return the spritesheet row index for *state* (clamped, never raises).""" + rows = state_rows_for_grid(row_count) + for name in state_aliases_for(state): + try: + return rows.index(name) + except ValueError: + continue + return 0 # fall back to the idle row diff --git a/agent/pet/manifest.py b/agent/pet/manifest.py new file mode 100644 index 00000000000..94d539691dc --- /dev/null +++ b/agent/pet/manifest.py @@ -0,0 +1,128 @@ +"""Fetch the public petdex manifest. + +``https://petdex.dev/api/manifest`` 307-redirects to a JSON document on R2: + + { + "generatedAt": "...", + "total": 2926, + "pets": [ + {"slug": "boba", "displayName": "Boba", "kind": "creature", + "submittedBy": "railly", + "spritesheetUrl": "https://assets.petdex.dev/.../spritesheet.webp", + "petJsonUrl": "https://assets.petdex.dev/.../pet.json", + "zipUrl": "https://assets.petdex.dev/.../boba.zip"}, + ... + ] + } + +Read-only and unauthenticated; no credentials involved. +""" + +from __future__ import annotations + +import logging +import time +from dataclasses import dataclass + +logger = logging.getLogger(__name__) + +MANIFEST_URL = "https://petdex.dev/api/manifest" + +_DEFAULT_TIMEOUT = 20.0 + +# In-process cache for the (large, slow, identical-per-call) manifest. The list +# is a static CDN object that barely changes, yet a single session can ask for +# it many times — every gallery open, plus a full re-fetch per install/select +# (``find_entry``). A short TTL collapses those into one network hit without +# going stale for long. Cleared by :func:`clear_cache` (tests). +_MANIFEST_TTL = 300.0 +_cache: tuple[float, list[ManifestEntry]] | None = None + + +def clear_cache() -> None: + """Drop the cached manifest (forces the next fetch to hit the network).""" + global _cache + _cache = None + + +@dataclass(frozen=True) +class ManifestEntry: + """A single pet's row in the manifest.""" + + slug: str + display_name: str + kind: str + submitted_by: str + spritesheet_url: str + pet_json_url: str + zip_url: str + + @classmethod + def from_dict(cls, data: dict) -> "ManifestEntry": + return cls( + slug=str(data.get("slug", "")).strip(), + display_name=str(data.get("displayName", "") or data.get("slug", "")), + kind=str(data.get("kind", "") or "pet"), + submitted_by=str(data.get("submittedBy", "") or ""), + spritesheet_url=str(data.get("spritesheetUrl", "") or ""), + pet_json_url=str(data.get("petJsonUrl", "") or ""), + zip_url=str(data.get("zipUrl", "") or ""), + ) + + +class ManifestError(RuntimeError): + """Raised when the manifest can't be fetched or parsed.""" + + +def fetch_manifest(*, timeout: float = _DEFAULT_TIMEOUT, force: bool = False) -> list[ManifestEntry]: + """Return every approved pet from the public manifest. + + Cached in-process for ``_MANIFEST_TTL`` seconds (pass ``force=True`` to + bypass). Follows the 307 redirect to R2. Raises :class:`ManifestError` on + any network/parse failure so callers can surface a clean message. + """ + global _cache + + if not force and _cache is not None and time.monotonic() - _cache[0] < _MANIFEST_TTL: + return _cache[1] + + try: + import httpx + except ImportError as exc: # pragma: no cover - httpx is a core dep + raise ManifestError("httpx is required to fetch the petdex manifest") from exc + + try: + resp = httpx.get( + MANIFEST_URL, + timeout=timeout, + follow_redirects=True, + headers={"User-Agent": "hermes-agent-petdex"}, + ) + resp.raise_for_status() + payload = resp.json() + except Exception as exc: # noqa: BLE001 - normalize to one error type + raise ManifestError(f"could not fetch petdex manifest: {exc}") from exc + + pets = payload.get("pets") if isinstance(payload, dict) else None + if not isinstance(pets, list): + raise ManifestError("petdex manifest had no 'pets' array") + + entries: list[ManifestEntry] = [] + for raw in pets: + if not isinstance(raw, dict): + continue + entry = ManifestEntry.from_dict(raw) + if entry.slug and entry.spritesheet_url: + entries.append(entry) + + _cache = (time.monotonic(), entries) + return entries + + +def find_entry(slug: str, *, timeout: float = _DEFAULT_TIMEOUT) -> ManifestEntry | None: + """Return the manifest entry for *slug*, or ``None`` if not listed.""" + slug = slug.strip().lower() + for entry in fetch_manifest(timeout=timeout): + if entry.slug.lower() == slug: + return entry + return None diff --git a/agent/pet/render.py b/agent/pet/render.py new file mode 100644 index 00000000000..1618c0751d2 --- /dev/null +++ b/agent/pet/render.py @@ -0,0 +1,618 @@ +"""Decode a pet spritesheet and encode frames for a terminal. + +Shared by the base CLI (writes the escape bytes to its own stdout) and the +TUI (``tui_gateway`` ships the encoded bytes to Ink, which writes them) so the +decode + capability-detection + protocol-encoding logic exists exactly once. + +Supported output modes, in fidelity order: + +- ``kitty`` — the kitty graphics protocol (kitty, Ghostty, WezTerm). +- ``iterm`` — iTerm2 inline images (iTerm2, WezTerm). +- ``sixel`` — DEC sixel (xterm -ti vt340, foot, mlterm, WezTerm, …). +- ``unicode`` — 24-bit half-block downscale; works in any truecolor terminal. + +Frame decoding requires Pillow (a core Hermes dependency). If Pillow or the +spritesheet is unavailable the renderer degrades to ``unicode`` text or an +empty string rather than raising. +""" + +from __future__ import annotations + +import base64 +import io +import logging +import os +import sys +from functools import lru_cache +from pathlib import Path + +from agent.pet.constants import ( + DEFAULT_SCALE, + FRAME_H, + FRAME_W, + FRAMES_PER_STATE, + PetState, + state_row_index, +) + +logger = logging.getLogger(__name__) + +# Public render-mode names accepted by ``display.pet.render_mode``. +RENDER_MODES = ("auto", "kitty", "iterm", "sixel", "unicode", "off") + + +# ───────────────────────────────────────────────────────────────────────── +# Terminal capability detection +# ───────────────────────────────────────────────────────────────────────── + +def detect_terminal_graphics() -> str: + """Best-effort detection of the richest graphics protocol available. + + Env-based (non-blocking — we never issue a DA1/terminal query that could + hang a pipe). Returns one of ``kitty`` / ``iterm`` / ``sixel`` / + ``unicode``. Conservative: unknown terminals get ``unicode``, which works + anywhere with truecolor. + """ + term = os.environ.get("TERM", "").lower() + term_program = os.environ.get("TERM_PROGRAM", "").lower() + + # The VS Code / Cursor integrated terminal sets TERM_PROGRAM=vscode + # authoritatively but does NOT scrub the terminal env vars it inherits when + # launched from another emulator (ITERM_SESSION_ID, KITTY_WINDOW_ID, …). + # Trusting those leaks emits an image protocol the embedded xterm.js can't + # display — you get a blank frame. Inline images there are opt-in + # (terminal.integrated.enableImages), so default to half-blocks, which + # always render in its truecolor grid. Users who enabled images can pin + # display.pet.render_mode explicitly. + if term_program == "vscode": + return "unicode" + + # kitty graphics protocol + if os.environ.get("KITTY_WINDOW_ID") or "kitty" in term or "ghostty" in term: + return "kitty" + if term_program in {"ghostty"}: + return "kitty" + + # WezTerm speaks both kitty and iterm; prefer kitty (richer placement). + if term_program == "wezterm" or os.environ.get("WEZTERM_PANE"): + return "kitty" + + # iTerm2 inline images + if term_program == "iterm.app" or os.environ.get("ITERM_SESSION_ID"): + return "iterm" + + # sixel-capable terminals (env heuristics only) + if term_program in {"mintty"} or "foot" in term or "mlterm" in term: + return "sixel" + if "sixel" in term: + return "sixel" + + return "unicode" + + +def resolve_mode(configured: str | None, *, stream=None) -> str: + """Resolve the effective render mode from config + the environment. + + ``configured`` is ``display.pet.render_mode`` (``auto`` → detect). Returns + ``off`` when not attached to a TTY (no point emitting graphics into a pipe + or logfile). + """ + mode = (configured or "auto").strip().lower() + if mode not in RENDER_MODES: + mode = "auto" + if mode == "off": + return "off" + + stream = stream or sys.stdout + try: + if not (hasattr(stream, "isatty") and stream.isatty()): + return "off" + except (ValueError, OSError): + return "off" + + if mode == "auto": + return detect_terminal_graphics() + return mode + + +# ───────────────────────────────────────────────────────────────────────── +# Frame decoding +# ───────────────────────────────────────────────────────────────────────── + +def _open_sheet(path: Path): + from PIL import Image + + img = Image.open(path) + return img.convert("RGBA") + + +# Max alpha at/below which a frame counts as blank padding. petdex sheets are +# left-packed: a state with fewer real frames than ``FRAMES_PER_STATE`` fills +# the trailing columns with fully transparent cells. Animating into one flashes +# the pet blank, so we stop the row at the first such gap. +_BLANK_ALPHA = 8 + + +def _frame_is_blank(frame) -> bool: + """True if *frame* has no meaningfully opaque pixel (transparent padding).""" + return frame.getchannel("A").getextrema()[1] <= _BLANK_ALPHA + + +@lru_cache(maxsize=16) +def _raw_frames( + sheet_path: str, + state_value: str, + frame_w: int, + frame_h: int, + frames_per_state: int, +) -> tuple: + """Cropped, padding-trimmed RGBA frames for one state row (unscaled). + + Steps across the row until the first blank column so pets with ragged + per-state frame counts never animate into empty padding. Cached; returns + ``()`` on any decode failure. + """ + try: + sheet = _open_sheet(Path(sheet_path)) + cols = max(1, sheet.width // frame_w) + rows = max(1, sheet.height // frame_h) + row = state_row_index(state_value, rows) + top = row * frame_h + # Clamp the row to the sheet (some pets ship fewer rows than the 8 the + # taxonomy reserves). + if top + frame_h > sheet.height: + top = max(0, sheet.height - frame_h) + + frames = [] + for i in range(min(frames_per_state, cols)): + left = i * frame_w + frame = sheet.crop((left, top, left + frame_w, top + frame_h)) + if _frame_is_blank(frame): + break # trailing transparent padding — real frames end here + frames.append(frame) + return tuple(frames) + except Exception as exc: # noqa: BLE001 - cosmetic feature, never fatal + logger.debug("pet frame decode failed (%s, %s): %s", sheet_path, state_value, exc) + return () + + +@lru_cache(maxsize=8) +def _frames_for( + sheet_path: str, + state_value: str, + frame_w: int, + frame_h: int, + frames_per_state: int, + scale_w: int, + scale_h: int, +): + """Return padding-trimmed RGBA frames for one state row, scaled. + + Thin scaling layer over :func:`_raw_frames`; both are cached so repeated + frame requests during animation are free. + """ + raw = _raw_frames(sheet_path, state_value, frame_w, frame_h, frames_per_state) + if not raw or (scale_w, scale_h) == (frame_w, frame_h): + return list(raw) + from PIL import Image + + return [f.resize((scale_w, scale_h), Image.LANCZOS) for f in raw] + + +def state_frame_counts( + sheet_path: str | Path, + *, + frame_w: int = FRAME_W, + frame_h: int = FRAME_H, + frames_per_state: int = FRAMES_PER_STATE, +) -> dict[str, int]: + """Map each driven :class:`PetState` → its real (padding-trimmed) frame count. + + The single source of truth for "how many frames does this state actually + have?". The CLI/TUI consume the trimmed frame lists directly; the gateway + ships this map to the desktop canvas, which steps its own loop. + """ + return { + state.value: len( + _raw_frames(str(sheet_path), state.value, frame_w, frame_h, frames_per_state) + ) + for state in PetState + } + + +# ───────────────────────────────────────────────────────────────────────── +# Encoders +# ───────────────────────────────────────────────────────────────────────── + +def _png_bytes(frame) -> bytes: + buf = io.BytesIO() + frame.save(buf, format="PNG") + return buf.getvalue() + + +def _kitty_apc(ctrl: str, data: str) -> str: + """Emit a kitty APC escape for *data*, chunked into ≤4096-byte ``m`` pieces.""" + chunk = 4096 + if len(data) <= chunk: + return f"\x1b_G{ctrl},m=0;{data}\x1b\\" + out = [f"\x1b_G{ctrl},m=1;{data[:chunk]}\x1b\\"] + rest = data[chunk:] + while rest: + piece, rest = rest[:chunk], rest[chunk:] + out.append(f"\x1b_Gm={1 if rest else 0};{piece}\x1b\\") + return "".join(out) + + +def _encode_kitty(frame, *, cell_cols: int | None = None, cell_rows: int | None = None) -> str: + """Encode one frame via the kitty graphics protocol (transmit + display). + + ``a=T`` transmits & displays at the cursor; ``c``/``r`` request a display + box in terminal cells so successive frames overwrite the same area. + """ + ctrl = "f=100,a=T,q=2" + if cell_cols: + ctrl += f",c={cell_cols}" + if cell_rows: + ctrl += f",r={cell_rows}" + return _kitty_apc(ctrl, base64.standard_b64encode(_png_bytes(frame)).decode("ascii")) + + +# ───────────────────────────────────────────────────────────────────────── +# kitty Unicode placeholders +# +# Ink (the TUI's React-for-terminal layer) owns the screen and measures every +# cell's width, so it can't host raw kitty image escapes (no width to count, +# clobbered on the next repaint). kitty's *Unicode placeholder* protocol is the +# grid-safe path: transmit the image once (q=2, virtual placement U=1), then the +# host app prints ordinary-width placeholder cells (U+10EEEE + diacritics) whose +# foreground color encodes the image id. Ink counts those as width-1 text, so +# layout stays correct and the terminal paints the image underneath. +# https://sw.kovidgoyal.net/kitty/graphics-protocol/#unicode-placeholders +# ───────────────────────────────────────────────────────────────────────── + +_KITTY_PLACEHOLDER = "\U0010eeee" + +# Row/column diacritics, in order (index → diacritic). Verbatim from kitty's +# gen/rowcolumn-diacritics.txt (Unicode 6.0.0, combining class 230). Index i is +# the diacritic that encodes the number i; we only ever need the row index. +_ROWCOL_DIACRITICS: tuple[int, ...] = ( + 0x0305, 0x030D, 0x030E, 0x0310, 0x0312, 0x033D, 0x033E, 0x033F, 0x0346, 0x034A, + 0x034B, 0x034C, 0x0350, 0x0351, 0x0352, 0x0357, 0x035B, 0x0363, 0x0364, 0x0365, + 0x0366, 0x0367, 0x0368, 0x0369, 0x036A, 0x036B, 0x036C, 0x036D, 0x036E, 0x036F, + 0x0483, 0x0484, 0x0485, 0x0486, 0x0487, 0x0592, 0x0593, 0x0594, 0x0595, 0x0597, + 0x0598, 0x0599, 0x059C, 0x059D, 0x059E, 0x059F, 0x05A0, 0x05A1, 0x05A8, 0x05A9, + 0x05AB, 0x05AC, 0x05AF, 0x05C4, 0x0610, 0x0611, 0x0612, 0x0613, 0x0614, 0x0615, + 0x0616, 0x0617, 0x0657, 0x0658, 0x0659, 0x065A, 0x065B, 0x065D, 0x065E, 0x06D6, + 0x06D7, 0x06D8, 0x06D9, 0x06DA, 0x06DB, 0x06DC, 0x06DF, 0x06E0, 0x06E1, 0x06E2, + 0x06E4, 0x06E7, 0x06E8, 0x06EB, 0x06EC, 0x0730, 0x0732, 0x0733, 0x0735, 0x0736, + 0x073A, 0x073D, 0x073F, 0x0740, 0x0741, 0x0743, 0x0745, 0x0747, 0x0749, 0x074A, + 0x07EB, 0x07EC, 0x07ED, 0x07EE, 0x07EF, 0x07F0, 0x07F1, 0x07F3, 0x0816, 0x0817, + 0x0818, 0x0819, 0x081B, 0x081C, 0x081D, 0x081E, 0x081F, 0x0820, 0x0821, 0x0822, + 0x0823, 0x0825, 0x0826, 0x0827, 0x0829, 0x082A, 0x082B, 0x082C, 0x082D, 0x0951, + 0x0953, 0x0954, 0x0F82, 0x0F83, 0x0F86, 0x0F87, 0x135D, 0x135E, 0x135F, 0x17DD, + 0x193A, 0x1A17, 0x1A75, 0x1A76, 0x1A77, 0x1A78, 0x1A79, 0x1A7A, 0x1A7B, 0x1A7C, + 0x1B6B, 0x1B6D, 0x1B6E, 0x1B6F, 0x1B70, 0x1B71, 0x1B72, 0x1B73, 0x1CD0, 0x1CD1, + 0x1CD2, 0x1CDA, 0x1CDB, 0x1CE0, 0x1DC0, 0x1DC1, 0x1DC3, 0x1DC4, 0x1DC5, 0x1DC6, + 0x1DC7, 0x1DC8, 0x1DC9, 0x1DCB, 0x1DCC, 0x1DD1, 0x1DD2, 0x1DD3, 0x1DD4, 0x1DD5, + 0x1DD6, 0x1DD7, 0x1DD8, 0x1DD9, 0x1DDA, 0x1DDB, 0x1DDC, 0x1DDD, 0x1DDE, 0x1DDF, + 0x1DE0, 0x1DE1, 0x1DE2, 0x1DE3, 0x1DE4, 0x1DE5, 0x1DE6, 0x1DFE, 0x20D0, 0x20D1, + 0x20D4, 0x20D5, 0x20D6, 0x20D7, 0x20DB, 0x20DC, 0x20E1, 0x20E7, 0x20E9, 0x20F0, + 0x2CEF, 0x2CF0, 0x2CF1, 0x2DE0, 0x2DE1, 0x2DE2, 0x2DE3, 0x2DE4, 0x2DE5, 0x2DE6, + 0x2DE7, 0x2DE8, 0x2DE9, 0x2DEA, 0x2DEB, 0x2DEC, 0x2DED, 0x2DEE, 0x2DEF, 0x2DF0, + 0x2DF1, 0x2DF2, 0x2DF3, 0x2DF4, 0x2DF5, 0x2DF6, 0x2DF7, 0x2DF8, 0x2DF9, 0x2DFA, + 0x2DFB, 0x2DFC, 0x2DFD, 0x2DFE, 0x2DFF, 0xA66F, 0xA67C, 0xA67D, 0xA6F0, 0xA6F1, + 0xA8E0, 0xA8E1, 0xA8E2, 0xA8E3, 0xA8E4, 0xA8E5, 0xA8E6, 0xA8E7, 0xA8E8, 0xA8E9, + 0xA8EA, 0xA8EB, 0xA8EC, 0xA8ED, 0xA8EE, 0xA8EF, 0xA8F0, 0xA8F1, 0xAAB0, 0xAAB2, + 0xAAB3, 0xAAB7, 0xAAB8, 0xAABE, 0xAABF, 0xAAC1, 0xFE20, 0xFE21, 0xFE22, 0xFE23, + 0xFE24, 0xFE25, 0xFE26, 0x10A0F, 0x10A38, 0x1D185, 0x1D186, 0x1D187, 0x1D188, + 0x1D189, 0x1D1AA, 0x1D1AB, 0x1D1AC, 0x1D1AD, 0x1D242, 0x1D243, 0x1D244, +) + + +def kitty_image_id(slug: str) -> int: + """Stable per-pet image id in ``[1, 0x7FFF]``. + + The id is encoded in the placeholder's 24-bit foreground color, so it must + be non-zero and fit comfortably under ``0xFFFFFF``. A small CRC keeps it + deterministic per slug (so re-renders reuse the same terminal-side image) + while making collisions between two different pets unlikely. + """ + import zlib + + return (zlib.crc32(slug.encode("utf-8")) % 0x7FFE) + 1 + + +def kitty_color_hex(image_id: int) -> str: + """Hex foreground color (``#rrggbb``) that encodes *image_id* for kitty.""" + return "#%06x" % (image_id & 0xFFFFFF) + + +def kitty_placeholder_rows(cols: int, rows: int) -> list[str]: + """Build the placeholder text grid for an *rows*×*cols* image. + + Each line is one row of the grid: the first cell carries the row diacritic + (column defaults to 0), and the remaining ``cols-1`` bare placeholders let + the terminal auto-increment the column. The foreground color (the image id) + is applied by the caller / Ink, not embedded here. + """ + cols = max(1, cols) + out: list[str] = [] + for r in range(max(1, rows)): + idx = min(r, len(_ROWCOL_DIACRITICS) - 1) + first = _KITTY_PLACEHOLDER + chr(_ROWCOL_DIACRITICS[idx]) + out.append(first + _KITTY_PLACEHOLDER * (cols - 1)) + return out + + +def _encode_kitty_virtual(frame, *, image_id: int, cols: int, rows: int) -> str: + """Transmit a frame as a kitty *virtual* placement for Unicode placeholders. + + ``a=T`` transmits and creates the placement in one shot; ``U=1`` marks it + virtual (no on-screen output, cursor untouched); ``q=2`` suppresses the + terminal's OK/error replies that would otherwise corrupt the host app's + output. Re-sending with the same ``i`` replaces the image, so the static + placeholder cells animate underneath. + """ + ctrl = f"a=T,U=1,i={image_id},c={cols},r={rows},f=100,q=2" + return _kitty_apc(ctrl, base64.standard_b64encode(_png_bytes(frame)).decode("ascii")) + + +def _encode_iterm(frame, *, cell_cols: int | None = None, cell_rows: int | None = None) -> str: + """Encode one frame as an iTerm2 inline image (OSC 1337 File).""" + payload = base64.standard_b64encode(_png_bytes(frame)).decode("ascii") + size = len(payload) + args = [f"inline=1", f"size={size}", "preserveAspectRatio=1"] + if cell_cols: + args.append(f"width={cell_cols}") + if cell_rows: + args.append(f"height={cell_rows}") + return f"\x1b]1337;File={';'.join(args)}:{payload}\x07" + + +def _encode_sixel(frame) -> str: + """Encode one frame as DEC sixel. + + Quantizes to an adaptive palette (≤255 colors) and emits the sixel band + stream. Pillow has no sixel writer, so this is a compact hand-rolled + encoder. Transparent pixels render as background (color register skipped). + """ + from PIL import Image + + rgba = frame + # Composite onto transparent-as-skip: track alpha to decide background. + pal = rgba.convert("RGB").quantize(colors=255, method=Image.MEDIANCUT) + palette = pal.getpalette() or [] + px = pal.load() + alpha = rgba.getchannel("A").load() + w, h = pal.size + + out = ["\x1bP0;1;0q", '"1;1;%d;%d' % (w, h)] + # Color register definitions (sixel uses 0..100 scale). + used = sorted({px[x, y] for y in range(h) for x in range(w)}) + for idx in used: + r = palette[idx * 3] if idx * 3 < len(palette) else 0 + g = palette[idx * 3 + 1] if idx * 3 + 1 < len(palette) else 0 + b = palette[idx * 3 + 2] if idx * 3 + 2 < len(palette) else 0 + out.append("#%d;2;%d;%d;%d" % (idx, r * 100 // 255, g * 100 // 255, b * 100 // 255)) + + # Emit in 6-row bands. + for band in range(0, h, 6): + for color_idx in used: + line = ["#%d" % color_idx] + run_char = None + run_len = 0 + + def flush(): + nonlocal run_char, run_len + if run_char is None: + return + if run_len > 3: + line.append("!%d%s" % (run_len, run_char)) + else: + line.append(run_char * run_len) + run_char, run_len = None, 0 + + for x in range(w): + bits = 0 + for bit in range(6): + y = band + bit + if y < h and alpha[x, y] > 32 and px[x, y] == color_idx: + bits |= 1 << bit + ch = chr(63 + bits) + if ch == run_char: + run_len += 1 + else: + flush() + run_char, run_len = ch, 1 + flush() + out.append("".join(line) + "$") # carriage return within band + out.append("-") # next band + out.append("\x1b\\") + return "".join(out) + + +_HALF_BLOCK = "▀" + +# A single half-block cell: top pixel + bottom pixel as (r, g, b, a) tuples. +Cell = tuple[tuple[int, int, int, int], tuple[int, int, int, int]] + + +def _downscale_cells(frame, *, target_cols: int) -> list[list[Cell]]: + """Downscale a frame to a grid of half-block cells. + + Each cell pairs a top and bottom pixel so one terminal row encodes two + pixel rows. Returns rows of ``((tr,tg,tb,ta),(br,bg,bb,ba))`` — the + framework-neutral representation shared by the ANSI encoder (CLI) and the + structured ``cells`` API (Ink). + """ + from PIL import Image + + target_cols = max(4, target_cols) + aspect = frame.height / max(1, frame.width) + target_rows = max(2, int(round(target_cols * aspect * 0.5)) * 2) + small = frame.resize((target_cols, target_rows), Image.LANCZOS).convert("RGBA") + px = small.load() + + grid: list[list[Cell]] = [] + for y in range(0, target_rows, 2): + row: list[Cell] = [] + for x in range(target_cols): + top = px[x, y] + bottom = px[x, y + 1] if y + 1 < target_rows else (0, 0, 0, 0) + row.append((top, bottom)) + grid.append(row) + return grid + + +def _encode_unicode(frame, *, target_cols: int) -> str: + """Downscale to truecolor ANSI half-blocks (one char = 2 vertical pixels).""" + lines: list[str] = [] + for row in _downscale_cells(frame, target_cols=target_cols): + cells: list[str] = [] + for (tr, tg, tb, ta), (br, bg, bb, ba) in row: + if ta < 32 and ba < 32: + cells.append("\x1b[0m ") # fully transparent → blank + continue + cells.append(f"\x1b[38;2;{tr};{tg};{tb}m\x1b[48;2;{br};{bg};{bb}m{_HALF_BLOCK}") + lines.append("".join(cells) + "\x1b[0m") + return "\n".join(lines) + + +# ───────────────────────────────────────────────────────────────────────── +# Public renderer +# ───────────────────────────────────────────────────────────────────────── + +class PetRenderer: + """Holds a pet's spritesheet and yields encoded frames per (state, index). + + Construct once per pet, then call :meth:`frame` on an animation timer. + Cheap to call repeatedly — decoded frames are cached. + """ + + def __init__( + self, + spritesheet: str | Path, + *, + mode: str = "unicode", + scale: float = DEFAULT_SCALE, + unicode_cols: int = 20, + frame_w: int = FRAME_W, + frame_h: int = FRAME_H, + frames_per_state: int = FRAMES_PER_STATE, + ) -> None: + self.spritesheet = str(spritesheet) + self.mode = mode if mode in RENDER_MODES else "unicode" + self.scale = scale + self.unicode_cols = unicode_cols + self.frame_w = frame_w + self.frame_h = frame_h + self.frames_per_state = frames_per_state + + @property + def available(self) -> bool: + return self.mode != "off" and Path(self.spritesheet).is_file() + + def frame_count(self, state: PetState | str) -> int: + return len(self._frames(state)) + + def _frames(self, state: PetState | str): + value = state.value if isinstance(state, PetState) else str(state) + scale_w = max(1, int(self.frame_w * self.scale)) + scale_h = max(1, int(self.frame_h * self.scale)) + return _frames_for( + self.spritesheet, + value, + self.frame_w, + self.frame_h, + self.frames_per_state, + scale_w, + scale_h, + ) + + def cells(self, state: PetState | str, index: int, *, cols: int | None = None) -> list[list[Cell]]: + """Return one frame as a half-block cell grid (framework-neutral). + + Used by the TUI, which renders the grid with native Ink color props + instead of raw ANSI. Returns ``[]`` when no frame is available. + """ + frames = self._frames(state) + if not frames: + return [] + frame = frames[index % len(frames)] + return _downscale_cells(frame, target_cols=cols or self.unicode_cols) + + def _cell_box(self, frame) -> tuple[int, int]: + """Terminal cell box for a scaled frame (~8×16 px per cell). + + Must match :meth:`frame` graphics sizing — kitty stretches the image to + fill ``c``×``r`` cells, so these must reflect the scaled pixel + dimensions, not a native-aspect column count (that upscales small pets). + """ + return max(1, frame.width // 8), max(1, frame.height // 16) + + def kitty_payload(self, state: PetState | str, *, image_id: int) -> dict | None: + """Build the kitty Unicode-placeholder payload for one state. + + Returns ``{cols, rows, placeholder, frames}`` where ``frames`` is a + list of transmit escapes (one per animation frame, all reusing + ``image_id``) and ``placeholder`` is the static text grid Ink paints. + Placement geometry is derived from the scaled frame pixels (via + :meth:`_cell_box`), not ``unicode_cols`` — kitty upscales to fill + ``c``×``r`` cells. ``None`` when no frame is available. + """ + frames = self._frames(state) + if not frames: + return None + cols, rows = self._cell_box(frames[0]) + return { + "cols": cols, + "rows": rows, + "placeholder": kitty_placeholder_rows(cols, rows), + "frames": [ + _encode_kitty_virtual(f, image_id=image_id, cols=cols, rows=rows) for f in frames + ], + } + + def frame(self, state: PetState | str, index: int) -> str: + """Return the encoded escape string for one frame, or ``""``. + + ``index`` is taken modulo the available frame count so callers can pass + a free-running counter. + """ + if self.mode == "off": + return "" + frames = self._frames(state) + if not frames: + return "" + frame = frames[index % len(frames)] + cell_cols, cell_rows = self._cell_box(frame) + + try: + if self.mode == "kitty": + return _encode_kitty(frame, cell_cols=cell_cols, cell_rows=cell_rows) + if self.mode == "iterm": + return _encode_iterm(frame, cell_cols=cell_cols, cell_rows=cell_rows) + if self.mode == "sixel": + return _encode_sixel(frame) + return _encode_unicode(frame, target_cols=self.unicode_cols) + except Exception as exc: # noqa: BLE001 - degrade silently + logger.debug("pet frame encode failed (mode=%s): %s", self.mode, exc) + return "" + + +def build_renderer( + spritesheet: str | Path, + *, + configured_mode: str | None = None, + scale: float = DEFAULT_SCALE, + unicode_cols: int = 20, + stream=None, +) -> PetRenderer: + """Convenience factory: resolve the mode from config+env, then construct.""" + mode = resolve_mode(configured_mode, stream=stream) + return PetRenderer( + spritesheet, + mode=mode, + scale=scale, + unicode_cols=unicode_cols, + ) diff --git a/agent/pet/state.py b/agent/pet/state.py new file mode 100644 index 00000000000..a9ad5afd801 --- /dev/null +++ b/agent/pet/state.py @@ -0,0 +1,81 @@ +"""Map agent activity → a :class:`PetState`. + +This is the one place the "what is the agent doing right now?" → "which +animation row?" decision lives. Each surface feeds it the signals it already +tracks: + +- CLI — ``KawaiiSpinner`` waiting/thinking state + tool outcomes. +- TUI — gateway ``tool.start/complete`` + ``message.delta/complete`` events. +- Desktop — the ``$busy``/``$awaitingResponse``/tool-event nanostores + (re-implemented in TS, but mirroring this priority order). + +Keeping the priority order here (and documenting it) lets the TypeScript +mirror stay faithful without a second design. +""" + +from __future__ import annotations + +from collections.abc import Iterable +from typing import Any + +from agent.pet.constants import PetState + + +def todos_all_done(todos: Iterable[Any] | None) -> bool: + """True iff there's ≥1 todo and every one is completed/cancelled. + + The "celebrate" beat (``JUMP``) fires when a plan finishes; this mirrors + the TUI's ``isTodoDone`` so the trigger is defined once across surfaces. + Accepts dicts (``{"status": ...}``) or objects with a ``status`` attr. + """ + items = list(todos or []) + if not items: + return False + + def _status(t: Any) -> Any: + return t.get("status") if isinstance(t, dict) else getattr(t, "status", None) + + return all(_status(t) in ("completed", "cancelled") for t in items) + + +def derive_pet_state( + *, + busy: bool = False, + awaiting_input: bool = False, + error: bool = False, + celebrate: bool = False, + just_completed: bool = False, + tool_running: bool = False, + reasoning: bool = False, +) -> PetState: + """Resolve the animation state from coarse activity signals. + + Priority (highest first) — only one row can show at a time, so the most + salient signal wins: + + 1. ``error`` → ``FAILED`` (a tool/turn just failed) + 2. ``celebrate`` → ``JUMP`` (explicit success beat, e.g. todos done) + 3. ``just_completed`` → ``WAVE`` (turn finished cleanly / greeting) + 4. ``awaiting_input`` → ``WAITING`` (blocked on the user — a clarify/approval + prompt is open; this outranks the in-flight signals below because the turn + is paused on *you*, even though a tool is technically mid-call) + 5. ``tool_running`` → ``RUN`` (a tool is executing) + 6. ``reasoning`` → ``REVIEW`` (model is thinking / reading) + 7. ``busy`` → ``RUN`` (turn in flight, unspecified work) + 8. otherwise → ``IDLE`` + """ + if error: + return PetState.FAILED + if celebrate: + return PetState.JUMP + if just_completed: + return PetState.WAVE + if awaiting_input: + return PetState.WAITING + if tool_running: + return PetState.RUN + if reasoning: + return PetState.REVIEW + if busy: + return PetState.RUN + return PetState.IDLE diff --git a/agent/pet/store.py b/agent/pet/store.py new file mode 100644 index 00000000000..46cc3bc9cac --- /dev/null +++ b/agent/pet/store.py @@ -0,0 +1,316 @@ +"""On-disk pet store — install / list / resolve pets. + +Pets live under ``get_hermes_home()/pets//`` so every profile gets its +own set (we deliberately do **not** reuse petdex's ``~/.codex/pets`` default — +that's owned by the petdex npm CLI and isn't profile-aware). Each installed +pet directory holds: + + pets// + pet.json # {id, displayName, description, spritesheetPath} + spritesheet.webp # (or .png) + +The active pet is resolved from the caller-supplied ``display.pet.slug`` config +value (falling back to the first installed pet), so this module stays free of +the config loader. +""" + +from __future__ import annotations + +import json +import logging +from dataclasses import dataclass +from pathlib import Path + +from hermes_constants import get_hermes_home + +logger = logging.getLogger(__name__) + +_DOWNLOAD_TIMEOUT = 60.0 + + +class PetStoreError(RuntimeError): + """Raised on install/IO failures.""" + + +@dataclass(frozen=True) +class InstalledPet: + """A pet present on disk.""" + + slug: str + display_name: str + description: str + directory: Path + spritesheet: Path + + @property + def exists(self) -> bool: + return self.spritesheet.is_file() + + +def pets_dir() -> Path: + """Return the profile-scoped pets directory (created on demand).""" + path = get_hermes_home() / "pets" + path.mkdir(parents=True, exist_ok=True) + return path + + +def _read_pet_json(directory: Path) -> dict: + pet_json = directory / "pet.json" + if not pet_json.is_file(): + return {} + try: + return json.loads(pet_json.read_text(encoding="utf-8")) + except (OSError, ValueError) as exc: + logger.debug("unreadable pet.json in %s: %s", directory, exc) + return {} + + +def _resolve_spritesheet(directory: Path, meta: dict) -> Path: + """Find the spritesheet for a pet dir. + + Honors ``spritesheetPath`` from pet.json, else probes the conventional + filenames (``spritesheet.{webp,png}`` and petdex R2's ``sprite.webp``). + """ + declared = str(meta.get("spritesheetPath", "") or "").strip() + if declared: + candidate = directory / declared + if candidate.is_file(): + return candidate + for name in ("spritesheet.webp", "spritesheet.png", "sprite.webp", "sprite.png"): + candidate = directory / name + if candidate.is_file(): + return candidate + # Default expectation even if missing, so callers get a stable path. + return directory / "spritesheet.webp" + + +def load_pet(slug: str) -> InstalledPet | None: + """Return the :class:`InstalledPet` for *slug*, or ``None`` if absent.""" + slug = slug.strip() + directory = pets_dir() / slug + if not directory.is_dir(): + return None + meta = _read_pet_json(directory) + return InstalledPet( + slug=slug, + display_name=str(meta.get("displayName", "") or slug), + description=str(meta.get("description", "") or ""), + directory=directory, + spritesheet=_resolve_spritesheet(directory, meta), + ) + + +def installed_pets() -> list[InstalledPet]: + """Return every installed pet (dirs containing a usable spritesheet).""" + out: list[InstalledPet] = [] + for child in sorted(pets_dir().iterdir()): + if not child.is_dir(): + continue + pet = load_pet(child.name) + if pet and pet.exists: + out.append(pet) + return out + + +def resolve_active_pet(configured_slug: str | None = None) -> InstalledPet | None: + """Resolve which pet to display. + + Precedence: the configured slug (``display.pet.slug``) if it's installed, + otherwise the first installed pet alphabetically, otherwise ``None``. + """ + if configured_slug: + pet = load_pet(configured_slug.strip()) + if pet and pet.exists: + return pet + pets = installed_pets() + return pets[0] if pets else None + + +def install_pet(slug: str, *, force: bool = False, timeout: float = _DOWNLOAD_TIMEOUT) -> InstalledPet: + """Download *slug* from the manifest into the pets directory. + + Idempotent: a fully-installed pet is returned as-is unless *force*. Raises + :class:`PetStoreError` / :class:`~agent.pet.manifest.ManifestError` on + failure. + """ + from agent.pet.manifest import find_entry + + slug = slug.strip() + existing = load_pet(slug) + if existing and existing.exists and not force: + return existing + + entry = find_entry(slug, timeout=timeout) + if entry is None: + raise PetStoreError(f"pet '{slug}' is not in the petdex manifest") + + directory = pets_dir() / slug + directory.mkdir(parents=True, exist_ok=True) + + sprite_ext = ".png" if entry.spritesheet_url.lower().split("?")[0].endswith(".png") else ".webp" + sprite_path = directory / f"spritesheet{sprite_ext}" + + _download(entry.spritesheet_url, sprite_path, timeout=timeout) + + # Fetch the upstream pet.json if present; otherwise synthesize a minimal + # one so the local layout is self-describing. + meta: dict = {} + if entry.pet_json_url: + try: + meta = _download_json(entry.pet_json_url, timeout=timeout) + except Exception as exc: # noqa: BLE001 - non-fatal, fall back below + logger.debug("pet.json fetch failed for %s: %s", slug, exc) + if not isinstance(meta, dict) or not meta: + meta = {"id": slug, "displayName": entry.display_name, "description": ""} + meta["spritesheetPath"] = sprite_path.name + meta.setdefault("id", slug) + meta.setdefault("displayName", entry.display_name) + (directory / "pet.json").write_text(json.dumps(meta, indent=2), encoding="utf-8") + + pet = load_pet(slug) + if pet is None or not pet.exists: + raise PetStoreError(f"install of '{slug}' did not produce a spritesheet") + return pet + + +_THUMB_FRAME_W = 192 +_THUMB_FRAME_H = 208 +_THUMB_W = 96 # rendered ~40px; 2x+ keeps it crisp on HiDPI + + +def _thumbs_dir() -> Path: + path = pets_dir() / ".thumbs" + path.mkdir(parents=True, exist_ok=True) + return path + + +def _is_petdex_host(url: str) -> bool: + """True only for petdex.dev hosts — bounds server-side fetch (anti-SSRF).""" + from urllib.parse import urlparse + + try: + host = (urlparse(url).hostname or "").lower() + except ValueError: + return False + return host == "petdex.dev" or host.endswith(".petdex.dev") + + +def thumbnail_png(slug: str, *, source_url: str = "", timeout: float = 30.0) -> bytes | None: + """Return a small idle-frame PNG for *slug*, cached on disk. + + Crops the top-left (idle, frame 0) cell of the spritesheet and downsamples + it to a thumbnail. Source preference: an installed spritesheet on disk, else + *source_url* — but only when it points at petdex (so the gateway never + fetches an arbitrary client-supplied URL). Returns ``None`` when there's no + usable source or Pillow/network fails; callers render a placeholder. + + Doing this server-side sidesteps the renderer's CSP / R2 hotlink limits that + break a direct ```` and lets the result ride the authenticated + gateway as a same-origin data URL. + """ + slug = slug.strip() + if not slug: + return None + + cache = _thumbs_dir() / f"{slug}.png" + if cache.is_file(): + try: + return cache.read_bytes() + except OSError: + pass + + sheet_bytes: bytes | None = None + pet = load_pet(slug) + if pet and pet.exists: + try: + sheet_bytes = pet.spritesheet.read_bytes() + except OSError: + sheet_bytes = None + + if sheet_bytes is None and source_url and _is_petdex_host(source_url): + try: + import httpx + + resp = httpx.get( + source_url, + timeout=timeout, + follow_redirects=True, + headers={"User-Agent": "hermes-agent-petdex"}, + ) + resp.raise_for_status() + sheet_bytes = resp.content + except Exception as exc: # noqa: BLE001 - cosmetic, degrade to placeholder + logger.debug("thumb fetch failed for %s: %s", slug, exc) + + if not sheet_bytes: + return None + + try: + import io + + from PIL import Image + + with Image.open(io.BytesIO(sheet_bytes)) as im: + frame = im.convert("RGBA").crop( + (0, 0, min(_THUMB_FRAME_W, im.width), min(_THUMB_FRAME_H, im.height)) + ) + height = round(_THUMB_W * _THUMB_FRAME_H / _THUMB_FRAME_W) + frame = frame.resize((_THUMB_W, height), Image.NEAREST) + buf = io.BytesIO() + frame.save(buf, format="PNG") + data = buf.getvalue() + except Exception as exc: # noqa: BLE001 + logger.debug("thumb crop failed for %s: %s", slug, exc) + return None + + try: + cache.write_bytes(data) + except OSError: + pass + return data + + +def remove_pet(slug: str) -> bool: + """Delete an installed pet directory. Returns True if anything was removed.""" + import shutil + + directory = pets_dir() / slug.strip() + if not directory.is_dir(): + return False + shutil.rmtree(directory, ignore_errors=True) + return not directory.exists() + + +def _download(url: str, dest: Path, *, timeout: float) -> None: + import httpx + + try: + with httpx.stream( + "GET", + url, + timeout=timeout, + follow_redirects=True, + headers={"User-Agent": "hermes-agent-petdex"}, + ) as resp: + resp.raise_for_status() + tmp = dest.with_suffix(dest.suffix + ".part") + with tmp.open("wb") as fh: + for chunk in resp.iter_bytes(): + fh.write(chunk) + tmp.replace(dest) + except Exception as exc: # noqa: BLE001 + raise PetStoreError(f"download failed for {url}: {exc}") from exc + + +def _download_json(url: str, *, timeout: float) -> dict: + import httpx + + resp = httpx.get( + url, + timeout=timeout, + follow_redirects=True, + headers={"User-Agent": "hermes-agent-petdex"}, + ) + resp.raise_for_status() + data = resp.json() + return data if isinstance(data, dict) else {} diff --git a/hermes_cli/config.py b/hermes_cli/config.py index f698c11d5ac..a557899ae98 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1633,6 +1633,31 @@ DEFAULT_CONFIG = { "fields": ["model", "context_pct", "cwd"], # Order shown; drop any to hide }, "copy_shortcut": "auto", # "auto" (platform default) | "ctrl_c" | "ctrl_shift_c" | "disabled" + # Petdex animated mascot (https://github.com/crafter-station/petdex). + # A purely cosmetic sprite that reacts to agent activity across the + # CLI, TUI, and desktop app. Manage with `hermes pets`. Disabled until + # a pet is installed + selected (no effect on prompt caching — this is + # a display concern only). + "pet": { + "enabled": False, + # Active pet slug; resolved against installed pets in + # get_hermes_home()/pets/. Empty → first installed pet. + "slug": "", + # Terminal render protocol for CLI/TUI: + # auto — detect kitty/iTerm2/sixel, else unicode half-blocks + # kitty | iterm | sixel | unicode | off + "render_mode": "auto", + # Master size scalar (relative to native 192×208 frames). One knob + # shrinks every surface: the desktop canvas scales its pixels by it + # and the CLI/TUI derive their terminal column width from it. The + # half-block fallback clamps to a legibility floor (it can't shrink + # as far as true-pixel kitty/GUI without turning to mush). + "scale": 0.33, + # Hard override for terminal column width. 0 = auto (derive from + # scale); set a positive int only to pin the half-block/kitty width + # independently of scale. + "unicode_cols": 0, + }, }, # Web dashboard settings diff --git a/tests/agent/test_pet_engine.py b/tests/agent/test_pet_engine.py new file mode 100644 index 00000000000..e7816134133 --- /dev/null +++ b/tests/agent/test_pet_engine.py @@ -0,0 +1,371 @@ +"""Tests for the petdex pet engine (agent/pet/*). + +Behavior/invariant focused — no network, no live manifest. A tiny synthetic +spritesheet is generated with Pillow so render paths exercise real decode +without depending on a downloaded pet. +""" + +from __future__ import annotations + +import io + +import pytest + +from agent.pet import constants, render, state, store +from agent.pet.constants import FRAME_H, FRAME_W, PetState + + +# ───────────────────────────────────────────────────────────────────────── +# state mapping — priority invariants +# ───────────────────────────────────────────────────────────────────────── + +def test_derive_idle_default(): + assert state.derive_pet_state() is PetState.IDLE + # awaiting input uses the dedicated waiting row when available. + assert state.derive_pet_state(awaiting_input=True) is PetState.WAITING + + +def test_derive_priority_order(): + # error beats everything + assert state.derive_pet_state(error=True, celebrate=True, busy=True) is PetState.FAILED + # celebrate beats completion/tool + assert state.derive_pet_state(celebrate=True, just_completed=True, tool_running=True) is PetState.JUMP + # completion beats waiting/tool + assert state.derive_pet_state(just_completed=True, awaiting_input=True) is PetState.WAVE + # waiting (blocked on the user) outranks the in-flight signals — a clarify + # mid-turn pauses on you even though a tool is technically still open. + assert state.derive_pet_state(awaiting_input=True, tool_running=True, busy=True) is PetState.WAITING + # tool beats reasoning + assert state.derive_pet_state(tool_running=True, reasoning=True) is PetState.RUN + # reasoning beats bare-busy + assert state.derive_pet_state(reasoning=True, busy=True) is PetState.REVIEW + # bare busy runs + assert state.derive_pet_state(busy=True) is PetState.RUN + + +def test_todos_all_done(): + # empty / falsy → not done (no plan to celebrate) + assert state.todos_all_done(None) is False + assert state.todos_all_done([]) is False + # any open item → not done + assert state.todos_all_done([{"status": "completed"}, {"status": "pending"}]) is False + assert state.todos_all_done([{"status": "in_progress"}]) is False + # every item terminal → done (completed and/or cancelled) + assert state.todos_all_done([{"status": "completed"}, {"status": "cancelled"}]) is True + + # objects with a .status attr work too (mirrors dict + attr access) + class _T: + def __init__(self, status): + self.status = status + + assert state.todos_all_done([_T("completed")]) is True + assert state.todos_all_done([_T("completed"), _T("pending")]) is False + + +def test_state_row_index_maps_to_supported_atlas_taxonomies(): + # Current Petdex sheets are 8 columns x 9 rows. + assert constants.state_row_index(PetState.IDLE, 9) == 0 + assert constants.state_row_index(PetState.WAVE, 9) == 3 + assert constants.state_row_index(PetState.JUMP, 9) == 4 + assert constants.state_row_index(PetState.FAILED, 9) == 5 + assert constants.state_row_index(PetState.WAITING, 9) == 6 + assert constants.state_row_index(PetState.RUN, 9) == 7 + assert constants.state_row_index(PetState.REVIEW, 9) == 8 + + # Legacy Hermes/petdex sheets were 8 rows with Hermes state names packed in + # order. Keep those readable instead of forcing old installs through the + # newer Codex taxonomy. + assert constants.state_row_index(PetState.WAVE, 8) == 1 + assert constants.state_row_index(PetState.RUN, 8) == 2 + assert constants.state_row_index(PetState.FAILED, 8) == 3 + assert constants.state_row_index(PetState.REVIEW, 8) == 4 + assert constants.state_row_index(PetState.JUMP, 8) == 5 + assert constants.state_row_index(PetState.WAITING, 8) == 0 + + # Alias rows resolve as expected. + assert constants.state_row_index("wave", 9) == constants.state_row_index("waving", 9) == 3 + assert constants.state_row_index("jump", 9) == constants.state_row_index("jumping", 9) == 4 + assert constants.state_row_index("run", 9) == constants.state_row_index("running", 9) == 7 + + # unknown row names clamp to idle (row 0), never raise + assert constants.state_row_index("nonsense") == 0 + + +def test_cols_for_scale_is_monotonic_and_floored(): + # scale is the master size knob: smaller scale never yields more columns, + # and half-blocks clamp to a legibility floor rather than devolving to mush. + sizes = [constants.cols_for_scale(s) for s in (0.1, 0.3, 0.5, 0.7, 1.0, 1.5)] + assert sizes == sorted(sizes) + assert all(c >= constants.UNICODE_MIN_COLS for c in sizes) + # tiny scales pin to the floor; large scales grow past it. + assert constants.cols_for_scale(0.05) == constants.UNICODE_MIN_COLS + assert constants.cols_for_scale(0.33) == constants.UNICODE_MIN_COLS + assert constants.cols_for_scale(2.0) > constants.UNICODE_MIN_COLS + + +def test_resolve_cols_override_else_scale(): + # 0 / falsy → derive from scale; a positive int hard-overrides scale. + assert constants.resolve_cols(0.7, 0) == constants.cols_for_scale(0.7) + assert constants.resolve_cols(0.7, None) == constants.cols_for_scale(0.7) + assert constants.resolve_cols(2.0, 12) == 12 + assert constants.resolve_cols(0.1, -5) == constants.cols_for_scale(0.1) + + +# ───────────────────────────────────────────────────────────────────────── +# synthetic spritesheet fixture +# ───────────────────────────────────────────────────────────────────────── + +@pytest.fixture +def boba_like(tmp_path, monkeypatch): + """Install a synthetic 8-col × 9-row pet into a temp HERMES_HOME.""" + 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)) + # paint each row a distinct opaque color so frames are non-empty + 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 pet_dir + + +def test_store_install_resolution(boba_like): + pets = store.installed_pets() + assert [p.slug for p in pets] == ["boba"] + assert store.installed_pets()[0].exists + + # configured slug wins when installed + assert store.resolve_active_pet("boba").slug == "boba" + # bogus slug falls back to first installed + assert store.resolve_active_pet("does-not-exist").slug == "boba" + # display metadata flows from pet.json + assert store.load_pet("boba").display_name == "Boba" + + +def test_store_remove(boba_like): + assert store.remove_pet("boba") is True + assert store.installed_pets() == [] + assert store.remove_pet("boba") is False # idempotent + + +# ───────────────────────────────────────────────────────────────────────── +# render — decode + every encoder produces output +# ───────────────────────────────────────────────────────────────────────── + +def test_renderer_decodes_frames(boba_like): + sprite = store.load_pet("boba").spritesheet + r = render.PetRenderer(str(sprite), mode="unicode", scale=0.5, unicode_cols=12) + assert r.available + # standard sheet yields FRAMES_PER_STATE frames per state + assert r.frame_count("idle") == constants.FRAMES_PER_STATE + assert r.frame_count(PetState.RUN) == constants.FRAMES_PER_STATE + + +def test_trims_trailing_blank_frames(tmp_path): + """Ragged state rows (real frames + transparent padding) trim to real count. + + petdex sheets are left-packed: a state with fewer than FRAMES_PER_STATE real + frames pads the trailing columns transparent. Stepping into one flashes the + pet blank, so the engine must stop the row at the first gap. + """ + from PIL import Image + + cols, rows = 8, 9 + sheet = Image.new("RGBA", (FRAME_W * cols, FRAME_H * rows), (0, 0, 0, 0)) + # row index -> number of real (opaque) frames; the rest stay transparent. + # Codex row taxonomy: idle, running-right, running-left, wave, jump, failed, + # waiting, run, review. + real = {0: 6, 3: 4, 4: 5, 5: 8, 7: 6, 8: 5} + for r, k in real.items(): + for c in range(k): + block = Image.new("RGBA", (FRAME_W, FRAME_H), (200, 80, 80, 255)) + sheet.paste(block, (c * FRAME_W, r * FRAME_H)) + sprite = tmp_path / "ragged.webp" + sheet.save(sprite) + + r = render.PetRenderer(str(sprite), mode="unicode", scale=0.5) + # Full rows cap at FRAMES_PER_STATE; ragged rows trim to their real count. + assert r.frame_count("idle") == constants.FRAMES_PER_STATE + assert r.frame_count("run") == constants.FRAMES_PER_STATE + assert r.frame_count("wave") == 4 + assert r.frame_count("jump") == 5 + assert r.frame_count("failed") == constants.FRAMES_PER_STATE + assert r.frame_count("review") == 5 + + # Every stepped frame is non-empty — no blank flash for the trimmed states. + for state in ("wave", "jump", "review"): + for i in range(r.frame_count(state)): + assert r.frame(state, i), f"{state}[{i}] rendered blank" + + counts = render.state_frame_counts(str(sprite)) + assert counts == { + "idle": 6, + "wave": 4, + "run": 6, + "failed": 6, + "review": 5, + "jump": 5, + "waiting": 0, + } + + +@pytest.mark.parametrize("mode", ["unicode", "kitty", "iterm", "sixel"]) +def test_every_encoder_emits(boba_like, mode): + sprite = store.load_pet("boba").spritesheet + r = render.PetRenderer(str(sprite), mode=mode, scale=0.4) + frame = r.frame("run", 1) + assert isinstance(frame, str) and frame, f"{mode} produced no frame" + if mode == "unicode": + assert "\x1b[" in frame # has color escapes + elif mode == "kitty": + assert frame.startswith("\x1b_G") + elif mode == "iterm": + assert frame.startswith("\x1b]1337;File=") + elif mode == "sixel": + assert frame.startswith("\x1bP") + + +def test_frame_index_wraps(boba_like): + sprite = store.load_pet("boba").spritesheet + r = render.PetRenderer(str(sprite), mode="unicode", scale=0.4) + # index beyond count wraps rather than indexing out of range + assert r.frame("idle", 999) == r.frame("idle", 999 % r.frame_count("idle")) + + +def test_cells_grid_shape(boba_like): + sprite = store.load_pet("boba").spritesheet + r = render.PetRenderer(str(sprite), mode="unicode", scale=0.4, unicode_cols=14) + grid = r.cells("run", 0, cols=14) + assert grid, "no cells produced" + # every row is the requested width; every cell is (top, bottom) RGBA pairs + assert all(len(row) == 14 for row in grid) + (top, bottom) = grid[0][0] + assert len(top) == 4 and len(bottom) == 4 + # missing-sheet renderer yields no cells, never raises + assert render.PetRenderer(str(sprite.parent / "missing.webp"), mode="unicode").cells("idle", 0) == [] + + +# ───────────────────────────────────────────────────────────────────────── +# render — kitty Unicode placeholders (TUI graphics path) +# ───────────────────────────────────────────────────────────────────────── + +def test_kitty_image_id_stable_bounded_nonzero(): + # Deterministic per slug so re-renders reuse the same terminal-side image, + # and always a valid 24-bit-encodable, non-zero id. + a = render.kitty_image_id("boba") + assert a == render.kitty_image_id("boba") + assert 1 <= a <= 0x7FFF + + +def test_kitty_color_hex_decodes_to_id(): + # The placeholder's foreground color IS the image id (24-bit). The terminal + # reconstructs id = (r<<16)|(g<<8)|b, so the hex must round-trip. + for slug in ("boba", "clawd", "pixel-fox"): + image_id = render.kitty_image_id(slug) + h = render.kitty_color_hex(image_id) + assert h.startswith("#") and len(h) == 7 + assert int(h[1:], 16) == image_id + + +def test_kitty_placeholder_rows_grid_contract(): + cols, rows = 18, 10 + grid = render.kitty_placeholder_rows(cols, rows) + assert len(grid) == rows + placeholder = "\U0010eeee" + for r, row in enumerate(grid): + # Each line is exactly `cols` placeholder cells (combining diacritics + # are zero-width, so this is the rendered width Ink must measure). + assert row.count(placeholder) == cols + # First cell carries this row's diacritic; the rest inherit row + col. + assert row.startswith(placeholder + chr(render._ROWCOL_DIACRITICS[r])) + + +def test_kitty_payload_structure(boba_like): + sprite = store.load_pet("boba").spritesheet + image_id = render.kitty_image_id("boba") + scale = 0.4 + r = render.PetRenderer(str(sprite), mode="kitty", scale=scale, unicode_cols=18) + payload = r.kitty_payload("run", image_id=image_id) + assert payload is not None + # placement box must follow scaled pixels, not unicode_cols (kitty upscales to c×r). + frames = r._frames("run") + expect_cols, expect_rows = r._cell_box(frames[0]) + assert payload["cols"] == expect_cols + assert payload["rows"] == expect_rows + assert expect_cols < 18 # 0.4 scale is much smaller than a pinned 18-col box + # placeholder grid matches the requested geometry + assert len(payload["placeholder"]) == payload["rows"] + # one transmit escape per animation frame, each a kitty virtual placement + assert len(payload["frames"]) == r.frame_count("run") + for esc in payload["frames"]: + assert esc.startswith("\x1b_G") + assert esc.endswith("\x1b\\") + assert f"i={image_id}" in esc + assert "a=T" in esc and "U=1" in esc + assert f"c={payload['cols']}" in esc and f"r={payload['rows']}" in esc + + +def test_kitty_payload_none_when_no_frames(tmp_path): + r = render.PetRenderer(str(tmp_path / "missing.webp"), mode="kitty") + assert r.kitty_payload("idle", image_id=1) is None + + +def test_off_mode_and_missing_sheet_degrade(tmp_path): + # off mode never emits + r_off = render.PetRenderer(str(tmp_path / "nope.webp"), mode="off") + assert r_off.frame("idle", 0) == "" + # missing sheet → not available, empty frames, no raise + r_missing = render.PetRenderer(str(tmp_path / "nope.webp"), mode="unicode") + assert not r_missing.available + assert r_missing.frame("idle", 0) == "" + + +def test_resolve_mode_non_tty_is_off(): + # a non-tty stream forces 'off' regardless of configured mode + assert render.resolve_mode("kitty", stream=io.StringIO()) == "off" + assert render.resolve_mode("auto", stream=io.StringIO()) == "off" + + +def test_detect_terminal_graphics_env(monkeypatch): + for key in ("KITTY_WINDOW_ID", "TERM_PROGRAM", "ITERM_SESSION_ID", "WEZTERM_PANE", "TERM"): + monkeypatch.delenv(key, raising=False) + + monkeypatch.setenv("KITTY_WINDOW_ID", "1") + assert render.detect_terminal_graphics() == "kitty" + monkeypatch.delenv("KITTY_WINDOW_ID") + + monkeypatch.setenv("TERM_PROGRAM", "iTerm.app") + assert render.detect_terminal_graphics() == "iterm" + monkeypatch.delenv("TERM_PROGRAM") + + monkeypatch.setenv("TERM", "xterm-256color") + assert render.detect_terminal_graphics() == "unicode" + + +def test_vscode_terminal_ignores_leaked_graphics_env(monkeypatch): + # The VS Code / Cursor integrated terminal can't show inline images by + # default, yet inherits ITERM_SESSION_ID/KITTY_WINDOW_ID when launched from + # those terminals. TERM_PROGRAM=vscode must win → unicode, never a protocol + # whose escapes the embedded terminal would silently drop. + for key in ("KITTY_WINDOW_ID", "TERM_PROGRAM", "ITERM_SESSION_ID", "WEZTERM_PANE", "TERM"): + monkeypatch.delenv(key, raising=False) + monkeypatch.setenv("TERM_PROGRAM", "vscode") + + assert render.detect_terminal_graphics() == "unicode" + for leaked in ("ITERM_SESSION_ID", "KITTY_WINDOW_ID", "WEZTERM_PANE"): + monkeypatch.setenv(leaked, "1") + assert render.detect_terminal_graphics() == "unicode" + monkeypatch.delenv(leaked) From 83aa84ae3b00ba5f2923dad2194a491f6bab6888 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 20 Jun 2026 14:18:33 -0500 Subject: [PATCH 2/6] 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 From 75b36a138f43f2201b276a3c5d59f2aae0383fef Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 20 Jun 2026 14:18:36 -0500 Subject: [PATCH 3/6] feat(pets): TUI pet pane, picker + gateway RPCs Add the Ink pet sprite pane, the interactive /pet picker overlay, and live pet switching/rescale driven by new tui_gateway RPCs (pet state, pet.scale, per-state frames). Wires pet flash state and the picker into the TUI layout and slash handler. Covered by the slash-handler test. --- tui_gateway/server.py | 424 ++++++++++++++++++ .../src/__tests__/createSlashHandler.test.ts | 35 ++ ui-tui/src/app/createGatewayEventHandler.ts | 7 + ui-tui/src/app/interfaces.ts | 1 + ui-tui/src/app/overlayStore.ts | 5 +- ui-tui/src/app/petFlashStore.ts | 16 + ui-tui/src/app/slash/commands/session.ts | 28 ++ ui-tui/src/app/useInputHandlers.ts | 4 + ui-tui/src/app/usePet.ts | 313 +++++++++++++ ui-tui/src/components/appLayout.tsx | 22 + ui-tui/src/components/appOverlays.tsx | 8 + ui-tui/src/components/petPicker.tsx | 183 ++++++++ ui-tui/src/components/petSprite.tsx | 93 ++++ ui-tui/src/types/hermes-ink.d.ts | 5 +- 14 files changed, 1142 insertions(+), 2 deletions(-) create mode 100644 ui-tui/src/app/petFlashStore.ts create mode 100644 ui-tui/src/app/usePet.ts create mode 100644 ui-tui/src/components/petPicker.tsx create mode 100644 ui-tui/src/components/petSprite.tsx diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 294e543c230..005b26e0cb4 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -177,6 +177,14 @@ _LONG_HANDLERS = frozenset( "billing.step_up", "browser.manage", "cli.exec", + # Pet RPCs hit the network (manifest fetch / spritesheet download) or do + # per-frame PNG decode/encode (pet.cells): inline they serialize on the + # reader thread, so picker previews trickle in one at a time and the + # animation poll stutters. On the pool they run concurrently. + "pet.cells", + "pet.gallery", + "pet.select", + "pet.thumb", "plugins.manage", "session.branch", "session.compress", @@ -692,6 +700,29 @@ def _profile_home(profile: str | None) -> Path | None: return home if (home / "state.db").exists() or home.exists() else None +def _profile_scoped(handler): + """Bind ``params['profile']``'s HERMES_HOME around a pet RPC handler. + + Pets are per-profile: ``display.pet.*`` lives in the profile's config.yaml and + sprites install under its ``pets/`` dir (both resolve via ``get_hermes_home``). + The desktop sends ``profile`` on pet calls so config + pets dir resolve to the + focused profile even in app-global remote mode, where one backend serves every + profile. No-op for the launch profile (own-profile backends already resolve it). + """ + + def wrapper(rid, params): + home = _profile_home(params.get("profile") if isinstance(params, dict) else None) + if home is None: + return handler(rid, params) + token = set_hermes_home_override(home) + try: + return handler(rid, params) + finally: + reset_hermes_home_override(token) + + return wrapper + + # Placeholder ``terminal.cwd`` values that don't name a real directory — the # gateway resolves these to the home dir at runtime, so they must NOT be treated # as an explicit workspace (mirrors gateway/run.py's config bridge). @@ -5181,6 +5212,399 @@ def _(rid, params: dict) -> dict: return _ok(rid, usage) +def _pet_frame_counts(spritesheet) -> dict: + """Real (padding-trimmed) frame count per state, for the desktop canvas. + + Fail-open: a decode hiccup returns ``{}`` and the canvas falls back to its + static ``framesPerState`` rather than breaking the (cosmetic) pet. + """ + try: + from agent.pet import render + + return render.state_frame_counts(str(spritesheet)) + except Exception: # noqa: BLE001 - cosmetic, never break the surface + return {} + + +def _pet_state_rows(spritesheet) -> list[str]: + """Row taxonomy for the concrete active pet sheet. + + Hermes has to support both the legacy 8-row petdex atlas and the current + Codex/petdex 9-row atlas. The desktop canvas gets this list and indexes it + with the same `PetState` names the Python renderer uses. + """ + try: + from PIL import Image + + from agent.pet import constants + + with Image.open(spritesheet) as image: + row_count = max(1, image.height // constants.FRAME_H) + return list(constants.state_rows_for_grid(row_count)) + except Exception: # noqa: BLE001 - cosmetic, never break the surface + from agent.pet import constants + + return list(constants.STATE_ROWS) + + +@method("pet.info") +@_profile_scoped +def _(rid, params: dict) -> dict: + """Return the active petdex pet for surfaces that render sprites. + + Shared by the desktop (canvas) and the TUI (half-block). Carries the + spritesheet bytes (base64) plus the engine's frame geometry + state-row + taxonomy so the renderer is a thin, framework-native consumer. The + activity→state decision is mirrored from ``agent.pet.state`` client-side. + + Agent-independent (reads config + disk), so it works on any session and + before the agent finishes building. Fail-open: returns ``enabled=False`` + on any error rather than erroring the surface. + """ + import base64 + + try: + from agent.pet import constants, store + + try: + 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 {} + except Exception: + pet_cfg = {} + + enabled = bool(pet_cfg.get("enabled")) + configured_slug = str(pet_cfg.get("slug", "") or "") + pet = store.resolve_active_pet(configured_slug) if enabled else None + + if not enabled or pet is None or not pet.exists: + return _ok(rid, {"enabled": False}) + + raw = pet.spritesheet.read_bytes() + suffix = pet.spritesheet.suffix.lower() + mime = "image/png" if suffix == ".png" else "image/webp" + return _ok( + rid, + { + "enabled": True, + "slug": pet.slug, + "displayName": pet.display_name, + "mime": mime, + "spritesheetBase64": base64.standard_b64encode(raw).decode("ascii"), + "frameW": constants.FRAME_W, + "frameH": constants.FRAME_H, + "framesPerState": constants.FRAMES_PER_STATE, + "framesByState": _pet_frame_counts(pet.spritesheet), + "loopMs": constants.LOOP_MS, + "scale": float(pet_cfg.get("scale", constants.DEFAULT_SCALE) or constants.DEFAULT_SCALE), + "stateRows": _pet_state_rows(pet.spritesheet), + }, + ) + except Exception as exc: # noqa: BLE001 - cosmetic, never break the surface + logger.debug("pet.info failed: %s", exc) + return _ok(rid, {"enabled": False}) + + +@method("pet.cells") +@_profile_scoped +def _(rid, params: dict) -> dict: + """Return half-block cell frames for one pet state (TUI renderer). + + The TUI can't draw a canvas, so the engine downsamples the spritesheet to + a grid of half-block cells and the Ink side paints them with native color + props. Each cell is ``[tr,tg,tb,ta, br,bg,bb,ba]`` (top + bottom pixel). + + Params: ``state`` (idle/run/review/failed/wave/jump), ``cols`` (width). + Fail-open: ``enabled=False`` on any problem. + """ + try: + from agent.pet import constants, render, store + from agent.pet.render import PetRenderer + + try: + 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 {} + except Exception: + pet_cfg = {} + + if not bool(pet_cfg.get("enabled")): + return _ok(rid, {"enabled": False}) + + pet = store.resolve_active_pet(str(pet_cfg.get("slug", "") or "")) + if pet is None or not pet.exists: + return _ok(rid, {"enabled": False}) + + state = str(params.get("state") or constants.PetState.IDLE.value) + scale = float(pet_cfg.get("scale", constants.DEFAULT_SCALE) or constants.DEFAULT_SCALE) + cols = int(params.get("cols") or 0) or constants.resolve_cols(scale, pet_cfg.get("unicode_cols", 0)) + + # Graphics path: when the TUI is attached to a real TTY (``graphics``) + # and the terminal speaks the kitty protocol, return a Unicode- + # placeholder payload for a crisp image instead of half-blocks. Env + # detection (KITTY_WINDOW_ID / TERM / TERM_PROGRAM) is shared with the + # Ink process since it spawns us; the dashboard PTY (xterm.js) has no + # such env, so it falls through to half-blocks automatically. Only + # kitty is grid-safe in Ink — iTerm/sixel stay on the fallback. + if params.get("graphics"): + configured = str(pet_cfg.get("render_mode", "auto") or "auto").lower() + gmode = render.detect_terminal_graphics() if configured in ("", "auto") else configured + if gmode == "kitty": + image_id = render.kitty_image_id(pet.slug) + # kitty sizes from scaled pixels (_cell_box), so unicode_cols is moot here. + payload = PetRenderer( + str(pet.spritesheet), mode="kitty", scale=scale + ).kitty_payload(state, image_id=image_id) + if payload: + kcount = len(payload["frames"]) or 1 + return _ok( + rid, + { + "enabled": True, + "slug": pet.slug, + "displayName": pet.display_name, + "state": state, + "graphics": "kitty", + "imageId": image_id, + "color": render.kitty_color_hex(image_id), + "cols": payload["cols"], + "rows": payload["rows"], + "placeholder": payload["placeholder"], + "frames": payload["frames"], + "frameMs": constants.LOOP_MS / max(1, kcount), + "scale": scale, + }, + ) + + renderer = PetRenderer( + str(pet.spritesheet), + mode="unicode", + scale=scale, + unicode_cols=cols, + ) + count = renderer.frame_count(state) or 1 + frames = [] + for i in range(count): + grid = renderer.cells(state, i, cols=cols) + frames.append( + [[[*top, *bottom] for (top, bottom) in row] for row in grid] + ) + + return _ok( + rid, + { + "enabled": True, + "slug": pet.slug, + "displayName": pet.display_name, + "state": state, + "cols": cols, + "frameMs": constants.LOOP_MS / max(1, count), + "frames": frames, + "scale": scale, + }, + ) + except Exception as exc: # noqa: BLE001 + logger.debug("pet.cells failed: %s", exc) + return _ok(rid, {"enabled": False}) + + +@method("pet.gallery") +@_profile_scoped +def _(rid, params: dict) -> dict: + """List adoptable pets for the desktop appearance picker. + + Returns the petdex gallery merged with local install state plus the + current config (active slug + enabled). Agent-independent. Fail-open: + returns whatever is installed locally if the gallery can't be reached, so + the picker still works offline. + """ + try: + from agent.pet import store + + try: + 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 {} + except Exception: + pet_cfg = {} + + installed = {p.slug: p for p in store.installed_pets()} + + gallery: list[dict] = [] + seen: set[str] = set() + try: + from agent.pet.manifest import fetch_manifest + + for entry in fetch_manifest(): + seen.add(entry.slug) + gallery.append( + { + "slug": entry.slug, + "displayName": entry.display_name, + "installed": entry.slug in installed, + "spritesheetUrl": entry.spritesheet_url, + # petdex exposes no popularity metric; "curated" (its + # hand-picked/official set, identified by the asset path) + # is the closest signal, so the picker can surface it first. + "curated": "/curated/" in entry.spritesheet_url, + } + ) + except Exception as exc: # noqa: BLE001 - offline: fall back to installed + logger.debug("pet.gallery manifest fetch failed: %s", exc) + + # Always include locally-installed pets even if the gallery is unreachable. + for slug, pet in installed.items(): + if slug not in seen: + gallery.append( + {"slug": slug, "displayName": pet.display_name, "installed": True, "spritesheetUrl": ""} + ) + + return _ok( + rid, + { + "enabled": bool(pet_cfg.get("enabled")), + "active": str(pet_cfg.get("slug", "") or ""), + "pets": gallery, + }, + ) + except Exception as exc: # noqa: BLE001 + logger.debug("pet.gallery failed: %s", exc) + return _ok(rid, {"enabled": False, "active": "", "pets": []}) + + +@method("pet.select") +@_profile_scoped +def _(rid, params: dict) -> dict: + """Adopt a pet from the desktop picker: install (if needed) + activate. + + Params: ``slug`` (required). Writes ``display.pet.*`` to config and returns + ``{ok, slug, displayName}``. The surface re-pulls ``pet.info`` to render it. + """ + slug = str(params.get("slug") or "").strip() + if not slug: + return _err(rid, 4004, "missing slug") + try: + from agent.pet import store + from agent.pet.manifest import ManifestError + from hermes_cli.pets import _set_active + + try: + pet = store.install_pet(slug) + except (store.PetStoreError, ManifestError) as exc: + return _err(rid, 5031, f"could not adopt '{slug}': {exc}") + _set_active(slug) + return _ok(rid, {"ok": True, "slug": slug, "displayName": pet.display_name}) + except Exception as exc: # noqa: BLE001 + logger.debug("pet.select failed: %s", exc) + return _err(rid, 5031, f"pet.select failed: {exc}") + + +@method("pet.remove") +@_profile_scoped +def _(rid, params: dict) -> dict: + """Uninstall a pet from the desktop picker (delete its on-disk directory). + + Params: ``slug`` (required). If the removed pet was the active one, the + display is turned off so nothing tries to render a now-missing sprite. + Returns ``{ok, slug}`` where ``ok`` reflects whether a directory was deleted. + """ + slug = str(params.get("slug") or "").strip() + if not slug: + return _err(rid, 4004, "missing slug") + try: + from agent.pet import store + from hermes_cli.pets import _clear_active_if + + removed = store.remove_pet(slug) + + # If that was the active pet, stop surfaces pointing at a deleted sprite. + try: + _clear_active_if(slug) + except Exception as exc: # noqa: BLE001 - removal already succeeded + logger.debug("pet.remove config update failed: %s", exc) + + return _ok(rid, {"ok": removed, "slug": slug}) + except Exception as exc: # noqa: BLE001 + logger.debug("pet.remove failed: %s", exc) + return _err(rid, 5031, f"pet.remove failed: {exc}") + + +@method("pet.thumb") +@_profile_scoped +def _(rid, params: dict) -> dict: + """Return a small idle-frame PNG (data URI) for one pet — the picker preview. + + Cropped + cached server-side so the renderer gets a same-origin data URL + instead of a CDN ```` (which the desktop CSP / R2 hotlink rules break). + Params: ``slug`` (required), ``url`` (optional petdex spritesheet URL used + only for not-yet-installed pets). Fail-open: ``{ok: false}`` with no error. + """ + slug = str(params.get("slug") or "").strip() + if not slug: + return _err(rid, 4004, "missing slug") + try: + import base64 + + from agent.pet import store + + data = store.thumbnail_png(slug, source_url=str(params.get("url") or "")) + if not data: + return _ok(rid, {"ok": False, "slug": slug}) + + return _ok( + rid, + { + "ok": True, + "slug": slug, + "dataUri": "data:image/png;base64," + base64.standard_b64encode(data).decode("ascii"), + }, + ) + except Exception as exc: # noqa: BLE001 + logger.debug("pet.thumb failed: %s", exc) + return _ok(rid, {"ok": False, "slug": slug}) + + +@method("pet.disable") +@_profile_scoped +def _(rid, params: dict) -> dict: + """Turn the pet off from the desktop picker (``display.pet.enabled=false``).""" + try: + from hermes_cli.pets import _set_enabled + + _set_enabled(False) + return _ok(rid, {"ok": True}) + except Exception as exc: # noqa: BLE001 + logger.debug("pet.disable failed: %s", exc) + return _err(rid, 5031, f"pet.disable failed: {exc}") + + +@method("pet.scale") +@_profile_scoped +def _(rid, params: dict) -> dict: + """Persist ``display.pet.scale`` from the desktop slider. Params: ``scale``. + + Clamped to the engine bounds. The renderer updates its own ``$petInfo`` for + instant feedback; this just makes the change durable + visible to the other + terminal surfaces on their next read. + """ + try: + from hermes_cli.pets import set_pet_scale + + scale, err = set_pet_scale(params.get("scale")) + if err: + return _err(rid, 4004, err) + return _ok(rid, {"ok": True, "scale": scale}) + except Exception as exc: # noqa: BLE001 + logger.debug("pet.scale failed: %s", exc) + return _err(rid, 5031, f"pet.scale failed: {exc}") + + @method("credits.view") def _(rid, params: dict) -> dict: """Structured Nous credit view for the TUI /credits command. diff --git a/ui-tui/src/__tests__/createSlashHandler.test.ts b/ui-tui/src/__tests__/createSlashHandler.test.ts index a671063e5e9..7124cbfcd75 100644 --- a/ui-tui/src/__tests__/createSlashHandler.test.ts +++ b/ui-tui/src/__tests__/createSlashHandler.test.ts @@ -208,6 +208,41 @@ describe('createSlashHandler', () => { }) }) + it('opens the pet picker for /pet list only', () => { + const ctx = buildCtx() + + expect(createSlashHandler(ctx)('/pet list')).toBe(true) + expect(getOverlayState().petPicker).toBe(true) + expect(ctx.gateway.gw.request).not.toHaveBeenCalled() + + resetOverlayState() + 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 to the slash worker without opening the picker', () => { + const ctx = buildCtx() + + expect(createSlashHandler(ctx)('/pet boba')).toBe(true) + expect(getOverlayState().petPicker).toBe(false) + expect(ctx.gateway.gw.request).toHaveBeenCalledWith( + 'slash.exec', + expect.objectContaining({ command: 'pet boba' }) + ) + }) + it('routes /skills inspect to skills.manage', () => { const ctx = buildCtx() diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index de2f774f149..b0b09a725c3 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -9,6 +9,7 @@ import type { GatewaySkin, SessionMostRecentResponse } from '../gatewayTypes.js' +import { isTodoDone } from '../lib/liveProgress.js' import { rpcErrorMessage } from '../lib/rpc.js' import { openExternalUrl } from '../lib/openExternalUrl.js' import { topLevelSubagents } from '../lib/subagentTree.js' @@ -19,7 +20,9 @@ import type { Msg, SubagentProgress, SubagentStatus } from '../types.js' import { applyDelegationStatus, getDelegationState } from './delegationStore.js' import type { GatewayEventHandlerContext } from './interfaces.js' import { getOverlayState, patchOverlayState } from './overlayStore.js' +import { flashPet } from './petFlashStore.js' import { turnController } from './turnController.js' +import { getTurnState } from './turnStore.js' import { getUiState, patchUiState } from './uiStore.js' const NO_PROVIDER_RE = /\bNo (?:LLM|inference) provider configured\b/i @@ -908,6 +911,9 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: const msgs: Msg[] = finalMessages.length ? finalMessages : [{ role: 'assistant', text: finalText }] msgs.forEach(appendMessage) + // Pet beat: celebrate a finished plan, otherwise a clean-finish wave. + flashPet(isTodoDone(getTurnState().todos) ? 'jump' : 'wave') + if (bellOnComplete && stdout?.isTTY) { stdout.write('\x07') } @@ -924,6 +930,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: case 'error': turnController.recordError() + flashPet('failed') { const message = String(ev.payload?.message || 'unknown error') diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index f570cf2b6ab..1d1f12a3a2a 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -136,6 +136,7 @@ export interface OverlayState { confirm: ConfirmReq | null modelPicker: boolean pager: null | PagerState + petPicker: boolean pluginsHub: boolean secret: null | SecretReq sessions: boolean diff --git a/ui-tui/src/app/overlayStore.ts b/ui-tui/src/app/overlayStore.ts index c0290d71cab..b716b313c90 100644 --- a/ui-tui/src/app/overlayStore.ts +++ b/ui-tui/src/app/overlayStore.ts @@ -11,6 +11,7 @@ const buildOverlayState = (): OverlayState => ({ confirm: null, modelPicker: false, pager: null, + petPicker: false, pluginsHub: false, secret: null, sessions: false, @@ -22,7 +23,7 @@ export const $overlayState = atom(buildOverlayState()) export const $isBlocked = computed( $overlayState, - ({ agents, approval, billing, clarify, confirm, modelPicker, pager, pluginsHub, secret, sessions, skillsHub, sudo }) => + ({ agents, approval, billing, clarify, confirm, modelPicker, pager, petPicker, pluginsHub, secret, sessions, skillsHub, sudo }) => Boolean( agents || approval || @@ -31,6 +32,7 @@ export const $isBlocked = computed( confirm || modelPicker || pager || + petPicker || pluginsHub || secret || sessions || @@ -61,6 +63,7 @@ export const resetFlowOverlays = () => agents: $overlayState.get().agents, agentsInitialHistoryIndex: $overlayState.get().agentsInitialHistoryIndex, modelPicker: $overlayState.get().modelPicker, + petPicker: $overlayState.get().petPicker, pluginsHub: $overlayState.get().pluginsHub, sessions: $overlayState.get().sessions, skillsHub: $overlayState.get().skillsHub diff --git a/ui-tui/src/app/petFlashStore.ts b/ui-tui/src/app/petFlashStore.ts new file mode 100644 index 00000000000..d4865e47a2b --- /dev/null +++ b/ui-tui/src/app/petFlashStore.ts @@ -0,0 +1,16 @@ +import { atom } from 'nanostores' + +import type { PetState } from './usePet.js' + +interface PetFlash { + state: PetState + until: number +} + +// Transient reaction beats (wave/jump/failed) the pet shows for a moment at +// turn end before falling back to its steady state. The gateway event handler +// sets these; usePet reads them with priority over the derived state. +export const $petFlash = atom(null) + +export const flashPet = (state: PetState, ms = 1600) => + $petFlash.set({ state, until: Date.now() + ms }) diff --git a/ui-tui/src/app/slash/commands/session.ts b/ui-tui/src/app/slash/commands/session.ts index b716504b353..a8b4b3ca4e1 100644 --- a/ui-tui/src/app/slash/commands/session.ts +++ b/ui-tui/src/app/slash/commands/session.ts @@ -8,6 +8,7 @@ import type { SessionBranchResponse, SessionCompressResponse, SessionUsageResponse, + SlashExecResponse, VoiceToggleResponse } from '../../../gatewayTypes.js' import { formatVoiceRecordKey, parseVoiceRecordKey } from '../../../lib/platform.js' @@ -340,6 +341,31 @@ export const sessionCommands: SlashCommand[] = [ } }, + { + help: 'toggle / adopt / resize an animated pet', + name: 'pet', + usage: '/pet [toggle | list | scale | ]', + run: (arg, ctx, cmd) => { + const sub = arg.trim().toLowerCase() + + // 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('slash.exec', { command: cmd.slice(1), session_id: ctx.sid }) + .then( + ctx.guarded(r => { + const body = r.output || '/pet: no output' + ctx.transcript.sys(r.warning ? `warning: ${r.warning}\n${body}` : body) + }) + ) + .catch(ctx.guardedErr) + } + }, + { help: 'switch theme skin (fires skin.changed)', name: 'skin', @@ -553,6 +579,7 @@ export const sessionCommands: SlashCommand[] = [ // even with zero API calls or on a resumed session. Render it whenever // present, before the token panel. const creditsLines = r?.credits_lines ?? [] + if (creditsLines.length) { ctx.transcript.panel('Nous credits', [{ text: creditsLines.join('\n') }]) } @@ -561,6 +588,7 @@ export const sessionCommands: SlashCommand[] = [ if (!creditsLines.length) { ctx.transcript.sys('no API calls yet') } + return } diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts index 20d3493f547..f765322163d 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -147,6 +147,10 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { return patchOverlayState({ modelPicker: false }) } + if (overlay.petPicker) { + return patchOverlayState({ petPicker: false }) + } + if (overlay.billing) { return patchOverlayState({ billing: null }) } diff --git a/ui-tui/src/app/usePet.ts b/ui-tui/src/app/usePet.ts new file mode 100644 index 00000000000..8943c1ae7f0 --- /dev/null +++ b/ui-tui/src/app/usePet.ts @@ -0,0 +1,313 @@ +import { useStdout } from '@hermes/ink' +import { useCallback, useEffect, useRef, useState } from 'react' + +import type { PetGrid } from '../components/petSprite.js' + +import { useGateway } from './gatewayContext.js' +import { getOverlayState, $overlayState } from './overlayStore.js' +import { $petFlash } from './petFlashStore.js' +import { $turnState } from './turnStore.js' +import { $uiState } from './uiStore.js' + +export type PetState = 'idle' | 'wave' | 'run' | 'failed' | 'review' | 'jump' | 'waiting' + +interface PetActivity { + busy: boolean + toolRunning: boolean + reasoning: boolean + awaitingInput: boolean +} + +/** + * Resolve the animation state — mirrors `agent.pet.state.derive_pet_state` + * (and the desktop's `derivePetState`) so all surfaces agree. `awaitingInput` + * (a clarify/approval blocking on the user) outranks the in-flight signals + * because the turn is paused on you, not working. + */ +export function derivePetState({ busy, toolRunning, reasoning, awaitingInput }: PetActivity): PetState { + if (awaitingInput) { + return 'waiting' + } + + if (toolRunning) { + return 'run' + } + + if (reasoning) { + return 'review' + } + + if (busy) { + return 'run' + } + + return 'idle' +} + +// The overlays that mean "the agent is blocked on the user" (vs. user-toggled +// pickers like model/sessions, which aren't the agent waiting). +function isAwaitingInput(): boolean { + const o = getOverlayState() + + return Boolean(o.clarify || o.approval || o.sudo || o.secret || o.confirm) +} + +// A kitty Unicode-placeholder frame set: a static placeholder grid (painted by +// Ink in the image-id color) plus per-frame transmit escapes written straight +// to the terminal out-of-band. +interface KittyView { + color: string + placeholder: string[] +} + +interface PetCellsResult { + color?: string + enabled?: boolean + frameMs?: number + // unicode mode: cell grids; kitty mode: transmit-escape strings. + frames?: PetGrid[] | string[] + graphics?: string + imageId?: number + placeholder?: string[] + scale?: number + slug?: string + state?: string +} + +type CacheEntry = + | { kind: 'cells'; frameMs: number; frames: PetGrid[] } + | { kind: 'kitty'; frameMs: number; frames: string[]; placeholder: string[]; color: string } + +const FRAME_MS = 160 +const POLL_MS = 2500 + +// Only the standalone TUI owns a real terminal it can splat image escapes into; +// when piped (or running under the dashboard PTY the gateway resolves to +// half-blocks anyway) we never ask for graphics. +const IS_TTY = Boolean(process.stdout?.isTTY) + +export interface PetRender { + enabled: boolean + grid: PetGrid | null + kitty: KittyView | null +} + +/** + * Drives the TUI pet. Fetches each (slug, state)'s frames via the `pet.cells` + * RPC (cached) and animates the frame index. Two render paths: + * + * - **kitty** (Ghostty/kitty): the engine returns a static placeholder grid + + * per-frame transmit escapes. We paint the placeholder with Ink and write the + * current frame's escape to the terminal out-of-band, so the image animates + * underneath without Ink ever repainting. + * - **cells** (everywhere else): truecolor half-block grids painted by Ink. + * + * A steady poll keeps it reactive to config changes made elsewhere (`/pet`, the + * picker, `hermes pets select`) so adopting/switching/disabling takes effect + * live. The frame cache is keyed by `slug:state` so a switch re-pulls cleanly. + */ +export function usePet(): PetRender { + const { rpc } = useGateway() + const { write } = useStdout() + const [enabled, setEnabled] = useState(false) + const [grid, setGrid] = useState(null) + const [kitty, setKitty] = useState(null) + + const cache = useRef>(new Map()) + const slugRef = useRef('') + const scaleRef = useRef(0) + const imageIdRef = useRef(0) + const stateRef = useRef('idle') + const frameRef = useRef(0) + + const [petState, setPetState] = useState('idle') + + // Recompute the desired state on every turn/ui/flash change. A transient + // flash (wave/jump/failed) wins until it expires; a timer re-runs at expiry. + useEffect(() => { + let expiry: ReturnType | undefined + + const apply = (next: PetState) => { + if (next !== stateRef.current) { + stateRef.current = next + frameRef.current = 0 + setPetState(next) + } + } + + const recompute = () => { + clearTimeout(expiry) + + const flash = $petFlash.get() + const now = Date.now() + + if (flash && now < flash.until) { + apply(flash.state) + expiry = setTimeout(recompute, flash.until - now) + + return + } + + const turn = $turnState.get() + const ui = $uiState.get() + + apply( + derivePetState({ + awaitingInput: isAwaitingInput(), + busy: ui.busy, + reasoning: turn.reasoningActive, + toolRunning: turn.tools.length > 0 + }) + ) + } + + recompute() + const unsubTurn = $turnState.listen(recompute) + const unsubUi = $uiState.listen(recompute) + const unsubFlash = $petFlash.listen(recompute) + const unsubOverlay = $overlayState.listen(recompute) + + return () => { + clearTimeout(expiry) + unsubTurn() + unsubUi() + unsubFlash() + unsubOverlay() + } + }, []) + + // Free the terminal-side image when the pet goes away or the hook unmounts. + const releaseKitty = useCallback(() => { + if (imageIdRef.current) { + try { + write(`\x1b_Ga=d,d=i,i=${imageIdRef.current},q=2\x1b\\`) + } catch { + // best-effort cleanup + } + + imageIdRef.current = 0 + } + }, [write]) + + // Fetch + cache one (slug, state). `pet.cells` resolves the active pet from + // config, so its `slug`/`enabled` are the source of truth. + const sync = useCallback( + async (state: PetState) => { + try { + const res = (await rpc('pet.cells', { graphics: IS_TTY, state })) as PetCellsResult | null + + if (!res) { + return + } + + if (!res.enabled) { + releaseKitty() + slugRef.current = '' + cache.current.clear() + setGrid(null) + setKitty(null) + setEnabled(false) + + return + } + + const slug = res.slug ?? '' + const scale = res.scale ?? 0 + + // A switch OR a live `/pet scale` change invalidates the cached frames + // (they're rendered at the old size), so the steady poll repaints at the + // new scale without a restart. + if (slug !== slugRef.current || (scale > 0 && scale !== scaleRef.current)) { + releaseKitty() + slugRef.current = slug + scaleRef.current = scale + cache.current.clear() + frameRef.current = 0 + } + + if (res.graphics === 'kitty' && res.frames?.length && res.placeholder?.length) { + imageIdRef.current = res.imageId ?? 0 + cache.current.set(`${slug}:${state}`, { + color: res.color ?? '#000001', + frameMs: res.frameMs ?? FRAME_MS, + frames: res.frames as string[], + kind: 'kitty', + placeholder: res.placeholder + }) + } else if (res.frames?.length) { + cache.current.set(`${slug}:${state}`, { + frameMs: res.frameMs ?? FRAME_MS, + frames: res.frames as PetGrid[], + kind: 'cells' + }) + } + + setEnabled(true) + } catch { + // cosmetic — ignore RPC failures + } + }, + [rpc, releaseKitty] + ) + + // Pull frames whenever the state changes (if not already cached for the + // active pet), plus a steady poll that catches adopt/switch/disable. + useEffect(() => { + if (!cache.current.has(`${slugRef.current}:${petState}`)) { + void sync(petState) + } + + const timer = setInterval(() => void sync(stateRef.current), POLL_MS) + + return () => clearInterval(timer) + }, [petState, sync]) + + useEffect(() => releaseKitty, [releaseKitty]) + + // Animation timer. + useEffect(() => { + if (!enabled) { + return + } + + const tick = () => { + const entry = cache.current.get(`${slugRef.current}:${stateRef.current}`) + + if (!entry?.frames.length) { + return // keep the last frame painted while the new state loads + } + + const idx = frameRef.current % entry.frames.length + frameRef.current = idx + 1 + + if (entry.kind === 'kitty') { + // Transmit this frame's image under the shared id; the static + // placeholder cells (set below) render it. No Ink repaint needed. + try { + write(entry.frames[idx] ?? '') + } catch { + // ignore transmit failures + } + + setGrid(null) + setKitty(prev => + prev && prev.color === entry.color && prev.placeholder === entry.placeholder + ? prev + : { color: entry.color, placeholder: entry.placeholder } + ) + + return + } + + setKitty(null) + setGrid(entry.frames[idx] ?? null) + } + + tick() + const interval = setInterval(tick, FRAME_MS) + + return () => clearInterval(interval) + }, [enabled, petState, write]) + + return { enabled, grid, kitty } +} diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index d54f5c6da90..7fa5dec1886 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -6,6 +6,7 @@ import { useGateway } from '../app/gatewayContext.js' import type { AppLayoutProps } from '../app/interfaces.js' import { $isBlocked, $overlayState, patchOverlayState } from '../app/overlayStore.js' import { $uiState } from '../app/uiStore.js' +import { usePet } from '../app/usePet.js' import { INLINE_MODE, SHOW_FPS, TERMUX_TUI_MODE } from '../config/env.js' import { PLACEHOLDER } from '../content/placeholders.js' import { prevRenderedMsg } from '../domain/blockLayout.js' @@ -25,10 +26,29 @@ import { Banner, Panel, SessionPanel } from './branding.js' import { FpsOverlay } from './fpsOverlay.js' import { HelpHint } from './helpHint.js' import { MessageLine } from './messageLine.js' +import { PetKitty, PetSprite } from './petSprite.js' import { QueuedMessages } from './queuedMessages.js' import { LiveTodoPanel, StreamingAssistant } from './streamingAssistant.js' import { TextInput, type TextInputMouseApi } from './textInput.js' +// Petdex mascot — sits just above the composer, right-aligned. Renders +// nothing unless a pet is installed + enabled (`hermes pets select `), +// so it's a no-op for everyone else. +const PetPane = memo(function PetPane() { + const { enabled, grid, kitty } = usePet() + + if (!enabled || (!grid && !kitty)) { + return null + } + + return ( + + {kitty ? : null} + {!kitty && grid ? : null} + + ) +}) + const PromptPrefix = memo(function PromptPrefix({ bold = false, color, @@ -420,6 +440,8 @@ export const AppLayout = memo(function AppLayout({ {!overlay.agents && ( <> + + )} + {overlay.petPicker && ( + + patchOverlayState({ petPicker: false })} t={theme} /> + + )} + {overlay.skillsHub && ( patchOverlayState({ skillsHub: false })} t={theme} /> diff --git a/ui-tui/src/components/petPicker.tsx b/ui-tui/src/components/petPicker.tsx new file mode 100644 index 00000000000..dacb553082e --- /dev/null +++ b/ui-tui/src/components/petPicker.tsx @@ -0,0 +1,183 @@ +import { Box, Text, useInput, useStdout } from '@hermes/ink' +import { useEffect, useMemo, useState } from 'react' + +import type { GatewayClient } from '../gatewayClient.js' +import { rpcErrorMessage } from '../lib/rpc.js' +import type { Theme } from '../theme.js' + +import { OverlayHint, windowItems } from './overlayControls.js' + +const VISIBLE = 10 +const MIN_WIDTH = 40 +const MAX_WIDTH = 90 + +interface GalleryPet { + slug: string + displayName: string + installed: boolean + curated?: boolean +} + +interface Gallery { + enabled: boolean + active: string + pets: GalleryPet[] +} + +/** + * Interactive petdex picker overlay. Pulls the gallery via `pet.gallery`, + * filters as you type, and adopts the highlighted pet with `pet.select` + * (install-on-demand). The mascot lights up live once `usePet` next polls — + * no restart. This is the interactive sibling of the text `/pet ` path. + */ +export function PetPicker({ gw, onClose, t }: PetPickerProps) { + const [gallery, setGallery] = useState(null) + const [query, setQuery] = useState('') + const [idx, setIdx] = useState(0) + const [busy, setBusy] = useState(false) + const [err, setErr] = useState('') + const [loading, setLoading] = useState(true) + + const { stdout } = useStdout() + const width = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (stdout?.columns ?? 80) - 6)) + + useEffect(() => { + gw.request('pet.gallery') + .then(r => { + setGallery(r) + setErr('') + }) + .catch((e: unknown) => setErr(rpcErrorMessage(e))) + .finally(() => setLoading(false)) + }, [gw]) + + const enabled = gallery?.enabled ?? false + const active = gallery?.active ?? '' + + // Rank by the signals petdex gives us — active, then installed, then curated + // (its official set), then the rest — and hide the clawd placeholders. + const view = useMemo(() => { + const pets = (gallery?.pets ?? []).filter(p => !/^clawd(-|$)/i.test(p.slug)) + const needle = query.trim().toLowerCase() + + const matched = needle + ? pets.filter(p => p.slug.toLowerCase().includes(needle) || p.displayName.toLowerCase().includes(needle)) + : pets + + const rank = (p: GalleryPet) => (enabled && p.slug === active ? 4 : 0) + (p.installed ? 2 : 0) + (p.curated ? 1 : 0) + + return [...matched].sort((a, b) => rank(b) - rank(a)) + }, [gallery, query, enabled, active]) + + const adopt = (slug: string) => { + setBusy(true) + setErr('') + gw.request('pet.select', { slug }) + .then(() => onClose()) + .catch((e: unknown) => { + setErr(rpcErrorMessage(e)) + setBusy(false) + }) + } + + useInput((input, key) => { + if (busy) { + return + } + + if (key.escape) { + return onClose() + } + + if (key.upArrow) { + return setIdx(i => Math.max(0, i - 1)) + } + + if (key.downArrow) { + return setIdx(i => Math.min(view.length - 1, i + 1)) + } + + if (key.return) { + const pet = view[idx] + + return pet ? adopt(pet.slug) : undefined + } + + if (key.backspace || key.delete) { + setQuery(q => q.slice(0, -1)) + + return setIdx(0) + } + + // Printable char → extend the filter (ignore control/chorded keys). + if (input && input.length === 1 && input >= ' ' && !key.ctrl && !key.meta) { + setQuery(q => q + input) + setIdx(0) + } + }) + + if (loading) { + return loading pets… + } + + if (err && !gallery) { + return ( + + error: {err} + Esc cancel + + ) + } + + const { items, offset } = windowItems(view, idx, VISIBLE) + + return ( + + + Pets + + + + {query ? `filter: ${query}` : 'type to filter'} · {view.length} pet{view.length === 1 ? '' : 's'} + + + {offset > 0 && ↑ {offset} more} + + {view.length === 0 ? ( + {query ? `no pets match "${query}"` : 'no pets available'} + ) : ( + items.map((pet, i) => { + const at = offset + i === idx + const isActive = enabled && pet.slug === active + const mark = isActive ? '●' : pet.installed ? '✓' : ' ' + const tag = pet.installed ? '' : pet.curated ? ' · official' : '' + + return ( + + {at ? '▸ ' : ' '} + {mark} {pet.displayName} + + {' '} + ({pet.slug} + {tag}) + + + ) + }) + )} + + {offset + VISIBLE < view.length && ↓ {view.length - offset - VISIBLE} more} + + {err ? error: {err} : null} + {busy ? adopting… : null} + + ↑/↓ select · Enter adopt · type to filter · Esc cancel + + ) +} + +interface PetPickerProps { + gw: GatewayClient + onClose: () => void + t: Theme +} diff --git a/ui-tui/src/components/petSprite.tsx b/ui-tui/src/components/petSprite.tsx new file mode 100644 index 00000000000..5a17f6337d4 --- /dev/null +++ b/ui-tui/src/components/petSprite.tsx @@ -0,0 +1,93 @@ +import { Box, Text } from '@hermes/ink' +import { memo } from 'react' + +// A cell is [tr,tg,tb,ta, br,bg,bb,ba] — the top + bottom pixel of one +// half-block, as produced by the `pet.cells` gateway RPC. +export type PetCell = number[] +export type PetGrid = PetCell[][] + +const UPPER_HALF = '▀' +const LOWER_HALF = '▄' + +const hex = (r: number, g: number, b: number) => + `#${[r, g, b].map(v => Math.max(0, Math.min(255, v | 0)).toString(16).padStart(2, '0')).join('')}` + +/** + * Renders one petdex frame as truecolor half-blocks using native Ink color + * props (no raw ANSI, so width measurement stays correct). The engine + * (`agent/pet/render.py`) does the decode + downscale; this is a thin painter. + */ +export const PetSprite = memo(function PetSprite({ grid }: { grid: PetGrid }) { + if (!grid.length) { + return null + } + + return ( + + {grid.map((row, y) => ( + + {row.map((cell, x) => { + const [tr, tg, tb, ta, br, bg, bb, ba] = cell + const top = (ta ?? 0) >= 32 + const bot = (ba ?? 0) >= 32 + + if (!top && !bot) { + return + } + + // Both halves opaque → fg=top over bg=bottom. One half opaque → + // draw it fg-only so the other stays the terminal bg (no black + // boxes bleeding around transparent sprite edges). + if (top && bot) { + return ( + + {UPPER_HALF} + + ) + } + + return top ? ( + + {UPPER_HALF} + + ) : ( + + {LOWER_HALF} + + ) + })} + + ))} + + ) +}) + +/** + * Renders a kitty Unicode-placeholder grid: each line is a row of U+10EEEE + * cells whose foreground color encodes the image id. The actual pixels are + * drawn by the terminal (the frame image is transmitted out-of-band by + * `usePet`); this only emits the placeholder text Ink can measure as width-1 + * cells. Truecolor-only — the color must reach the terminal verbatim for the + * id to decode, which Ghostty/kitty support. + */ +export const PetKitty = memo(function PetKitty({ + color, + placeholder +}: { + color: string + placeholder: string[] +}) { + if (!placeholder.length) { + return null + } + + return ( + + {placeholder.map((row, y) => ( + + {row} + + ))} + + ) +}) diff --git a/ui-tui/src/types/hermes-ink.d.ts b/ui-tui/src/types/hermes-ink.d.ts index ca2a05dc449..034e04cf6da 100644 --- a/ui-tui/src/types/hermes-ink.d.ts +++ b/ui-tui/src/types/hermes-ink.d.ts @@ -156,7 +156,10 @@ declare module '@hermes/ink' { readonly setSelectionBgColor: (color: string) => void } export function useHasSelection(): boolean - export function useStdout(): { readonly stdout?: NodeJS.WriteStream } + export function useStdout(): { + readonly stdout?: NodeJS.WriteStream + readonly write: (data: string) => boolean + } export function useTerminalFocus(): boolean export function useTerminalTitle(title: string | null): void export function useDeclaredCursor(args: { From 86b990fe0fac40a54294fa0b5d02c8e055bc36ec Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 20 Jun 2026 14:18:40 -0500 Subject: [PATCH 4/6] feat(desktop): floating pet, pop-out overlay + Cmd+K picker Add the in-window floating pet (sprite, speech bubble, contact shadow, profile-scoped, resize-safe) and a pop-out always-on-top overlay window with gestures and notifications. Add the Cmd+K pet picker page plus the appearance gallery and size slider in settings. Includes the pet stores, electron overlay wiring, i18n strings, and store tests. --- apps/desktop/electron/main.cjs | 255 ++++++++++ apps/desktop/electron/preload.cjs | 26 ++ .../desktop/src/app/command-palette/index.tsx | 98 ++-- .../app/command-palette/pet-palette-page.tsx | 185 ++++++++ apps/desktop/src/app/desktop-controller.tsx | 50 ++ .../src/app/pet-overlay/overlay-root.tsx | 38 ++ .../src/app/pet-overlay/pet-overlay-app.tsx | 345 ++++++++++++++ .../app/session/hooks/use-message-stream.ts | 37 ++ .../app/session/hooks/use-prompt-actions.ts | 34 +- .../src/app/settings/appearance-settings.tsx | 440 ++++++++++++------ .../desktop/src/app/settings/pet-settings.tsx | 231 +++++++++ apps/desktop/src/app/shell/app-shell.tsx | 5 + .../src/components/pet/floating-pet.tsx | 313 +++++++++++++ .../desktop/src/components/pet/pet-bubble.tsx | 142 ++++++ .../desktop/src/components/pet/pet-sprite.tsx | 178 +++++++ apps/desktop/src/components/pet/pet-thumb.tsx | 79 ++++ apps/desktop/src/components/ui/command.tsx | 8 +- apps/desktop/src/global.d.ts | 21 + apps/desktop/src/i18n/en.ts | 43 +- apps/desktop/src/i18n/ja.ts | 43 +- apps/desktop/src/i18n/types.ts | 37 ++ apps/desktop/src/i18n/zh-hant.ts | 41 +- apps/desktop/src/i18n/zh.ts | 41 +- .../src/lib/desktop-slash-commands.test.ts | 10 + .../desktop/src/lib/desktop-slash-commands.ts | 4 +- apps/desktop/src/lib/icons.ts | 4 + apps/desktop/src/lib/selectable-card.ts | 31 ++ apps/desktop/src/main.tsx | 41 +- apps/desktop/src/store/command-palette.ts | 14 + apps/desktop/src/store/pet-gallery.ts | 322 +++++++++++++ apps/desktop/src/store/pet-overlay.ts | 260 +++++++++++ apps/desktop/src/store/pet.test.ts | 48 ++ apps/desktop/src/store/pet.ts | 160 +++++++ 33 files changed, 3367 insertions(+), 217 deletions(-) create mode 100644 apps/desktop/src/app/command-palette/pet-palette-page.tsx create mode 100644 apps/desktop/src/app/pet-overlay/overlay-root.tsx create mode 100644 apps/desktop/src/app/pet-overlay/pet-overlay-app.tsx create mode 100644 apps/desktop/src/app/settings/pet-settings.tsx create mode 100644 apps/desktop/src/components/pet/floating-pet.tsx create mode 100644 apps/desktop/src/components/pet/pet-bubble.tsx create mode 100644 apps/desktop/src/components/pet/pet-sprite.tsx create mode 100644 apps/desktop/src/components/pet/pet-thumb.tsx create mode 100644 apps/desktop/src/lib/selectable-card.ts create mode 100644 apps/desktop/src/store/pet-gallery.ts create mode 100644 apps/desktop/src/store/pet-overlay.ts create mode 100644 apps/desktop/src/store/pet.test.ts create mode 100644 apps/desktop/src/store/pet.ts diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index be89c6c91cf..42f81c38123 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -5154,6 +5154,142 @@ function createNewSessionWindow() { return spawnSecondaryWindow({ newSession: true }) } +// The pet overlay: a single transparent, frameless, always-on-top window that +// hosts ONLY the floating mascot. Shift-clicking the in-window pet "pops it out" +// here so it can leave the app's bounds and stay visible while Hermes is +// minimized (Codex-style task-completion glance). It carries no gateway +// connection of its own — the main renderer is the single source of truth and +// pushes pet state over IPC (hermes:pet-overlay:state); the overlay just renders +// it. Control flows back (pop-in, composer submit) via hermes:pet-overlay:control. +let petOverlayWindow = null + +function petOverlayUrl() { + if (DEV_SERVER) { + return `${DEV_SERVER.endsWith('/') ? DEV_SERVER.slice(0, -1) : DEV_SERVER}/?win=overlay#/` + } + + return `${pathToFileURL(resolveRendererIndex()).toString()}?win=overlay#/` +} + +function spawnPetOverlayWindow(bounds) { + const win = new BrowserWindow({ + width: Math.max(80, Math.round(bounds?.width || 220)), + height: Math.max(80, Math.round(bounds?.height || 220)), + x: Number.isFinite(bounds?.x) ? Math.round(bounds.x) : undefined, + y: Number.isFinite(bounds?.y) ? Math.round(bounds.y) : undefined, + frame: false, + transparent: true, + resizable: false, + movable: true, + minimizable: false, + maximizable: false, + fullscreenable: false, + // Windows/Linux need this so the helper window does not get its own + // taskbar/alt-tab entry. On macOS, cmd-tab is app-level and this can make + // the whole app look like it vanished when the only newly-created visible + // window is a frameless overlay. Use NSPanel + Mission Control hiding below + // instead, leaving the main Hermes app as the Dock/cmd-tab anchor. + skipTaskbar: !IS_MAC, + hasShadow: false, + alwaysOnTop: true, + // macOS panels are non-activating helper windows and can float over full + // screen spaces without becoming the app's main switcher window. + type: IS_MAC ? 'panel' : undefined, + hiddenInMissionControl: IS_MAC, + // Non-activating: the overlay must never become the app's key/main window, + // or it (a frameless, taskbar-skipping panel) becomes the app's switcher + // anchor and the Hermes icon drops out of cmd/alt-tab — especially when the + // main window is minimized. We flip this on only while the composer needs + // the keyboard (see hermes:pet-overlay:set-focusable). + focusable: false, + show: false, + // Fully transparent — the renderer paints only the sprite + bubble. + backgroundColor: '#00000000', + webPreferences: { + preload: path.join(__dirname, 'preload.cjs'), + contextIsolation: true, + sandbox: true, + nodeIntegration: false, + devTools: true, + // Keep the sprite animating + bubble updating while the main window is + // minimized/blurred — the whole point of the overlay. + backgroundThrottling: false + } + }) + + // Float above other apps and follow the user across desktops so the pet is + // always reachable. `floating` + `type: panel` is the macOS NSPanel path; the + // more aggressive `screen-saver` level can interfere with normal app/window + // switching semantics. + win.setAlwaysOnTop(true, IS_MAC ? 'floating' : 'screen-saver') + win.setHiddenInMissionControl?.(true) + try { + // Electron docs: macOS may transform process type on each + // setVisibleOnAllWorkspaces() call unless skipTransformProcessType=true, + // which briefly hides the Dock/cmd-tab presence. Keep Hermes in the normal + // ForegroundApplication class so shift-clicking the pet never drops the app + // out of app switchers. + win.setVisibleOnAllWorkspaces( + true, + IS_MAC ? { visibleOnFullScreen: true, skipTransformProcessType: true } : undefined + ) + } catch { + // Not supported everywhere — best effort. + } + + wireCommonWindowHandlers(win) + + win.once('ready-to-show', () => { + if (!win.isDestroyed()) win.showInactive() + }) + + win.on('closed', () => { + if (petOverlayWindow === win) { + petOverlayWindow = null + } + + // If the overlay went away on its own (e.g. ⌘W), tell the main renderer to + // pop the pet back in so it doesn't stay hidden. Harmless echo when we're + // the ones who closed it (popInPet already cleared the active flag). + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('hermes:pet-overlay:control', { type: 'pop-in' }) + } + }) + + win.loadURL(petOverlayUrl()) + + return win +} + +function openPetOverlay(bounds) { + if (petOverlayWindow && !petOverlayWindow.isDestroyed()) { + if (bounds) { + petOverlayWindow.setBounds({ + x: Math.round(bounds.x), + y: Math.round(bounds.y), + width: Math.max(80, Math.round(bounds.width)), + height: Math.max(80, Math.round(bounds.height)) + }) + } + + petOverlayWindow.showInactive() + + return petOverlayWindow + } + + petOverlayWindow = spawnPetOverlayWindow(bounds) + + return petOverlayWindow +} + +function closePetOverlay() { + if (petOverlayWindow && !petOverlayWindow.isDestroyed()) { + petOverlayWindow.close() + } + + petOverlayWindow = null +} + function createWindow() { const icon = getAppIconPath() mainWindow = new BrowserWindow({ @@ -5211,6 +5347,11 @@ function createWindow() { mainWindow.on('will-leave-full-screen', () => sendWindowStateChanged(false)) mainWindow.on('leave-full-screen', () => sendWindowStateChanged(false)) + // The overlay rides the main window — closing the app's primary window must + // tear it down too (otherwise it strands as an orphan that blocks + // window-all-closed from quitting on Windows/Linux). + mainWindow.on('closed', () => closePetOverlay()) + wireCommonWindowHandlers(mainWindow) mainWindow.webContents.on('render-process-gone', (_event, details) => { @@ -5331,6 +5472,116 @@ ipcMain.handle('hermes:window:openNewSession', async () => { return { ok: true } }) + +// --- Pet overlay (pop-out mascot) ----------------------------------------- +// `request` is `{ bounds, screen }`. A fresh pop-out passes viewport-space +// bounds (screen=false): convert to screen space by adding the main window's +// content origin so the pet lands where it sat in-window. A remembered/dragged +// spot passes screen-space bounds (screen=true) and is used as-is. We return the +// resolved screen bounds so the renderer can persist exactly where it opened. +ipcMain.handle('hermes:pet-overlay:open', async (_event, request) => { + const bounds = request && request.bounds ? request.bounds : request + const isScreen = Boolean(request && request.screen) + let screenBounds = bounds + + try { + if (bounds && !isScreen && mainWindow && !mainWindow.isDestroyed()) { + const content = mainWindow.getContentBounds() + screenBounds = { + x: content.x + (bounds.x || 0), + y: content.y + (bounds.y || 0), + width: bounds.width, + height: bounds.height + } + } + } catch { + // Fall back to raw bounds if the window geometry is unavailable. + } + + openPetOverlay(screenBounds) + + return { ok: true, bounds: screenBounds } +}) +ipcMain.handle('hermes:pet-overlay:close', async () => { + closePetOverlay() + + return { ok: true } +}) +// Drag: the overlay reports a new absolute screen position (it already knows the +// pointer's screen coords), we just move the window. +ipcMain.on('hermes:pet-overlay:set-bounds', (_event, bounds) => { + if (!petOverlayWindow || petOverlayWindow.isDestroyed() || !bounds) { + return + } + + petOverlayWindow.setBounds({ + x: Math.round(bounds.x), + y: Math.round(bounds.y), + width: Math.max(80, Math.round(bounds.width)), + height: Math.max(80, Math.round(bounds.height)) + }) +}) +// Click-through: the overlay window is a full rectangle but only the pet pixels +// should be interactive. The renderer toggles this as the cursor enters/leaves +// the sprite so transparent margins pass clicks to whatever is behind. +ipcMain.on('hermes:pet-overlay:ignore-mouse', (_event, ignore) => { + if (petOverlayWindow && !petOverlayWindow.isDestroyed()) { + petOverlayWindow.setIgnoreMouseEvents(Boolean(ignore), { forward: true }) + } +}) +// The overlay is a non-activating panel (focusable:false) so it never steals +// the app's cmd/alt-tab anchor from the main window. But the pop-up composer +// needs the keyboard, so the renderer asks us to flip it focusable + focus it +// while the composer is open, then back to non-activating when it closes. +ipcMain.on('hermes:pet-overlay:set-focusable', (_event, focusable) => { + if (!petOverlayWindow || petOverlayWindow.isDestroyed()) { + return + } + + petOverlayWindow.setFocusable(Boolean(focusable)) + if (focusable) { + petOverlayWindow.focus() + } +}) +// Main renderer → overlay: forward the latest pet state for the overlay to render. +ipcMain.on('hermes:pet-overlay:state', (_event, payload) => { + if (petOverlayWindow && !petOverlayWindow.isDestroyed()) { + petOverlayWindow.webContents.send('hermes:pet-overlay:state', payload) + } +}) +// Overlay → main renderer: control messages (pop back in, composer submit). +ipcMain.on('hermes:pet-overlay:control', (_event, payload) => { + if (!mainWindow || mainWindow.isDestroyed()) { + return + } + + // Double-click toggles the app window: hide it away if it's up front, bring it + // back if it's minimized/buried. Pure window control — nothing for the + // renderer to do, so don't forward it. + if (payload && payload.type === 'toggle-app') { + if (mainWindow.isMinimized() || !mainWindow.isVisible()) { + mainWindow.show() + mainWindow.focus() + } else { + mainWindow.minimize() + } + + return + } + + // The mail icon means "take me to the app": raise the main window (it may be + // minimized or buried) before the renderer navigates to the latest thread. + if (payload && payload.type === 'open-app') { + if (mainWindow.isMinimized()) { + mainWindow.restore() + } + + mainWindow.show() + mainWindow.focus() + } + + mainWindow.webContents.send('hermes:pet-overlay:control', payload) +}) ipcMain.handle('hermes:bootstrap:reset', async () => { // Renderer's "Reload and retry" path. Clear the latched failure and // reset connection state so the next startHermes() call restarts the @@ -6535,6 +6786,10 @@ function configureSpellChecker() { } app.on('before-quit', () => { + // The always-on-top overlay isn't a "real" app window; close it so a stray + // pet can't keep the process alive or float over a quit app. + closePetOverlay() + // Quitting mid-install should stop the installer, not orphan it. if (bootstrapAbortController) { try { diff --git a/apps/desktop/electron/preload.cjs b/apps/desktop/electron/preload.cjs index 413abd77b32..93620facdf4 100644 --- a/apps/desktop/electron/preload.cjs +++ b/apps/desktop/electron/preload.cjs @@ -7,6 +7,32 @@ contextBridge.exposeInMainWorld('hermesDesktop', { getGatewayWsUrl: profile => ipcRenderer.invoke('hermes:gateway:ws-url', profile), openSessionWindow: (sessionId, opts) => ipcRenderer.invoke('hermes:window:openSession', sessionId, opts), openNewSessionWindow: () => ipcRenderer.invoke('hermes:window:openNewSession'), + petOverlay: { + // Main renderer → main process: window lifecycle + drag. `request` is + // `{ bounds, screen }`; resolves with the screen bounds it actually used. + open: request => ipcRenderer.invoke('hermes:pet-overlay:open', request), + close: () => ipcRenderer.invoke('hermes:pet-overlay:close'), + setBounds: bounds => ipcRenderer.send('hermes:pet-overlay:set-bounds', bounds), + setIgnoreMouse: ignore => ipcRenderer.send('hermes:pet-overlay:ignore-mouse', ignore), + // Flip the overlay focusable (and focus it) while the composer needs keys. + setFocusable: focusable => ipcRenderer.send('hermes:pet-overlay:set-focusable', focusable), + // Main renderer → overlay (forwarded by main): push the latest pet state. + pushState: payload => ipcRenderer.send('hermes:pet-overlay:state', payload), + // Overlay → main renderer (forwarded by main): pop back in / composer submit. + control: payload => ipcRenderer.send('hermes:pet-overlay:control', payload), + // Overlay subscribes to state pushes. + onState: callback => { + const listener = (_event, payload) => callback(payload) + ipcRenderer.on('hermes:pet-overlay:state', listener) + return () => ipcRenderer.removeListener('hermes:pet-overlay:state', listener) + }, + // Main renderer subscribes to overlay control messages. + onControl: callback => { + const listener = (_event, payload) => callback(payload) + ipcRenderer.on('hermes:pet-overlay:control', listener) + return () => ipcRenderer.removeListener('hermes:pet-overlay:control', listener) + } + }, getBootProgress: () => ipcRenderer.invoke('hermes:boot-progress:get'), getConnectionConfig: profile => ipcRenderer.invoke('hermes:connection-config:get', profile), saveConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:save', payload), diff --git a/apps/desktop/src/app/command-palette/index.tsx b/apps/desktop/src/app/command-palette/index.tsx index 19ea7976344..d91a6c92756 100644 --- a/apps/desktop/src/app/command-palette/index.tsx +++ b/apps/desktop/src/app/command-palette/index.tsx @@ -29,6 +29,7 @@ import { Moon, Package, Palette, + PawPrint, Plus, Settings, Settings2, @@ -39,7 +40,7 @@ import { Zap } from '@/lib/icons' import { cn } from '@/lib/utils' -import { $commandPaletteOpen, closeCommandPalette, setCommandPaletteOpen } from '@/store/command-palette' +import { $commandPaletteOpen, $commandPalettePage, closeCommandPalette, setCommandPaletteOpen } from '@/store/command-palette' import { $bindings } from '@/store/keybinds' import { luminance } from '@/themes/color' import { type ThemeMode, useTheme } from '@/themes/context' @@ -62,6 +63,7 @@ import { fieldCopyForSchemaKey } from '../settings/field-copy' import { prettyName } from '../settings/helpers' import { MarketplaceThemePage } from './marketplace-theme-page' +import { PetInlineToggle, PetPalettePage } from './pet-palette-page' interface PaletteItem { /** Keybind action id — its live combo renders as a hotkey hint. */ @@ -205,6 +207,7 @@ function themeSupportsMode(name: string, target: 'light' | 'dark'): boolean { export function CommandPalette() { const { t } = useI18n() const open = useStore($commandPaletteOpen) + const pendingPage = useStore($commandPalettePage) const bindings = useStore($bindings) const navigate = useNavigate() const { availableThemes, resolvedMode, setMode, setTheme, themeName } = useTheme() @@ -250,6 +253,14 @@ export function CommandPalette() { } }, [open]) + // Deep-link into a nested page (e.g. `/pet list` → pets picker). + useEffect(() => { + if (open && pendingPage) { + setPage(pendingPage) + $commandPalettePage.set(null) + } + }, [open, pendingPage]) + const go = useCallback((path: string) => () => navigate(path), [navigate]) // Step up one nested page (or back to the root list), clearing the filter so @@ -382,6 +393,13 @@ export function CommandPalette() { keywords: ['appearance', 'color mode', 'brightness', 'dark', 'light', 'system'], label: cc.changeColorMode, to: 'color-mode' + }, + { + icon: PawPrint, + id: 'appearance-pets', + keywords: ['pet', 'petdex', 'mascot', 'pets', '/pet', 'paw'], + label: cc.pets.title, + to: 'pets' } ] }, @@ -550,6 +568,12 @@ export function CommandPalette() { } ] }, + // Server-driven page: browse petdex gallery, adopt/switch, toggle off. + pets: { + title: t.commandCenter.pets.title, + placeholder: t.commandCenter.pets.placeholder, + groups: [] + }, // Server-driven page: items come from the Marketplace, rendered by // (loader + live search + per-row install). 'install-theme': { @@ -624,45 +648,51 @@ export function CommandPalette() { }} onValueChange={setSearch} placeholder={placeholder} + right={page === 'pets' ? : undefined} value={search} /> - {page === 'install-theme' ? ( + {/* Server-driven pages render their own list; the rest show groups. */} + {page === 'pets' ? ( + + ) : page === 'install-theme' ? ( ) : ( - {t.commandCenter.noResults} - )} - {visibleGroups.map((group, index) => ( - - {group.items.map(item => { - const Icon = item.icon - const combo = item.action ? bindings[item.action]?.[0] : undefined + <> + {t.commandCenter.noResults} + {visibleGroups.map((group, index) => ( + + {group.items.map(item => { + const Icon = item.icon + const combo = item.action ? bindings[item.action]?.[0] : undefined - return ( - handleSelect(item)} - value={`${item.label} ${item.keywords?.join(' ') ?? ''} ${item.id}`} - > - - {item.label} - {combo && } - {item.to && ( - - )} - - ) - })} - - ))} + return ( + handleSelect(item)} + value={`${item.label} ${item.keywords?.join(' ') ?? ''} ${item.id}`} + > + + {item.label} + {combo && } + {item.to && ( + + )} + + ) + })} + + ))} + + )} diff --git a/apps/desktop/src/app/command-palette/pet-palette-page.tsx b/apps/desktop/src/app/command-palette/pet-palette-page.tsx new file mode 100644 index 00000000000..891637c67cb --- /dev/null +++ b/apps/desktop/src/app/command-palette/pet-palette-page.tsx @@ -0,0 +1,185 @@ +/** + * Cmd-K "Pets…" page — browse the petdex gallery, adopt/switch, toggle off. + * + * A thin view over the `pet-gallery` store: it subscribes to the shared atoms + * and calls the store's actions. The store owns fetching, caching, the thumb + * cache, and optimistic mutations, so reopening this page is instant and a + * toggle never re-pulls the network gallery. + */ + +import { useStore } from '@nanostores/react' +import { useEffect, useMemo } from 'react' + +import { HUD_ITEM, HUD_TEXT } from '@/app/floating-hud' +import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request' +import { PetThumb } from '@/components/pet/pet-thumb' +import { useI18n } from '@/i18n' +import { triggerHaptic } from '@/lib/haptics' +import { Check, Loader2, PawPrint } from '@/lib/icons' +import { cn } from '@/lib/utils' +import { + $petBusy, + $petGallery, + $petGalleryError, + $petGalleryStatus, + adoptPet, + loadPetGallery, + loadPetThumb, + rankedGalleryPets, + setPetEnabled +} from '@/store/pet-gallery' + +interface PetPalettePageProps { + search: string +} + +export function PetPalettePage({ search }: PetPalettePageProps) { + const { t } = useI18n() + const copy = t.commandCenter.pets + const { requestGateway } = useGatewayRequest() + + const gallery = useStore($petGallery) + const status = useStore($petGalleryStatus) + const error = useStore($petGalleryError) + const busy = useStore($petBusy) + + useEffect(() => { + void loadPetGallery(requestGateway) + }, [requestGateway]) + + const enabled = gallery?.enabled ?? false + const active = gallery?.active ?? '' + + const shown = useMemo(() => rankedGalleryPets(gallery, search).slice(0, 50), [gallery, search]) + + const adopt = (slug: string) => { + void adoptPet(requestGateway, slug, copy.adoptFailed).then(ok => ok && triggerHaptic('crisp')) + } + + if (status === 'loading' && !gallery) { + return } text={copy.loading} /> + } + + if (status === 'stale') { + return + } + + if (!gallery?.pets.length && error) { + return + } + + const mutating = Boolean(busy) + + return ( +
+ {error &&

{error}

} + + {shown.length === 0 ? ( + + ) : ( + shown.map(pet => { + const isActive = enabled && pet.slug === active + const isBusy = busy === pet.slug + + return ( + + ) + }) + )} +
+ ) +} + +/** + * Single on/off toggle, rendered inline on the palette's search row (see + * `CommandInput`'s `right` slot). The paw lights up when pets are on. Reads the + * same shared gallery atoms, so it stays in sync with the list below. + */ +export function PetInlineToggle() { + const { t } = useI18n() + const copy = t.commandCenter.pets + const { requestGateway } = useGatewayRequest() + const gallery = useStore($petGallery) + const busy = useStore($petBusy) + + if (!gallery) { + return null + } + + const enabled = gallery.enabled + + const toggle = () => { + void setPetEnabled(requestGateway, !enabled, { + noneAvailable: copy.noneAvailable, + fallback: copy.toggleFailed + }).then(ok => ok && triggerHaptic('crisp')) + } + + return ( + + ) +} + +function Status({ icon, text, tone }: { icon?: React.ReactNode; text: string; tone?: 'error' }) { + return ( +
+ {icon} + {text} +
+ ) +} diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx index 05dfbbc764f..8ed097e29ee 100644 --- a/apps/desktop/src/app/desktop-controller.tsx +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -38,6 +38,8 @@ import { unpinSession } from '../store/layout' import { respondToApprovalAction } from '../store/native-notifications' +import { setPetActivity } from '../store/pet' +import { setPetOverlayOpenAppHandler, setPetOverlaySubmitHandler } from '../store/pet-overlay' import { $filePreviewTarget, $previewTarget, closeActiveRightRailTab } from '../store/preview' import { $activeGatewayProfile, @@ -49,6 +51,7 @@ import { } from '../store/profile' import { $activeSessionId, + $attentionSessionIds, $currentCwd, $freshDraftReady, $gatewayState, @@ -834,6 +837,53 @@ export function DesktopController() { updateSessionState }) + // The popped-out pet drives two actions back into the app: send a prompt, and + // open the most recent thread. Both are registered ONCE through refs that track + // the latest callbacks — re-registering on every `submitText`/`resumeSession` + // identity change left a brief window where the handler was nulled (cleanup + // before re-register), which could drop a submit fired from the overlay (e.g. + // creating a session from the new-session screen). The ref form keeps a stable, + // always-current handler. Primary window only — it owns the overlay. + const submitTextRef = useRef(submitText) + submitTextRef.current = submitText + const resumeSessionRef = useRef(resumeSession) + resumeSessionRef.current = resumeSession + + useEffect(() => { + if (isSecondaryWindow()) { + return + } + + setPetOverlaySubmitHandler(text => void submitTextRef.current(text)) + // Mail icon: $sessions is ordered most-recent-first; the pet is global (not + // per session) so "most recent" is the right target. main.cjs already raised + // the window before forwarding this. + setPetOverlayOpenAppHandler(() => { + const recent = $sessions.get()[0] + + if (recent?.id) { + void resumeSessionRef.current(recent.id) + } + }) + + return () => { + setPetOverlaySubmitHandler(null) + setPetOverlayOpenAppHandler(null) + } + }, []) + + // Mirror "a session is blocked on the user" (clarify/approval) into the pet's + // awaitingInput flag so it shows the `waiting` pose. Lives on $petActivity so + // it rides the same atom the pop-out overlay mirrors — no session list needed + // there. Every window keeps its own in-window pet in sync. + useEffect(() => { + const sync = () => setPetActivity({ awaitingInput: $attentionSessionIds.get().length > 0 }) + + sync() + + return $attentionSessionIds.listen(sync) + }, []) + useGatewayBoot({ handleGatewayEvent: handleDesktopGatewayEvent, onConnectionReady: c => { diff --git a/apps/desktop/src/app/pet-overlay/overlay-root.tsx b/apps/desktop/src/app/pet-overlay/overlay-root.tsx new file mode 100644 index 00000000000..de446bdb6a5 --- /dev/null +++ b/apps/desktop/src/app/pet-overlay/overlay-root.tsx @@ -0,0 +1,38 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' + +import { ErrorBoundary } from '@/components/error-boundary' +import { ThemeProvider } from '@/themes/context' + +import { PetOverlayApp } from './pet-overlay-app' + +/** + * Boot the pet-overlay window. Loaded by the same bundle as the main app but + * via `?win=overlay`, so it shares CSS/atoms while mounting a minimal, transparent + * surface (no app shell, no gateway, no I18n — the bubble strings are inline). + * + * The index.html boot script paints an OPAQUE themed background to avoid a flash + * in normal windows; the overlay must be see-through, so we force every host + * layer transparent with a late, high-specificity style tag. + */ +export function mountPetOverlay(): void { + const style = document.createElement('style') + style.textContent = 'html,body,#root{background:transparent !important;}' + document.head.appendChild(style) + + const root = document.getElementById('root') + + if (!root) { + return + } + + createRoot(root).render( + + + + + + + + ) +} diff --git a/apps/desktop/src/app/pet-overlay/pet-overlay-app.tsx b/apps/desktop/src/app/pet-overlay/pet-overlay-app.tsx new file mode 100644 index 00000000000..1fcd21169f0 --- /dev/null +++ b/apps/desktop/src/app/pet-overlay/pet-overlay-app.tsx @@ -0,0 +1,345 @@ +import { useStore } from '@nanostores/react' +import { useEffect, useRef, useState } from 'react' + +import { PetBubble } from '@/components/pet/pet-bubble' +import { PetSprite } from '@/components/pet/pet-sprite' +import { Mail } from '@/lib/icons' +import { $petActivity, $petInfo, setPetInfo } from '@/store/pet' +import { setAwaitingResponse, setBusy } from '@/store/session' + +/** + * The pop-out overlay's only view: a transparent, draggable mascot with a mini + * composer. + * + * This runs in a separate, gateway-less BrowserWindow (`?win=overlay`). It is a + * pure puppet — the main renderer pushes the live pet state over IPC and we + * mirror it into the same atoms the in-window pet reads, so `PetSprite` / + * `PetBubble` render identically with zero extra logic. + * + * The window is a full rectangle but mostly transparent; we toggle OS-level + * mouse click-through so only the sprite (or the open composer) is interactive + * and the empty margins pass clicks through to whatever is behind. + * + * Gestures on the pet: drag to move it anywhere on screen (even outside the + * app), shift-click to pop it back into the window, single-click to open a small + * composer, double-click to toggle the app window (minimize ↔ restore). A mail + * icon (shown only when a turn finished while you were away) raises the app on + * the most recent thread. + */ + +// Below this much pointer travel, a press counts as a click, not a drag. +const CLICK_SLOP_PX = 3 +// A second click within this window is a double-click (raise app) and cancels +// the deferred single-click (open composer), so a double never flashes it open. +const DOUBLE_CLICK_MS = 250 + +interface DragState { + startX: number + startY: number + offX: number + offY: number + width: number + height: number + moved: boolean +} + +export function PetOverlayApp() { + const info = useStore($petInfo) + const [composerOpen, setComposerOpen] = useState(false) + const [draft, setDraft] = useState('') + // Mirrored from the main renderer: a finish landed while you were away. + const [unread, setUnread] = useState(false) + + const dragRef = useRef(null) + const petRef = useRef(null) + const inputRef = useRef(null) + const ignoreRef = useRef(true) + const composerOpenRef = useRef(false) + const clickTimerRef = useRef | undefined>(undefined) + + const setIgnore = (ignore: boolean) => { + if (ignoreRef.current !== ignore) { + ignoreRef.current = ignore + window.hermesDesktop?.petOverlay?.setIgnoreMouse(ignore) + } + } + + // Mirror pushed state into the shared atoms so PetSprite/PetBubble just work. + useEffect(() => { + const off = window.hermesDesktop?.petOverlay?.onState(payload => { + setPetInfo(payload.info) + $petActivity.set(payload.activity ?? {}) + setBusy(Boolean(payload.busy)) + setAwaitingResponse(Boolean(payload.awaiting)) + setUnread(Boolean(payload.unread)) + }) + + // Tell the main renderer we're mounted so it pushes the current frame (the + // subscribe-time pushes during open() can land before this view exists). + window.hermesDesktop?.petOverlay?.control({ type: 'ready' }) + + return off + }, []) + + // Click-through: make only the sprite (or an open composer) interactive. With + // ignore+forward, the renderer still receives mousemove so we can re-enable + // hit-testing the moment the cursor returns to the pet. + useEffect(() => { + setIgnore(true) + + const onMove = (ev: MouseEvent) => { + if (dragRef.current || composerOpenRef.current) { + setIgnore(false) + + return + } + + const el = petRef.current + + if (!el) { + return + } + + const r = el.getBoundingClientRect() + const over = ev.clientX >= r.left && ev.clientX <= r.right && ev.clientY >= r.top && ev.clientY <= r.bottom + setIgnore(!over) + } + + window.addEventListener('mousemove', onMove) + + return () => { + window.removeEventListener('mousemove', onMove) + clearTimeout(clickTimerRef.current) + } + }, []) + + // The whole window must stay interactive while the composer is open (so the + // input keeps focus); focus it on open. The overlay is a non-activating panel + // (so it never steals the app's cmd/alt-tab anchor) — flip it focusable while + // the composer needs the keyboard, then back to non-activating when it closes. + useEffect(() => { + composerOpenRef.current = composerOpen + + window.hermesDesktop?.petOverlay?.setFocusable(composerOpen) + + if (composerOpen) { + setIgnore(false) + // The OS window has to become key first (setFocusable + focus happen in + // the main process), so focus the input on the next frame. + requestAnimationFrame(() => inputRef.current?.focus()) + } + }, [composerOpen]) + + const onPetPointerDown = (e: React.PointerEvent) => { + if (e.button !== 0) { + return + } + + ;(e.target as Element).setPointerCapture?.(e.pointerId) + dragRef.current = { + height: window.outerHeight, + moved: false, + offX: e.screenX - window.screenX, + offY: e.screenY - window.screenY, + startX: e.screenX, + startY: e.screenY, + width: window.outerWidth + } + } + + const onPetPointerMove = (e: React.PointerEvent) => { + const drag = dragRef.current + + if (!drag) { + return + } + + if (Math.hypot(e.screenX - drag.startX, e.screenY - drag.startY) > CLICK_SLOP_PX) { + drag.moved = true + } + + window.hermesDesktop?.petOverlay?.setBounds({ + height: drag.height, + width: drag.width, + x: e.screenX - drag.offX, + y: e.screenY - drag.offY + }) + } + + const onPetPointerUp = (e: React.PointerEvent) => { + const drag = dragRef.current + dragRef.current = null + ;(e.target as Element).releasePointerCapture?.(e.pointerId) + + if (!drag) { + return + } + + if (drag.moved) { + // A drag cancels any deferred single-click so the composer can't pop open + // after you reposition the pet. + clearTimeout(clickTimerRef.current) + clickTimerRef.current = undefined + + // Remember the spot on the desktop (screen coords) so the pet reopens here + // next time / after a restart. + window.hermesDesktop?.petOverlay?.control({ + bounds: { height: drag.height, width: drag.width, x: e.screenX - drag.offX, y: e.screenY - drag.offY }, + type: 'bounds' + }) + + return + } + + // Shift-click always pops the pet back in (no double-click ambiguity). + if (e.shiftKey) { + window.hermesDesktop?.petOverlay?.control({ type: 'pop-in' }) + + return + } + + // Double-click toggles the app window (minimize ↔ restore); defer the + // single-click composer toggle so a double never flashes the composer open. + if (clickTimerRef.current) { + clearTimeout(clickTimerRef.current) + clickTimerRef.current = undefined + window.hermesDesktop?.petOverlay?.control({ type: 'toggle-app' }) + + return + } + + clickTimerRef.current = setTimeout(() => { + clickTimerRef.current = undefined + setComposerOpen(open => !open) + }, DOUBLE_CLICK_MS) + } + + const send = () => { + const text = draft.trim() + + if (text) { + window.hermesDesktop?.petOverlay?.control({ text, type: 'submit' }) + } + + setDraft('') + setComposerOpen(false) + } + + const openApp = () => { + // Hide the icon immediately; the main renderer also clears the source flag. + setUnread(false) + window.hermesDesktop?.petOverlay?.control({ type: 'open-app' }) + } + + if (!info.enabled || !info.spritesheetBase64) { + return null + } + + return ( +
{ + // Click on the transparent backdrop (not the pet/composer) dismisses + // the composer. + if (composerOpen && e.target === e.currentTarget) { + setComposerOpen(false) + } + }} + style={{ + alignItems: 'center', + background: 'transparent', + display: 'flex', + flexDirection: 'column', + height: '100vh', + justifyContent: 'flex-end', + paddingBottom: 24, + userSelect: 'none', + width: '100vw' + }} + > + {composerOpen && ( + setDraft(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + send() + } else if (e.key === 'Escape') { + setComposerOpen(false) + } + }} + placeholder="Message…" + ref={inputRef} + style={{ + background: 'var(--ui-bg-elevated)', + border: '1px solid var(--ui-stroke-secondary)', + borderRadius: 2, + boxShadow: '0 6px 18px rgba(0,0,0,0.28)', + color: 'var(--foreground)', + fontSize: 12, + marginBottom: 8, + outline: 'none', + padding: '4px 8px', + width: 184 + }} + value={draft} + /> + )} + +
+
+ +
+
+ + + {/* Mail icon: only when a finish landed while you were away. Jumps to + the app's most recent thread. Anchored to the sprite (kept inside + its box so the overlay's click-through hit-test still catches it); + stopPropagation keeps a click from starting a window drag. */} + {unread && ( + + )} +
+
+
+ ) +} diff --git a/apps/desktop/src/app/session/hooks/use-message-stream.ts b/apps/desktop/src/app/session/hooks/use-message-stream.ts index 909c1424796..9ae7e13976c 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream.ts @@ -34,6 +34,7 @@ import { $gateway } from '@/store/gateway' import { dispatchNativeNotification } from '@/store/native-notifications' import { notify } from '@/store/notifications' import { requestDesktopOnboarding } from '@/store/onboarding' +import { flashPetActivity, markPetUnread, setPetActivity } from '@/store/pet' import { clearAllPrompts, setApprovalRequest, setSecretRequest, setSudoRequest } from '@/store/prompts' import { setCurrentBranch, @@ -870,10 +871,18 @@ export function useMessageStream({ if (sessionId) { appendReasoningDelta(sessionId, coerceThinkingText(payload?.text)) } + + if (isActiveEvent) { + setPetActivity({ reasoning: true }) + } } else if (event.type === 'reasoning.available') { if (sessionId) { appendReasoningDelta(sessionId, coerceThinkingText(payload?.text), true) } + + if (isActiveEvent) { + setPetActivity({ reasoning: true }) + } } else if (event.type === 'message.complete') { if (!sessionId) { return @@ -895,6 +904,20 @@ export function useMessageStream({ if (isActiveEvent) { setTurnStartedAt(null) + + // Pet beat: a finished turn always celebrates — go straight to the + // jump, never linger on the run/reason pose. One atom update (clears + // toolRunning/reasoning AND sets celebrate together) so no stray "run" + // frame leaks to the sprite — including the popped-out overlay, which + // mirrors each activity change. The jump runs ~2 loops, then settles. + flashPetActivity({ celebrate: true, reasoning: false, toolRunning: false }, 2200) + + // Light up the pet's mail icon if the user wasn't looking when the turn + // finished — a glanceable "new message" hint on the popped-out overlay. + // Cleared when they open the app via the mail icon or refocus the window. + if (typeof document !== 'undefined' && !document.hasFocus()) { + markPetUnread() + } } if (payload?.usage) { @@ -907,10 +930,19 @@ export function useMessageStream({ flushQueuedDeltas(sessionId) upsertToolCall(sessionId, toTodoPayload(payload) ?? payload, 'running', event.type) + + if (isActiveEvent) { + setPetActivity({ reasoning: false, toolRunning: true }) + } } else if (event.type === 'tool.complete') { if (sessionId) { flushQueuedDeltas(sessionId) upsertToolCall(sessionId, toTodoPayload(payload) ?? payload, 'complete', event.type) + + if (isActiveEvent) { + setPetActivity({ toolRunning: false }) + } + // A pending clarify blocks the turn, so the first tool.complete after // one is the clarify resolving — drop the "needs input" flag here so // the sidebar indicator clears as soon as it's answered, not only at @@ -1120,6 +1152,11 @@ export function useMessageStream({ compactedTurnRef.current.delete(sessionId) } + if (isActiveEvent) { + setPetActivity({ reasoning: false, toolRunning: false }) + flashPetActivity({ error: true }) + } + dispatchNativeNotification({ body: errorMessage, kind: 'turnError', diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions.ts index 829119f65b4..f1a32771443 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions.ts @@ -27,6 +27,7 @@ import { triggerHaptic } from '@/lib/haptics' import { setMutableRef } from '@/lib/mutable-ref' import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors' import { setSessionYolo } from '@/lib/yolo-session' +import { openCommandPalettePage } from '@/store/command-palette' import { $composerAttachments, clearComposerAttachments, @@ -38,6 +39,7 @@ import { import { resetSessionBackground } from '@/store/composer-status' import { clearNotifications, notify, notifyError } from '@/store/notifications' import { requestDesktopOnboarding } from '@/store/onboarding' +import { setPetScale } from '@/store/pet-gallery' import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile' import { $busy, @@ -57,8 +59,8 @@ import { clearSessionSubagents } from '@/store/subagents' import { clearSessionTodos } from '@/store/todos' import type { - ClientSessionState, BrowserManageResponse, + ClientSessionState, FileAttachResponse, HandoffFailResponse, HandoffRequestResponse, @@ -1143,6 +1145,35 @@ export function usePromptActions({ renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`) } }, + pet: async ctx => { + const [sub = '', rawValue = ''] = ctx.arg.trim().split(/\s+/) + const lower = sub.toLowerCase() + + if (lower === 'list' || lower === 'gallery' || lower === 'browse' || lower === 'all') { + openCommandPalettePage('pets') + + return + } + + // `/pet scale ` resizes the floating pet locally (instant) and + // persists via the store — no round-trip to the slash worker. + if (lower === 'scale') { + const value = Number(rawValue) + + if (!rawValue || Number.isNaN(value)) { + const resolved = await withSlashOutput(ctx) + resolved?.render('usage: /pet scale (e.g. /pet scale 0.5)') + + return + } + + setPetScale(requestGateway, value) + + return + } + + await runExec(ctx) + }, // /browser connect|disconnect|status manages the live CDP connection on // the gateway host, mirroring the TUI's browser.manage RPC. It mutates // BROWSER_CDP_URL (and may launch Chrome) in the gateway process — only @@ -1359,6 +1390,7 @@ export function usePromptActions({ const cancelRun = useCallback(async () => { const sessionId = activeSessionId || activeSessionIdRef.current + const releaseBusy = () => { setMutableRef(busyRef, false) setBusy(false) diff --git a/apps/desktop/src/app/settings/appearance-settings.tsx b/apps/desktop/src/app/settings/appearance-settings.tsx index 80b74090f33..36fb9e91687 100644 --- a/apps/desktop/src/app/settings/appearance-settings.tsx +++ b/apps/desktop/src/app/settings/appearance-settings.tsx @@ -1,30 +1,31 @@ import { useStore } from '@nanostores/react' -import { useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import { useEffect, useState } from 'react' import { LanguageSwitcher } from '@/components/language-switcher' import { SegmentedControl } from '@/components/ui/segmented-control' +import type { DesktopMarketplaceSearchItem } from '@/global' import { useI18n } from '@/i18n' import { triggerHaptic } from '@/lib/haptics' import { Check, Download, Loader2, Palette, Trash2 } from '@/lib/icons' +import { selectableCardClass } from '@/lib/selectable-card' import { cn } from '@/lib/utils' import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/profile' import { $toolViewMode, setToolViewMode } from '@/store/tool-view' import { $translucency, setTranslucency } from '@/store/translucency' -import { useTheme } from '@/themes/context' +import { getBaseColors, useTheme } from '@/themes/context' import { installVscodeThemeFromMarketplace } from '@/themes/install' -import { isUserTheme, removeUserTheme, resolveTheme } from '@/themes/user-themes' +import { isUserTheme, removeUserTheme } from '@/themes/user-themes' import { MODE_OPTIONS } from './constants' +import { PetSettings } from './pet-settings' import { ListRow, SectionHeading, SettingsContent } from './primitives' -function ThemePreview({ name }: { name: string }) { - const t = resolveTheme(name) - - if (!t) { - return null - } - - const c = t.colors +function ThemePreview({ name, mode }: { name: string; mode: 'light' | 'dark' }) { + // Preview in the *current* mode: the dark palette in Dark, and the light + // palette in Light — synthesizing one for dark-only themes — so every card + // tracks the Light/Dark toggle, exactly like the app itself does. + const c = getBaseColors(name, mode) return (
(value: T, delayMs: number): T { + const [debounced, setDebounced] = useState(value) + + useEffect(() => { + const handle = setTimeout(() => setDebounced(value), delayMs) + + return () => clearTimeout(handle) + }, [value, delayMs]) + + return debounced +} + +const compactNumber = new Intl.NumberFormat(undefined, { notation: 'compact', maximumFractionDigits: 1 }) + +/** + * Live VS Code Marketplace theme search (the same backend as the Cmd-K "Install + * theme…" page). Renders below the local grid when there's a query: each row + * downloads + converts + installs via `installVscodeThemeFromMarketplace` and + * activates it. Extensions already imported locally are marked installed. + */ +function MarketplaceThemeResults({ + query, + installedExtIds, + onInstalled +}: { + query: string + installedExtIds: Set + onInstalled: (name: string) => void +}) { const { t } = useI18n() - const { setTheme } = useTheme() - const a = t.settings.appearance - const [id, setId] = useState('') - const [busy, setBusy] = useState(false) - const [status, setStatus] = useState<{ kind: 'error' | 'success'; text: string } | null>(null) + const copy = t.commandCenter.installTheme + const debounced = useDebounced(query.trim(), 300) + const [installingId, setInstallingId] = useState(null) + const [installedHere, setInstalledHere] = useState>({}) + const [error, setError] = useState(null) - const install = async () => { - const trimmed = id.trim() + const search = useQuery({ + enabled: debounced.length > 0, + queryFn: () => window.hermesDesktop?.themes?.searchMarketplace(debounced) ?? Promise.resolve([]), + queryKey: ['marketplace-themes-settings', debounced], + staleTime: 5 * 60 * 1000 + }) - if (!trimmed || busy) { + const install = async (item: DesktopMarketplaceSearchItem) => { + if (installingId) { return } - setBusy(true) - setStatus(null) + setInstallingId(item.extensionId) + setError(null) try { - const theme = await installVscodeThemeFromMarketplace(trimmed) + const theme = await installVscodeThemeFromMarketplace(item.extensionId) triggerHaptic('crisp') - setTheme(theme.name) - setStatus({ kind: 'success', text: a.installed(theme.label) }) - setId('') - } catch (error) { - setStatus({ kind: 'error', text: error instanceof Error ? error.message : a.installError }) + setInstalledHere(prev => ({ ...prev, [item.extensionId]: true })) + onInstalled(theme.name) + } catch (e) { + setError(e instanceof Error ? e.message : copy.error) } finally { - setBusy(false) + setInstallingId(null) } } - return ( -
-
- { - setId(event.target.value) - setStatus(null) - }} - onKeyDown={event => { - if (event.key === 'Enter') { - void install() - } - }} - placeholder={a.installPlaceholder} - spellCheck={false} - value={id} - /> - -
- {status && ( -

- {status.text} + if (!debounced) { + return null + } + + const header = ( +

+ From the VS Code Marketplace +

+ ) + + if (search.isLoading) { + return ( + <> + {header} +

+ + {copy.loading}

- )} -
+ + ) + } + + if (search.isError) { + return ( + <> + {header} +

{copy.error}

+ + ) + } + + const results = search.data ?? [] + + if (results.length === 0) { + return ( + <> + {header} +

{copy.empty}

+ + ) + } + + return ( + <> + {header} + {error &&

{error}

} +
+ {results.map(item => { + const busy = installingId === item.extensionId + const done = installedHere[item.extensionId] || installedExtIds.has(item.extensionId) + + return ( + + ) + })} +
+ ) } export function AppearanceSettings() { const { t, isSavingLocale } = useI18n() - const { themeName, mode, availableThemes, setTheme, setMode } = useTheme() + const { themeName, mode, resolvedMode, availableThemes, setTheme, setMode } = useTheme() const toolViewMode = useStore($toolViewMode) const translucency = useStore($translucency) const profiles = useStore($profiles) const activeProfileKey = normalizeProfileKey(useStore($activeGatewayProfile)) const a = t.settings.appearance + const [query, setQuery] = useState('') + + // One box does double duty: filter installed themes live (below), and run a + // name search against the VS Code Marketplace (the Cmd-K "Install theme…" + // backend) for anything not already installed. + const needle = query.trim().toLowerCase() + + const filteredThemes = availableThemes + .filter( + theme => + !needle || + theme.label.toLowerCase().includes(needle) || + theme.name.toLowerCase().includes(needle) || + theme.description.toLowerCase().includes(needle) + ) + // Active theme first; stable sort keeps the rest in their original order. + .sort((a, b) => Number(b.name === themeName) - Number(a.name === themeName)) + + // Marketplace imports describe themselves as "VS Code · "; + // pull those ids back out so search results already imported show as installed. + const MARKETPLACE_DESC_PREFIX = 'VS Code · ' + + const installedExtIds = new Set( + availableThemes + .map(theme => + theme.description.startsWith(MARKETPLACE_DESC_PREFIX) + ? theme.description.slice(MARKETPLACE_DESC_PREFIX.length) + : '' + ) + .filter(Boolean) + ) + // Themes save per profile. Surface that only when the user actually has more // than one profile (single-profile installs never see the distinction). const showProfileNote = profiles.length > 1 @@ -163,7 +274,7 @@ export function AppearanceSettings() { {a.intro}

-
+
} description={isSavingLocale ? t.language.saving : t.language.description} @@ -171,18 +282,107 @@ export function AppearanceSettings() { /> { - triggerHaptic('crisp') - setMode(id) - }} - options={modeOptions} - value={mode} - /> + below={ + <> + {/* One search box: filters your installed themes (the grid) + and live-searches the VS Code Marketplace below. */} +
+ setQuery(event.target.value)} + placeholder="Search your themes or the VS Code Marketplace…" + spellCheck={false} + value={query} + /> +
+ + {/* Fixed-height scroll area so the (growing) theme list never + runs the page long; the grid scrolls inside it. */} +
+ {filteredThemes.length === 0 ? ( + needle ? ( +

+ No installed themes match "{query.trim()}". +

+ ) : null + ) : ( +
+ {filteredThemes.map(theme => { + const active = themeName === theme.name + const removable = isUserTheme(theme.name) + + return ( +
+ + {removable && ( + + )} +
+ ) + })} +
+ )} + setTheme(name)} + query={query} + /> +
+ {showProfileNote && ( +

+ {a.themeProfileNote(activeProfileName)} +

+ )} + } - description={a.colorModeDesc} - title={a.colorMode} + description={a.themeDesc} + title={ +
+ {a.themeTitle} + { + triggerHaptic('crisp') + setMode(id) + }} + options={modeOptions} + value={mode} + /> +
+ } + wide /> - -
- {availableThemes.map(theme => { - const active = themeName === theme.name - const removable = isUserTheme(theme.name) - - return ( -
- - {removable && ( - - )} -
- ) - })} -
- - {showProfileNote && ( -

- {a.themeProfileNote(activeProfileName)} -

- )} - - } - description={a.themeDesc} - title={a.themeTitle} - wide - /> -
+ +
+ +
) } diff --git a/apps/desktop/src/app/settings/pet-settings.tsx b/apps/desktop/src/app/settings/pet-settings.tsx new file mode 100644 index 00000000000..e9b0e925ce1 --- /dev/null +++ b/apps/desktop/src/app/settings/pet-settings.tsx @@ -0,0 +1,231 @@ +import { useStore } from '@nanostores/react' +import { useEffect, useState } from 'react' + +import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request' +import { PetThumb } from '@/components/pet/pet-thumb' +import { SegmentedControl } from '@/components/ui/segmented-control' +import { useI18n } from '@/i18n' +import { triggerHaptic } from '@/lib/haptics' +import { Loader2, PawPrint, Trash2 } from '@/lib/icons' +import { selectableCardClass } from '@/lib/selectable-card' +import { cn } from '@/lib/utils' +import { $petInfo } from '@/store/pet' +import { + $petBusy, + $petGallery, + $petGalleryError, + $petGalleryStatus, + adoptPet, + loadPetGallery, + loadPetThumb, + PET_SCALE_DEFAULT, + PET_SCALE_MAX, + PET_SCALE_MIN, + rankedGalleryPets, + removePet as removePetAction, + setPetEnabled, + setPetScale +} from '@/store/pet-gallery' +import { $gatewayState } from '@/store/session' + +import { ListRow, SectionHeading } from './primitives' + +/** + * Appearance opt-in for the floating petdex mascot. A thin view over the shared + * `pet-gallery` store — it subscribes to the atoms and calls the store actions, + * so the gallery is fetched once + cached and adopt/toggle/remove patch local + * state instead of re-pulling the network gallery. The floating mascot polls + * `pet.info`, so picking a pet here lights it up within a couple seconds. + */ +export function PetSettings() { + const { t } = useI18n() + const copy = t.settings.appearance.pet + const { requestGateway } = useGatewayRequest() + const gatewayState = useStore($gatewayState) + const gallery = useStore($petGallery) + const status = useStore($petGalleryStatus) + const error = useStore($petGalleryError) + const busySlug = useStore($petBusy) + const petInfo = useStore($petInfo) + const [query, setQuery] = useState('') + const scale = petInfo.scale ?? PET_SCALE_DEFAULT + + useEffect(() => { + if (gatewayState !== 'open') { + return + } + + void loadPetGallery(requestGateway) + }, [gatewayState, requestGateway]) + + const enabled = gallery?.enabled ?? false + const active = gallery?.active ?? '' + const pets = gallery?.pets ?? [] + const staleBackend = status === 'stale' + + const selectPet = (slug: string) => { + void adoptPet(requestGateway, slug, copy.adoptFailed(slug)).then(ok => ok && triggerHaptic('crisp')) + } + + const removePet = (slug: string) => { + void removePetAction(requestGateway, slug, copy.uninstallFailed(slug)).then(ok => ok && triggerHaptic('crisp')) + } + + const toggle = (on: boolean) => { + void setPetEnabled(requestGateway, on, { + noneAvailable: copy.noneAvailable, + fallback: on ? copy.turnOnFailed : copy.turnOffFailed + }).then(ok => ok && triggerHaptic('crisp')) + } + + // The petdex catalog is thousands of entries, so rank + cap how many render. + const RENDER_CAP = 60 + const sorted = rankedGalleryPets(gallery, query) + const shown = sorted.slice(0, RENDER_CAP) + + return ( +
+ +

+ {copy.intro} +

+ + {staleBackend && ( +

+ {copy.restartHint} +

+ )} + +
+ + setQuery(event.target.value)} + placeholder={copy.searchPlaceholder} + spellCheck={false} + value={query} + /> + {/* Fixed-height scroll area so filtering never grows/shrinks the + page (no layout thrash); the grid scrolls inside it. */} +
+ {pets.length === 0 ? ( +

+ {copy.unreachable} +

+ ) : shown.length === 0 ? ( +

+ {copy.noMatch(query)} +

+ ) : ( +
+ {shown.map(pet => { + const isActive = enabled && active === pet.slug + const isBusy = busySlug === pet.slug + + return ( +
+ + {pet.installed && !isBusy && ( + + )} +
+ ) + })} +
+ )} +
+ {/* Always-present status line so its appearance never shifts layout. */} +

+ {error ? ( + {error} + ) : sorted.length > RENDER_CAP ? ( + copy.countCapped(RENDER_CAP, sorted.length) + ) : ( + copy.count(sorted.length) + )} +

+ + } + description={copy.chooseDesc} + title={ +
+ {copy.chooseTitle} + void toggle(id === 'on')} + options={[ + { id: 'off', label: copy.off }, + { id: 'on', label: copy.on } + ]} + value={enabled ? 'on' : 'off'} + /> +
+ } + wide + /> + + {enabled && ( + + { + triggerHaptic('selection') + setPetScale(requestGateway, Number(event.target.value)) + }} + step={0.05} + style={{ accentColor: 'var(--dt-primary)' }} + type="range" + value={scale} + /> + + {`${Math.round(scale * 100)}%`} + +
+ } + description={copy.scaleDesc} + title={copy.scaleTitle} + /> + )} +
+
+ ) +} diff --git a/apps/desktop/src/app/shell/app-shell.tsx b/apps/desktop/src/app/shell/app-shell.tsx index 7cbcaacfb41..b0981681c6c 100644 --- a/apps/desktop/src/app/shell/app-shell.tsx +++ b/apps/desktop/src/app/shell/app-shell.tsx @@ -4,6 +4,7 @@ import { useSyncExternalStore } from 'react' import { NotificationStack } from '@/components/notifications' import { PaneShell } from '@/components/pane-shell' +import { FloatingPet } from '@/components/pet/floating-pet' import { SidebarProvider } from '@/components/ui/sidebar' import { useMediaQuery } from '@/hooks/use-media-query' import { @@ -202,6 +203,10 @@ export function AppShell({ {/* Mounted at the shell root (after overlays) so success/error toasts surface above every route and overlay — not just the chat view. */} + + {/* Petdex floating mascot — in-window, always-on-top, reactive to agent + activity. Renders nothing unless a pet is installed + enabled. */} + ) } diff --git a/apps/desktop/src/components/pet/floating-pet.tsx b/apps/desktop/src/components/pet/floating-pet.tsx new file mode 100644 index 00000000000..d69c35ab6b6 --- /dev/null +++ b/apps/desktop/src/components/pet/floating-pet.tsx @@ -0,0 +1,313 @@ +import { useStore } from '@nanostores/react' +import { useCallback, useEffect, useRef, useState } from 'react' + +import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request' +import { persistString, storedString } from '@/lib/storage' +import { $petInfo, clearPetUnread, type PetInfo, petProfile, setPetInfo } from '@/store/pet' +import { resetPetGallery } from '@/store/pet-gallery' +import { $petOverlayActive, initPetOverlayBridge, popOutPet, restorePetOverlay } from '@/store/pet-overlay' +import { $activeGatewayProfile, normalizeProfileKey } from '@/store/profile' +import { $gatewayState } from '@/store/session' +import { isSecondaryWindow } from '@/store/windows' +import { useTheme } from '@/themes/context' + +import { PetSprite } from './pet-sprite' + +// v2: positions are now top/left anchored (v1 stored bottom-anchored values, +// which dragged inverted). Bumping the key discards stale v1 coordinates. +const POSITION_KEY = 'hermes.desktop.pet-position.v2' + +interface Point { + x: number + y: number +} + +function clampToViewport({ x, y }: Point): Point { + const maxX = Math.max(0, (window.innerWidth || 800) - 80) + const maxY = Math.max(0, (window.innerHeight || 600) - 80) + + return { x: Math.min(Math.max(0, x), maxX), y: Math.min(Math.max(0, y), maxY) } +} + +// The sprite art faces left by default, so mirror it when the pet's center sits +// on the left half of the window — it always faces inward, toward the content. +function facing(leftX: number, petW: number): string { + return leftX + petW / 2 < (window.innerWidth || 800) / 2 ? 'scaleX(-1)' : 'none' +} + +function loadPosition(): Point { + try { + const raw = storedString(POSITION_KEY) + + if (raw) { + const parsed = JSON.parse(raw) as Point + + if (typeof parsed.x === 'number' && typeof parsed.y === 'number') { + return clampToViewport(parsed) + } + } + } catch { + // fall through to default + } + + // Default: lower-left corner (top/left anchored). + return clampToViewport({ x: 24, y: (window.innerHeight || 600) - 220 }) +} + +/** + * In-window floating petdex mascot. Always-on-top within the app, draggable, + * and reactive to agent activity via `$petState`. Fetches the active pet via + * the shared `pet.info` RPC; renders nothing until a pet is installed + + * enabled. + * + * Adopting a pet is fully in-app: type `/pet boba` in the composer. That + * writes `display.pet.*` from the slash worker, so we keep polling `pet.info` + * while no pet is active and the mascot pops in within a few seconds — no + * reload, no CLI. Once a pet is live we stop polling. + * + * Promotion to a separate frameless OS-level window is a follow-up — the + * sprite + state logic here is reused as-is, only the host changes. + */ +const PET_POLL_MS = 3000 + +export function FloatingPet() { + const { requestGateway } = useGatewayRequest() + const { resolvedMode } = useTheme() + const gatewayState = useStore($gatewayState) + const info = useStore($petInfo) + const overlayActive = useStore($petOverlayActive) + + const [position, setPosition] = useState(loadPosition) + const containerRef = useRef(null) + // The facing mirror lives on the sprite wrapper, not the container, so the + // speech bubble (a container child) never renders flipped/backwards. + const spriteWrapRef = useRef(null) + const petW = (info.frameW ?? 192) * (info.scale ?? 0.33) + // Soft contact shadow, sized off the pet so every scale/species grounds the + // same way (cf. lairp's per-actor feet ellipse). Lighter on light backgrounds. + const shadowW = Math.round(petW * 0.55) + const shadowH = Math.max(3, Math.round(shadowW * 0.28)) + const shadowAlpha = resolvedMode === 'light' ? 0.2 : 0.55 + // Live drag offset (pointer → element top-left). Drag updates the DOM + // directly to avoid a React re-render (and canvas reflow) per pointermove — + // state is only committed on release. + const dragRef = useRef<{ dx: number; dy: number; x: number; y: number } | null>(null) + + // Fetch pet.info on connect, then keep polling while no pet is active so an + // in-app `/pet ` shows up live. Stops polling once a pet is enabled. + const active = info.enabled && Boolean(info.spritesheetBase64) + useEffect(() => { + if (gatewayState !== 'open' || active) { + return + } + + let cancelled = false + + const pull = async () => { + try { + const next = await requestGateway('pet.info', { profile: petProfile() }) + + if (!cancelled && next) { + setPetInfo(next) + } + } catch { + // cosmetic feature — never surface gateway errors + } + } + + void pull() + const timer = window.setInterval(() => void pull(), PET_POLL_MS) + + return () => { + cancelled = true + window.clearInterval(timer) + } + }, [gatewayState, active, requestGateway]) + + // Pets are per-profile. When the active profile changes, drop the previous + // profile's mascot + gallery cache so the poll above refetches the new + // profile's pet (its config + pets dir resolve per-profile on the backend). + const profileRef = useRef(normalizeProfileKey($activeGatewayProfile.get())) + useEffect( + () => + $activeGatewayProfile.subscribe(next => { + const key = normalizeProfileKey(next) + + if (key === profileRef.current) { + return + } + + profileRef.current = key + setPetInfo({ enabled: false }) + resetPetGallery() + }), + [] + ) + + // Wire the overlay control channel once, only in the primary window — the + // pop-out overlay belongs to it (main.cjs positions it against the main + // window and routes control messages back to it). + useEffect(() => { + if (isSecondaryWindow()) { + return + } + + return initPetOverlayBridge() + }, []) + + // Returning to the app (by any route, not just the mail icon) clears the pet's + // "new message" hint — you've seen it now. + useEffect(() => { + if (isSecondaryWindow()) { + return + } + + const onFocus = () => clearPetUnread() + window.addEventListener('focus', onFocus) + + return () => window.removeEventListener('focus', onFocus) + }, []) + + // Restore a popped-out pet on boot, once the pet has loaded (so we never spawn + // an empty overlay window). Primary window only; runs at most once. + const restoredRef = useRef(false) + useEffect(() => { + if (isSecondaryWindow() || restoredRef.current || !active) { + return + } + + restoredRef.current = true + restorePetOverlay() + }, [active]) + + // A window resize must never strand the pet off-screen — re-clamp the + // committed position (and persist it) whenever the viewport shrinks. + useEffect(() => { + const onResize = () => + setPosition(prev => { + const next = clampToViewport(prev) + + if (next.x === prev.x && next.y === prev.y) { + return prev + } + + persistString(POSITION_KEY, JSON.stringify(next)) + + return next + }) + + window.addEventListener('resize', onResize) + + return () => window.removeEventListener('resize', onResize) + }, []) + + const onPointerDown = useCallback((e: React.PointerEvent) => { + const el = containerRef.current + + if (!el) { + return + } + + const rect = el.getBoundingClientRect() + + // Shift-click pops the pet out into a free-floating desktop overlay (it can + // leave the window and stays visible while Hermes is minimized) instead of + // starting an in-window drag. Primary window only — the overlay is anchored + // to it. + if (e.shiftKey && !isSecondaryWindow()) { + popOutPet({ height: rect.height, width: rect.width, x: rect.left, y: rect.top }) + + return + } + + dragRef.current = { dx: e.clientX - rect.left, dy: e.clientY - rect.top, x: rect.left, y: rect.top } + el.setPointerCapture(e.pointerId) + el.style.cursor = 'grabbing' + }, []) + + const onPointerMove = useCallback( + (e: React.PointerEvent) => { + const drag = dragRef.current + const el = containerRef.current + + if (!drag || !el) { + return + } + + const next = clampToViewport({ x: e.clientX - drag.dx, y: e.clientY - drag.dy }) + drag.x = next.x + drag.y = next.y + // Mutate the DOM directly — no setState, so no re-render while dragging. The + // mirror follows the pointer across the midline for the same reason; it + // rides the sprite wrapper so the bubble stays upright. + el.style.left = `${next.x}px` + el.style.top = `${next.y}px` + + if (spriteWrapRef.current) { + spriteWrapRef.current.style.transform = facing(next.x, petW) + } + }, + [petW] + ) + + const onPointerUp = useCallback((e: React.PointerEvent) => { + const drag = dragRef.current + + if (drag) { + dragRef.current = null + const committed = { x: drag.x, y: drag.y } + setPosition(committed) + persistString(POSITION_KEY, JSON.stringify(committed)) + } + + const el = containerRef.current + + if (el) { + el.style.cursor = 'grab' + el.releasePointerCapture?.(e.pointerId) + } + }, []) + + // While popped out, the desktop overlay window owns the mascot — hide the + // in-window one so there aren't two. + if (!info.enabled || !info.spritesheetBase64 || overlayActive) { + return null + } + + return ( +
+
+
+ +
+
+ ) +} diff --git a/apps/desktop/src/components/pet/pet-bubble.tsx b/apps/desktop/src/components/pet/pet-bubble.tsx new file mode 100644 index 00000000000..3d3c8109681 --- /dev/null +++ b/apps/desktop/src/components/pet/pet-bubble.tsx @@ -0,0 +1,142 @@ +import { useStore } from '@nanostores/react' +import { useEffect, useState } from 'react' + +import { AlertCircle, Clock, type IconComponent } from '@/lib/icons' +import { $petActivity, $petState, type PetState } from '@/store/pet' + +/** + * Speech bubble + status glyph for the popped-out pet overlay — the + * "notification" half of the mascot. It externalizes what the agent is doing + * (Codex-style) so a glance at the desktop pet replaces switching back to the + * window. The in-window pet doesn't show it (the app itself is the surface); + * only the overlay renders it. + * + * Text is derived purely from the same `$petState` / `$petActivity` the sprite + * already reacts to, so it never drifts from the animation. The bubble is shown + * only when there's something worth saying (working / reviewing / a transient + * done/error beat / waiting on the user) and is hidden at plain idle. + */ + +type Tone = 'error' | 'wait' + +interface Spec { + lines: string[] + glyph?: IconComponent + tone?: Tone +} + +// Phrasings per mood, picked at random (no immediate repeat) for a bit of life. +// Keep them short — the bubble is tiny and never wraps. +const SPECS: Partial> = { + run: { + lines: ['working…', 'on it…', 'crunching…', 'tinkering…', 'cooking…', 'in the weeds…', 'wiring it up…', 'making moves…', 'heads down…', 'hammering away…'] + }, + review: { + lines: ['thinking…', 'reading…', 'reviewing…', 'pondering…', 'connecting dots…', 'sizing it up…', 'tracing it…', 'mulling…', 'scheming…', 'hmm…'] + }, + failed: { + glyph: AlertCircle, + lines: ['hit a snag', 'welp', 'that broke', 'oof', 'snagged'], + tone: 'error' + }, + waiting: { + glyph: Clock, + lines: ['your turn', 'all yours', 'over to you', 'ball’s in your court', 'awaiting orders'], + tone: 'wait' + } +} + +const TONE_COLOR: Record = { + error: 'var(--ui-red)', + wait: 'var(--ui-yellow)' +} + +// Random pick that avoids repeating the line we're already showing. +function pick(lines: string[], prev: string): string { + if (lines.length <= 1) { + return lines[0] ?? '' + } + + let next = prev + + while (next === prev) { + next = lines[Math.floor(Math.random() * lines.length)] + } + + return next +} + +export function PetBubble() { + const state = useStore($petState) + const activity = useStore($petActivity) + const [line, setLine] = useState('') + + // Finish beats are carried by the sprite/mail icon; idle only speaks up when + // it's actually the user's turn. Everything else maps to a mood spec. + const specKey: null | PetState = + state in SPECS ? state : state === 'idle' && activity.awaitingInput ? 'waiting' : null + const rotating = specKey === 'run' || specKey === 'review' + + // Pick a fresh line on every mood change, then keep rotating (random, no + // repeat) only while the agent is actively working/thinking. + useEffect(() => { + const spec = specKey ? SPECS[specKey] : null + + if (!spec) { + setLine('') + + return + } + + setLine(prev => pick(spec.lines, prev)) + + if (!rotating || spec.lines.length <= 1) { + return + } + + const id = window.setInterval(() => setLine(prev => pick(spec.lines, prev)), 2600) + + return () => window.clearInterval(id) + }, [specKey, rotating]) + + const spec = specKey ? SPECS[specKey] : null + + if (!spec) { + return null + } + + const Glyph = spec.glyph + const text = line || spec.lines[0] + const hasText = Boolean(text) + + return ( +
+ {Glyph && ( + + + + )} + {text} +
+ ) +} diff --git a/apps/desktop/src/components/pet/pet-sprite.tsx b/apps/desktop/src/components/pet/pet-sprite.tsx new file mode 100644 index 00000000000..753c6f34af2 --- /dev/null +++ b/apps/desktop/src/components/pet/pet-sprite.tsx @@ -0,0 +1,178 @@ +import { memo, useEffect, useMemo, useRef } from 'react' + +import { $petState, type PetInfo, type PetState } from '@/store/pet' + +const DEFAULT_FRAME_W = 192 +const DEFAULT_FRAME_H = 208 +const DEFAULT_FRAMES = 6 +const DEFAULT_LOOP_MS = 1100 +// Mirrors agent.pet.constants.DEFAULT_SCALE — fallback only; the gateway sends +// the configured scale. +const DEFAULT_SCALE = 0.33 +// Mirrors agent.pet.constants.CODEX_STATE_ROWS (Petdex current taxonomy). +const DEFAULT_STATE_ROWS = [ + 'idle', + 'running-right', + 'running-left', + 'waving', + 'jumping', + 'failed', + 'waiting', + 'running', + 'review' +] + +const STATE_ALIASES: Record = { + idle: ['idle'], + wave: ['wave', 'waving'], + jump: ['jump', 'jumping'], + run: ['run', 'running'], + failed: ['failed'], + review: ['review'], + waiting: ['waiting'] +} + +interface PetSpriteProps { + info: PetInfo + /** On-screen scale multiplier applied on top of the pet's native scale. */ + zoom?: number +} + +/** + * Canvas renderer for a petdex spritesheet — the one piece that must be + * TypeScript (the engine's decode/encode is Python). Draws the row matching the + * live `$petState`, stepping `framesPerState` frames across a `loopMs` loop. + * + * State is read from `$petState` via a ref + subscription rather than a prop, + * so the frequent activity-driven state changes during an agent turn update the + * canvas (inside its RAF loop) WITHOUT triggering a React re-render. Combined + * with `memo`, this component effectively never re-renders after mount until + * the pet itself changes. + */ +function PetSpriteImpl({ info, zoom = 1 }: PetSpriteProps) { + const canvasRef = useRef(null) + const stateRef = useRef($petState.get()) + + const frameW = info.frameW ?? DEFAULT_FRAME_W + const frameH = info.frameH ?? DEFAULT_FRAME_H + const frames = info.framesPerState ?? DEFAULT_FRAMES + const framesByState = info.framesByState + const loopMs = info.loopMs ?? DEFAULT_LOOP_MS + const scale = (info.scale ?? DEFAULT_SCALE) * zoom + const rows = info.stateRows ?? DEFAULT_STATE_ROWS + + const drawW = Math.round(frameW * scale) + const drawH = Math.round(frameH * scale) + + const image = useMemo(() => { + if (!info.spritesheetBase64) { + return null + } + + const img = new Image() + img.src = `data:${info.mime ?? 'image/webp'};base64,${info.spritesheetBase64}` + + return img + }, [info.spritesheetBase64, info.mime]) + + useEffect(() => { + const canvas = canvasRef.current + + if (!canvas || !image) { + return + } + + const ctx = canvas.getContext('2d') + + if (!ctx) { + return + } + + // Track state via subscription, not a prop — no re-render on activity ticks. + stateRef.current = $petState.get() + + const unsubState = $petState.listen(next => { + stateRef.current = next + }) + + let raf = 0 + let frame = 0 + let lastStep = performance.now() + let drawnFrame = -1 + let drawnRow = -1 + + const rowIndexForState = (s: PetState): number => { + for (const key of STATE_ALIASES[s] ?? [s]) { + const idx = rows.indexOf(key) + if (idx >= 0) { + return idx + } + } + return 0 + } + + // Resolve a state to the row it draws and its real frame count. A state + // with no real frames (ragged sheet, empty row) falls back to idle rather + // than flashing blank padding. + const resolve = (s: PetState): { row: number; count: number } => { + const real = framesByState?.[s] ?? frames + if (real > 0) { + return { row: rowIndexForState(s), count: real } + } + + return { row: rowIndexForState('idle'), count: Math.max(1, framesByState?.idle ?? frames) } + } + + const render = (now: number) => { + const { row, count } = resolve(stateRef.current) + // Per-state step keeps every state's loop ~loopMs even when frame counts + // differ; counts vary per row so derive the cadence here, not once. + const stepMs = loopMs / count + + if (now - lastStep >= stepMs) { + frame += 1 + lastStep = now + } + + frame %= count + + // Only touch the canvas when the visible cell actually changes. The RAF + // ticks at ~60Hz but the sprite only steps ~5Hz, so this skips ~90% of + // the clear+draw work and keeps the main thread free. + if ((frame !== drawnFrame || row !== drawnRow) && image.complete && image.naturalWidth > 0) { + const sx = frame * frameW + const sy = row * frameH + ctx.clearRect(0, 0, canvas.width, canvas.height) + ctx.imageSmoothingEnabled = false + ctx.drawImage(image, sx, sy, frameW, frameH, 0, 0, drawW, drawH) + drawnFrame = frame + drawnRow = row + } + + raf = requestAnimationFrame(render) + } + + raf = requestAnimationFrame(render) + + return () => { + cancelAnimationFrame(raf) + unsubState() + } + }, [image, frameW, frameH, frames, framesByState, loopMs, drawW, drawH, rows]) + + return ( + + ) +} + +/** + * Memoized so a parent re-render (e.g. a position commit on drag-end) doesn't + * re-run the canvas setup. Props change only when the pet itself changes. + */ +export const PetSprite = memo(PetSpriteImpl) diff --git a/apps/desktop/src/components/pet/pet-thumb.tsx b/apps/desktop/src/components/pet/pet-thumb.tsx new file mode 100644 index 00000000000..088514c0804 --- /dev/null +++ b/apps/desktop/src/components/pet/pet-thumb.tsx @@ -0,0 +1,79 @@ +import { useEffect, useRef, useState } from 'react' + +import { PawPrint } from '@/lib/icons' + +// petdex frames are a fixed 192×208 grid; the box matches that aspect. +const THUMB_W = 40 +const THUMB_H = Math.round((THUMB_W * 208) / 192) + +export type PetThumbLoader = (slug: string, url?: string) => Promise + +/** + * Idle-frame preview for one pet. The backend crops + caches the frame and + * returns it as a same-origin data URI (`pet.thumb`), which dodges the renderer + * CSP / R2 hotlink rules that break a direct ``. + */ +export function PetThumb({ + slug, + url, + alt, + load, + size = THUMB_W +}: { + slug: string + url?: string + alt: string + load: PetThumbLoader + /** Width in px; height follows the petdex frame aspect. */ + size?: number +}) { + const [src, setSrc] = useState(null) + const boxRef = useRef(null) + const height = Math.round((size * 208) / 192) + + useEffect(() => { + const el = boxRef.current + + if (!el || src) { + return + } + + const observer = new IntersectionObserver( + entries => { + if (entries.some(entry => entry.isIntersecting)) { + observer.disconnect() + void load(slug, url).then(uri => { + if (uri) { + setSrc(uri) + } + }) + } + }, + { rootMargin: '120px' } + ) + + observer.observe(el) + + return () => observer.disconnect() + }, [slug, url, src, load]) + + return ( + + {src ? ( + {alt} + ) : ( + + )} + + ) +} diff --git a/apps/desktop/src/components/ui/command.tsx b/apps/desktop/src/components/ui/command.tsx index dbbc655d690..4324c8e8e68 100644 --- a/apps/desktop/src/components/ui/command.tsx +++ b/apps/desktop/src/components/ui/command.tsx @@ -17,7 +17,12 @@ function Command({ className, ...props }: React.ComponentProps) { +interface CommandInputProps extends React.ComponentProps { + /** Inline trailing slot, rendered on the right of the search row. */ + right?: React.ReactNode +} + +function CommandInput({ className, right, ...props }: CommandInputProps) { return (
@@ -29,6 +34,7 @@ function CommandInput({ className, ...props }: React.ComponentProps + {right}
) } diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index c615ad2d61a..5e41d3e7423 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -1,3 +1,10 @@ +import type { + PetOverlayBounds, + PetOverlayControl, + PetOverlayOpenRequest, + PetOverlayStatePayload +} from './store/pet-overlay' + export {} declare global { @@ -26,6 +33,20 @@ declare global { openSessionWindow: (sessionId: string, opts?: { watch?: boolean }) => Promise<{ ok: boolean; error?: string }> // Open (or focus) a compact secondary window on the new-session draft. openNewSessionWindow: () => Promise<{ ok: boolean; error?: string }> + // The pop-out pet overlay: a transparent always-on-top window hosting only + // the mascot. The main renderer drives it (open/close/drag + state push); + // the overlay sends control messages back (pop-in, composer submit). + petOverlay: { + open: (request: PetOverlayOpenRequest) => Promise<{ ok: boolean; bounds?: PetOverlayBounds }> + close: () => Promise<{ ok: boolean }> + setBounds: (bounds: PetOverlayBounds) => void + setIgnoreMouse: (ignore: boolean) => void + setFocusable: (focusable: boolean) => void + pushState: (payload: PetOverlayStatePayload) => void + control: (payload: PetOverlayControl) => void + onState: (callback: (payload: PetOverlayStatePayload) => void) => () => void + onControl: (callback: (payload: PetOverlayControl) => void) => () => void + } getBootProgress: () => Promise getConnectionConfig: (profile?: null | string) => Promise saveConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 3c1a7ec3879..e8be5a6dec8 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -366,7 +366,32 @@ export const en: Translations = { installError: 'Could not install that theme.', installed: name => `Installed “${name}”.`, removeTheme: 'Remove theme', - importedBadge: 'Imported' + importedBadge: 'Imported', + pet: { + title: 'Pet', + intro: + 'Adopt an animated petdex mascot that floats over the app and reacts to what Hermes is doing — running while tools execute, celebrating on success, sulking on errors.', + restartHint: + 'Pets need a quick restart — the running app started before this feature was added. Quit and reopen Hermes, then come back here.', + on: 'On', + off: 'Off', + scaleTitle: 'Size', + scaleDesc: 'Resize the floating mascot. Applies everywhere instantly.', + chooseTitle: 'Choose a pet', + chooseDesc: 'Picking one installs it (if needed) and makes it active.', + searchPlaceholder: 'Search pets…', + unreachable: "Couldn't reach the petdex gallery. Check your connection and reopen this page.", + noMatch: query => `No pets match "${query}".`, + installedTag: 'installed', + countCapped: (cap, total) => `Showing ${cap} of ${total} — type to narrow it down.`, + count: n => `${n} pet${n === 1 ? '' : 's'}.`, + uninstall: name => `Uninstall ${name}`, + adoptFailed: slug => `Could not adopt ${slug}`, + uninstallFailed: slug => `Could not uninstall ${slug}`, + noneAvailable: 'No pets available to turn on right now.', + turnOnFailed: 'Could not turn the pet on.', + turnOffFailed: 'Could not turn the pet off.' + } }, fieldLabels: FIELD_LABELS, fieldDescriptions: FIELD_DESCRIPTIONS, @@ -714,8 +739,22 @@ export const en: Translations = { commandCenter: 'Command Center', appearance: 'Appearance', settings: 'Settings', - changeTheme: 'Change theme...', + changeTheme: 'Change theme', changeColorMode: 'Change color mode...', + pets: { + title: 'Pets', + placeholder: 'Search pets…', + loading: 'Loading petdex gallery…', + error: 'Could not reach the petdex gallery.', + staleBackend: 'Restart Hermes to use pets — the backend predates this feature.', + empty: 'No matching pets.', + turnOff: 'Turn off', + turnOn: 'Turn on', + installed: 'Installed', + adoptFailed: 'Could not adopt that pet.', + toggleFailed: 'Could not toggle the pet.', + noneAvailable: 'No pets available — pick one below to install.' + }, installTheme: { title: 'Install theme...', placeholder: 'Search the VS Code Marketplace...', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 904e4b25c53..3a28b50aac3 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -281,7 +281,32 @@ export const ja = defineLocale({ installError: 'そのテーマをインストールできませんでした。', installed: name => `「${name}」をインストールしました。`, removeTheme: 'テーマを削除', - importedBadge: 'インポート済み' + importedBadge: 'インポート済み', + pet: { + title: 'ペット', + intro: + 'アプリ上に浮かぶ petdex のアニメーションマスコットを採用しましょう。ツール実行中は走り、成功すると喜び、エラーでしょんぼりと、Hermes の状態に反応します。', + restartHint: + 'ペット機能には再起動が必要です。この機能が追加される前に起動したアプリが動作中です。Hermes を終了して再度開き、このページに戻ってください。', + scaleTitle: 'サイズ', + scaleDesc: '浮遊マスコットの大きさを変更します。すべての画面に即時反映されます。', + on: 'オン', + off: 'オフ', + chooseTitle: 'ペットを選ぶ', + chooseDesc: '選ぶと(必要に応じて)インストールされ、アクティブになります。', + searchPlaceholder: 'ペットを検索…', + unreachable: 'petdex ギャラリーに接続できませんでした。接続を確認してこのページを開き直してください。', + noMatch: query => `「${query}」に一致するペットがありません。`, + installedTag: 'インストール済み', + countCapped: (cap, total) => `${total} 件中 ${cap} 件を表示中——入力して絞り込めます。`, + count: n => `${n} 件のペット。`, + uninstall: name => `${name} をアンインストール`, + adoptFailed: slug => `${slug} を採用できませんでした`, + uninstallFailed: slug => `${slug} をアンインストールできませんでした`, + noneAvailable: 'オンにできるペットがありません。', + turnOnFailed: 'ペットをオンにできませんでした。', + turnOffFailed: 'ペットをオフにできませんでした。' + } }, fieldLabels: defineFieldCopy({ model: 'デフォルトモデル', @@ -834,8 +859,22 @@ export const ja = defineLocale({ commandCenter: 'コマンドセンター', appearance: '外観', settings: '設定', - changeTheme: 'テーマを変更...', + changeTheme: 'テーマを変更', changeColorMode: 'カラーモードを変更...', + pets: { + title: 'ペット', + placeholder: 'ペットを検索…', + loading: 'petdex ギャラリーを読み込み中…', + error: 'petdex ギャラリーに接続できません。', + staleBackend: 'ペット機能を使うには Hermes を再起動してください。', + empty: '一致するペットがありません。', + turnOff: 'オフ', + turnOn: 'オン', + installed: 'インストール済み', + adoptFailed: 'ペットを採用できませんでした。', + toggleFailed: 'ペットを切り替えできませんでした。', + noneAvailable: '利用可能なペットがありません。' + }, installTheme: { title: 'テーマをインストール...', placeholder: 'VS Code Marketplace を検索...', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index dcf1028fb4b..70807da8bf7 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -265,6 +265,29 @@ export interface Translations { installed: (name: string) => string removeTheme: string importedBadge: string + pet: { + title: string + intro: string + restartHint: string + on: string + off: string + scaleTitle: string + scaleDesc: string + chooseTitle: string + chooseDesc: string + searchPlaceholder: string + unreachable: string + noMatch: (query: string) => string + installedTag: string + countCapped: (cap: number, total: number) => string + count: (n: number) => string + uninstall: (name: string) => string + adoptFailed: (slug: string) => string + uninstallFailed: (slug: string) => string + noneAvailable: string + turnOnFailed: string + turnOffFailed: string + } } fieldLabels: Record fieldDescriptions: Record @@ -594,6 +617,20 @@ export interface Translations { settings: string changeTheme: string changeColorMode: string + pets: { + title: string + placeholder: string + loading: string + error: string + staleBackend: string + empty: string + turnOff: string + turnOn: string + installed: string + adoptFailed: string + toggleFailed: string + noneAvailable: string + } installTheme: { title: string placeholder: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 8f208aff341..3e1420d3414 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -271,7 +271,30 @@ export const zhHant = defineLocale({ installError: '無法安裝該主題。', installed: name => `已安裝「${name}」。`, removeTheme: '移除主題', - importedBadge: '已匯入' + importedBadge: '已匯入', + pet: { + title: '寵物', + intro: '領養一隻懸浮在應用上的 petdex 動畫寵物,它會根據 Hermes 的狀態做出反應——工具執行時奔跑、成功時歡呼、出錯時沮喪。', + restartHint: '寵物功能需要重新啟動——目前執行的應用在此功能加入前啟動。請結束並重新開啟 Hermes,然後回到此處。', + scaleTitle: '大小', + scaleDesc: '調整懸浮寵物的大小,所有介面即時生效。', + on: '開啟', + off: '關閉', + chooseTitle: '選擇寵物', + chooseDesc: '選擇後會自動安裝(如需)並設為目前寵物。', + searchPlaceholder: '搜尋寵物…', + unreachable: '無法連線至 petdex 畫廊。請檢查網路連線並重新開啟此頁面。', + noMatch: query => `沒有符合「${query}」的寵物。`, + installedTag: '已安裝', + countCapped: (cap, total) => `顯示 ${total} 個中的 ${cap} 個——輸入關鍵字以縮小範圍。`, + count: n => `${n} 個寵物。`, + uninstall: name => `解除安裝 ${name}`, + adoptFailed: slug => `無法領養 ${slug}`, + uninstallFailed: slug => `無法解除安裝 ${slug}`, + noneAvailable: '目前沒有可開啟的寵物。', + turnOnFailed: '無法開啟寵物。', + turnOffFailed: '無法關閉寵物。' + } }, fieldLabels: defineFieldCopy({ model: '預設模型', @@ -807,8 +830,22 @@ export const zhHant = defineLocale({ commandCenter: '命令中心', appearance: '外觀', settings: '設定', - changeTheme: '變更主題...', + changeTheme: '變更主題', changeColorMode: '變更色彩模式...', + pets: { + title: '寵物', + placeholder: '搜尋寵物…', + loading: '正在載入 petdex 畫廊…', + error: '無法連線至 petdex 畫廊。', + staleBackend: '請重新啟動 Hermes 以使用寵物功能。', + empty: '沒有符合的寵物。', + turnOff: '關閉', + turnOn: '開啟', + installed: '已安裝', + adoptFailed: '無法領養該寵物。', + toggleFailed: '無法切換寵物顯示。', + noneAvailable: '尚無可用寵物——請在下方選擇一個安裝。' + }, installTheme: { title: '安裝主題...', placeholder: '搜尋 VS Code Marketplace...', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index f368d3585ca..34ddd474359 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -359,7 +359,30 @@ export const zh: Translations = { installError: '无法安装该主题。', installed: name => `已安装「${name}」。`, removeTheme: '移除主题', - importedBadge: '已导入' + importedBadge: '已导入', + pet: { + title: '宠物', + intro: '领养一只悬浮在应用上的 petdex 动画宠物,它会根据 Hermes 的状态做出反应——工具执行时奔跑、成功时欢呼、出错时沮丧。', + restartHint: '宠物功能需要重启——当前运行的应用在此功能加入前启动。请退出并重新打开 Hermes,然后回到此处。', + scaleTitle: '大小', + scaleDesc: '调整悬浮宠物的大小,所有界面即时生效。', + on: '开启', + off: '关闭', + chooseTitle: '选择宠物', + chooseDesc: '选择后会自动安装(如需)并设为当前宠物。', + searchPlaceholder: '搜索宠物…', + unreachable: '无法连接到 petdex 画廊。请检查网络连接并重新打开此页面。', + noMatch: query => `没有匹配「${query}」的宠物。`, + installedTag: '已安装', + countCapped: (cap, total) => `显示 ${total} 个中的 ${cap} 个——输入关键词以缩小范围。`, + count: n => `${n} 个宠物。`, + uninstall: name => `卸载 ${name}`, + adoptFailed: slug => `无法领养 ${slug}`, + uninstallFailed: slug => `无法卸载 ${slug}`, + noneAvailable: '当前没有可开启的宠物。', + turnOnFailed: '无法开启宠物。', + turnOffFailed: '无法关闭宠物。' + } }, fieldLabels: defineFieldCopy({ model: '默认模型', @@ -904,8 +927,22 @@ export const zh: Translations = { commandCenter: '命令中心', appearance: '外观', settings: '设置', - changeTheme: '更改主题...', + changeTheme: '更改主题', changeColorMode: '更改颜色模式...', + pets: { + title: '宠物', + placeholder: '搜索宠物…', + loading: '正在加载 petdex 画廊…', + error: '无法连接到 petdex 画廊。', + staleBackend: '请重启 Hermes 以使用宠物功能——当前后端版本过旧。', + empty: '没有匹配的宠物。', + turnOff: '关闭', + turnOn: '开启', + installed: '已安装', + adoptFailed: '无法领养该宠物。', + toggleFailed: '无法切换宠物显示。', + noneAvailable: '暂无可用宠物——请在下方选择一个安装。' + }, installTheme: { title: '安装主题...', placeholder: '搜索 VS Code Marketplace...', diff --git a/apps/desktop/src/lib/desktop-slash-commands.test.ts b/apps/desktop/src/lib/desktop-slash-commands.test.ts index 54f5a6f89df..8e30e5bfcfb 100644 --- a/apps/desktop/src/lib/desktop-slash-commands.test.ts +++ b/apps/desktop/src/lib/desktop-slash-commands.test.ts @@ -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. diff --git a/apps/desktop/src/lib/desktop-slash-commands.ts b/apps/desktop/src/lib/desktop-slash-commands.ts index f9ae934edf4..e1a0f2d773c 100644 --- a/apps/desktop/src/lib/desktop-slash-commands.ts +++ b/apps/desktop/src/lib/desktop-slash-commands.ts @@ -34,6 +34,7 @@ export type DesktopActionId = | 'handoff' | 'help' | 'new' + | 'pet' | 'profile' | 'skin' | 'title' @@ -128,6 +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: '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() }, @@ -155,7 +157,7 @@ const NO_DESKTOP_SURFACE: Record = '/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'] } diff --git a/apps/desktop/src/lib/icons.ts b/apps/desktop/src/lib/icons.ts index a2cd4ec7b0b..9e07f529ce6 100644 --- a/apps/desktop/src/lib/icons.ts +++ b/apps/desktop/src/lib/icons.ts @@ -51,6 +51,7 @@ import { IconLoader2 as Loader2Icon, IconLock as Lock, IconLogin as LogIn, + IconMail as Mail, IconMessageCircle as MessageCircle, IconMessage2 as MessageSquareText, IconMicrophone as Mic, @@ -67,6 +68,7 @@ import { IconLayoutBottombar as PanelBottom, IconLayoutSidebar as PanelLeftIcon, IconPlayerPause as Pause, + IconPaw as PawPrint, IconPencil as Pencil, IconPencil as PencilIcon, IconPencil as PencilLine, @@ -153,6 +155,7 @@ export { Loader2Icon, Lock, LogIn, + Mail, MessageCircle, MessageSquareText, Mic, @@ -169,6 +172,7 @@ export { PanelBottom, PanelLeftIcon, Pause, + PawPrint, Pencil, PencilIcon, PencilLine, diff --git a/apps/desktop/src/lib/selectable-card.ts b/apps/desktop/src/lib/selectable-card.ts new file mode 100644 index 00000000000..617898b7e23 --- /dev/null +++ b/apps/desktop/src/lib/selectable-card.ts @@ -0,0 +1,31 @@ +import { cn } from '@/lib/utils' + +export interface SelectableCardState { + /** Currently selected / active — the strongest emphasis. */ + active?: boolean + /** + * Configured / installed / "you have this" — solid surface + border. When + * false the card renders muted (transparent, dimmed) until hovered, so the + * eye lands on what you already have. Ignored when `active` is set. + */ + prominent?: boolean +} + +/** + * Shared emphasis for selectable list cards across settings surfaces (theme + * picker, pet picker, Marketplace results, provider rows…). Three tiers: + * active > prominent > muted. Keeps the "installed = solid, not-installed = + * quiet" pattern consistent everywhere instead of each picker rolling its own. + * + * Callers own layout (padding, flex, width); this owns only border + surface. + */ +export function selectableCardClass({ active, prominent }: SelectableCardState): string { + return cn( + 'rounded-lg border transition-colors', + active + ? 'border-primary bg-primary/[0.06] ring-2 ring-primary/20' + : prominent + ? 'border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) hover:bg-(--chrome-action-hover)' + : 'border-transparent bg-transparent text-(--ui-text-tertiary) hover:border-(--ui-stroke-tertiary) hover:bg-(--ui-bg-quinary)' + ) +} diff --git a/apps/desktop/src/main.tsx b/apps/desktop/src/main.tsx index b78c583264a..5b7621aa015 100644 --- a/apps/desktop/src/main.tsx +++ b/apps/desktop/src/main.tsx @@ -26,20 +26,27 @@ if (import.meta.env.MODE !== 'production') { import('./app/chat/perf-probe') } -createRoot(document.getElementById('root')!).render( - - - - - - - - - - - - - - - -) +// The pet overlay rides this same bundle (`?win=overlay`) but mounts a tiny, +// transparent, gateway-less surface instead of the full app. Branch before any +// app-shell work so the overlay window stays cheap. +if (new URLSearchParams(window.location.search).get('win') === 'overlay') { + void import('./app/pet-overlay/overlay-root').then(({ mountPetOverlay }) => mountPetOverlay()) +} else { + createRoot(document.getElementById('root')!).render( + + + + + + + + + + + + + + + + ) +} diff --git a/apps/desktop/src/store/command-palette.ts b/apps/desktop/src/store/command-palette.ts index d214d1c2f26..490e3ee1eac 100644 --- a/apps/desktop/src/store/command-palette.ts +++ b/apps/desktop/src/store/command-palette.ts @@ -3,16 +3,30 @@ import { atom } from 'nanostores' /** Whether the global command palette (Cmd/Ctrl+K) is currently open. */ export const $commandPaletteOpen = atom(false) +/** Optional nested page to open when the palette next opens (e.g. `pets`). */ +export const $commandPalettePage = atom(null) + export function openCommandPalette(): void { $commandPaletteOpen.set(true) } +/** Open the palette directly on a nested page (`theme`, `pets`, …). */ +export function openCommandPalettePage(page: string): void { + $commandPalettePage.set(page) + $commandPaletteOpen.set(true) +} + export function closeCommandPalette(): void { $commandPaletteOpen.set(false) + $commandPalettePage.set(null) } export function setCommandPaletteOpen(open: boolean): void { $commandPaletteOpen.set(open) + + if (!open) { + $commandPalettePage.set(null) + } } export function toggleCommandPalette(): void { diff --git a/apps/desktop/src/store/pet-gallery.ts b/apps/desktop/src/store/pet-gallery.ts new file mode 100644 index 00000000000..a9a23734b8b --- /dev/null +++ b/apps/desktop/src/store/pet-gallery.ts @@ -0,0 +1,322 @@ +import { atom } from 'nanostores' + +import { $petInfo, type PetInfo, petProfile, setPetInfo } from '@/store/pet' + +/** + * Feature store for the petdex gallery picker (Cmd+K "Pets…" + Settings). + * + * Why this exists: `pet.gallery` does a *network* manifest fetch on the gateway, + * so re-pulling it after every adopt/toggle made the picker feel laggy and made + * two components (palette + settings) each carry their own copy of the same + * fetch / thumb-cache / optimistic-mutation logic. This store centralizes it: + * + * - The gallery is fetched once and cached; reopening the picker is instant. + * - Mutations (adopt / enable / remove) patch local state and only re-pull the + * cheap, local `pet.info` — never the network manifest again. + * - Thumbnails are deduped in a process-global cache (the backend disk-caches + * too, so a slug is fetched at most once per session). + * + * Consumers just `useStore($petGallery)` and call the actions; no component + * owns gallery state anymore. + */ + +export interface GalleryPet { + slug: string + displayName: string + installed: boolean + spritesheetUrl?: string + /** petdex's hand-picked set — used only to rank "popular" pets first. */ + curated?: boolean +} + +export interface PetGallery { + enabled: boolean + active: string + pets: GalleryPet[] +} + +export type PetGalleryStatus = 'idle' | 'loading' | 'ready' | 'stale' | 'error' + +/** The recovering `requestGateway` from `useGatewayRequest` — passed in so the + * store reuses the hook's reconnect/reauth handling instead of duplicating it. */ +export type GatewayRequest = (method: string, params?: Record) => Promise + +/** Profile-scoped pet RPC. Pets are per-profile, so every call carries the active + * profile (the gateway no-ops it for the launch profile). One chokepoint so no + * call site can forget it. */ +const petRpc = (request: GatewayRequest, method: string, params: Record = {}): Promise => + request(method, { ...params, profile: petProfile() }) + +/** A JSON-RPC "method not found" — the backend predates the pet RPCs. */ +function isMissingMethod(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error) + + return /method not found|-32601|unknown method|no such method/i.test(message) +} + +export const $petGallery = atom(null) +export const $petGalleryStatus = atom('idle') +export const $petGalleryError = atom(null) + +// Which action is in flight, so rows/buttons can show a spinner. A slug for a +// per-pet mutation; the `TOGGLE_*` sentinels for the on/off switch. +export const TOGGLE_ON = '\u0000on' +export const TOGGLE_OFF = '\u0000off' +export const $petBusy = atom(null) + +// Process-global caches (survive component unmount → instant reopen). +const thumbCache = new Map>() +let galleryLoad: Promise | null = null + +/** + * Drop the cached gallery, thumbnails, and in-flight load so the next open + * refetches against the now-active profile's backend. Called on a profile switch + * (pets are per-profile) — the floating pet's own `pet.info` poll repaints the + * new profile's mascot, and the picker reloads its gallery on next mount. + */ +export function resetPetGallery(): void { + galleryLoad = null + thumbCache.clear() + $petGallery.set(null) + $petGalleryStatus.set('idle') + $petGalleryError.set(null) + $petBusy.set(null) +} + +export function loadPetThumb(request: GatewayRequest, slug: string, url?: string): Promise { + let pending = thumbCache.get(slug) + + if (!pending) { + pending = petRpc<{ ok: boolean; dataUri?: string }>(request, 'pet.thumb', { slug, url: url ?? '' }) + .then(result => (result?.ok && result.dataUri ? result.dataUri : null)) + .catch(() => null) + thumbCache.set(slug, pending) + } + + return pending +} + +/** + * Fetch the gallery once and cache it. Subsequent calls are no-ops while a + * ready snapshot is held; pass `{ force: true }` to bypass the cache (e.g. a + * manual refresh). Concurrent callers share a single in-flight request. + */ +export function loadPetGallery(request: GatewayRequest, options: { force?: boolean } = {}): Promise { + if (!options.force && $petGallery.get() && $petGalleryStatus.get() === 'ready') { + return Promise.resolve() + } + + if (galleryLoad) { + return galleryLoad + } + + galleryLoad = (async () => { + if (!$petGallery.get()) { + $petGalleryStatus.set('loading') + } + + try { + const [next, info] = await Promise.all([ + petRpc(request, 'pet.gallery'), + petRpc(request, 'pet.info') + ]) + + if (next) { + $petGallery.set(next) + $petGalleryStatus.set('ready') + $petGalleryError.set(null) + } + + if (info) { + setPetInfo(info) + } + } catch (e) { + if (isMissingMethod(e)) { + $petGalleryStatus.set('stale') + } else if (!$petGallery.get()) { + // Only surface a hard error when we have nothing to show; a transient + // hiccup mid-session leaves the cached gallery intact. + $petGalleryStatus.set('error') + $petGalleryError.set(e instanceof Error ? e.message : 'Could not reach the petdex gallery.') + } + } finally { + galleryLoad = null + } + })() + + return galleryLoad +} + +// Push the live mascot state (cheap, local config read) without re-pulling the +// network gallery — the floating pet repaints, the picker keeps its cache. +async function syncInfo(request: GatewayRequest): Promise { + try { + const info = await petRpc(request, 'pet.info') + + if (info) { + setPetInfo(info) + } + } catch { + // The mutation already succeeded; a stale mascot self-heals on its poll. + } +} + +/** + * Filter (drop the internal `clawd*` pets + apply a search query) and rank the + * gallery for a picker. Ranking has no popularity data, so it leans on the + * signals we do have: active pet first, then installed, then curated. Shared by + * the Cmd-K palette and the Settings grid so the two can't drift — each caller + * applies its own cap and reads `.length` for the total. + */ +export function rankedGalleryPets(gallery: PetGallery | null, query = ''): GalleryPet[] { + if (!gallery) { + return [] + } + + const needle = query.trim().toLowerCase() + + const rank = (p: GalleryPet) => + Number(gallery.enabled && p.slug === gallery.active) * 4 + Number(p.installed) * 2 + Number(p.curated) + + return gallery.pets + .filter( + p => + !/^clawd(-|$)/i.test(p.slug) && + (!needle || p.slug.toLowerCase().includes(needle) || p.displayName.toLowerCase().includes(needle)) + ) + .sort((a, b) => rank(b) - rank(a)) +} + +function patchGallery(fn: (gallery: PetGallery) => PetGallery): void { + const current = $petGallery.get() + + if (current) { + $petGallery.set(fn(current)) + } +} + +/** Shared mutation wrapper: spin, fire, patch on success, surface failures. */ +async function mutate( + busyKey: string, + fallback: string, + request: GatewayRequest, + run: () => Promise +): Promise { + $petBusy.set(busyKey) + $petGalleryError.set(null) + + try { + await run() + await syncInfo(request) + + return true + } catch (e) { + if (isMissingMethod(e)) { + $petGalleryStatus.set('stale') + } else { + $petGalleryError.set(e instanceof Error ? e.message : fallback) + } + + return false + } finally { + $petBusy.set(null) + } +} + +/** Install (if needed) + activate a pet. Optimistically marks it active. */ +export function adoptPet(request: GatewayRequest, slug: string, fallback: string): Promise { + return mutate(slug, fallback, request, async () => { + await petRpc(request, 'pet.select', { slug }) + patchGallery(g => ({ + ...g, + enabled: true, + active: slug, + pets: g.pets.map(p => (p.slug === slug ? { ...p, installed: true } : p)) + })) + }) +} + +/** + * Turn the floating mascot on/off. On enable, activates the current pet (or the + * first installed one). Returns false without firing if there's nothing to show. + */ +export function setPetEnabled( + request: GatewayRequest, + on: boolean, + copy: { noneAvailable: string; fallback: string } +): Promise { + const gallery = $petGallery.get() + + if (!on && !(gallery?.enabled ?? false)) { + return Promise.resolve(true) + } + + let slug = gallery?.active || '' + + if (on) { + slug = slug || gallery?.pets.find(p => p.installed)?.slug || '' + + if (!slug) { + $petGalleryError.set(copy.noneAvailable) + + return Promise.resolve(false) + } + } + + return mutate(on ? TOGGLE_ON : TOGGLE_OFF, copy.fallback, request, async () => { + if (on) { + await petRpc(request, 'pet.select', { slug }) + } else { + await petRpc(request, 'pet.disable') + } + + patchGallery(g => ({ ...g, enabled: on, active: on ? slug : g.active })) + }) +} + +// Pet scale bounds — mirror `agent/pet/constants.py` (MIN_SCALE / MAX_SCALE) so +// the slider and the server clamp to the same range. +export const PET_SCALE_MIN = 0.1 +export const PET_SCALE_MAX = 3.0 +export const PET_SCALE_DEFAULT = 0.33 +export const clampPetScale = (n: number) => Math.max(PET_SCALE_MIN, Math.min(PET_SCALE_MAX, n)) + +let scalePersist: ReturnType | undefined + +/** + * Resize the floating pet. Updates `$petInfo` synchronously so the on-screen pet + * (and the slider) react on the same frame, then debounce-persists to + * `display.pet.scale` so a slider drag fires one RPC, not one per pixel. No poll + * or event needed — the pet already renders from `$petInfo.scale`. + */ +export function setPetScale(request: GatewayRequest, scale: number): void { + const next = clampPetScale(scale) + + setPetInfo({ ...$petInfo.get(), scale: next }) + + clearTimeout(scalePersist) + scalePersist = setTimeout(() => { + petRpc<{ ok: boolean; scale?: number }>(request, 'pet.scale', { scale: next }) + .then(result => { + // Reconcile with the server's clamp (cheap; only matters at the bounds). + if (typeof result?.scale === 'number' && result.scale !== $petInfo.get().scale) { + setPetInfo({ ...$petInfo.get(), scale: result.scale }) + } + }) + .catch(() => { + // Cosmetic — the pet already resized; persistence self-heals next write. + }) + }, 200) +} + +/** Uninstall a pet; turns the mascot off if it was the active one. */ +export function removePet(request: GatewayRequest, slug: string, fallback: string): Promise { + return mutate(slug, fallback, request, async () => { + await petRpc(request, 'pet.remove', { slug }) + patchGallery(g => ({ + ...g, + enabled: g.active === slug ? false : g.enabled, + pets: g.pets.map(p => (p.slug === slug ? { ...p, installed: false } : p)) + })) + }) +} diff --git a/apps/desktop/src/store/pet-overlay.ts b/apps/desktop/src/store/pet-overlay.ts new file mode 100644 index 00000000000..3fda5e83b3c --- /dev/null +++ b/apps/desktop/src/store/pet-overlay.ts @@ -0,0 +1,260 @@ +import { atom } from 'nanostores' + +import { persistBoolean, persistString, storedBoolean, storedString } from '@/lib/storage' +import { $petActivity, $petInfo, $petUnread, clearPetUnread, type PetActivity, type PetInfo } from '@/store/pet' +import { $awaitingResponse, $busy } from '@/store/session' + +/** + * Controller for the pop-out pet overlay (main-renderer side). + * + * Shift-clicking the in-window pet "pops it out" into a transparent, + * always-on-top OS window (created in electron/main.cjs) that can leave the + * app's bounds and stays visible while Hermes is minimized. That window carries + * NO gateway connection — this renderer remains the single source of truth and + * pushes the live pet state to it over IPC. Control flows back (pop the pet back + * in, submit a composer message) via `onControl`. + * + * The overlay renders the same `PetSprite` / `PetBubble` as the in-window pet by + * mirroring the four reactive inputs of `$petState` (`$petInfo`, `$petActivity`, + * `$busy`, `$awaitingResponse`) into its own copies of those atoms — so the + * popped-out mascot is pixel-identical and needs zero bespoke render logic. + */ + +export interface PetOverlayBounds { + x: number + y: number + width: number + height: number +} + +/** + * Request to open the overlay window. `screen` says whether `bounds` are already + * in absolute screen coordinates (a remembered/dragged spot) or in the main + * window's viewport space (a fresh shift-click pop-out, which main.cjs converts + * by adding the content origin). + */ +export interface PetOverlayOpenRequest { + bounds: PetOverlayBounds + screen?: boolean +} + +/** Everything the overlay needs to reproduce the live mascot. */ +export interface PetOverlayStatePayload { + info: PetInfo + activity: PetActivity + busy: boolean + awaiting: boolean + /** Drives the overlay's mail icon: a finish landed while you were away. */ + unread: boolean +} + +export type PetOverlayControl = + | { type: 'pop-in' } + | { type: 'ready' } + | { type: 'submit'; text: string } + | { type: 'bounds'; bounds: PetOverlayBounds } + | { type: 'open-app' } + | { type: 'toggle-app' } + +// Persisted across restarts: was the pet popped out, and where on the desktop +// did the user leave it. Keyed v1; bump if the bounds shape ever changes. +const OVERLAY_ACTIVE_KEY = 'hermes.desktop.pet-overlay-active.v1' +const OVERLAY_BOUNDS_KEY = 'hermes.desktop.pet-overlay-bounds.v1' + +export const $petOverlayActive = atom(storedBoolean(OVERLAY_ACTIVE_KEY, false)) + +// Persist the in/out choice so a popped-out pet comes back popped out. +$petOverlayActive.subscribe(active => persistBoolean(OVERLAY_ACTIVE_KEY, active)) + +function loadSavedBounds(): null | PetOverlayBounds { + try { + const raw = storedString(OVERLAY_BOUNDS_KEY) + + if (!raw) { + return null + } + + const parsed = JSON.parse(raw) as Partial + + if ( + typeof parsed.x === 'number' && + typeof parsed.y === 'number' && + typeof parsed.width === 'number' && + typeof parsed.height === 'number' + ) { + return { height: parsed.height, width: parsed.width, x: parsed.x, y: parsed.y } + } + } catch { + // fall through to null + } + + return null +} + +function saveBounds(bounds: PetOverlayBounds): void { + persistString(OVERLAY_BOUNDS_KEY, JSON.stringify(bounds)) +} + +// The overlay window is padded around the sprite so the bubble (above), the +// drag area, and the pop-up composer all have room; the pet sits near the +// bottom and the rest of the rectangle is transparent + click-through. +const OVERLAY_PAD_X = 100 +const OVERLAY_PAD_Y = 200 +const OVERLAY_MIN_W = 240 +const OVERLAY_MIN_H = 300 + +let stateUnsubs: Array<() => void> = [] +let controlUnsub: (() => void) | null = null +let submitHandler: ((text: string) => void) | null = null +let openAppHandler: (() => void) | null = null + +function currentPayload(): PetOverlayStatePayload { + return { + info: $petInfo.get(), + activity: $petActivity.get(), + busy: $busy.get(), + awaiting: $awaitingResponse.get(), + unread: $petUnread.get() + } +} + +function pushNow(): void { + window.hermesDesktop?.petOverlay?.pushState(currentPayload()) +} + +/** + * Open the overlay window and start mirroring live state into it. The main + * process echoes back the actual screen bounds it used, which we persist so the + * pet reopens exactly where the user left it. + */ +function openOverlay(request: PetOverlayOpenRequest): void { + const api = window.hermesDesktop?.petOverlay + + if (!api || stateUnsubs.length) { + return + } + + $petOverlayActive.set(true) + void api.open(request).then(res => { + if (res?.bounds) { + saveBounds(res.bounds) + } + + pushNow() + }) + + // Mirror live state into the overlay. subscribe() fires immediately, so the + // overlay also gets a first frame the moment it's ready (it asks via 'ready'). + stateUnsubs = [ + $petInfo.subscribe(pushNow), + $petActivity.subscribe(pushNow), + $busy.subscribe(pushNow), + $awaitingResponse.subscribe(pushNow), + $petUnread.subscribe(pushNow) + ] +} + +/** + * Pop the pet out of the window. `petRect` is the in-window sprite's viewport + * rect; we grow it to the padded overlay size and center the window on the + * pet's old spot (main.cjs adds the window's screen origin). If the user has + * popped out before, reopen at that remembered desktop spot instead. + */ +export function popOutPet(petRect: PetOverlayBounds): void { + if ($petOverlayActive.get() || stateUnsubs.length) { + return + } + + const saved = loadSavedBounds() + + if (saved) { + openOverlay({ bounds: saved, screen: true }) + + return + } + + const width = Math.max(OVERLAY_MIN_W, Math.round(petRect.width + OVERLAY_PAD_X)) + const height = Math.max(OVERLAY_MIN_H, Math.round(petRect.height + OVERLAY_PAD_Y)) + const x = Math.round(petRect.x - (width - petRect.width) / 2) + const y = Math.round(petRect.y - (height - petRect.height) / 2) + + openOverlay({ bounds: { height, width, x, y }, screen: false }) +} + +/** + * Restore the overlay on boot if the pet was popped out when the app last + * closed. Requires a remembered desktop spot — without one we fall back to the + * in-window pet rather than spawning an orphan window at the origin. + */ +export function restorePetOverlay(): void { + if (!window.hermesDesktop?.petOverlay || !$petOverlayActive.get() || stateUnsubs.length) { + return + } + + const saved = loadSavedBounds() + + if (!saved) { + $petOverlayActive.set(false) + + return + } + + openOverlay({ bounds: saved, screen: true }) +} + +/** Pop the pet back into the window (closes the overlay window). */ +export function popInPet(): void { + for (const off of stateUnsubs) { + off() + } + + stateUnsubs = [] + $petOverlayActive.set(false) + void window.hermesDesktop?.petOverlay?.close() +} + +/** Register the handler that turns an overlay composer submit into a real send. */ +export function setPetOverlaySubmitHandler(fn: ((text: string) => void) | null): void { + submitHandler = fn +} + +/** Register the handler that opens the app to the most recent thread (mail icon). */ +export function setPetOverlayOpenAppHandler(fn: (() => void) | null): void { + openAppHandler = fn +} + +/** + * Wire the overlay→renderer control channel once. Returns a disposer. Idempotent + * — a second call while already wired is a no-op. + */ +export function initPetOverlayBridge(): () => void { + const api = window.hermesDesktop?.petOverlay + + if (!api || controlUnsub) { + return () => {} + } + + controlUnsub = api.onControl(payload => { + if (payload?.type === 'pop-in') { + popInPet() + } else if (payload?.type === 'ready') { + // The overlay just mounted — hand it the current frame. + pushNow() + } else if (payload?.type === 'submit' && typeof payload.text === 'string') { + submitHandler?.(payload.text) + } else if (payload?.type === 'bounds' && payload.bounds) { + // The user dragged the overlay to a new desktop spot — remember it. + saveBounds(payload.bounds) + } else if (payload?.type === 'open-app') { + // Mail icon: surface the app on the most recent thread (main.cjs already + // focused the window before forwarding this) and mark it read. + clearPetUnread() + openAppHandler?.() + } + }) + + return () => { + controlUnsub?.() + controlUnsub = null + } +} diff --git a/apps/desktop/src/store/pet.test.ts b/apps/desktop/src/store/pet.test.ts new file mode 100644 index 00000000000..2837334ab37 --- /dev/null +++ b/apps/desktop/src/store/pet.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' + +import { $petActivity, $petState, derivePetState, flashPetActivity, setPetActivity } from './pet' + +describe('derivePetState', () => { + it('rests at idle by default and uses waiting when awaiting input', () => { + expect(derivePetState({})).toBe('idle') + expect(derivePetState({ awaitingInput: true })).toBe('waiting') + }) + + it('runs when busy or a tool is executing', () => { + expect(derivePetState({ busy: true })).toBe('run') + expect(derivePetState({ toolRunning: true })).toBe('run') + }) + + it('reviews while reasoning (below tool, above bare busy)', () => { + expect(derivePetState({ reasoning: true })).toBe('review') + expect(derivePetState({ reasoning: true, busy: true })).toBe('review') + expect(derivePetState({ reasoning: true, toolRunning: true })).toBe('run') + }) + + it('waits (blocked on the user) above the in-flight signals', () => { + expect(derivePetState({ awaitingInput: true, toolRunning: true, busy: true })).toBe('waiting') + // but a finish beat still wins over waiting + expect(derivePetState({ justCompleted: true, awaitingInput: true })).toBe('wave') + }) + + it('honors the full priority chain: error > celebrate > complete > tool', () => { + expect(derivePetState({ error: true, celebrate: true, busy: true })).toBe('failed') + expect(derivePetState({ celebrate: true, justCompleted: true, toolRunning: true })).toBe('jump') + expect(derivePetState({ justCompleted: true, toolRunning: true })).toBe('wave') + }) +}) + +describe('flashPetActivity', () => { + it('clears stale sibling beats so a completion never inherits a prior error', () => { + // A turn errors (sad), then the next turn finishes cleanly. The celebrate + // beat must win — error is highest priority, so a merge-only flash would + // keep the pet on the failed pose. + setPetActivity({ error: true }) + flashPetActivity({ celebrate: true }) + + expect($petActivity.get().error).toBe(false) + expect($petState.get()).toBe('jump') + + setPetActivity({}) + }) +}) diff --git a/apps/desktop/src/store/pet.ts b/apps/desktop/src/store/pet.ts new file mode 100644 index 00000000000..e4863f45712 --- /dev/null +++ b/apps/desktop/src/store/pet.ts @@ -0,0 +1,160 @@ +import { atom, computed } from 'nanostores' + +import { $activeGatewayProfile, normalizeProfileKey } from '@/store/profile' +import { $busy } from '@/store/session' + +/** + * Petdex mascot state for the desktop floating pet. + * + * The spritesheet payload comes from the gateway `pet.info` RPC (shared with + * the TUI). The animation *state* is derived here from the same activity + * signals the chat already tracks, mirroring the priority order documented in + * `agent/pet/state.py` so the Python and TS surfaces never drift. + */ + +export type PetState = 'idle' | 'wave' | 'run' | 'failed' | 'review' | 'jump' | 'waiting' + +export interface PetInfo { + enabled: boolean + slug?: string + displayName?: string + mime?: string + spritesheetBase64?: string + frameW?: number + frameH?: number + framesPerState?: number + // Real (padding-trimmed) frame count per state row, from the engine. Lets the + // canvas step only frames that exist instead of a fixed framesPerState, which + // would animate into the transparent padding of ragged sheets (blank flash). + framesByState?: Record + loopMs?: number + scale?: number + stateRows?: string[] +} + +export interface PetActivity { + busy?: boolean + awaitingInput?: boolean + toolRunning?: boolean + reasoning?: boolean + error?: boolean + justCompleted?: boolean + celebrate?: boolean +} + +/** + * Resolve the animation state from coarse activity signals. + * + * Priority (highest first) mirrors `agent.pet.state.derive_pet_state`: + * error → celebrate → justCompleted → awaitingInput → toolRunning → reasoning → + * busy → idle. `awaitingInput` (a clarify/approval blocking on the user) outranks + * the in-flight signals because the turn is paused on you, not working. + */ +export function derivePetState(activity: PetActivity): PetState { + if (activity.error) { + return 'failed' + } + + if (activity.celebrate) { + return 'jump' + } + + if (activity.justCompleted) { + return 'wave' + } + + if (activity.awaitingInput) { + return 'waiting' + } + + if (activity.toolRunning) { + return 'run' + } + + if (activity.reasoning) { + return 'review' + } + + if (activity.busy) { + return 'run' + } + + return 'idle' +} + +export const $petInfo = atom({ enabled: false }) +export const $petActivity = atom({}) + +/** + * Profile the pet RPCs should resolve against. Pets are per-profile — the active + * pet (`display.pet.*`) and the installed sprites live under each profile's + * HERMES_HOME — so every pet RPC carries this. The gateway no-ops it for the + * launch profile (own-profile backends already resolve it) and rebinds for any + * other profile, which is what makes per-profile pets work in app-global remote + * mode (one backend serving every profile). + */ +export function petProfile(): string { + return normalizeProfileKey($activeGatewayProfile.get()) +} + +/** + * Pet-local "you have a new message" flag, surfaced as the overlay's mail icon. + * Deliberately not real unread tracking: it flips on when a turn finishes while + * the app isn't focused, and off when the user opens the app via the mail icon + * (or returns to the window). No persistence — it's a glance hint, not state. + */ +export const $petUnread = atom(false) +export const markPetUnread = () => $petUnread.set(true) +export const clearPetUnread = () => $petUnread.set(false) + +/** Steady activity flags (toolRunning / reasoning) set + cleared by the stream. */ +export const setPetActivity = (next: Partial) => + $petActivity.set({ ...$petActivity.get(), ...next }) + +let flashTimer: ReturnType | undefined + +/** Fire a transient reaction beat (error / celebrate / justCompleted) that + * decays back to the steady state after `ms`. + * + * Each beat first clears its siblings so a stale one can't win the priority + * race: without this, a completion beat (`celebrate`) would merge on top of a + * lingering `error`, and `derivePetState` checks `error` first — so a clean + * finish would render the sad/failed pose. */ +export const flashPetActivity = (next: Partial, ms = 1600) => { + setPetActivity({ celebrate: false, error: false, justCompleted: false, ...next }) + clearTimeout(flashTimer) + flashTimer = setTimeout( + () => setPetActivity({ celebrate: false, error: false, justCompleted: false }), + ms + ) +} + +export const setPetInfo = (info: PetInfo) => $petInfo.set(info) + +/** + * The live pet state. Derives from the dedicated activity atom, falling back to + * the always-present `$busy` chat signal so the pet reacts out of the box. + * + * `awaitingInput` (a clarify/approval blocking on the user) is an explicit flag + * on `$petActivity` — set by the controller from `$attentionSessionIds` and + * mirrored to the pop-out overlay through the same atom, so both surfaces agree + * without the overlay needing the session list. + */ +export const $petState = computed( + [$petActivity, $busy], + (activity, busy): PetState => { + const live = activity.busy ?? busy + + return derivePetState({ + busy: live, + awaitingInput: activity.awaitingInput, + // Steady flags only count mid-turn — ignore stale ones once at rest so an + // interrupted turn can't pin the pet on `run`/`review`. + toolRunning: live && activity.toolRunning, + reasoning: live && activity.reasoning, + error: activity.error, + justCompleted: activity.justCompleted, + celebrate: activity.celebrate + }) + } +) From 6fd839ac84d0c5032977271e8b2b57a2539dbd4f Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 20 Jun 2026 14:18:43 -0500 Subject: [PATCH 5/6] docs(pets): feature guide, petdex skill + catalog Add the pets feature guide and the petdex skill (SKILL.md + bundled doc), and register them in the website sidebar and skills catalog. --- skills/productivity/petdex/SKILL.md | 89 ++++++++ website/docs/reference/skills-catalog.md | 1 + website/docs/user-guide/features/pets.md | 190 ++++++++++++++++++ .../productivity/productivity-petdex.md | 105 ++++++++++ website/sidebars.ts | 2 + 5 files changed, 387 insertions(+) create mode 100644 skills/productivity/petdex/SKILL.md create mode 100644 website/docs/user-guide/features/pets.md create mode 100644 website/docs/user-guide/skills/bundled/productivity/productivity-petdex.md diff --git a/skills/productivity/petdex/SKILL.md b/skills/productivity/petdex/SKILL.md new file mode 100644 index 00000000000..416e0c6c2ca --- /dev/null +++ b/skills/productivity/petdex/SKILL.md @@ -0,0 +1,89 @@ +--- +name: petdex +description: Install and select animated petdex mascots for Hermes. +version: 1.0.0 +author: Hermes Agent +license: MIT +platforms: [linux, macos, windows] +metadata: + hermes: + tags: [petdex, mascot, display, cli, tui, desktop] + category: productivity + homepage: https://petdex.dev +--- + +# Petdex Skill + +Browse, install, and select animated "pet" mascots from the public +[petdex](https://github.com/crafter-station/petdex) gallery. An installed pet +reacts to agent activity (idle, running a tool, reviewing, error, done) across +the Hermes CLI, TUI, and desktop app. This skill drives the `hermes pets` CLI +and the `display.pet` config — it does not generate sprites. + +## When to Use + +- The user wants a desktop/terminal mascot or asks about "pets" / petdex. +- The user wants to change, preview, or disable the active pet. +- Diagnosing why a pet isn't showing (terminal graphics support, config). + +## Prerequisites + +- Network access to `petdex.dev` for the gallery/manifest (read-only, no auth). +- Pillow (a core Hermes dependency) for sprite decoding — already installed. +- For full-fidelity terminal rendering: a graphics-capable terminal (kitty, + Ghostty, WezTerm, iTerm2, or sixel). Otherwise a truecolor Unicode + half-block fallback is used automatically. + +## How to Run + +Use the `terminal` tool to run `hermes pets `. + +## Quick Reference + +| Goal | Command | +| --- | --- | +| Browse the gallery | `hermes pets list` (add a substring to filter: `hermes pets list cat`) | +| List installed pets | `hermes pets list --installed` | +| Install a pet | `hermes pets install ` (add `--select` to make it active) | +| Set the active pet | `hermes pets select ` (omit slug for a picker) | +| Resize the pet everywhere | `hermes pets scale ` (e.g. `0.5`, clamped 0.1–3.0) | +| Preview/animate in terminal | `hermes pets show [slug] [--cycle] [--state run]` | +| Disable the pet | `hermes pets off` | +| Remove a pet | `hermes pets remove ` | +| Diagnose setup | `hermes pets doctor` | + +## Procedure + +1. Find a pet: `hermes pets list ` and note its `slug`. +2. Install + activate: `hermes pets install --select`. +3. Preview it: `hermes pets show` (Ctrl+C to stop). +4. Confirm setup: `hermes pets doctor` — shows the resolved pet, configured + render mode, detected terminal graphics protocol, and effective mode. + +Pets install into `/pets//` (profile-aware). Selecting a pet +writes `display.pet.slug` + `display.pet.enabled` to `config.yaml`. + +## Configuration + +Under `display.pet` in `config.yaml`: + +- `enabled` (bool) — master on/off. +- `slug` (str) — active pet; empty = first installed. +- `render_mode` — `auto` (detect) | `kitty` | `iterm` | `sixel` | `unicode` | `off`. +- `scale` (float) — on-screen size of the native 192×208 frames (default 0.33, + clamped 0.1–3.0). One knob resizes every surface; set it with + `hermes pets scale `, the `/pet scale` slash command, or the desktop + Appearance slider. +- `unicode_cols` (int) — width in columns for the Unicode fallback. + +## Pitfalls + +- A pet only shows once one is installed AND selected (`enabled: true`). +- Inside a pipe/redirect (no TTY) terminal rendering is disabled by design. +- The petdex npm CLI installs to `~/.codex/pets`; Hermes uses its own + profile-scoped `/pets/` instead — install through `hermes pets`. + +## Verification + +- `hermes pets doctor` reports `✓ ready` when a pet is installed, selected, + enabled, and Pillow is importable. diff --git a/website/docs/reference/skills-catalog.md b/website/docs/reference/skills-catalog.md index 5ccb1f5f5ca..09bb32ee472 100644 --- a/website/docs/reference/skills-catalog.md +++ b/website/docs/reference/skills-catalog.md @@ -125,6 +125,7 @@ If a skill is missing from this list but present in the repo, the catalog is reg | [`nano-pdf`](/docs/user-guide/skills/bundled/productivity/productivity-nano-pdf) | Edit PDF text/typos/titles via nano-pdf CLI (NL prompts). | `productivity/nano-pdf` | | [`notion`](/docs/user-guide/skills/bundled/productivity/productivity-notion) | Notion API + ntn CLI: pages, databases, markdown, Workers. | `productivity/notion` | | [`ocr-and-documents`](/docs/user-guide/skills/bundled/productivity/productivity-ocr-and-documents) | Extract text from PDFs/scans (pymupdf, marker-pdf). | `productivity/ocr-and-documents` | +| [`petdex`](/docs/user-guide/skills/bundled/productivity/productivity-petdex) | Install and select animated petdex mascots for Hermes. | `productivity/petdex` | | [`powerpoint`](/docs/user-guide/skills/bundled/productivity/productivity-powerpoint) | Create, read, edit .pptx decks, slides, notes, templates. | `productivity/powerpoint` | | [`teams-meeting-pipeline`](/docs/user-guide/skills/bundled/productivity/productivity-teams-meeting-pipeline) | Operate the Teams meeting summary pipeline via Hermes CLI — summarize meetings, inspect pipeline status, replay jobs, manage Microsoft Graph subscriptions. | `productivity/teams-meeting-pipeline` | diff --git a/website/docs/user-guide/features/pets.md b/website/docs/user-guide/features/pets.md new file mode 100644 index 00000000000..bd23c41f9ee --- /dev/null +++ b/website/docs/user-guide/features/pets.md @@ -0,0 +1,190 @@ +--- +sidebar_position: 11 +title: "Pets (Petdex Mascots)" +description: "Adopt an animated mascot that reacts to agent activity across the CLI, TUI, and desktop app" +--- + +# Pets + +Hermes can show an animated **pet** — a small mascot sprite that reacts to what +the agent is doing (idle, running a tool, thinking, finishing, failing) across +the **CLI**, **TUI**, and **desktop app**. Pets come from the public +[petdex](https://github.com/crafter-station/petdex) gallery. + +Pets are purely cosmetic. They have **no effect on prompt caching, tokens, or +the agent's behavior** — the sprite is a display concern only. The feature is +**off by default** and stays dormant until you install and select a pet. + +## How it works + +- Pets are installed into your profile's `pets/` directory + (`/pets//`), so each [profile](../profiles.md) keeps its + own set. +- Selecting a pet writes `display.pet.slug` and `display.pet.enabled` to + `config.yaml` — nothing is stored as a secret or env var. +- Each surface watches the activity it already tracks and maps it to one of six + animation states. The mapping lives in one place so every surface behaves the + same: + + | Agent activity | Pet state | + | --- | --- | + | A tool/turn just failed | `failed` | + | A plan finished (all todos done) | `jump` (celebrate) | + | A turn finished cleanly | `wave` | + | A tool is executing | `run` | + | The model is thinking/reading | `review` | + | Turn in flight (unspecified) | `run` | + | Blocked on you (a clarify/approval prompt is open) | `waiting` (falls back to `idle` on legacy 8-row sheets) | + | Nothing happening | `idle` | + +## Rendering + +In the terminal (CLI/TUI), Hermes renders the sprite at full fidelity when your +terminal supports a graphics protocol (**kitty**, **Ghostty**, **WezTerm**, +**iTerm2**, or **sixel**). Otherwise it falls back automatically to a truecolor +Unicode **half-block** rendering. Inside a pipe or redirect (no TTY), terminal +rendering is disabled by design. + +The desktop app draws the pet as a floating sprite on a canvas and toggles it +from **Settings → Appearance**. + +## Quick start (CLI) + +```bash +# Browse the gallery (filter by substring) +hermes pets list +hermes pets list cat + +# Install a pet and make it active in one step +hermes pets install boba --select + +# Preview / animate it in your terminal (Ctrl+C to stop) +hermes pets show + +# Check your setup +hermes pets doctor +``` + +## `hermes pets` commands + +| Goal | Command | +| --- | --- | +| Browse the gallery | `hermes pets list [query] [--limit N]` | +| List installed pets | `hermes pets list --installed` | +| Install a pet | `hermes pets install [--select] [--force]` | +| Set the active pet | `hermes pets select [slug]` (omit slug for a picker) | +| Resize the pet everywhere | `hermes pets scale ` (e.g. `0.5`, clamped 0.1–3.0) | +| Preview/animate | `hermes pets show [slug] [--state ] [--cycle] [--once] [--mode ] [--scale ]` | +| Disable the pet | `hermes pets off` | +| Remove an installed pet | `hermes pets remove ` | +| Diagnose setup | `hermes pets doctor` | + +`hermes pets show` flags: + +- `--state` — play a single state (`idle`, `wave`, `run`, `failed`, `review`, + `jump`). +- `--cycle` — cycle through every state. +- `--once` — play once instead of looping. +- `--mode` — override the render protocol (`kitty`, `iterm`, `sixel`, + `unicode`, `auto`). +- `--scale` — override the on-screen scale (`0` = use config). + +## `/pet` slash command + +Inside the CLI and TUI you can manage the pet without leaving the session: + +- `/pet` — toggle the pet on/off (adopts the first installed pet if none is + active). +- `/pet list` — browse the gallery. +- `/pet scale ` — resize the pet everywhere (e.g. `/pet scale 0.5`). +- `/pet ` — adopt a specific pet. +- `/pet off` — disable the pet. + +In the TUI, `/pet list` opens an interactive picker overlay; in the desktop app +it opens the Cmd+K pet palette. + +## Desktop app + +In the desktop app you can manage the pet two ways: + +- **Cmd+K → "Pets…"** — browse, search, adopt, and toggle pets without leaving + the keyboard (mirrors the theme picker). +- **Settings → Appearance** — the same gallery plus a **size slider** that + resizes the floating mascot live as you drag. + +Both adopt/toggle/resize the floating mascot in place — size changes apply +instantly; adopting a new pet lights it up within a moment. + +### Pop-out overlay + +**Shift-click** the floating pet to pop it out into its own transparent, +always-on-top desktop window. Out there it stays visible while Hermes is +minimized (Codex-style), so a glance tells you what the agent is doing. + +Gestures once it's popped out: + +| Gesture | Action | +| --- | --- | +| **Drag** | Move the pet anywhere on screen, even outside the app. Its spot and in/out state persist across restarts. | +| **Single-click** | Open a mini composer to send a prompt to the most recent session — without surfacing the app. | +| **Double-click** | Toggle the app window: minimize it if it's up front, restore it if it's hidden. | +| **Shift-click** | Pop the pet back into the window. | +| **Mail icon** | Appears only when a turn finished while you were away; click to raise the app on the most recent thread (and mark it read). | + +Only the popped-out pet shows a **speech bubble** (`working…`, `thinking…`, +`your turn`, …) — in-window the app itself is the surface, so the pet stays +quiet there. + +The overlay is a pure puppet of the in-app pet — it carries no separate gateway +connection and never appears in the dock or app switcher. + +## Configuration + +All settings live under `display.pet` in `config.yaml`: + +```yaml +display: + pet: + enabled: false # master on/off (true once you select a pet) + slug: "" # active pet; empty = first installed + render_mode: auto # auto | kitty | iterm | sixel | unicode | off + scale: 0.33 # master size knob (relative to native 192x208 frames) + unicode_cols: 0 # hard override for terminal width (0 = derive from scale) +``` + +- **`scale`** is the single master size knob. One number shrinks every surface: + the desktop canvas scales its pixels by it, and the CLI/TUI derive their + terminal column width from it. The half-block fallback clamps to a legibility + floor — it can't shrink as far as true-pixel kitty/GUI rendering without + turning to mush, so the same `scale` looks crisp under kitty but is floored in + half-blocks. +- **`render_mode: auto`** detects kitty/iTerm2/sixel and falls back to unicode + half-blocks. Set it explicitly to force a protocol or `off` to disable + terminal rendering while keeping the pet on the desktop. +- **`unicode_cols`** pins the terminal column width independently of `scale`; + leave it at `0` to derive width from `scale`. + +## Troubleshooting + +Run `hermes pets doctor` — it reports: + +- the pets directory and which pets are installed, +- `display.pet.enabled`, `display.pet.slug`, and the resolved active pet, +- the configured `render_mode`, the detected terminal graphics protocol, and the + effective mode for a TTY, +- whether Pillow (used for sprite decoding) is importable. + +It prints `✓ ready` once a pet is installed, selected, enabled, and Pillow is +available. + +Common gotchas: + +- A pet only shows once one is **installed AND selected** (`enabled: true`). +- Inside a pipe/redirect (no TTY), terminal rendering is disabled by design. +- The petdex npm CLI installs to `~/.codex/pets`; Hermes uses its own + profile-scoped `/pets/` instead — install through `hermes pets`. + +## See also + +- The [`petdex` skill](../skills/bundled/productivity/productivity-petdex.md) + lets the agent install and switch pets for you on request. diff --git a/website/docs/user-guide/skills/bundled/productivity/productivity-petdex.md b/website/docs/user-guide/skills/bundled/productivity/productivity-petdex.md new file mode 100644 index 00000000000..56ed48d0886 --- /dev/null +++ b/website/docs/user-guide/skills/bundled/productivity/productivity-petdex.md @@ -0,0 +1,105 @@ +--- +title: "Petdex — Install and select animated petdex mascots for Hermes" +sidebar_label: "Petdex" +description: "Install and select animated petdex mascots for Hermes" +--- + +{/* This page is auto-generated from the skill's SKILL.md by website/scripts/generate-skill-docs.py. Edit the source SKILL.md, not this page. */} + +# Petdex + +Install and select animated petdex mascots for Hermes. + +## Skill metadata + +| | | +|---|---| +| Source | Bundled (installed by default) | +| Path | `skills/productivity/petdex` | +| Version | `1.0.0` | +| Author | Hermes Agent | +| License | MIT | +| Platforms | linux, macos, windows | +| Tags | `petdex`, `mascot`, `display`, `cli`, `tui`, `desktop` | + +## Reference: full SKILL.md + +:::info +The following is the complete skill definition that Hermes loads when this skill is triggered. This is what the agent sees as instructions when the skill is active. +::: + +# Petdex Skill + +Browse, install, and select animated "pet" mascots from the public +[petdex](https://github.com/crafter-station/petdex) gallery. An installed pet +reacts to agent activity (idle, running a tool, reviewing, error, done) across +the Hermes CLI, TUI, and desktop app. This skill drives the `hermes pets` CLI +and the `display.pet` config — it does not generate sprites. + +## When to Use + +- The user wants a desktop/terminal mascot or asks about "pets" / petdex. +- The user wants to change, preview, or disable the active pet. +- Diagnosing why a pet isn't showing (terminal graphics support, config). + +## Prerequisites + +- Network access to `petdex.dev` for the gallery/manifest (read-only, no auth). +- Pillow (a core Hermes dependency) for sprite decoding — already installed. +- For full-fidelity terminal rendering: a graphics-capable terminal (kitty, + Ghostty, WezTerm, iTerm2, or sixel). Otherwise a truecolor Unicode + half-block fallback is used automatically. + +## How to Run + +Use the `terminal` tool to run `hermes pets `. + +## Quick Reference + +| Goal | Command | +| --- | --- | +| Browse the gallery | `hermes pets list` (add a substring to filter: `hermes pets list cat`) | +| List installed pets | `hermes pets list --installed` | +| Install a pet | `hermes pets install ` (add `--select` to make it active) | +| Set the active pet | `hermes pets select ` (omit slug for a picker) | +| Resize the pet everywhere | `hermes pets scale ` (e.g. `0.5`, clamped 0.1–3.0) | +| Preview/animate in terminal | `hermes pets show [slug] [--cycle] [--state run]` | +| Disable the pet | `hermes pets off` | +| Remove a pet | `hermes pets remove ` | +| Diagnose setup | `hermes pets doctor` | + +## Procedure + +1. Find a pet: `hermes pets list ` and note its `slug`. +2. Install + activate: `hermes pets install --select`. +3. Preview it: `hermes pets show` (Ctrl+C to stop). +4. Confirm setup: `hermes pets doctor` — shows the resolved pet, configured + render mode, detected terminal graphics protocol, and effective mode. + +Pets install into `/pets//` (profile-aware). Selecting a pet +writes `display.pet.slug` + `display.pet.enabled` to `config.yaml`. + +## Configuration + +Under `display.pet` in `config.yaml`: + +- `enabled` (bool) — master on/off. +- `slug` (str) — active pet; empty = first installed. +- `render_mode` — `auto` (detect) | `kitty` | `iterm` | `sixel` | `unicode` | `off`. +- `scale` (float) — on-screen size of the native 192×208 frames (default 0.33, + clamped 0.1–3.0). One knob resizes every surface; set it with + `hermes pets scale `, the `/pet scale` slash command, or the desktop + Appearance slider. +- `unicode_cols` (int) — width in columns for the Unicode fallback. + +## Pitfalls + +- A pet only shows once one is installed AND selected (`enabled: true`). +- Inside a pipe/redirect (no TTY) terminal rendering is disabled by design. +- The petdex npm CLI installs to `~/.codex/pets`; Hermes uses its own + profile-scoped `/pets/` instead — install through `hermes pets`. + +## Verification + +- `hermes pets doctor` reports `✓ ready` when a pet is installed, selected, + enabled, and Pillow is importable. diff --git a/website/sidebars.ts b/website/sidebars.ts index dec160700e2..bea46c7ed56 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -102,6 +102,7 @@ const sidebars: SidebarsConfig = { 'user-guide/features/vision', 'user-guide/features/image-generation', 'user-guide/features/spotify', + 'user-guide/features/pets', 'user-guide/features/tts', 'user-guide/features/deliverable-mode', ], @@ -277,6 +278,7 @@ const sidebars: SidebarsConfig = { 'user-guide/skills/bundled/productivity/productivity-nano-pdf', 'user-guide/skills/bundled/productivity/productivity-notion', 'user-guide/skills/bundled/productivity/productivity-ocr-and-documents', + 'user-guide/skills/bundled/productivity/productivity-petdex', 'user-guide/skills/bundled/productivity/productivity-powerpoint', 'user-guide/skills/bundled/productivity/productivity-teams-meeting-pipeline', ], From 6afeea2beaa7bf14a0e3de4757b8669e68b54795 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 23 Jun 2026 19:13:08 -0500 Subject: [PATCH 6/6] harden(pets): host-pin asset downloads + sanitize slug paths install_pet now refuses spritesheet/pet.json URLs that aren't on a petdex host (matching thumbnail_png's existing _is_petdex_host guard), so a spoofed manifest can't redirect a download at an arbitrary host. Slugs are normalized to a single path segment before indexing into pets_dir(), closing a path-traversal vector in load_pet/remove_pet/install_pet. --- agent/pet/store.py | 35 +++++++++++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/agent/pet/store.py b/agent/pet/store.py index 46cc3bc9cac..8f9f8376865 100644 --- a/agent/pet/store.py +++ b/agent/pet/store.py @@ -84,9 +84,25 @@ def _resolve_spritesheet(directory: Path, meta: dict) -> Path: return directory / "spritesheet.webp" +def _safe_slug(slug: str) -> str: + """Normalize a slug to a single bare path segment. + + Pet slugs index into ``pets_dir()//`` for load/remove, so a value + carrying path separators (``../``, absolute paths) could escape the pets + directory. Strip every separator and reject ``.``/``..`` so callers can + only ever name a direct child of the pets directory. + """ + segment = Path(str(slug).strip()).name + if segment in ("", ".", ".."): + return "" + return segment + + def load_pet(slug: str) -> InstalledPet | None: """Return the :class:`InstalledPet` for *slug*, or ``None`` if absent.""" - slug = slug.strip() + slug = _safe_slug(slug) + if not slug: + return None directory = pets_dir() / slug if not directory.is_dir(): return None @@ -135,7 +151,9 @@ def install_pet(slug: str, *, force: bool = False, timeout: float = _DOWNLOAD_TI """ from agent.pet.manifest import find_entry - slug = slug.strip() + slug = _safe_slug(slug) + if not slug: + raise PetStoreError("invalid pet slug") existing = load_pet(slug) if existing and existing.exists and not force: return existing @@ -144,6 +162,12 @@ def install_pet(slug: str, *, force: bool = False, timeout: float = _DOWNLOAD_TI if entry is None: raise PetStoreError(f"pet '{slug}' is not in the petdex manifest") + # Host-pin every asset URL to petdex. The manifest is trusted (HTTPS from + # petdex.dev), but pin the asset hosts too so a compromised/spoofed manifest + # can't redirect the download at an arbitrary host. Matches thumbnail_png. + if not _is_petdex_host(entry.spritesheet_url): + raise PetStoreError(f"refusing non-petdex spritesheet host for '{slug}'") + directory = pets_dir() / slug directory.mkdir(parents=True, exist_ok=True) @@ -155,7 +179,7 @@ def install_pet(slug: str, *, force: bool = False, timeout: float = _DOWNLOAD_TI # Fetch the upstream pet.json if present; otherwise synthesize a minimal # one so the local layout is self-describing. meta: dict = {} - if entry.pet_json_url: + if entry.pet_json_url and _is_petdex_host(entry.pet_json_url): try: meta = _download_json(entry.pet_json_url, timeout=timeout) except Exception as exc: # noqa: BLE001 - non-fatal, fall back below @@ -274,7 +298,10 @@ def remove_pet(slug: str) -> bool: """Delete an installed pet directory. Returns True if anything was removed.""" import shutil - directory = pets_dir() / slug.strip() + slug = _safe_slug(slug) + if not slug: + return False + directory = pets_dir() / slug if not directory.is_dir(): return False shutil.rmtree(directory, ignore_errors=True)