feat(pets): petdex animated mascots across CLI, TUI, and desktop
Adopt an animated petdex pet that reacts to agent activity (running on tool calls, celebrating on success, sulking on errors) across all three surfaces, driven by a shared Python pet engine so the base CLI and TUI don't duplicate logic. - agent/pet/: shared engine — manifest fetch, on-disk store, state mapping, and terminal-graphics/half-block encoding. - hermes pets CLI (list/install/select/remove/off/doctor/show) + display.pet config block; petdex skill. - TUI half-block PetPane via pet.cells RPC; desktop floating mascot with a canvas renderer + an Appearance opt-in picker (install/select/remove, lazy server-cropped thumbnails). - gateway pet.* RPCs run on the worker pool so picker previews fetch concurrently instead of serializing on the reader thread.
This commit is contained in:
parent
f795513782
commit
6681bef707
51
agent/pet/__init__.py
Normal file
51
agent/pet/__init__.py
Normal file
@ -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}`` — an 8-column × 9-row grid of 192×208 px frames
|
||||
where each *row* is an animation state (idle, wave, run, failed, review,
|
||||
jump, …). The official desktop only ever renders the idle row; reacting to
|
||||
real agent activity is the value Hermes adds here.
|
||||
|
||||
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",
|
||||
]
|
||||
69
agent/pet/constants.py
Normal file
69
agent/pet/constants.py
Normal file
@ -0,0 +1,69 @@
|
||||
"""Pet sprite geometry + animation-state taxonomy.
|
||||
|
||||
These values are *constants of the petdex format*, not per-pet data — the
|
||||
real ``pet.json`` only carries ``id``/``displayName``/``description``/
|
||||
``spritesheetPath``. The official petdex web app and desktop client both
|
||||
hardcode 192×208 frames, 6 frames per state, a 1100ms loop, and a 0.7 render
|
||||
scale; we match them so installed pets animate identically.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
|
||||
# Frame geometry (pixels). A standard petdex spritesheet is a 1536×1872 grid
|
||||
# → 8 columns × 9 rows of these frames.
|
||||
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 (petdex desktop uses
|
||||
# 0.7). Surfaces may override via ``display.pet.scale``.
|
||||
DEFAULT_SCALE = 0.7
|
||||
|
||||
|
||||
class PetState(str, Enum):
|
||||
"""Animation state a pet can be shown in.
|
||||
|
||||
Values are the petdex spritesheet *row names*. Membership maps directly
|
||||
onto :data:`STATE_ROWS` (row index = position in that list).
|
||||
"""
|
||||
|
||||
IDLE = "idle"
|
||||
WAVE = "wave"
|
||||
RUN = "run"
|
||||
FAILED = "failed"
|
||||
REVIEW = "review"
|
||||
JUMP = "jump"
|
||||
|
||||
|
||||
# Row order in the spritesheet (top → bottom). Index of a state name here is
|
||||
# the pixel row it occupies: ``row_y = STATE_ROWS.index(state) * FRAME_H``.
|
||||
# ``extra1``/``extra2`` are reserved petdex rows we don't drive yet but keep so
|
||||
# row math stays correct for sheets that include them.
|
||||
STATE_ROWS: list[str] = [
|
||||
PetState.IDLE.value,
|
||||
PetState.WAVE.value,
|
||||
PetState.RUN.value,
|
||||
PetState.FAILED.value,
|
||||
PetState.REVIEW.value,
|
||||
PetState.JUMP.value,
|
||||
"extra1",
|
||||
"extra2",
|
||||
]
|
||||
|
||||
|
||||
def state_row_index(state: "PetState | str") -> int:
|
||||
"""Return the spritesheet row index for *state* (clamped, never raises)."""
|
||||
value = state.value if isinstance(state, PetState) else str(state)
|
||||
try:
|
||||
return STATE_ROWS.index(value)
|
||||
except ValueError:
|
||||
return 0 # fall back to the idle row
|
||||
105
agent/pet/manifest.py
Normal file
105
agent/pet/manifest.py
Normal file
@ -0,0 +1,105 @@
|
||||
"""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
|
||||
from dataclasses import dataclass
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MANIFEST_URL = "https://petdex.dev/api/manifest"
|
||||
|
||||
_DEFAULT_TIMEOUT = 20.0
|
||||
|
||||
|
||||
@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) -> list[ManifestEntry]:
|
||||
"""Return every approved pet from the public manifest.
|
||||
|
||||
Follows the 307 redirect to R2. Raises :class:`ManifestError` on any
|
||||
network/parse failure so callers can surface a clean message.
|
||||
"""
|
||||
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)
|
||||
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
|
||||
431
agent/pet/render.py
Normal file
431
agent/pet/render.py
Normal file
@ -0,0 +1,431 @@
|
||||
"""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, VS Code terminal).
|
||||
- ``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()
|
||||
|
||||
# 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"
|
||||
if term_program == "vscode":
|
||||
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")
|
||||
|
||||
|
||||
@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 a list of RGBA PIL frames for one state row, scaled.
|
||||
|
||||
Cached by every argument so repeated frame requests during animation are
|
||||
free. Returns ``[]`` on any decode failure.
|
||||
"""
|
||||
try:
|
||||
from PIL import Image
|
||||
|
||||
sheet = _open_sheet(Path(sheet_path))
|
||||
cols = max(1, sheet.width // frame_w)
|
||||
n = min(frames_per_state, cols)
|
||||
row = state_row_index(state_value)
|
||||
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(n):
|
||||
left = i * frame_w
|
||||
box = (left, top, left + frame_w, top + frame_h)
|
||||
frame = sheet.crop(box)
|
||||
if (scale_w, scale_h) != (frame_w, frame_h):
|
||||
frame = frame.resize((scale_w, scale_h), Image.LANCZOS)
|
||||
frames.append(frame)
|
||||
return 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 []
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# Encoders
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _png_bytes(frame) -> bytes:
|
||||
buf = io.BytesIO()
|
||||
frame.save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
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).
|
||||
|
||||
Splits the base64 PNG into ≤4096-byte chunks per the protocol, using
|
||||
``a=T`` (transmit & display at the cursor). ``c``/``r`` request a display
|
||||
box in terminal cells so successive frames overwrite the same area.
|
||||
"""
|
||||
data = base64.standard_b64encode(_png_bytes(frame)).decode("ascii")
|
||||
extra = "f=100,a=T,q=2"
|
||||
if cell_cols:
|
||||
extra += f",c={cell_cols}"
|
||||
if cell_rows:
|
||||
extra += f",r={cell_rows}"
|
||||
|
||||
chunk = 4096
|
||||
out: list[str] = []
|
||||
if len(data) <= chunk:
|
||||
out.append(f"\x1b_G{extra},m=0;{data}\x1b\\")
|
||||
else:
|
||||
first = data[:chunk]
|
||||
out.append(f"\x1b_G{extra},m=1;{first}\x1b\\")
|
||||
rest = data[chunk:]
|
||||
while rest:
|
||||
piece, rest = rest[:chunk], rest[chunk:]
|
||||
more = 1 if rest else 0
|
||||
out.append(f"\x1b_Gm={more};{piece}\x1b\\")
|
||||
return "".join(out)
|
||||
|
||||
|
||||
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 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)]
|
||||
|
||||
# Display box in cells for graphics protocols (≈ scaled px / cell size,
|
||||
# assuming a ~8×16 cell; terminals re-fit anyway).
|
||||
cell_cols = max(1, frame.width // 8)
|
||||
cell_rows = max(1, frame.height // 16)
|
||||
|
||||
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,
|
||||
)
|
||||
59
agent/pet/state.py
Normal file
59
agent/pet/state.py
Normal file
@ -0,0 +1,59 @@
|
||||
"""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 agent.pet.constants import PetState
|
||||
|
||||
|
||||
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. ``tool_running`` → ``RUN`` (a tool is executing)
|
||||
5. ``reasoning`` → ``REVIEW`` (model is thinking / reading)
|
||||
6. ``busy`` → ``RUN`` (turn in flight, unspecified work)
|
||||
7. otherwise → ``IDLE`` (incl. ``awaiting_input``)
|
||||
|
||||
``awaiting_input`` is accepted for symmetry with the surfaces but maps to
|
||||
``IDLE`` — a pet waiting on the user should rest, not run.
|
||||
"""
|
||||
if error:
|
||||
return PetState.FAILED
|
||||
if celebrate:
|
||||
return PetState.JUMP
|
||||
if just_completed:
|
||||
return PetState.WAVE
|
||||
if tool_running:
|
||||
return PetState.RUN
|
||||
if reasoning:
|
||||
return PetState.REVIEW
|
||||
if busy:
|
||||
return PetState.RUN
|
||||
return PetState.IDLE
|
||||
316
agent/pet/store.py
Normal file
316
agent/pet/store.py
Normal file
@ -0,0 +1,316 @@
|
||||
"""On-disk pet store — install / list / resolve pets.
|
||||
|
||||
Pets live under ``get_hermes_home()/pets/<slug>/`` 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/<slug>/
|
||||
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 ``<img src=cdn>`` 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 {}
|
||||
@ -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 (
|
||||
<div
|
||||
@ -57,90 +58,200 @@ function ThemePreview({ name }: { name: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
function VscodeThemeInstaller() {
|
||||
function useDebounced<T>(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<string>
|
||||
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<string | null>(null)
|
||||
const [installedHere, setInstalledHere] = useState<Record<string, true>>({})
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="mt-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
className="min-w-0 flex-1 rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) px-3 py-1.5 font-mono text-[length:var(--conversation-caption-font-size)] outline-none placeholder:text-(--ui-text-tertiary) focus:border-(--ui-stroke-secondary)"
|
||||
disabled={busy}
|
||||
onChange={event => {
|
||||
setId(event.target.value)
|
||||
setStatus(null)
|
||||
}}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter') {
|
||||
void install()
|
||||
}
|
||||
}}
|
||||
placeholder={a.installPlaceholder}
|
||||
spellCheck={false}
|
||||
value={id}
|
||||
/>
|
||||
<button
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary) px-3 py-1.5 text-[length:var(--conversation-caption-font-size)] font-medium transition hover:bg-(--chrome-action-hover) disabled:opacity-50"
|
||||
disabled={busy || !id.trim()}
|
||||
onClick={() => void install()}
|
||||
type="button"
|
||||
>
|
||||
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Download className="size-3.5" />}
|
||||
{busy ? a.installing : a.installButton}
|
||||
</button>
|
||||
</div>
|
||||
{status && (
|
||||
<p
|
||||
className={cn(
|
||||
'mt-2 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height)',
|
||||
status.kind === 'error' ? 'text-(--ui-red)' : 'text-(--ui-text-tertiary)'
|
||||
)}
|
||||
>
|
||||
{status.text}
|
||||
if (!debounced) {
|
||||
return null
|
||||
}
|
||||
|
||||
const header = (
|
||||
<p className="mb-2 mt-4 text-[length:var(--conversation-caption-font-size)] font-medium text-(--ui-text-tertiary)">
|
||||
From the VS Code Marketplace
|
||||
</p>
|
||||
)
|
||||
|
||||
if (search.isLoading) {
|
||||
return (
|
||||
<>
|
||||
{header}
|
||||
<p className="flex items-center gap-2 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
{copy.loading}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (search.isError) {
|
||||
return (
|
||||
<>
|
||||
{header}
|
||||
<p className="text-[length:var(--conversation-caption-font-size)] text-(--ui-red)">{copy.error}</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const results = search.data ?? []
|
||||
|
||||
if (results.length === 0) {
|
||||
return (
|
||||
<>
|
||||
{header}
|
||||
<p className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">{copy.empty}</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{header}
|
||||
{error && <p className="mb-2 text-[length:var(--conversation-caption-font-size)] text-(--ui-red)">{error}</p>}
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{results.map(item => {
|
||||
const busy = installingId === item.extensionId
|
||||
const done = installedHere[item.extensionId] || installedExtIds.has(item.extensionId)
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'flex items-center gap-2.5 px-2.5 py-2 text-left disabled:opacity-60',
|
||||
selectableCardClass({ prominent: done })
|
||||
)}
|
||||
disabled={Boolean(installingId) && !busy}
|
||||
key={item.extensionId}
|
||||
onClick={() => void install(item)}
|
||||
type="button"
|
||||
>
|
||||
<Palette className="size-4 shrink-0 text-(--ui-text-tertiary)" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[length:var(--conversation-text-font-size)] font-medium">
|
||||
{item.displayName}
|
||||
</span>
|
||||
<span className="block truncate text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
{item.publisher}
|
||||
{item.installs > 0 ? ` · ${copy.installs(compactNumber.format(item.installs))}` : ''}
|
||||
</span>
|
||||
</span>
|
||||
<span className="shrink-0 text-(--ui-text-tertiary)">
|
||||
{busy ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : done ? (
|
||||
<Check className="size-4 text-(--ui-green)" />
|
||||
) : (
|
||||
<Download className="size-4" />
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
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 · <publisher.extension>";
|
||||
// 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}
|
||||
</p>
|
||||
|
||||
<div className="mt-2 divide-y divide-(--ui-stroke-tertiary)">
|
||||
<div className="mt-2">
|
||||
<ListRow
|
||||
action={<LanguageSwitcher />}
|
||||
description={isSavingLocale ? t.language.saving : t.language.description}
|
||||
@ -171,18 +282,107 @@ export function AppearanceSettings() {
|
||||
/>
|
||||
|
||||
<ListRow
|
||||
action={
|
||||
<SegmentedControl
|
||||
onChange={id => {
|
||||
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. */}
|
||||
<div className="mt-3">
|
||||
<input
|
||||
className="w-full rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) px-3 py-1.5 text-[length:var(--conversation-caption-font-size)] outline-none placeholder:text-(--ui-text-tertiary) focus:border-(--ui-stroke-secondary)"
|
||||
onChange={event => setQuery(event.target.value)}
|
||||
placeholder="Search your themes or the VS Code Marketplace…"
|
||||
spellCheck={false}
|
||||
value={query}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Fixed-height scroll area so the (growing) theme list never
|
||||
runs the page long; the grid scrolls inside it. */}
|
||||
<div className="mt-3 max-h-96 overflow-y-auto pr-1">
|
||||
{filteredThemes.length === 0 ? (
|
||||
needle ? (
|
||||
<p className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
No installed themes match "{query.trim()}".
|
||||
</p>
|
||||
) : null
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{filteredThemes.map(theme => {
|
||||
const active = themeName === theme.name
|
||||
const removable = isUserTheme(theme.name)
|
||||
|
||||
return (
|
||||
<div className="group relative" key={theme.name}>
|
||||
<button
|
||||
className={cn('w-full p-2 text-left', selectableCardClass({ active, prominent: true }))}
|
||||
onClick={() => {
|
||||
triggerHaptic('crisp')
|
||||
setTheme(theme.name)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<ThemePreview mode={resolvedMode} name={theme.name} />
|
||||
<div className="mt-3 px-1">
|
||||
<div className="truncate text-[length:var(--conversation-text-font-size)] font-medium">
|
||||
{theme.label}
|
||||
</div>
|
||||
<div className="mt-0.5 line-clamp-2 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{theme.description}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{removable && (
|
||||
<button
|
||||
aria-label={a.removeTheme}
|
||||
className="absolute right-1.5 top-1.5 grid size-6 place-items-center rounded-md bg-(--ui-bg-elevated)/80 text-(--ui-text-tertiary) opacity-0 backdrop-blur-sm transition hover:text-(--ui-red) focus-visible:opacity-100 group-hover:opacity-100"
|
||||
onClick={() => {
|
||||
triggerHaptic('crisp')
|
||||
removeUserTheme(theme.name)
|
||||
|
||||
// Re-normalize off the now-missing skin → default.
|
||||
if (active) {
|
||||
setTheme(theme.name)
|
||||
}
|
||||
}}
|
||||
title={a.removeTheme}
|
||||
type="button"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<MarketplaceThemeResults
|
||||
installedExtIds={installedExtIds}
|
||||
onInstalled={name => setTheme(name)}
|
||||
query={query}
|
||||
/>
|
||||
</div>
|
||||
{showProfileNote && (
|
||||
<p className="mt-3 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{a.themeProfileNote(activeProfileName)}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
description={a.colorModeDesc}
|
||||
title={a.colorMode}
|
||||
description={a.themeDesc}
|
||||
title={
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span>{a.themeTitle}</span>
|
||||
<SegmentedControl
|
||||
onChange={id => {
|
||||
triggerHaptic('crisp')
|
||||
setMode(id)
|
||||
}}
|
||||
options={modeOptions}
|
||||
value={mode}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
wide
|
||||
/>
|
||||
|
||||
<ListRow
|
||||
@ -211,80 +411,6 @@ export function AppearanceSettings() {
|
||||
title={a.translucencyTitle}
|
||||
/>
|
||||
|
||||
<ListRow
|
||||
below={
|
||||
<>
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{availableThemes.map(theme => {
|
||||
const active = themeName === theme.name
|
||||
const removable = isUserTheme(theme.name)
|
||||
|
||||
return (
|
||||
<div className="group relative" key={theme.name}>
|
||||
<button
|
||||
className={cn(
|
||||
'w-full rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-2 text-left transition hover:bg-(--chrome-action-hover)',
|
||||
active && 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)'
|
||||
)}
|
||||
onClick={() => {
|
||||
triggerHaptic('crisp')
|
||||
setTheme(theme.name)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<ThemePreview name={theme.name} />
|
||||
<div className="mt-3 flex items-start justify-between gap-3 px-1">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[length:var(--conversation-text-font-size)] font-medium">
|
||||
{theme.label}
|
||||
</div>
|
||||
<div className="mt-0.5 line-clamp-2 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{theme.description}
|
||||
</div>
|
||||
</div>
|
||||
{active && (
|
||||
<span className="mt-0.5 grid size-5 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground">
|
||||
<Check className="size-3.5" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
{removable && (
|
||||
<button
|
||||
aria-label={a.removeTheme}
|
||||
className="absolute right-1.5 top-1.5 grid size-6 place-items-center rounded-md bg-(--ui-bg-elevated)/80 text-(--ui-text-tertiary) opacity-0 backdrop-blur-sm transition hover:text-(--ui-red) focus-visible:opacity-100 group-hover:opacity-100"
|
||||
onClick={() => {
|
||||
triggerHaptic('crisp')
|
||||
removeUserTheme(theme.name)
|
||||
|
||||
// Re-normalize off the now-missing skin → default.
|
||||
if (active) {
|
||||
setTheme(theme.name)
|
||||
}
|
||||
}}
|
||||
title={a.removeTheme}
|
||||
type="button"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<VscodeThemeInstaller />
|
||||
{showProfileNote && (
|
||||
<p className="mt-3 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{a.themeProfileNote(activeProfileName)}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
description={a.themeDesc}
|
||||
title={a.themeTitle}
|
||||
wide
|
||||
/>
|
||||
|
||||
<ListRow
|
||||
action={
|
||||
<SegmentedControl
|
||||
@ -301,6 +427,10 @@ export function AppearanceSettings() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<PetSettings />
|
||||
</div>
|
||||
</SettingsContent>
|
||||
)
|
||||
}
|
||||
|
||||
365
apps/desktop/src/app/settings/pet-settings.tsx
Normal file
365
apps/desktop/src/app/settings/pet-settings.tsx
Normal file
@ -0,0 +1,365 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
|
||||
import { SegmentedControl } from '@/components/ui/segmented-control'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Loader2, PawPrint, Trash2 } from '@/lib/icons'
|
||||
import { selectableCardClass } from '@/lib/selectable-card'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { type PetInfo, setPetInfo } from '@/store/pet'
|
||||
import { $gatewayState } from '@/store/session'
|
||||
|
||||
import { ListRow, SectionHeading } from './primitives'
|
||||
|
||||
/** 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)
|
||||
}
|
||||
|
||||
interface GalleryPet {
|
||||
slug: string
|
||||
displayName: string
|
||||
installed: boolean
|
||||
spritesheetUrl?: string
|
||||
/** petdex's hand-picked/official set — the closest thing to "popular." */
|
||||
curated?: boolean
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
type ThumbLoader = (slug: string, url?: string) => Promise<string | null>
|
||||
|
||||
/**
|
||||
* 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 `<img src=cdn>`. We only fire the
|
||||
* request once the thumb scrolls into view, so the picker never fetches the
|
||||
* whole catalog up front.
|
||||
*/
|
||||
function PetThumb({ slug, url, alt, load }: { slug: string; url?: string; alt: string; load: ThumbLoader }) {
|
||||
const [src, setSrc] = useState<string | null>(null)
|
||||
const boxRef = useRef<HTMLSpanElement | null>(null)
|
||||
|
||||
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 (
|
||||
<span
|
||||
className="grid shrink-0 place-items-center overflow-hidden rounded-md bg-(--ui-bg-tertiary) text-(--ui-text-tertiary)"
|
||||
ref={boxRef}
|
||||
style={{ height: THUMB_H, width: THUMB_W }}
|
||||
>
|
||||
{src ? (
|
||||
<img
|
||||
alt={alt}
|
||||
aria-hidden
|
||||
className="pointer-events-none size-full object-contain"
|
||||
src={src}
|
||||
style={{ imageRendering: 'pixelated' }}
|
||||
/>
|
||||
) : (
|
||||
<PawPrint className="size-4" />
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
interface PetGallery {
|
||||
enabled: boolean
|
||||
active: string
|
||||
pets: GalleryPet[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Appearance opt-in for the floating petdex mascot. Reads the gallery + current
|
||||
* config via `pet.gallery`, adopts a pet with `pet.select` (installs on demand),
|
||||
* and toggles off with `pet.disable`. The floating mascot polls `pet.info`, so
|
||||
* picking a pet here lights it up within a couple seconds — no reload, no CLI.
|
||||
*/
|
||||
export function PetSettings() {
|
||||
const { requestGateway } = useGatewayRequest()
|
||||
const gatewayState = useStore($gatewayState)
|
||||
const [gallery, setGallery] = useState<PetGallery | null>(null)
|
||||
const [busySlug, setBusySlug] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [staleBackend, setStaleBackend] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
|
||||
// Dedupe thumb requests per slug (across re-renders and re-filters); the
|
||||
// backend also disk-caches, so a slug is fetched at most once per session.
|
||||
const thumbCache = useRef<Map<string, Promise<string | null>>>(new Map())
|
||||
|
||||
const loadThumb = useCallback<ThumbLoader>(
|
||||
(slug, url) => {
|
||||
const cache = thumbCache.current
|
||||
let pending = cache.get(slug)
|
||||
|
||||
if (!pending) {
|
||||
pending = requestGateway<{ ok: boolean; dataUri?: string }>('pet.thumb', { slug, url: url ?? '' })
|
||||
.then(result => (result?.ok && result.dataUri ? result.dataUri : null))
|
||||
.catch(() => null)
|
||||
cache.set(slug, pending)
|
||||
}
|
||||
|
||||
return pending
|
||||
},
|
||||
[requestGateway]
|
||||
)
|
||||
|
||||
const RESTART_HINT =
|
||||
'Pets need a quick restart — the running app started before this feature was added. Quit and reopen Hermes, then come back here.'
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
// Pull the picker state AND push the live mascot state into the shared
|
||||
// `$petInfo` store, so the floating pet reflects a change/disable here
|
||||
// immediately instead of clinging to its cached sprite.
|
||||
const [next, info] = await Promise.all([
|
||||
requestGateway<PetGallery>('pet.gallery'),
|
||||
requestGateway<PetInfo>('pet.info')
|
||||
])
|
||||
|
||||
if (next) {
|
||||
setGallery(next)
|
||||
setStaleBackend(false)
|
||||
}
|
||||
|
||||
if (info) {
|
||||
setPetInfo(info)
|
||||
}
|
||||
} catch (e) {
|
||||
if (isMissingMethod(e)) {
|
||||
setStaleBackend(true)
|
||||
}
|
||||
// otherwise cosmetic — leave the picker as-is on a transient hiccup
|
||||
}
|
||||
}, [requestGateway])
|
||||
|
||||
useEffect(() => {
|
||||
if (gatewayState !== 'open') {
|
||||
return
|
||||
}
|
||||
|
||||
void refresh()
|
||||
}, [gatewayState, refresh])
|
||||
|
||||
const enabled = gallery?.enabled ?? false
|
||||
const active = gallery?.active ?? ''
|
||||
const pets = gallery?.pets ?? []
|
||||
|
||||
// Every mutation shares the same shape: spin the row, fire the RPC, resync.
|
||||
// A missing method means a stale backend; anything else is a real error.
|
||||
const runPetRpc = useCallback(
|
||||
async (method: string, slug: string, failMsg: string) => {
|
||||
setBusySlug(slug)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
await requestGateway(method, slug ? { slug } : undefined)
|
||||
triggerHaptic('crisp')
|
||||
await refresh()
|
||||
} catch (e) {
|
||||
if (isMissingMethod(e)) {
|
||||
setStaleBackend(true)
|
||||
} else {
|
||||
setError(e instanceof Error ? e.message : failMsg)
|
||||
}
|
||||
} finally {
|
||||
setBusySlug(null)
|
||||
}
|
||||
},
|
||||
[refresh, requestGateway]
|
||||
)
|
||||
|
||||
const selectPet = useCallback((slug: string) => runPetRpc('pet.select', slug, `Could not adopt ${slug}`), [runPetRpc])
|
||||
|
||||
const removePet = useCallback(
|
||||
(slug: string) => runPetRpc('pet.remove', slug, `Could not uninstall ${slug}`),
|
||||
[runPetRpc]
|
||||
)
|
||||
|
||||
const toggle = useCallback(
|
||||
(on: boolean) => {
|
||||
if (!on) {
|
||||
return runPetRpc('pet.disable', '', 'Could not turn the pet off.')
|
||||
}
|
||||
|
||||
const slug = gallery?.active || gallery?.pets[0]?.slug
|
||||
|
||||
if (!slug) {
|
||||
setError('No pets available to turn on right now.')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
return selectPet(slug)
|
||||
},
|
||||
[gallery, runPetRpc, selectPet]
|
||||
)
|
||||
|
||||
// Installed pets first, then the rest of the gallery. The petdex catalog is
|
||||
// thousands of entries, so filter by query and cap how many we render.
|
||||
const RENDER_CAP = 60
|
||||
const needle = query.trim().toLowerCase()
|
||||
|
||||
const filtered = pets.filter(
|
||||
pet =>
|
||||
!/^clawd(-|$)/i.test(pet.slug) &&
|
||||
(!needle || pet.slug.toLowerCase().includes(needle) || pet.displayName.toLowerCase().includes(needle))
|
||||
)
|
||||
|
||||
// petdex has no popularity data, so rank by the signals we do have: the
|
||||
// active pet first, then installed, then curated (official), then the rest.
|
||||
const rank = (pet: GalleryPet) =>
|
||||
Number(enabled && pet.slug === active) * 4 + Number(pet.installed) * 2 + Number(pet.curated)
|
||||
|
||||
const sorted = [...filtered].sort((a, b) => rank(b) - rank(a))
|
||||
const shown = sorted.slice(0, RENDER_CAP)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SectionHeading icon={PawPrint} title="Pet" />
|
||||
<p className="max-w-2xl text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
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.
|
||||
</p>
|
||||
|
||||
{staleBackend && (
|
||||
<p className="mt-2 rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) px-3 py-2 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{RESTART_HINT}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-2">
|
||||
<ListRow
|
||||
action={
|
||||
<SegmentedControl
|
||||
onChange={id => void toggle(id === 'on')}
|
||||
options={[
|
||||
{ id: 'off', label: 'Off' },
|
||||
{ id: 'on', label: 'On' }
|
||||
]}
|
||||
value={enabled ? 'on' : 'off'}
|
||||
/>
|
||||
}
|
||||
description={
|
||||
enabled && active ? `Showing ${active}.` : 'Turn on to show your mascot in the corner of the window.'
|
||||
}
|
||||
title="Floating mascot"
|
||||
/>
|
||||
|
||||
<ListRow
|
||||
below={
|
||||
<>
|
||||
<input
|
||||
className="mt-3 w-full rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) px-3 py-1.5 text-[length:var(--conversation-caption-font-size)] outline-none placeholder:text-(--ui-text-tertiary) focus:border-(--ui-stroke-secondary)"
|
||||
onChange={event => setQuery(event.target.value)}
|
||||
placeholder="Search pets…"
|
||||
spellCheck={false}
|
||||
value={query}
|
||||
/>
|
||||
{/* Fixed-height scroll area so filtering never grows/shrinks the
|
||||
page (no layout thrash); the grid scrolls inside it. */}
|
||||
<div className="mt-3 h-72 overflow-y-auto pr-1">
|
||||
{pets.length === 0 ? (
|
||||
<p className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
Couldn't reach the petdex gallery. Check your connection and reopen this page.
|
||||
</p>
|
||||
) : shown.length === 0 ? (
|
||||
<p className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
No pets match "{query}".
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{shown.map(pet => {
|
||||
const isActive = enabled && active === pet.slug
|
||||
const isBusy = busySlug === pet.slug
|
||||
|
||||
return (
|
||||
<div className="group relative" key={pet.slug}>
|
||||
<button
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2.5 px-2.5 py-2 text-left disabled:opacity-50',
|
||||
selectableCardClass({ active: isActive, prominent: pet.installed })
|
||||
)}
|
||||
disabled={isBusy}
|
||||
onClick={() => void selectPet(pet.slug)}
|
||||
type="button"
|
||||
>
|
||||
<PetThumb alt={pet.displayName} load={loadThumb} slug={pet.slug} url={pet.spritesheetUrl} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[length:var(--conversation-text-font-size)] font-medium">
|
||||
{pet.displayName}
|
||||
</span>
|
||||
<span className="block truncate text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
{pet.slug}
|
||||
{pet.installed ? ' · installed' : pet.curated ? ' · official' : ''}
|
||||
</span>
|
||||
</span>
|
||||
{isBusy && <Loader2 className="size-4 shrink-0 animate-spin text-(--ui-text-tertiary)" />}
|
||||
</button>
|
||||
{pet.installed && !isBusy && (
|
||||
<button
|
||||
aria-label={`Uninstall ${pet.displayName}`}
|
||||
className="absolute right-1.5 top-1.5 grid size-6 place-items-center rounded-md bg-(--ui-bg-elevated)/80 text-(--ui-text-tertiary) opacity-0 backdrop-blur-sm transition hover:text-(--ui-red) focus-visible:opacity-100 group-hover:opacity-100"
|
||||
onClick={() => void removePet(pet.slug)}
|
||||
title={`Uninstall ${pet.displayName}`}
|
||||
type="button"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Always-present status line so its appearance never shifts layout. */}
|
||||
<p className="mt-2 min-h-4 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
{error ? (
|
||||
<span className="text-(--ui-red)">{error}</span>
|
||||
) : sorted.length > RENDER_CAP ? (
|
||||
`Showing ${RENDER_CAP} of ${sorted.length} — type to narrow it down.`
|
||||
) : (
|
||||
`${sorted.length} pet${sorted.length === 1 ? '' : 's'}.`
|
||||
)}
|
||||
</p>
|
||||
</>
|
||||
}
|
||||
description="Picking one installs it (if needed) and makes it active."
|
||||
title="Choose a pet"
|
||||
wide
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -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 {
|
||||
@ -194,6 +195,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. */}
|
||||
<NotificationStack />
|
||||
|
||||
{/* Petdex floating mascot — in-window, always-on-top, reactive to agent
|
||||
activity. Renders nothing unless a pet is installed + enabled. */}
|
||||
<FloatingPet />
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
|
||||
177
apps/desktop/src/components/pet/floating-pet.tsx
Normal file
177
apps/desktop/src/components/pet/floating-pet.tsx
Normal file
@ -0,0 +1,177 @@
|
||||
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, type PetInfo, setPetInfo } from '@/store/pet'
|
||||
import { $gatewayState } from '@/store/session'
|
||||
|
||||
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) }
|
||||
}
|
||||
|
||||
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 gatewayState = useStore($gatewayState)
|
||||
const info = useStore($petInfo)
|
||||
|
||||
const [position, setPosition] = useState<Point>(loadPosition)
|
||||
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||
// 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 <slug>` 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<PetInfo>('pet.info')
|
||||
|
||||
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])
|
||||
|
||||
const onPointerDown = useCallback((e: React.PointerEvent) => {
|
||||
const el = containerRef.current
|
||||
|
||||
if (!el) {
|
||||
return
|
||||
}
|
||||
|
||||
const rect = el.getBoundingClientRect()
|
||||
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.
|
||||
el.style.left = `${next.x}px`
|
||||
el.style.top = `${next.y}px`
|
||||
}, [])
|
||||
|
||||
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)
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (!info.enabled || !info.spritesheetBase64) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
ref={containerRef}
|
||||
style={{
|
||||
cursor: 'grab',
|
||||
left: position.x,
|
||||
pointerEvents: 'auto',
|
||||
position: 'fixed',
|
||||
top: position.y,
|
||||
touchAction: 'none',
|
||||
userSelect: 'none',
|
||||
zIndex: 60
|
||||
}}
|
||||
title={info.displayName || 'pet'}
|
||||
>
|
||||
<PetSprite info={info} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
134
apps/desktop/src/components/pet/pet-sprite.tsx
Normal file
134
apps/desktop/src/components/pet/pet-sprite.tsx
Normal file
@ -0,0 +1,134 @@
|
||||
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
|
||||
const DEFAULT_STATE_ROWS = ['idle', 'wave', 'run', 'failed', 'review', 'jump', 'extra1', 'extra2']
|
||||
|
||||
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<HTMLCanvasElement | null>(null)
|
||||
const stateRef = useRef<PetState>($petState.get())
|
||||
|
||||
const frameW = info.frameW ?? DEFAULT_FRAME_W
|
||||
const frameH = info.frameH ?? DEFAULT_FRAME_H
|
||||
const frames = info.framesPerState ?? DEFAULT_FRAMES
|
||||
const loopMs = info.loopMs ?? DEFAULT_LOOP_MS
|
||||
const scale = (info.scale ?? 0.7) * 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 stepMs = loopMs / Math.max(1, frames)
|
||||
|
||||
const rowIndex = (s: PetState) => {
|
||||
const idx = rows.indexOf(s)
|
||||
|
||||
return idx >= 0 ? idx : 0
|
||||
}
|
||||
|
||||
const render = (now: number) => {
|
||||
if (now - lastStep >= stepMs) {
|
||||
frame = (frame + 1) % Math.max(1, frames)
|
||||
lastStep = now
|
||||
}
|
||||
|
||||
const row = rowIndex(stateRef.current)
|
||||
|
||||
// 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 sheetCols = Math.max(1, Math.floor(image.width / frameW))
|
||||
const sx = (frame % sheetCols) * 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, loopMs, drawW, drawH, rows])
|
||||
|
||||
return (
|
||||
<canvas
|
||||
aria-label={info.displayName ? `${info.displayName} pet` : 'pet'}
|
||||
height={drawH}
|
||||
ref={canvasRef}
|
||||
style={{ height: drawH, width: drawW }}
|
||||
width={drawW}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
@ -121,6 +121,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: 'Adopt an animated petdex mascot (/pet boba, /pet off)', surface: exec(), 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() },
|
||||
|
||||
@ -67,6 +67,7 @@ import {
|
||||
IconLayoutBottombar as PanelBottom,
|
||||
IconLayoutSidebar as PanelLeftIcon,
|
||||
IconPlayerPause as Pause,
|
||||
IconPaw as PawPrint,
|
||||
IconPencil as Pencil,
|
||||
IconPencil as PencilIcon,
|
||||
IconPencil as PencilLine,
|
||||
@ -169,6 +170,7 @@ export {
|
||||
PanelBottom,
|
||||
PanelLeftIcon,
|
||||
Pause,
|
||||
PawPrint,
|
||||
Pencil,
|
||||
PencilIcon,
|
||||
PencilLine,
|
||||
|
||||
31
apps/desktop/src/lib/selectable-card.ts
Normal file
31
apps/desktop/src/lib/selectable-card.ts
Normal file
@ -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)'
|
||||
)
|
||||
}
|
||||
27
apps/desktop/src/store/pet.test.ts
Normal file
27
apps/desktop/src/store/pet.test.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { derivePetState } from './pet'
|
||||
|
||||
describe('derivePetState', () => {
|
||||
it('rests at idle by default and while awaiting input', () => {
|
||||
expect(derivePetState({})).toBe('idle')
|
||||
expect(derivePetState({ awaitingInput: true })).toBe('idle')
|
||||
})
|
||||
|
||||
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('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')
|
||||
})
|
||||
})
|
||||
96
apps/desktop/src/store/pet.ts
Normal file
96
apps/desktop/src/store/pet.ts
Normal file
@ -0,0 +1,96 @@
|
||||
import { atom, computed } from 'nanostores'
|
||||
|
||||
import { $awaitingResponse, $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'
|
||||
|
||||
export interface PetInfo {
|
||||
enabled: boolean
|
||||
slug?: string
|
||||
displayName?: string
|
||||
mime?: string
|
||||
spritesheetBase64?: string
|
||||
frameW?: number
|
||||
frameH?: number
|
||||
framesPerState?: number
|
||||
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 → toolRunning → reasoning → busy → idle.
|
||||
*/
|
||||
export function derivePetState(activity: PetActivity): PetState {
|
||||
if (activity.error) {
|
||||
return 'failed'
|
||||
}
|
||||
if (activity.celebrate) {
|
||||
return 'jump'
|
||||
}
|
||||
if (activity.justCompleted) {
|
||||
return 'wave'
|
||||
}
|
||||
if (activity.toolRunning) {
|
||||
return 'run'
|
||||
}
|
||||
if (activity.reasoning) {
|
||||
return 'review'
|
||||
}
|
||||
if (activity.busy) {
|
||||
return 'run'
|
||||
}
|
||||
return 'idle'
|
||||
}
|
||||
|
||||
export const $petInfo = atom<PetInfo>({ enabled: false })
|
||||
export const $petActivity = atom<PetActivity>({})
|
||||
|
||||
/** Transient flags the message stream can set without owning the full activity
|
||||
* object. They decay back to false (handled by callers / timers). */
|
||||
export const setPetActivity = (next: Partial<PetActivity>) =>
|
||||
$petActivity.set({ ...$petActivity.get(), ...next })
|
||||
|
||||
export const setPetInfo = (info: PetInfo) => $petInfo.set(info)
|
||||
|
||||
/**
|
||||
* The live pet state. Derives from the dedicated activity atom when any of its
|
||||
* richer flags are set, otherwise falls back to the always-present chat
|
||||
* signals (`$busy` / `$awaitingResponse`) so the pet reacts out of the box
|
||||
* even before deeper tool/error wiring is added.
|
||||
*/
|
||||
export const $petState = computed(
|
||||
[$petActivity, $busy, $awaitingResponse],
|
||||
(activity, busy, awaiting): PetState =>
|
||||
derivePetState({
|
||||
busy: activity.busy ?? busy,
|
||||
awaitingInput: activity.awaitingInput ?? awaiting,
|
||||
toolRunning: activity.toolRunning,
|
||||
reasoning: activity.reasoning,
|
||||
error: activity.error,
|
||||
justCompleted: activity.justCompleted,
|
||||
celebrate: activity.celebrate
|
||||
})
|
||||
)
|
||||
2
cli.py
2
cli.py
@ -7424,6 +7424,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'):
|
||||
|
||||
@ -1009,6 +1009,65 @@ class CLICommandsMixin:
|
||||
print(" Usage: /personality <name>")
|
||||
print()
|
||||
|
||||
def _handle_pet_command(self, cmd: str):
|
||||
"""Install / select / disable an animated petdex mascot.
|
||||
|
||||
``/pet`` or ``/pet status`` → show current pet + installed list
|
||||
``/pet <slug>`` → install (if needed) + make active
|
||||
``/pet off`` → disable the pet display
|
||||
``/pet list`` → browse the petdex gallery (first 20)
|
||||
|
||||
Writes ``display.pet.*`` to config; the CLI/TUI/desktop pet surfaces
|
||||
pick the change up on their next poll, so the pet appears shortly.
|
||||
"""
|
||||
from agent.pet import store
|
||||
from agent.pet.manifest import ManifestError
|
||||
from hermes_cli.pets import _pet_config, _set_active, _set_enabled
|
||||
|
||||
parts = cmd.split(maxsplit=1)
|
||||
arg = parts[1].strip() if len(parts) > 1 else ""
|
||||
low = arg.lower()
|
||||
|
||||
if not arg or low == "status":
|
||||
cfg = _pet_config()
|
||||
installed = [p.slug for p in store.installed_pets()]
|
||||
active = store.resolve_active_pet(str(cfg.get("slug", "") or ""))
|
||||
state = "on" if cfg.get("enabled") else "off"
|
||||
print(f"(^_^) Pet: {state} · active: {active.slug if active else 'none'}")
|
||||
print(f" installed: {', '.join(installed) or 'none (try /pet boba)'}")
|
||||
print(" Usage: /pet <slug> · /pet off · /pet list")
|
||||
return
|
||||
|
||||
if low == "off":
|
||||
_set_enabled(False)
|
||||
print("(-_-)zzZ Pet disabled.")
|
||||
return
|
||||
|
||||
if low == "list":
|
||||
from agent.pet.manifest import 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()}
|
||||
print(f"(^o^)/ petdex gallery — first 20 of {len(entries)}:")
|
||||
for entry in entries[:20]:
|
||||
mark = "✓" if entry.slug in installed else " "
|
||||
print(f" {mark} {entry.slug:<24} {entry.display_name}")
|
||||
print(" Adopt one with: /pet <slug>")
|
||||
return
|
||||
|
||||
# Treat the argument as a slug: install (if needed) + activate.
|
||||
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} adopted and set as your active pet — it'll pop in shortly.")
|
||||
|
||||
def _handle_cron_command(self, cmd: str):
|
||||
"""Handle the /cron command to manage scheduled tasks."""
|
||||
from cli import get_job
|
||||
|
||||
@ -176,6 +176,8 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
||||
subcommands=("pending", "approve", "reject", "approval")),
|
||||
CommandDef("bundles", "List skill bundles (aliases /<name> for multiple skills)",
|
||||
"Tools & Skills"),
|
||||
CommandDef("pet", "Install/select an animated petdex mascot", "Tools & Skills",
|
||||
args_hint="[slug|off|list]", subcommands=("off", "list", "status")),
|
||||
CommandDef("cron", "Manage scheduled tasks", "Tools & Skills",
|
||||
cli_only=True, args_hint="[subcommand]",
|
||||
subcommands=("list", "add", "create", "edit", "pause", "resume", "run", "remove")),
|
||||
|
||||
@ -1517,6 +1517,25 @@ 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",
|
||||
# On-screen scale relative to native 192×208 frames (petdex uses 0.7).
|
||||
"scale": 0.7,
|
||||
# Width in terminal columns for the unicode half-block fallback.
|
||||
"unicode_cols": 18,
|
||||
},
|
||||
},
|
||||
|
||||
# Web dashboard settings
|
||||
|
||||
@ -10925,7 +10925,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",
|
||||
@ -11815,6 +11815,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)
|
||||
# =========================================================================
|
||||
|
||||
373
hermes_cli/pets.py
Normal file
373
hermes_cli/pets.py
Normal file
@ -0,0 +1,373 @@
|
||||
"""CLI subcommand: ``hermes pets <subcommand>``.
|
||||
|
||||
Thin shell around :mod:`agent.pet`. Browses the public petdex gallery,
|
||||
installs pets into the profile's ``pets/`` directory, selects the active
|
||||
mascot (writes ``display.pet.*`` to config.yaml), and runs a doctor check.
|
||||
|
||||
No side effects at import time — ``main.py`` wires the argparse subparsers on
|
||||
demand via :func:`register_cli`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
|
||||
def _print(msg: str = "") -> None:
|
||||
print(msg)
|
||||
|
||||
|
||||
def _err(msg: str) -> None:
|
||||
print(msg, file=sys.stderr)
|
||||
|
||||
|
||||
def _cmd_list(args) -> int:
|
||||
"""List gallery pets (or only installed ones with ``--installed``)."""
|
||||
from agent.pet import store
|
||||
|
||||
if getattr(args, "installed", False):
|
||||
pets = store.installed_pets()
|
||||
if not pets:
|
||||
_print("No pets installed. Try: hermes pets install boba")
|
||||
return 0
|
||||
_print(f"Installed pets ({len(pets)}):")
|
||||
for pet in pets:
|
||||
_print(f" {pet.slug:<24} {pet.display_name}")
|
||||
return 0
|
||||
|
||||
from agent.pet.manifest import ManifestError, fetch_manifest
|
||||
|
||||
try:
|
||||
entries = fetch_manifest()
|
||||
except ManifestError as exc:
|
||||
_err(f"✗ {exc}")
|
||||
return 1
|
||||
|
||||
query = (getattr(args, "query", "") or "").strip().lower()
|
||||
if query:
|
||||
entries = [
|
||||
e
|
||||
for e in entries
|
||||
if query in e.slug.lower() or query in e.display_name.lower()
|
||||
]
|
||||
|
||||
limit = getattr(args, "limit", 0) or 0
|
||||
shown = entries[:limit] if limit > 0 else entries
|
||||
installed = {p.slug for p in store.installed_pets()}
|
||||
|
||||
_print(f"petdex gallery — {len(entries)} pet(s){' matching ' + repr(query) if query else ''}:")
|
||||
for entry in shown:
|
||||
mark = "✓" if entry.slug in installed else " "
|
||||
_print(f" {mark} {entry.slug:<28} {entry.display_name} ({entry.kind})")
|
||||
if limit and len(entries) > limit:
|
||||
_print(f" … {len(entries) - limit} more (use --limit 0 or --query to filter)")
|
||||
_print("\nInstall one with: hermes pets install <slug>")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_install(args) -> int:
|
||||
from agent.pet import store
|
||||
from agent.pet.manifest import ManifestError
|
||||
|
||||
slug = args.slug.strip()
|
||||
try:
|
||||
pet = store.install_pet(slug, force=getattr(args, "force", False))
|
||||
except (store.PetStoreError, ManifestError) as exc:
|
||||
_err(f"✗ install failed: {exc}")
|
||||
return 1
|
||||
|
||||
_print(f"✓ installed {pet.display_name} → {pet.directory}")
|
||||
|
||||
if getattr(args, "select", False) or not _has_active_pet():
|
||||
_set_active(slug)
|
||||
_print(f"✓ {pet.display_name} is now the active pet (display.pet.slug={slug}, enabled)")
|
||||
else:
|
||||
_print(f" Make it active with: hermes pets select {slug}")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_remove(args) -> int:
|
||||
from agent.pet import store
|
||||
|
||||
slug = args.slug.strip()
|
||||
if store.remove_pet(slug):
|
||||
_print(f"✓ removed {slug}")
|
||||
return 0
|
||||
_err(f"✗ '{slug}' is not installed")
|
||||
return 1
|
||||
|
||||
|
||||
def _cmd_select(args) -> int:
|
||||
from agent.pet import store
|
||||
|
||||
slug = (getattr(args, "slug", "") or "").strip()
|
||||
if not slug:
|
||||
pets = store.installed_pets()
|
||||
if not pets:
|
||||
_err("✗ no pets installed — run: hermes pets install boba")
|
||||
return 1
|
||||
slug = _interactive_pick(pets)
|
||||
if not slug:
|
||||
return 1
|
||||
|
||||
pet = store.load_pet(slug)
|
||||
if pet is None or not pet.exists:
|
||||
_err(f"✗ '{slug}' is not installed — run: hermes pets install {slug}")
|
||||
return 1
|
||||
|
||||
_set_active(slug)
|
||||
_print(f"✓ active pet set to {pet.display_name} (display.pet.slug={slug}, enabled)")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_off(args) -> int:
|
||||
_set_enabled(False)
|
||||
_print("✓ pet disabled (display.pet.enabled=false)")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_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 LOOP_MS, STATE_ROWS, PetState
|
||||
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", 0.7) or 0.7)
|
||||
cols = int(cfg.get("unicode_cols", 18) or 18)
|
||||
|
||||
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))
|
||||
|
||||
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 prev_lines:
|
||||
out.write(f"\x1b[{prev_lines}F") # cursor up to redraw
|
||||
out.write(encoded)
|
||||
out.write("\x1b[0m\n")
|
||||
prev_lines = encoded.count("\n") + 2
|
||||
else:
|
||||
out.write("\x1b[2J\x1b[3J\x1b[H") # clear for image protocols
|
||||
out.write(f"{pet.display_name} [{state}]\n")
|
||||
out.write(encoded)
|
||||
out.write("\n")
|
||||
out.flush()
|
||||
time.sleep(frame_delay)
|
||||
loops += 1
|
||||
if getattr(args, "once", False) and loops >= len(states):
|
||||
break
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
out.write("\x1b[?25h") # show cursor
|
||||
out.write("\x1b[0m\n")
|
||||
out.flush()
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_doctor(args) -> int:
|
||||
"""Report install state, active pet, config, and terminal capability."""
|
||||
from agent.pet import store
|
||||
from agent.pet.render import detect_terminal_graphics, resolve_mode
|
||||
|
||||
cfg = _pet_config()
|
||||
enabled = bool(cfg.get("enabled"))
|
||||
configured_slug = str(cfg.get("slug", "") or "")
|
||||
mode_cfg = str(cfg.get("render_mode", "auto") or "auto")
|
||||
|
||||
pets = store.installed_pets()
|
||||
active = store.resolve_active_pet(configured_slug)
|
||||
|
||||
_print("petdex doctor")
|
||||
_print(f" pets dir: {store.pets_dir()}")
|
||||
_print(f" installed: {len(pets)} ({', '.join(p.slug for p in pets) or 'none'})")
|
||||
_print(f" display.pet.enabled: {enabled}")
|
||||
_print(f" display.pet.slug: {configured_slug or '(unset)'}")
|
||||
_print(f" active (resolved): {active.slug if active else '(none)'}")
|
||||
_print(f" display.pet.render_mode: {mode_cfg}")
|
||||
_print(f" detected graphics: {detect_terminal_graphics()}")
|
||||
_print(f" effective mode (TTY): {resolve_mode(mode_cfg)}")
|
||||
|
||||
ok = True
|
||||
if not pets:
|
||||
_print(" → no pets installed. Run: hermes pets install boba")
|
||||
ok = False
|
||||
elif active is None:
|
||||
_print(" → active pet unresolved. Run: hermes pets select <slug>")
|
||||
ok = False
|
||||
elif not enabled:
|
||||
_print(" → pet display is disabled. Run: hermes pets select " + active.slug)
|
||||
|
||||
try:
|
||||
import PIL # noqa: F401
|
||||
except ImportError:
|
||||
_print(" ✗ Pillow not importable — sprite decoding will be unavailable")
|
||||
ok = False
|
||||
|
||||
_print(" ✓ ready" if ok and enabled else " (run the suggestions above to finish setup)")
|
||||
return 0
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# config helpers
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _pet_config() -> dict:
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
cfg = load_config()
|
||||
display = cfg.get("display", {}) if isinstance(cfg.get("display"), dict) else {}
|
||||
pet = display.get("pet", {})
|
||||
return pet if isinstance(pet, dict) else {}
|
||||
|
||||
|
||||
def _has_active_pet() -> bool:
|
||||
return bool(_pet_config().get("enabled")) and bool(_pet_config().get("slug"))
|
||||
|
||||
|
||||
def _set_active(slug: str) -> None:
|
||||
from hermes_cli.config import load_config, save_config
|
||||
|
||||
cfg = load_config()
|
||||
display = cfg.setdefault("display", {})
|
||||
pet = display.setdefault("pet", {})
|
||||
pet["slug"] = slug
|
||||
pet["enabled"] = True
|
||||
save_config(cfg)
|
||||
|
||||
|
||||
def _set_enabled(enabled: bool) -> None:
|
||||
from hermes_cli.config import load_config, save_config
|
||||
|
||||
cfg = load_config()
|
||||
display = cfg.setdefault("display", {})
|
||||
pet = display.setdefault("pet", {})
|
||||
pet["enabled"] = enabled
|
||||
save_config(cfg)
|
||||
|
||||
|
||||
def _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_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
|
||||
)
|
||||
85
skills/productivity/petdex/SKILL.md
Normal file
85
skills/productivity/petdex/SKILL.md
Normal file
@ -0,0 +1,85 @@
|
||||
---
|
||||
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 <subcommand>`.
|
||||
|
||||
## 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 <slug>` (add `--select` to make it active) |
|
||||
| Set the active pet | `hermes pets select <slug>` (omit slug for a picker) |
|
||||
| Preview/animate in terminal | `hermes pets show [slug] [--cycle] [--state run]` |
|
||||
| Disable the pet | `hermes pets off` |
|
||||
| Remove a pet | `hermes pets remove <slug>` |
|
||||
| Diagnose setup | `hermes pets doctor` |
|
||||
|
||||
## Procedure
|
||||
|
||||
1. Find a pet: `hermes pets list <query>` and note its `slug`.
|
||||
2. Install + activate: `hermes pets install <slug> --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 `<HERMES_HOME>/pets/<slug>/` (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 scale of the native 192×208 frames (default 0.7).
|
||||
- `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 `<HERMES_HOME>/pets/` instead — install through `hermes pets`.
|
||||
|
||||
## Verification
|
||||
|
||||
- `hermes pets doctor` reports `✓ ready` when a pet is installed, selected,
|
||||
enabled, and Pillow is importable.
|
||||
179
tests/agent/test_pet_engine.py
Normal file
179
tests/agent/test_pet_engine.py
Normal file
@ -0,0 +1,179 @@
|
||||
"""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 rests, doesn't run
|
||||
assert state.derive_pet_state(awaiting_input=True) is PetState.IDLE
|
||||
|
||||
|
||||
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 tool/reasoning
|
||||
assert state.derive_pet_state(just_completed=True, tool_running=True) is PetState.WAVE
|
||||
# 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_state_row_index_maps_to_taxonomy():
|
||||
# row index must equal position in STATE_ROWS for every driveable state
|
||||
for st in PetState:
|
||||
assert constants.STATE_ROWS[constants.state_row_index(st)] == st.value
|
||||
# unknown row names clamp to idle (row 0), never raise
|
||||
assert constants.state_row_index("nonsense") == 0
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# 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
|
||||
|
||||
|
||||
@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) == []
|
||||
|
||||
|
||||
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"
|
||||
@ -176,6 +176,12 @@ _LONG_HANDLERS = frozenset(
|
||||
{
|
||||
"browser.manage",
|
||||
"cli.exec",
|
||||
# Pet RPCs hit the network (manifest fetch / spritesheet download): inline
|
||||
# they serialize on the reader thread, so picker previews trickle in one at
|
||||
# a time. On the pool they fetch concurrently and stop blocking other RPCs.
|
||||
"pet.gallery",
|
||||
"pet.select",
|
||||
"pet.thumb",
|
||||
"plugins.manage",
|
||||
"session.branch",
|
||||
"session.compress",
|
||||
@ -4804,6 +4810,296 @@ def _(rid, params: dict) -> dict:
|
||||
return _ok(rid, usage)
|
||||
|
||||
|
||||
@method("pet.info")
|
||||
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,
|
||||
"loopMs": constants.LOOP_MS,
|
||||
"scale": float(pet_cfg.get("scale", constants.DEFAULT_SCALE) or constants.DEFAULT_SCALE),
|
||||
"stateRows": list(constants.STATE_ROWS),
|
||||
},
|
||||
)
|
||||
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")
|
||||
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, 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)
|
||||
cols = int(params.get("cols") or pet_cfg.get("unicode_cols", 18) or 18)
|
||||
|
||||
renderer = PetRenderer(
|
||||
str(pet.spritesheet),
|
||||
mode="unicode",
|
||||
scale=float(pet_cfg.get("scale", constants.DEFAULT_SCALE) or constants.DEFAULT_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,
|
||||
},
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.debug("pet.cells failed: %s", exc)
|
||||
return _ok(rid, {"enabled": False})
|
||||
|
||||
|
||||
@method("pet.gallery")
|
||||
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")
|
||||
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")
|
||||
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")
|
||||
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 ``<img>`` (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")
|
||||
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("credits.view")
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""Structured Nous credit view for the TUI /credits command.
|
||||
|
||||
184
ui-tui/src/app/usePet.ts
Normal file
184
ui-tui/src/app/usePet.ts
Normal file
@ -0,0 +1,184 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import type { PetGrid } from '../components/petSprite.js'
|
||||
|
||||
import { useGateway } from './gatewayContext.js'
|
||||
import { $turnState } from './turnStore.js'
|
||||
import { $uiState } from './uiStore.js'
|
||||
|
||||
export type PetState = 'idle' | 'wave' | 'run' | 'failed' | 'review' | 'jump'
|
||||
|
||||
interface PetActivity {
|
||||
busy: boolean
|
||||
toolRunning: boolean
|
||||
reasoning: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the animation state — mirrors `agent.pet.state.derive_pet_state`
|
||||
* (and the desktop's `derivePetState`) so all surfaces agree.
|
||||
*/
|
||||
export function derivePetState({ busy, toolRunning, reasoning }: PetActivity): PetState {
|
||||
if (toolRunning) {
|
||||
return 'run'
|
||||
}
|
||||
|
||||
if (reasoning) {
|
||||
return 'review'
|
||||
}
|
||||
|
||||
if (busy) {
|
||||
return 'run'
|
||||
}
|
||||
|
||||
return 'idle'
|
||||
}
|
||||
|
||||
interface PetCellsResult {
|
||||
enabled?: boolean
|
||||
frameMs?: number
|
||||
frames?: PetGrid[]
|
||||
state?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives the TUI pet: derives the live state from the turn/ui stores, lazily
|
||||
* fetches each state's half-block frames via the `pet.cells` RPC (cached),
|
||||
* and animates the frame index. Returns the grid to paint, or null when no
|
||||
* pet is enabled/installed.
|
||||
*/
|
||||
export function usePet(): { enabled: boolean; grid: PetGrid | null } {
|
||||
const { rpc } = useGateway()
|
||||
const [enabled, setEnabled] = useState(false)
|
||||
const [grid, setGrid] = useState<PetGrid | null>(null)
|
||||
|
||||
const cache = useRef<Map<PetState, { frameMs: number; frames: PetGrid[] }>>(new Map())
|
||||
const stateRef = useRef<PetState>('idle')
|
||||
const frameRef = useRef(0)
|
||||
const probed = useRef(false)
|
||||
|
||||
// Recompute the desired state on every turn/ui change.
|
||||
const [petState, setPetState] = useState<PetState>('idle')
|
||||
useEffect(() => {
|
||||
const recompute = () => {
|
||||
const turn = $turnState.get()
|
||||
const ui = $uiState.get()
|
||||
|
||||
const next = derivePetState({
|
||||
busy: ui.busy,
|
||||
toolRunning: turn.tools.length > 0,
|
||||
reasoning: turn.reasoningActive
|
||||
})
|
||||
|
||||
stateRef.current = next
|
||||
setPetState(next)
|
||||
}
|
||||
|
||||
recompute()
|
||||
const unsubTurn = $turnState.listen(recompute)
|
||||
const unsubUi = $uiState.listen(recompute)
|
||||
|
||||
return () => {
|
||||
unsubTurn()
|
||||
unsubUi()
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Fetch frames for the current state (lazily, cached).
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
if (cache.current.has(petState)) {
|
||||
frameRef.current = 0
|
||||
|
||||
return
|
||||
}
|
||||
void (async () => {
|
||||
try {
|
||||
const res = (await rpc('pet.cells', { state: petState })) as PetCellsResult | null
|
||||
|
||||
if (cancelled || !res) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!probed.current) {
|
||||
probed.current = true
|
||||
setEnabled(Boolean(res.enabled))
|
||||
}
|
||||
|
||||
if (res.enabled && res.frames?.length) {
|
||||
cache.current.set(petState, { frameMs: res.frameMs ?? 180, frames: res.frames })
|
||||
frameRef.current = 0
|
||||
}
|
||||
} catch {
|
||||
// cosmetic — ignore RPC failures
|
||||
}
|
||||
})()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [petState, rpc])
|
||||
|
||||
// While no pet is active, poll `pet.cells` so an in-app `/pet <slug>` (which
|
||||
// writes display.pet.* from the slash worker) lights the pet up live — no
|
||||
// restart. Stops once a pet is enabled.
|
||||
useEffect(() => {
|
||||
if (enabled) {
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
|
||||
const probe = async () => {
|
||||
try {
|
||||
const res = (await rpc('pet.cells', { state: stateRef.current })) as PetCellsResult | null
|
||||
|
||||
if (cancelled || !res?.enabled || !res.frames?.length) {
|
||||
return
|
||||
}
|
||||
|
||||
cache.current.set(stateRef.current, { frameMs: res.frameMs ?? 180, frames: res.frames })
|
||||
frameRef.current = 0
|
||||
setEnabled(true)
|
||||
} catch {
|
||||
// cosmetic — ignore RPC failures
|
||||
}
|
||||
}
|
||||
|
||||
const timer = setInterval(() => void probe(), 3000)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearInterval(timer)
|
||||
}
|
||||
}, [enabled, rpc])
|
||||
|
||||
// Animation timer.
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
return
|
||||
}
|
||||
|
||||
const tick = () => {
|
||||
const entry = cache.current.get(stateRef.current)
|
||||
|
||||
if (!entry || !entry.frames.length) {
|
||||
setGrid(null)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const idx = frameRef.current % entry.frames.length
|
||||
setGrid(entry.frames[idx] ?? null)
|
||||
frameRef.current = idx + 1
|
||||
}
|
||||
|
||||
tick()
|
||||
const interval = setInterval(tick, 160)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [enabled, petState])
|
||||
|
||||
return { enabled, grid }
|
||||
}
|
||||
@ -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'
|
||||
@ -26,9 +27,27 @@ import { FpsOverlay } from './fpsOverlay.js'
|
||||
import { HelpHint } from './helpHint.js'
|
||||
import { MessageLine } from './messageLine.js'
|
||||
import { QueuedMessages } from './queuedMessages.js'
|
||||
import { PetSprite } from './petSprite.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 <slug>`),
|
||||
// so it's a no-op for everyone else.
|
||||
const PetPane = memo(function PetPane() {
|
||||
const { enabled, grid } = usePet()
|
||||
|
||||
if (!enabled || !grid) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<NoSelect alignItems="flex-end" flexShrink={0} paddingX={1}>
|
||||
<PetSprite grid={grid} />
|
||||
</NoSelect>
|
||||
)
|
||||
})
|
||||
|
||||
const PromptPrefix = memo(function PromptPrefix({
|
||||
bold = false,
|
||||
color,
|
||||
@ -420,6 +439,8 @@ export const AppLayout = memo(function AppLayout({
|
||||
|
||||
{!overlay.agents && (
|
||||
<>
|
||||
<PetPane />
|
||||
|
||||
<PerfPane id="prompt">
|
||||
<PromptZone
|
||||
cols={composer.cols}
|
||||
|
||||
43
ui-tui/src/components/petSprite.tsx
Normal file
43
ui-tui/src/components/petSprite.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
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 HALF_BLOCK = '▀'
|
||||
|
||||
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 (
|
||||
<Box flexDirection="column">
|
||||
{grid.map((row, y) => (
|
||||
<Box key={y}>
|
||||
{row.map((cell, x) => {
|
||||
const [tr, tg, tb, ta, br, bg, bb, ba] = cell
|
||||
if ((ta ?? 0) < 32 && (ba ?? 0) < 32) {
|
||||
return <Text key={x}> </Text>
|
||||
}
|
||||
return (
|
||||
<Text backgroundColor={hex(br, bg, bb)} color={hex(tr, tg, tb)} key={x}>
|
||||
{HALF_BLOCK}
|
||||
</Text>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user