Compare commits

..

3 commits

Author SHA1 Message Date
teknium
36ae958473 feat(gateway): gate message timestamps behind opt-in (default off)
Follow-up to salvaged PR #41633: the timestamp prefix injection was
unconditional. Gate the in-context render behind
gateway.message_timestamps.enabled (default false) at both the live-message
and history-replay sites; timestamp metadata is still captured + persisted
regardless so the toggle can be flipped on later. Add DEFAULT_CONFIG entry,
docs, and gate tests.
2026-06-16 15:49:59 -07:00
Wolfram Ravenwolf
bd7fc8fdcd feat(gateway): inject stable human-readable message timestamps
Consolidates these related Amy fork patches:
- 429830f39 feat(gateway): inject message timestamps into user messages for LLM context
- 3c3d6fac0 fix: handle both ISO string and epoch float timestamps in history replay
- 2874f7725 feat: human-friendly timestamp format with weekday and timezone name
- 3735f4c8b fix: render gateway message timestamps once
2026-06-16 15:49:59 -07:00
brooklyn!
b7f0c9cd52
fix(desktop): honor pre-session model pick + restore global reasoning/speed defaults (#47447)
* fix(desktop): keep the pre-session model pick selected in the picker

The composer picker derived its "current" row from `model.options ?? store`,
so model.options always won. Pre-session that query returns the PROFILE
DEFAULT, not the sticky composer pick — so selecting a model before a session
exists left the checkmark (and the picker's "current" line) on the default,
making the pick look ignored even though the pill updated.

Add `currentPickerSelection()`: with a live session the gateway's model.options
is authoritative; pre-session the sticky `$currentModel`/`$currentProvider`
wins, falling back to options. Wire it into ModelMenuPanel and ModelPickerDialog.

* feat(desktop): global reasoning/speed defaults in Settings → Model

The composer picker is now sticky-UI/per-session only and never writes the
profile default (#46959), but Settings → Model had no reasoning/speed control
and `agent.reasoning_effort` wasn't in the curated config surface at all
(`service_tier` was buried in Advanced) — so there was nowhere to set the
profile default that crons/subagents/messaging resolve from.

Add capability-gated Reasoning (effort) + Fast controls beside the main model,
gated by the applied model's reported capabilities (reasoning defaults on, fast
off when unreported — same as the composer). They read/write `agent.reasoning_effort`
and `agent.service_tier` by round-tripping the config record, matching the
gateway's value semantics (service_tier "fast"/"priority"/"on" ⇒ fast).

* refactor(desktop): don't open the reasoning select from its row label

A <label> wrapping the Select forwarded text clicks to the trigger, opening
the dropdown unexpectedly. Plain row for reasoning; Fast stays a <label> so
clicking its text toggles the switch (expected for a checkbox-like control).
2026-06-16 16:22:09 -05:00
78 changed files with 959 additions and 6183 deletions

View file

@ -599,6 +599,7 @@ def init_agent(
# (e.g. CLI voice mode adds a temporary prefix for the live call only).
agent._persist_user_message_idx = None
agent._persist_user_message_override = None
agent._persist_user_message_timestamp = None
# Cache anthropic image-to-text fallbacks per image payload/URL so a
# single tool loop does not repeatedly re-run auxiliary vision on the

View file

@ -474,6 +474,7 @@ def run_conversation(
task_id: str = None,
stream_callback: Optional[callable] = None,
persist_user_message: Optional[str] = None,
persist_user_timestamp: Optional[float] = None,
) -> Dict[str, Any]:
"""
Run a complete conversation with tool calling until completion.
@ -489,6 +490,8 @@ def run_conversation(
persist_user_message: Optional clean user message to store in
transcripts/history when user_message contains API-only
synthetic prefixes.
persist_user_timestamp: Optional platform event timestamp to store
as metadata on that persisted user message.
or queuing follow-up prefetch work.
Returns:
@ -510,6 +513,7 @@ def run_conversation(
task_id,
stream_callback,
persist_user_message,
persist_user_timestamp,
restore_or_build_system_prompt=_restore_or_build_system_prompt,
install_safe_stdio=_install_safe_stdio,
sanitize_surrogates=_sanitize_surrogates,

View file

@ -1,51 +0,0 @@
"""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",
]

View file

@ -1,117 +0,0 @@
"""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). Per the petdex package format a spritesheet is an
# 8-row × 9-column grid of these frames (1728×1664 px): one state per row (see
# ``STATE_ROWS``), frames stepping left→right across the 9 columns. We only
# read ``FRAMES_PER_STATE`` (6) of each row; renderers derive the real column
# count from the sheet width, so sheets with a different column count still work.
FRAME_W = 192
FRAME_H = 208
# Frames consumed per animation state (the petdex web app uses CSS
# ``steps(6)``). A sheet may physically contain more columns; we only step
# through the first ``FRAMES_PER_STATE``.
FRAMES_PER_STATE = 6
# Full-loop duration for one state, milliseconds (petdex default).
LOOP_MS = 1100
# Default on-screen scale relative to native frame size. ``display.pet.scale``
# is the single master scalar: the desktop canvas multiplies its native pixels
# by it and every terminal surface derives its half-block/kitty column width
# from it (see :func:`cols_for_scale`), so one number shrinks all three
# interfaces together. (petdex's own clients render at 0.7; we default smaller
# so the kitty/GUI mascot stays a glanceable corner sprite. The half-block
# fallback can't shrink as far — see ``UNICODE_MIN_COLS`` — and clamps to its
# legibility floor instead.)
DEFAULT_SCALE = 0.33
# User-settable scale bounds (``/pet scale``, desktop slider). Floor keeps the
# pet clickable/visible; ceiling stops a fat-fingered value from filling the
# screen. The unicode fallback additionally clamps to ``UNICODE_MIN_COLS``.
MIN_SCALE = 0.1
MAX_SCALE = 3.0
def clamp_scale(scale: float) -> float:
"""Clamp *scale* to ``[MIN_SCALE, MAX_SCALE]`` (the single validation point)."""
return max(MIN_SCALE, min(MAX_SCALE, scale))
# Terminal cells one native frame spans at ``scale == 1.0``. A cell is ~8px
# wide, a frame is ``FRAME_W`` (192) px → 24 cells. This mirrors the kitty
# graphics placement (``scaled_px // 8``) so at full scale every renderer agrees.
BASE_UNICODE_COLS = FRAME_W // 8
# Legibility floor for the half-block fallback. A half-block cell samples the
# sprite at only 1 horizontal + 2 vertical taps, so below this width a 192×208
# pet collapses into an unreadable blob *regardless* of scale. kitty/GUI draw
# true pixels and have no such floor — that's why the same ``scale: 0.33`` is
# crisp there but mush in half-blocks. ``scale`` shrinks the unicode pet down
# TO this floor (and grows it above), instead of past it into noise.
UNICODE_MIN_COLS = 16
def cols_for_scale(scale: float) -> int:
"""Half-block width implied by *scale*, clamped to the legibility floor.
Above the floor it tracks the kitty cell box (``scaled_px // 8``) so the two
renderers converge at larger sizes; below it the floor keeps the sprite
readable rather than letting it devolve into a blob.
"""
return max(UNICODE_MIN_COLS, round(BASE_UNICODE_COLS * (scale or DEFAULT_SCALE)))
def resolve_cols(scale: float, unicode_cols: int = 0) -> int:
"""Resolve terminal width: explicit *unicode_cols* override, else from *scale*."""
return int(unicode_cols) if unicode_cols and int(unicode_cols) > 0 else cols_for_scale(scale)
class PetState(str, Enum):
"""Animation state a pet can be shown in.
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

View file

@ -1,128 +0,0 @@
"""Fetch the public petdex manifest.
``https://petdex.dev/api/manifest`` 307-redirects to a JSON document on R2:
{
"generatedAt": "...",
"total": 2926,
"pets": [
{"slug": "boba", "displayName": "Boba", "kind": "creature",
"submittedBy": "railly",
"spritesheetUrl": "https://assets.petdex.dev/.../spritesheet.webp",
"petJsonUrl": "https://assets.petdex.dev/.../pet.json",
"zipUrl": "https://assets.petdex.dev/.../boba.zip"},
...
]
}
Read-only and unauthenticated; no credentials involved.
"""
from __future__ import annotations
import logging
import time
from dataclasses import dataclass
logger = logging.getLogger(__name__)
MANIFEST_URL = "https://petdex.dev/api/manifest"
_DEFAULT_TIMEOUT = 20.0
# In-process cache for the (large, slow, identical-per-call) manifest. The list
# is a static CDN object that barely changes, yet a single session can ask for
# it many times — every gallery open, plus a full re-fetch per install/select
# (``find_entry``). A short TTL collapses those into one network hit without
# going stale for long. Cleared by :func:`clear_cache` (tests).
_MANIFEST_TTL = 300.0
_cache: tuple[float, list[ManifestEntry]] | None = None
def clear_cache() -> None:
"""Drop the cached manifest (forces the next fetch to hit the network)."""
global _cache
_cache = None
@dataclass(frozen=True)
class ManifestEntry:
"""A single pet's row in the manifest."""
slug: str
display_name: str
kind: str
submitted_by: str
spritesheet_url: str
pet_json_url: str
zip_url: str
@classmethod
def from_dict(cls, data: dict) -> "ManifestEntry":
return cls(
slug=str(data.get("slug", "")).strip(),
display_name=str(data.get("displayName", "") or data.get("slug", "")),
kind=str(data.get("kind", "") or "pet"),
submitted_by=str(data.get("submittedBy", "") or ""),
spritesheet_url=str(data.get("spritesheetUrl", "") or ""),
pet_json_url=str(data.get("petJsonUrl", "") or ""),
zip_url=str(data.get("zipUrl", "") or ""),
)
class ManifestError(RuntimeError):
"""Raised when the manifest can't be fetched or parsed."""
def fetch_manifest(*, timeout: float = _DEFAULT_TIMEOUT, force: bool = False) -> list[ManifestEntry]:
"""Return every approved pet from the public manifest.
Cached in-process for ``_MANIFEST_TTL`` seconds (pass ``force=True`` to
bypass). Follows the 307 redirect to R2. Raises :class:`ManifestError` on
any network/parse failure so callers can surface a clean message.
"""
global _cache
if not force and _cache is not None and time.monotonic() - _cache[0] < _MANIFEST_TTL:
return _cache[1]
try:
import httpx
except ImportError as exc: # pragma: no cover - httpx is a core dep
raise ManifestError("httpx is required to fetch the petdex manifest") from exc
try:
resp = httpx.get(
MANIFEST_URL,
timeout=timeout,
follow_redirects=True,
headers={"User-Agent": "hermes-agent-petdex"},
)
resp.raise_for_status()
payload = resp.json()
except Exception as exc: # noqa: BLE001 - normalize to one error type
raise ManifestError(f"could not fetch petdex manifest: {exc}") from exc
pets = payload.get("pets") if isinstance(payload, dict) else None
if not isinstance(pets, list):
raise ManifestError("petdex manifest had no 'pets' array")
entries: list[ManifestEntry] = []
for raw in pets:
if not isinstance(raw, dict):
continue
entry = ManifestEntry.from_dict(raw)
if entry.slug and entry.spritesheet_url:
entries.append(entry)
_cache = (time.monotonic(), entries)
return entries
def find_entry(slug: str, *, timeout: float = _DEFAULT_TIMEOUT) -> ManifestEntry | None:
"""Return the manifest entry for *slug*, or ``None`` if not listed."""
slug = slug.strip().lower()
for entry in fetch_manifest(timeout=timeout):
if entry.slug.lower() == slug:
return entry
return None

View file

@ -1,617 +0,0 @@
"""Decode a pet spritesheet and encode frames for a terminal.
Shared by the base CLI (writes the escape bytes to its own stdout) and the
TUI (``tui_gateway`` ships the encoded bytes to Ink, which writes them) so the
decode + capability-detection + protocol-encoding logic exists exactly once.
Supported output modes, in fidelity order:
- ``kitty`` the kitty graphics protocol (kitty, Ghostty, WezTerm).
- ``iterm`` iTerm2 inline images (iTerm2, WezTerm).
- ``sixel`` DEC sixel (xterm -ti vt340, foot, mlterm, WezTerm, ).
- ``unicode`` 24-bit half-block downscale; works in any truecolor terminal.
Frame decoding requires Pillow (a core Hermes dependency). If Pillow or the
spritesheet is unavailable the renderer degrades to ``unicode`` text or an
empty string rather than raising.
"""
from __future__ import annotations
import base64
import io
import logging
import os
import sys
from functools import lru_cache
from pathlib import Path
from agent.pet.constants import (
DEFAULT_SCALE,
FRAME_H,
FRAME_W,
FRAMES_PER_STATE,
PetState,
state_row_index,
)
logger = logging.getLogger(__name__)
# Public render-mode names accepted by ``display.pet.render_mode``.
RENDER_MODES = ("auto", "kitty", "iterm", "sixel", "unicode", "off")
# ─────────────────────────────────────────────────────────────────────────
# Terminal capability detection
# ─────────────────────────────────────────────────────────────────────────
def detect_terminal_graphics() -> str:
"""Best-effort detection of the richest graphics protocol available.
Env-based (non-blocking we never issue a DA1/terminal query that could
hang a pipe). Returns one of ``kitty`` / ``iterm`` / ``sixel`` /
``unicode``. Conservative: unknown terminals get ``unicode``, which works
anywhere with truecolor.
"""
term = os.environ.get("TERM", "").lower()
term_program = os.environ.get("TERM_PROGRAM", "").lower()
# The VS Code / Cursor integrated terminal sets TERM_PROGRAM=vscode
# authoritatively but does NOT scrub the terminal env vars it inherits when
# launched from another emulator (ITERM_SESSION_ID, KITTY_WINDOW_ID, …).
# Trusting those leaks emits an image protocol the embedded xterm.js can't
# display — you get a blank frame. Inline images there are opt-in
# (terminal.integrated.enableImages), so default to half-blocks, which
# always render in its truecolor grid. Users who enabled images can pin
# display.pet.render_mode explicitly.
if term_program == "vscode":
return "unicode"
# kitty graphics protocol
if os.environ.get("KITTY_WINDOW_ID") or "kitty" in term or "ghostty" in term:
return "kitty"
if term_program in {"ghostty"}:
return "kitty"
# WezTerm speaks both kitty and iterm; prefer kitty (richer placement).
if term_program == "wezterm" or os.environ.get("WEZTERM_PANE"):
return "kitty"
# iTerm2 inline images
if term_program == "iterm.app" or os.environ.get("ITERM_SESSION_ID"):
return "iterm"
# sixel-capable terminals (env heuristics only)
if term_program in {"mintty"} or "foot" in term or "mlterm" in term:
return "sixel"
if "sixel" in term:
return "sixel"
return "unicode"
def resolve_mode(configured: str | None, *, stream=None) -> str:
"""Resolve the effective render mode from config + the environment.
``configured`` is ``display.pet.render_mode`` (``auto`` detect). Returns
``off`` when not attached to a TTY (no point emitting graphics into a pipe
or logfile).
"""
mode = (configured or "auto").strip().lower()
if mode not in RENDER_MODES:
mode = "auto"
if mode == "off":
return "off"
stream = stream or sys.stdout
try:
if not (hasattr(stream, "isatty") and stream.isatty()):
return "off"
except (ValueError, OSError):
return "off"
if mode == "auto":
return detect_terminal_graphics()
return mode
# ─────────────────────────────────────────────────────────────────────────
# Frame decoding
# ─────────────────────────────────────────────────────────────────────────
def _open_sheet(path: Path):
from PIL import Image
img = Image.open(path)
return img.convert("RGBA")
# Max alpha at/below which a frame counts as blank padding. petdex sheets are
# left-packed: a state with fewer real frames than ``FRAMES_PER_STATE`` fills
# the trailing columns with fully transparent cells. Animating into one flashes
# the pet blank, so we stop the row at the first such gap.
_BLANK_ALPHA = 8
def _frame_is_blank(frame) -> bool:
"""True if *frame* has no meaningfully opaque pixel (transparent padding)."""
return frame.getchannel("A").getextrema()[1] <= _BLANK_ALPHA
@lru_cache(maxsize=16)
def _raw_frames(
sheet_path: str,
state_value: str,
frame_w: int,
frame_h: int,
frames_per_state: int,
) -> tuple:
"""Cropped, padding-trimmed RGBA frames for one state row (unscaled).
Steps across the row until the first blank column so pets with ragged
per-state frame counts never animate into empty padding. Cached; returns
``()`` on any decode failure.
"""
try:
sheet = _open_sheet(Path(sheet_path))
cols = max(1, sheet.width // frame_w)
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(min(frames_per_state, cols)):
left = i * frame_w
frame = sheet.crop((left, top, left + frame_w, top + frame_h))
if _frame_is_blank(frame):
break # trailing transparent padding — real frames end here
frames.append(frame)
return tuple(frames)
except Exception as exc: # noqa: BLE001 - cosmetic feature, never fatal
logger.debug("pet frame decode failed (%s, %s): %s", sheet_path, state_value, exc)
return ()
@lru_cache(maxsize=8)
def _frames_for(
sheet_path: str,
state_value: str,
frame_w: int,
frame_h: int,
frames_per_state: int,
scale_w: int,
scale_h: int,
):
"""Return padding-trimmed RGBA frames for one state row, scaled.
Thin scaling layer over :func:`_raw_frames`; both are cached so repeated
frame requests during animation are free.
"""
raw = _raw_frames(sheet_path, state_value, frame_w, frame_h, frames_per_state)
if not raw or (scale_w, scale_h) == (frame_w, frame_h):
return list(raw)
from PIL import Image
return [f.resize((scale_w, scale_h), Image.LANCZOS) for f in raw]
def state_frame_counts(
sheet_path: str | Path,
*,
frame_w: int = FRAME_W,
frame_h: int = FRAME_H,
frames_per_state: int = FRAMES_PER_STATE,
) -> dict[str, int]:
"""Map each driven :class:`PetState` → its real (padding-trimmed) frame count.
The single source of truth for "how many frames does this state actually
have?". The CLI/TUI consume the trimmed frame lists directly; the gateway
ships this map to the desktop canvas, which steps its own loop.
"""
return {
state.value: len(
_raw_frames(str(sheet_path), state.value, frame_w, frame_h, frames_per_state)
)
for state in PetState
}
# ─────────────────────────────────────────────────────────────────────────
# Encoders
# ─────────────────────────────────────────────────────────────────────────
def _png_bytes(frame) -> bytes:
buf = io.BytesIO()
frame.save(buf, format="PNG")
return buf.getvalue()
def _kitty_apc(ctrl: str, data: str) -> str:
"""Emit a kitty APC escape for *data*, chunked into ≤4096-byte ``m`` pieces."""
chunk = 4096
if len(data) <= chunk:
return f"\x1b_G{ctrl},m=0;{data}\x1b\\"
out = [f"\x1b_G{ctrl},m=1;{data[:chunk]}\x1b\\"]
rest = data[chunk:]
while rest:
piece, rest = rest[:chunk], rest[chunk:]
out.append(f"\x1b_Gm={1 if rest else 0};{piece}\x1b\\")
return "".join(out)
def _encode_kitty(frame, *, cell_cols: int | None = None, cell_rows: int | None = None) -> str:
"""Encode one frame via the kitty graphics protocol (transmit + display).
``a=T`` transmits & displays at the cursor; ``c``/``r`` request a display
box in terminal cells so successive frames overwrite the same area.
"""
ctrl = "f=100,a=T,q=2"
if cell_cols:
ctrl += f",c={cell_cols}"
if cell_rows:
ctrl += f",r={cell_rows}"
return _kitty_apc(ctrl, base64.standard_b64encode(_png_bytes(frame)).decode("ascii"))
# ─────────────────────────────────────────────────────────────────────────
# kitty Unicode placeholders
#
# Ink (the TUI's React-for-terminal layer) owns the screen and measures every
# cell's width, so it can't host raw kitty image escapes (no width to count,
# clobbered on the next repaint). kitty's *Unicode placeholder* protocol is the
# grid-safe path: transmit the image once (q=2, virtual placement U=1), then the
# host app prints ordinary-width placeholder cells (U+10EEEE + diacritics) whose
# foreground color encodes the image id. Ink counts those as width-1 text, so
# layout stays correct and the terminal paints the image underneath.
# https://sw.kovidgoyal.net/kitty/graphics-protocol/#unicode-placeholders
# ─────────────────────────────────────────────────────────────────────────
_KITTY_PLACEHOLDER = "\U0010eeee"
# Row/column diacritics, in order (index → diacritic). Verbatim from kitty's
# gen/rowcolumn-diacritics.txt (Unicode 6.0.0, combining class 230). Index i is
# the diacritic that encodes the number i; we only ever need the row index.
_ROWCOL_DIACRITICS: tuple[int, ...] = (
0x0305, 0x030D, 0x030E, 0x0310, 0x0312, 0x033D, 0x033E, 0x033F, 0x0346, 0x034A,
0x034B, 0x034C, 0x0350, 0x0351, 0x0352, 0x0357, 0x035B, 0x0363, 0x0364, 0x0365,
0x0366, 0x0367, 0x0368, 0x0369, 0x036A, 0x036B, 0x036C, 0x036D, 0x036E, 0x036F,
0x0483, 0x0484, 0x0485, 0x0486, 0x0487, 0x0592, 0x0593, 0x0594, 0x0595, 0x0597,
0x0598, 0x0599, 0x059C, 0x059D, 0x059E, 0x059F, 0x05A0, 0x05A1, 0x05A8, 0x05A9,
0x05AB, 0x05AC, 0x05AF, 0x05C4, 0x0610, 0x0611, 0x0612, 0x0613, 0x0614, 0x0615,
0x0616, 0x0617, 0x0657, 0x0658, 0x0659, 0x065A, 0x065B, 0x065D, 0x065E, 0x06D6,
0x06D7, 0x06D8, 0x06D9, 0x06DA, 0x06DB, 0x06DC, 0x06DF, 0x06E0, 0x06E1, 0x06E2,
0x06E4, 0x06E7, 0x06E8, 0x06EB, 0x06EC, 0x0730, 0x0732, 0x0733, 0x0735, 0x0736,
0x073A, 0x073D, 0x073F, 0x0740, 0x0741, 0x0743, 0x0745, 0x0747, 0x0749, 0x074A,
0x07EB, 0x07EC, 0x07ED, 0x07EE, 0x07EF, 0x07F0, 0x07F1, 0x07F3, 0x0816, 0x0817,
0x0818, 0x0819, 0x081B, 0x081C, 0x081D, 0x081E, 0x081F, 0x0820, 0x0821, 0x0822,
0x0823, 0x0825, 0x0826, 0x0827, 0x0829, 0x082A, 0x082B, 0x082C, 0x082D, 0x0951,
0x0953, 0x0954, 0x0F82, 0x0F83, 0x0F86, 0x0F87, 0x135D, 0x135E, 0x135F, 0x17DD,
0x193A, 0x1A17, 0x1A75, 0x1A76, 0x1A77, 0x1A78, 0x1A79, 0x1A7A, 0x1A7B, 0x1A7C,
0x1B6B, 0x1B6D, 0x1B6E, 0x1B6F, 0x1B70, 0x1B71, 0x1B72, 0x1B73, 0x1CD0, 0x1CD1,
0x1CD2, 0x1CDA, 0x1CDB, 0x1CE0, 0x1DC0, 0x1DC1, 0x1DC3, 0x1DC4, 0x1DC5, 0x1DC6,
0x1DC7, 0x1DC8, 0x1DC9, 0x1DCB, 0x1DCC, 0x1DD1, 0x1DD2, 0x1DD3, 0x1DD4, 0x1DD5,
0x1DD6, 0x1DD7, 0x1DD8, 0x1DD9, 0x1DDA, 0x1DDB, 0x1DDC, 0x1DDD, 0x1DDE, 0x1DDF,
0x1DE0, 0x1DE1, 0x1DE2, 0x1DE3, 0x1DE4, 0x1DE5, 0x1DE6, 0x1DFE, 0x20D0, 0x20D1,
0x20D4, 0x20D5, 0x20D6, 0x20D7, 0x20DB, 0x20DC, 0x20E1, 0x20E7, 0x20E9, 0x20F0,
0x2CEF, 0x2CF0, 0x2CF1, 0x2DE0, 0x2DE1, 0x2DE2, 0x2DE3, 0x2DE4, 0x2DE5, 0x2DE6,
0x2DE7, 0x2DE8, 0x2DE9, 0x2DEA, 0x2DEB, 0x2DEC, 0x2DED, 0x2DEE, 0x2DEF, 0x2DF0,
0x2DF1, 0x2DF2, 0x2DF3, 0x2DF4, 0x2DF5, 0x2DF6, 0x2DF7, 0x2DF8, 0x2DF9, 0x2DFA,
0x2DFB, 0x2DFC, 0x2DFD, 0x2DFE, 0x2DFF, 0xA66F, 0xA67C, 0xA67D, 0xA6F0, 0xA6F1,
0xA8E0, 0xA8E1, 0xA8E2, 0xA8E3, 0xA8E4, 0xA8E5, 0xA8E6, 0xA8E7, 0xA8E8, 0xA8E9,
0xA8EA, 0xA8EB, 0xA8EC, 0xA8ED, 0xA8EE, 0xA8EF, 0xA8F0, 0xA8F1, 0xAAB0, 0xAAB2,
0xAAB3, 0xAAB7, 0xAAB8, 0xAABE, 0xAABF, 0xAAC1, 0xFE20, 0xFE21, 0xFE22, 0xFE23,
0xFE24, 0xFE25, 0xFE26, 0x10A0F, 0x10A38, 0x1D185, 0x1D186, 0x1D187, 0x1D188,
0x1D189, 0x1D1AA, 0x1D1AB, 0x1D1AC, 0x1D1AD, 0x1D242, 0x1D243, 0x1D244,
)
def kitty_image_id(slug: str) -> int:
"""Stable per-pet image id in ``[1, 0x7FFF]``.
The id is encoded in the placeholder's 24-bit foreground color, so it must
be non-zero and fit comfortably under ``0xFFFFFF``. A small CRC keeps it
deterministic per slug (so re-renders reuse the same terminal-side image)
while making collisions between two different pets unlikely.
"""
import zlib
return (zlib.crc32(slug.encode("utf-8")) % 0x7FFE) + 1
def kitty_color_hex(image_id: int) -> str:
"""Hex foreground color (``#rrggbb``) that encodes *image_id* for kitty."""
return "#%06x" % (image_id & 0xFFFFFF)
def kitty_placeholder_rows(cols: int, rows: int) -> list[str]:
"""Build the placeholder text grid for an *rows*×*cols* image.
Each line is one row of the grid: the first cell carries the row diacritic
(column defaults to 0), and the remaining ``cols-1`` bare placeholders let
the terminal auto-increment the column. The foreground color (the image id)
is applied by the caller / Ink, not embedded here.
"""
cols = max(1, cols)
out: list[str] = []
for r in range(max(1, rows)):
idx = min(r, len(_ROWCOL_DIACRITICS) - 1)
first = _KITTY_PLACEHOLDER + chr(_ROWCOL_DIACRITICS[idx])
out.append(first + _KITTY_PLACEHOLDER * (cols - 1))
return out
def _encode_kitty_virtual(frame, *, image_id: int, cols: int, rows: int) -> str:
"""Transmit a frame as a kitty *virtual* placement for Unicode placeholders.
``a=T`` transmits and creates the placement in one shot; ``U=1`` marks it
virtual (no on-screen output, cursor untouched); ``q=2`` suppresses the
terminal's OK/error replies that would otherwise corrupt the host app's
output. Re-sending with the same ``i`` replaces the image, so the static
placeholder cells animate underneath.
"""
ctrl = f"a=T,U=1,i={image_id},c={cols},r={rows},f=100,q=2"
return _kitty_apc(ctrl, base64.standard_b64encode(_png_bytes(frame)).decode("ascii"))
def _encode_iterm(frame, *, cell_cols: int | None = None, cell_rows: int | None = None) -> str:
"""Encode one frame as an iTerm2 inline image (OSC 1337 File)."""
payload = base64.standard_b64encode(_png_bytes(frame)).decode("ascii")
size = len(payload)
args = [f"inline=1", f"size={size}", "preserveAspectRatio=1"]
if cell_cols:
args.append(f"width={cell_cols}")
if cell_rows:
args.append(f"height={cell_rows}")
return f"\x1b]1337;File={';'.join(args)}:{payload}\x07"
def _encode_sixel(frame) -> str:
"""Encode one frame as DEC sixel.
Quantizes to an adaptive palette (255 colors) and emits the sixel band
stream. Pillow has no sixel writer, so this is a compact hand-rolled
encoder. Transparent pixels render as background (color register skipped).
"""
from PIL import Image
rgba = frame
# Composite onto transparent-as-skip: track alpha to decide background.
pal = rgba.convert("RGB").quantize(colors=255, method=Image.MEDIANCUT)
palette = pal.getpalette() or []
px = pal.load()
alpha = rgba.getchannel("A").load()
w, h = pal.size
out = ["\x1bP0;1;0q", '"1;1;%d;%d' % (w, h)]
# Color register definitions (sixel uses 0..100 scale).
used = sorted({px[x, y] for y in range(h) for x in range(w)})
for idx in used:
r = palette[idx * 3] if idx * 3 < len(palette) else 0
g = palette[idx * 3 + 1] if idx * 3 + 1 < len(palette) else 0
b = palette[idx * 3 + 2] if idx * 3 + 2 < len(palette) else 0
out.append("#%d;2;%d;%d;%d" % (idx, r * 100 // 255, g * 100 // 255, b * 100 // 255))
# Emit in 6-row bands.
for band in range(0, h, 6):
for color_idx in used:
line = ["#%d" % color_idx]
run_char = None
run_len = 0
def flush():
nonlocal run_char, run_len
if run_char is None:
return
if run_len > 3:
line.append("!%d%s" % (run_len, run_char))
else:
line.append(run_char * run_len)
run_char, run_len = None, 0
for x in range(w):
bits = 0
for bit in range(6):
y = band + bit
if y < h and alpha[x, y] > 32 and px[x, y] == color_idx:
bits |= 1 << bit
ch = chr(63 + bits)
if ch == run_char:
run_len += 1
else:
flush()
run_char, run_len = ch, 1
flush()
out.append("".join(line) + "$") # carriage return within band
out.append("-") # next band
out.append("\x1b\\")
return "".join(out)
_HALF_BLOCK = ""
# A single half-block cell: top pixel + bottom pixel as (r, g, b, a) tuples.
Cell = tuple[tuple[int, int, int, int], tuple[int, int, int, int]]
def _downscale_cells(frame, *, target_cols: int) -> list[list[Cell]]:
"""Downscale a frame to a grid of half-block cells.
Each cell pairs a top and bottom pixel so one terminal row encodes two
pixel rows. Returns rows of ``((tr,tg,tb,ta),(br,bg,bb,ba))`` the
framework-neutral representation shared by the ANSI encoder (CLI) and the
structured ``cells`` API (Ink).
"""
from PIL import Image
target_cols = max(4, target_cols)
aspect = frame.height / max(1, frame.width)
target_rows = max(2, int(round(target_cols * aspect * 0.5)) * 2)
small = frame.resize((target_cols, target_rows), Image.LANCZOS).convert("RGBA")
px = small.load()
grid: list[list[Cell]] = []
for y in range(0, target_rows, 2):
row: list[Cell] = []
for x in range(target_cols):
top = px[x, y]
bottom = px[x, y + 1] if y + 1 < target_rows else (0, 0, 0, 0)
row.append((top, bottom))
grid.append(row)
return grid
def _encode_unicode(frame, *, target_cols: int) -> str:
"""Downscale to truecolor ANSI half-blocks (one char = 2 vertical pixels)."""
lines: list[str] = []
for row in _downscale_cells(frame, target_cols=target_cols):
cells: list[str] = []
for (tr, tg, tb, ta), (br, bg, bb, ba) in row:
if ta < 32 and ba < 32:
cells.append("\x1b[0m ") # fully transparent → blank
continue
cells.append(f"\x1b[38;2;{tr};{tg};{tb}m\x1b[48;2;{br};{bg};{bb}m{_HALF_BLOCK}")
lines.append("".join(cells) + "\x1b[0m")
return "\n".join(lines)
# ─────────────────────────────────────────────────────────────────────────
# Public renderer
# ─────────────────────────────────────────────────────────────────────────
class PetRenderer:
"""Holds a pet's spritesheet and yields encoded frames per (state, index).
Construct once per pet, then call :meth:`frame` on an animation timer.
Cheap to call repeatedly decoded frames are cached.
"""
def __init__(
self,
spritesheet: str | Path,
*,
mode: str = "unicode",
scale: float = DEFAULT_SCALE,
unicode_cols: int = 20,
frame_w: int = FRAME_W,
frame_h: int = FRAME_H,
frames_per_state: int = FRAMES_PER_STATE,
) -> None:
self.spritesheet = str(spritesheet)
self.mode = mode if mode in RENDER_MODES else "unicode"
self.scale = scale
self.unicode_cols = unicode_cols
self.frame_w = frame_w
self.frame_h = frame_h
self.frames_per_state = frames_per_state
@property
def available(self) -> bool:
return self.mode != "off" and Path(self.spritesheet).is_file()
def frame_count(self, state: PetState | str) -> int:
return len(self._frames(state))
def _frames(self, state: PetState | str):
value = state.value if isinstance(state, PetState) else str(state)
scale_w = max(1, int(self.frame_w * self.scale))
scale_h = max(1, int(self.frame_h * self.scale))
return _frames_for(
self.spritesheet,
value,
self.frame_w,
self.frame_h,
self.frames_per_state,
scale_w,
scale_h,
)
def cells(self, state: PetState | str, index: int, *, cols: int | None = None) -> list[list[Cell]]:
"""Return one frame as a half-block cell grid (framework-neutral).
Used by the TUI, which renders the grid with native Ink color props
instead of raw ANSI. Returns ``[]`` when no frame is available.
"""
frames = self._frames(state)
if not frames:
return []
frame = frames[index % len(frames)]
return _downscale_cells(frame, target_cols=cols or self.unicode_cols)
def _cell_box(self, frame) -> tuple[int, int]:
"""Terminal cell box for a scaled frame (~8×16 px per cell).
Must match :meth:`frame` graphics sizing kitty stretches the image to
fill ``c``×``r`` cells, so these must reflect the scaled pixel
dimensions, not a native-aspect column count (that upscales small pets).
"""
return max(1, frame.width // 8), max(1, frame.height // 16)
def kitty_payload(self, state: PetState | str, *, image_id: int) -> dict | None:
"""Build the kitty Unicode-placeholder payload for one state.
Returns ``{cols, rows, placeholder, frames}`` where ``frames`` is a
list of transmit escapes (one per animation frame, all reusing
``image_id``) and ``placeholder`` is the static text grid Ink paints.
Placement geometry is derived from the scaled frame pixels (via
:meth:`_cell_box`), not ``unicode_cols`` kitty upscales to fill
``c``×``r`` cells. ``None`` when no frame is available.
"""
frames = self._frames(state)
if not frames:
return None
cols, rows = self._cell_box(frames[0])
return {
"cols": cols,
"rows": rows,
"placeholder": kitty_placeholder_rows(cols, rows),
"frames": [
_encode_kitty_virtual(f, image_id=image_id, cols=cols, rows=rows) for f in frames
],
}
def frame(self, state: PetState | str, index: int) -> str:
"""Return the encoded escape string for one frame, or ``""``.
``index`` is taken modulo the available frame count so callers can pass
a free-running counter.
"""
if self.mode == "off":
return ""
frames = self._frames(state)
if not frames:
return ""
frame = frames[index % len(frames)]
cell_cols, cell_rows = self._cell_box(frame)
try:
if self.mode == "kitty":
return _encode_kitty(frame, cell_cols=cell_cols, cell_rows=cell_rows)
if self.mode == "iterm":
return _encode_iterm(frame, cell_cols=cell_cols, cell_rows=cell_rows)
if self.mode == "sixel":
return _encode_sixel(frame)
return _encode_unicode(frame, target_cols=self.unicode_cols)
except Exception as exc: # noqa: BLE001 - degrade silently
logger.debug("pet frame encode failed (mode=%s): %s", self.mode, exc)
return ""
def build_renderer(
spritesheet: str | Path,
*,
configured_mode: str | None = None,
scale: float = DEFAULT_SCALE,
unicode_cols: int = 20,
stream=None,
) -> PetRenderer:
"""Convenience factory: resolve the mode from config+env, then construct."""
mode = resolve_mode(configured_mode, stream=stream)
return PetRenderer(
spritesheet,
mode=mode,
scale=scale,
unicode_cols=unicode_cols,
)

View file

@ -1,79 +0,0 @@
"""Map agent activity → a :class:`PetState`.
This is the one place the "what is the agent doing right now?" "which
animation row?" decision lives. Each surface feeds it the signals it already
tracks:
- CLI ``KawaiiSpinner`` waiting/thinking state + tool outcomes.
- TUI gateway ``tool.start/complete`` + ``message.delta/complete`` events.
- Desktop the ``$busy``/``$awaitingResponse``/tool-event nanostores
(re-implemented in TS, but mirroring this priority order).
Keeping the priority order here (and documenting it) lets the TypeScript
mirror stay faithful without a second design.
"""
from __future__ import annotations
from collections.abc import Iterable
from typing import Any
from agent.pet.constants import PetState
def todos_all_done(todos: Iterable[Any] | None) -> bool:
"""True iff there's ≥1 todo and every one is completed/cancelled.
The "celebrate" beat (``JUMP``) fires when a plan finishes; this mirrors
the TUI's ``isTodoDone`` so the trigger is defined once across surfaces.
Accepts dicts (``{"status": ...}``) or objects with a ``status`` attr.
"""
items = list(todos or [])
if not items:
return False
def _status(t: Any) -> Any:
return t.get("status") if isinstance(t, dict) else getattr(t, "status", None)
return all(_status(t) in ("completed", "cancelled") for t in items)
def derive_pet_state(
*,
busy: bool = False,
awaiting_input: bool = False,
error: bool = False,
celebrate: bool = False,
just_completed: bool = False,
tool_running: bool = False,
reasoning: bool = False,
) -> PetState:
"""Resolve the animation state from coarse activity signals.
Priority (highest first) only one row can show at a time, so the most
salient signal wins:
1. ``error`` ``FAILED`` (a tool/turn just failed)
2. ``celebrate`` ``JUMP`` (explicit success beat, e.g. todos done)
3. ``just_completed`` ``WAVE`` (turn finished cleanly / greeting)
4. ``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

View file

@ -1,316 +0,0 @@
"""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 {}

View file

@ -69,6 +69,7 @@ def build_turn_context(
task_id: Optional[str],
stream_callback,
persist_user_message: Optional[str],
persist_user_timestamp: Optional[float] = None,
*,
restore_or_build_system_prompt,
install_safe_stdio,
@ -121,6 +122,7 @@ def build_turn_context(
agent._stream_callback = stream_callback
agent._persist_user_message_idx = None
agent._persist_user_message_override = persist_user_message
agent._persist_user_message_timestamp = persist_user_timestamp
# Generate unique task_id if not provided to isolate VMs between tasks.
effective_task_id = task_id or str(uuid.uuid4())
agent._current_task_id = effective_task_id

View file

@ -29,7 +29,6 @@ import {
Moon,
Package,
Palette,
PawPrint,
Plus,
Settings,
Settings2,
@ -40,7 +39,7 @@ import {
Zap
} from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $commandPaletteOpen, $commandPalettePage, closeCommandPalette, setCommandPaletteOpen } from '@/store/command-palette'
import { $commandPaletteOpen, closeCommandPalette, setCommandPaletteOpen } from '@/store/command-palette'
import { $bindings } from '@/store/keybinds'
import { luminance } from '@/themes/color'
import { type ThemeMode, useTheme } from '@/themes/context'
@ -63,7 +62,6 @@ import { fieldCopyForSchemaKey } from '../settings/field-copy'
import { prettyName } from '../settings/helpers'
import { MarketplaceThemePage } from './marketplace-theme-page'
import { PetInlineToggle, PetPalettePage } from './pet-palette-page'
interface PaletteItem {
/** Keybind action id — its live combo renders as a hotkey hint. */
@ -207,7 +205,6 @@ function themeSupportsMode(name: string, target: 'light' | 'dark'): boolean {
export function CommandPalette() {
const { t } = useI18n()
const open = useStore($commandPaletteOpen)
const pendingPage = useStore($commandPalettePage)
const bindings = useStore($bindings)
const navigate = useNavigate()
const { availableThemes, resolvedMode, setMode, setTheme, themeName } = useTheme()
@ -253,14 +250,6 @@ export function CommandPalette() {
}
}, [open])
// Deep-link into a nested page (e.g. `/pet list` → pets picker).
useEffect(() => {
if (open && pendingPage) {
setPage(pendingPage)
$commandPalettePage.set(null)
}
}, [open, pendingPage])
const go = useCallback((path: string) => () => navigate(path), [navigate])
// Step up one nested page (or back to the root list), clearing the filter so
@ -393,13 +382,6 @@ export function CommandPalette() {
keywords: ['appearance', 'color mode', 'brightness', 'dark', 'light', 'system'],
label: cc.changeColorMode,
to: 'color-mode'
},
{
icon: PawPrint,
id: 'appearance-pets',
keywords: ['pet', 'petdex', 'mascot', 'pets', '/pet', 'paw'],
label: cc.pets.title,
to: 'pets'
}
]
},
@ -568,12 +550,6 @@ export function CommandPalette() {
}
]
},
// Server-driven page: browse petdex gallery, adopt/switch, toggle off.
pets: {
title: t.commandCenter.pets.title,
placeholder: t.commandCenter.pets.placeholder,
groups: []
},
// Server-driven page: items come from the Marketplace, rendered by
// <MarketplaceThemePage> (loader + live search + per-row install).
'install-theme': {
@ -648,51 +624,45 @@ export function CommandPalette() {
}}
onValueChange={setSearch}
placeholder={placeholder}
right={page === 'pets' ? <PetInlineToggle /> : undefined}
value={search}
/>
<CommandList className="dt-portal-scrollbar max-h-[min(20rem,56vh)]">
{/* Server-driven pages render their own list; the rest show groups. */}
{page === 'pets' ? (
<PetPalettePage search={search} />
) : page === 'install-theme' ? (
{page === 'install-theme' ? (
<MarketplaceThemePage onPickTheme={setTheme} search={search} />
) : (
<>
<CommandEmpty>{t.commandCenter.noResults}</CommandEmpty>
{visibleGroups.map((group, index) => (
<CommandGroup
className={HUD_HEADING}
heading={group.heading}
key={group.heading ?? `palette-group-${index}`}
>
{group.items.map(item => {
const Icon = item.icon
const combo = item.action ? bindings[item.action]?.[0] : undefined
return (
<CommandItem
className={cn(HUD_ITEM, HUD_TEXT)}
key={item.id}
keywords={item.keywords}
onSelect={() => handleSelect(item)}
value={`${item.label} ${item.keywords?.join(' ') ?? ''} ${item.id}`}
>
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">{item.label}</span>
{combo && <KbdCombo className="ml-auto opacity-55" combo={combo} size="sm" />}
{item.to && (
<ChevronRight
className={cn('size-3.5 shrink-0 text-muted-foreground/70', !combo && 'ml-auto')}
/>
)}
</CommandItem>
)
})}
</CommandGroup>
))}
</>
<CommandEmpty>{t.commandCenter.noResults}</CommandEmpty>
)}
{visibleGroups.map((group, index) => (
<CommandGroup
className={HUD_HEADING}
heading={group.heading}
key={group.heading ?? `palette-group-${index}`}
>
{group.items.map(item => {
const Icon = item.icon
const combo = item.action ? bindings[item.action]?.[0] : undefined
return (
<CommandItem
className={cn(HUD_ITEM, HUD_TEXT)}
key={item.id}
keywords={item.keywords}
onSelect={() => handleSelect(item)}
value={`${item.label} ${item.keywords?.join(' ') ?? ''} ${item.id}`}
>
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">{item.label}</span>
{combo && <KbdCombo className="ml-auto opacity-55" combo={combo} size="sm" />}
{item.to && (
<ChevronRight
className={cn('size-3.5 shrink-0 text-muted-foreground/70', !combo && 'ml-auto')}
/>
)}
</CommandItem>
)
})}
</CommandGroup>
))}
</CommandList>
</Command>
</DialogPrimitive.Content>

View file

@ -1,185 +0,0 @@
/**
* Cmd-K "Pets…" page browse the petdex gallery, adopt/switch, toggle off.
*
* A thin view over the `pet-gallery` store: it subscribes to the shared atoms
* and calls the store's actions. The store owns fetching, caching, the thumb
* cache, and optimistic mutations, so reopening this page is instant and a
* toggle never re-pulls the network gallery.
*/
import { useStore } from '@nanostores/react'
import { useEffect, useMemo } from 'react'
import { HUD_ITEM, HUD_TEXT } from '@/app/floating-hud'
import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
import { PetThumb } from '@/components/pet/pet-thumb'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { Check, Loader2, PawPrint } from '@/lib/icons'
import { cn } from '@/lib/utils'
import {
$petBusy,
$petGallery,
$petGalleryError,
$petGalleryStatus,
adoptPet,
loadPetGallery,
loadPetThumb,
rankedGalleryPets,
setPetEnabled
} from '@/store/pet-gallery'
interface PetPalettePageProps {
search: string
}
export function PetPalettePage({ search }: PetPalettePageProps) {
const { t } = useI18n()
const copy = t.commandCenter.pets
const { requestGateway } = useGatewayRequest()
const gallery = useStore($petGallery)
const status = useStore($petGalleryStatus)
const error = useStore($petGalleryError)
const busy = useStore($petBusy)
useEffect(() => {
void loadPetGallery(requestGateway)
}, [requestGateway])
const enabled = gallery?.enabled ?? false
const active = gallery?.active ?? ''
const shown = useMemo(() => rankedGalleryPets(gallery, search).slice(0, 50), [gallery, search])
const adopt = (slug: string) => {
void adoptPet(requestGateway, slug, copy.adoptFailed).then(ok => ok && triggerHaptic('crisp'))
}
if (status === 'loading' && !gallery) {
return <Status icon={<Loader2 className="size-3.5 animate-spin" />} text={copy.loading} />
}
if (status === 'stale') {
return <Status text={copy.staleBackend} tone="error" />
}
if (!gallery?.pets.length && error) {
return <Status text={error} tone="error" />
}
const mutating = Boolean(busy)
return (
<div role="listbox">
{error && <p className="px-2 pb-1 pt-1.5 text-[0.6875rem] text-(--ui-red)">{error}</p>}
{shown.length === 0 ? (
<Status text={copy.empty} />
) : (
shown.map(pet => {
const isActive = enabled && pet.slug === active
const isBusy = busy === pet.slug
return (
<button
className={cn(
'flex w-full items-center gap-2 rounded-md text-left transition-colors hover:bg-(--chrome-action-hover) disabled:opacity-60',
HUD_ITEM,
HUD_TEXT,
isActive && 'bg-(--chrome-action-hover)/70'
)}
disabled={mutating && !isBusy}
key={pet.slug}
onClick={() => adopt(pet.slug)}
onMouseDown={event => event.preventDefault()}
role="option"
type="button"
>
<PetThumb
alt={pet.displayName}
load={(slug, url) => loadPetThumb(requestGateway, slug, url)}
size={32}
slug={pet.slug}
url={pet.spritesheetUrl}
/>
<span className="flex min-w-0 flex-col">
<span className="truncate font-medium">{pet.displayName}</span>
<span className="truncate text-[0.6875rem] text-muted-foreground/80">
{pet.slug}
{pet.installed ? ` · ${copy.installed}` : ''}
</span>
</span>
<span className="ml-auto flex shrink-0 items-center text-[0.6875rem] text-muted-foreground">
{isBusy ? (
<Loader2 className="size-3 animate-spin" />
) : isActive ? (
<Check className="size-3.5 text-foreground" />
) : null}
</span>
</button>
)
})
)}
</div>
)
}
/**
* Single on/off toggle, rendered inline on the palette's search row (see
* `CommandInput`'s `right` slot). The paw lights up when pets are on. Reads the
* same shared gallery atoms, so it stays in sync with the list below.
*/
export function PetInlineToggle() {
const { t } = useI18n()
const copy = t.commandCenter.pets
const { requestGateway } = useGatewayRequest()
const gallery = useStore($petGallery)
const busy = useStore($petBusy)
if (!gallery) {
return null
}
const enabled = gallery.enabled
const toggle = () => {
void setPetEnabled(requestGateway, !enabled, {
noneAvailable: copy.noneAvailable,
fallback: copy.toggleFailed
}).then(ok => ok && triggerHaptic('crisp'))
}
return (
<button
aria-label={enabled ? copy.turnOff : copy.turnOn}
aria-pressed={enabled}
className={cn(
'flex shrink-0 items-center justify-center rounded-md p-1.5 transition-colors disabled:opacity-50',
enabled ? 'bg-(--chrome-action-hover) text-foreground' : 'text-muted-foreground hover:bg-(--chrome-action-hover)/60'
)}
disabled={Boolean(busy)}
onClick={toggle}
// Don't steal focus from the search input on click.
onMouseDown={event => event.preventDefault()}
title={enabled ? copy.turnOff : copy.turnOn}
type="button"
>
{busy ? <Loader2 className="size-4 animate-spin" /> : <PawPrint className="size-4" />}
</button>
)
}
function Status({ icon, text, tone }: { icon?: React.ReactNode; text: string; tone?: 'error' }) {
return (
<div
className={cn(
'flex items-center justify-center gap-2 px-2 py-6 text-xs',
tone === 'error' ? 'text-(--ui-red)' : 'text-muted-foreground'
)}
>
{icon}
{text}
</div>
)
}

View file

@ -33,7 +33,6 @@ import { $gateway } from '@/store/gateway'
import { dispatchNativeNotification } from '@/store/native-notifications'
import { notify } from '@/store/notifications'
import { requestDesktopOnboarding } from '@/store/onboarding'
import { flashPetActivity, setPetActivity } from '@/store/pet'
import { clearAllPrompts, setApprovalRequest, setSecretRequest, setSudoRequest } from '@/store/prompts'
import {
setCurrentBranch,
@ -50,7 +49,7 @@ import {
} from '@/store/session'
import { broadcastSessionsChanged } from '@/store/session-sync'
import { clearSessionSubagents, pruneDelegateFallbackSubagents, upsertSubagent } from '@/store/subagents'
import { $todosBySession, setSessionTodos, todoListActive } from '@/store/todos'
import { setSessionTodos } from '@/store/todos'
import { recordToolDiff } from '@/store/tool-diffs'
import type { RpcEvent } from '@/types/hermes'
@ -870,18 +869,10 @@ export function useMessageStream({
if (sessionId) {
appendReasoningDelta(sessionId, coerceThinkingText(payload?.text))
}
if (isActiveEvent) {
setPetActivity({ reasoning: true })
}
} else if (event.type === 'reasoning.available') {
if (sessionId) {
appendReasoningDelta(sessionId, coerceThinkingText(payload?.text), true)
}
if (isActiveEvent) {
setPetActivity({ reasoning: true })
}
} else if (event.type === 'message.complete') {
if (!sessionId) {
return
@ -903,12 +894,6 @@ export function useMessageStream({
if (isActiveEvent) {
setTurnStartedAt(null)
// Pet beat: celebrate a finished plan, else a clean-finish wave.
const todos = $todosBySession.get()[sessionId] ?? []
const done = todos.length > 0 && !todoListActive(todos)
setPetActivity({ reasoning: false, toolRunning: false })
flashPetActivity(done ? { celebrate: true } : { justCompleted: true })
}
if (payload?.usage) {
@ -921,19 +906,10 @@ export function useMessageStream({
flushQueuedDeltas(sessionId)
upsertToolCall(sessionId, toTodoPayload(payload) ?? payload, 'running', event.type)
if (isActiveEvent) {
setPetActivity({ reasoning: false, toolRunning: true })
}
} else if (event.type === 'tool.complete') {
if (sessionId) {
flushQueuedDeltas(sessionId)
upsertToolCall(sessionId, toTodoPayload(payload) ?? payload, 'complete', event.type)
if (isActiveEvent) {
setPetActivity({ toolRunning: false })
}
// A pending clarify blocks the turn, so the first tool.complete after
// one is the clarify resolving — drop the "needs input" flag here so
// the sidebar indicator clears as soon as it's answered, not only at
@ -1117,11 +1093,6 @@ export function useMessageStream({
compactedTurnRef.current.delete(sessionId)
}
if (isActiveEvent) {
setPetActivity({ reasoning: false, toolRunning: false })
flashPetActivity({ error: true })
}
dispatchNativeNotification({
body: errorMessage,
kind: 'turnError',

View file

@ -27,7 +27,6 @@ import { triggerHaptic } from '@/lib/haptics'
import { setMutableRef } from '@/lib/mutable-ref'
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
import { setSessionYolo } from '@/lib/yolo-session'
import { openCommandPalettePage } from '@/store/command-palette'
import {
$composerAttachments,
clearComposerAttachments,
@ -39,7 +38,6 @@ import {
import { resetSessionBackground } from '@/store/composer-status'
import { clearNotifications, notify, notifyError } from '@/store/notifications'
import { requestDesktopOnboarding } from '@/store/onboarding'
import { setPetScale } from '@/store/pet-gallery'
import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile'
import {
$busy,
@ -59,8 +57,8 @@ import { clearSessionSubagents } from '@/store/subagents'
import { clearSessionTodos } from '@/store/todos'
import type {
BrowserManageResponse,
ClientSessionState,
BrowserManageResponse,
FileAttachResponse,
HandoffFailResponse,
HandoffRequestResponse,
@ -1145,35 +1143,6 @@ export function usePromptActions({
renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`)
}
},
pet: async ctx => {
const [sub = '', rawValue = ''] = ctx.arg.trim().split(/\s+/)
const lower = sub.toLowerCase()
if (lower === 'list' || lower === 'gallery' || lower === 'browse' || lower === 'all') {
openCommandPalettePage('pets')
return
}
// `/pet scale <n>` resizes the floating pet locally (instant) and
// persists via the store — no round-trip to the slash worker.
if (lower === 'scale') {
const value = Number(rawValue)
if (!rawValue || Number.isNaN(value)) {
const resolved = await withSlashOutput(ctx)
resolved?.render('usage: /pet scale <factor> (e.g. /pet scale 0.5)')
return
}
setPetScale(requestGateway, value)
return
}
await runExec(ctx)
},
// /browser connect|disconnect|status manages the live CDP connection on
// the gateway host, mirroring the TUI's browser.manage RPC. It mutates
// BROWSER_CDP_URL (and may launch Chrome) in the gateway process — only
@ -1390,7 +1359,6 @@ export function usePromptActions({
const cancelRun = useCallback(async () => {
const sessionId = activeSessionId || activeSessionIdRef.current
const releaseBusy = () => {
setMutableRef(busyRef, false)
setBusy(false)

View file

@ -1,31 +1,30 @@
import { useStore } from '@nanostores/react'
import { useQuery } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { 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 { getBaseColors, useTheme } from '@/themes/context'
import { useTheme } from '@/themes/context'
import { installVscodeThemeFromMarketplace } from '@/themes/install'
import { isUserTheme, removeUserTheme } from '@/themes/user-themes'
import { isUserTheme, removeUserTheme, resolveTheme } from '@/themes/user-themes'
import { MODE_OPTIONS } from './constants'
import { PetSettings } from './pet-settings'
import { ListRow, SectionHeading, SettingsContent } from './primitives'
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)
function ThemePreview({ name }: { name: string }) {
const t = resolveTheme(name)
if (!t) {
return null
}
const c = t.colors
return (
<div
@ -58,200 +57,90 @@ function ThemePreview({ name, mode }: { name: string; mode: 'light' | 'dark' })
)
}
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
}) {
function VscodeThemeInstaller() {
const { t } = useI18n()
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 { 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 search = useQuery({
enabled: debounced.length > 0,
queryFn: () => window.hermesDesktop?.themes?.searchMarketplace(debounced) ?? Promise.resolve([]),
queryKey: ['marketplace-themes-settings', debounced],
staleTime: 5 * 60 * 1000
})
const install = async () => {
const trimmed = id.trim()
const install = async (item: DesktopMarketplaceSearchItem) => {
if (installingId) {
if (!trimmed || busy) {
return
}
setInstallingId(item.extensionId)
setError(null)
setBusy(true)
setStatus(null)
try {
const theme = await installVscodeThemeFromMarketplace(item.extensionId)
const theme = await installVscodeThemeFromMarketplace(trimmed)
triggerHaptic('crisp')
setInstalledHere(prev => ({ ...prev, [item.extensionId]: true }))
onInstalled(theme.name)
} catch (e) {
setError(e instanceof Error ? e.message : copy.error)
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 })
} finally {
setInstallingId(null)
setBusy(false)
}
}
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>
</>
)
}
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 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}
</p>
)}
</div>
)
}
export function AppearanceSettings() {
const { t, isSavingLocale } = useI18n()
const { themeName, mode, resolvedMode, availableThemes, setTheme, setMode } = useTheme()
const { themeName, mode, 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
@ -274,7 +163,7 @@ export function AppearanceSettings() {
{a.intro}
</p>
<div className="mt-2">
<div className="mt-2 divide-y divide-(--ui-stroke-tertiary)">
<ListRow
action={<LanguageSwitcher />}
description={isSavingLocale ? t.language.saving : t.language.description}
@ -282,107 +171,18 @@ export function AppearanceSettings() {
/>
<ListRow
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>
)}
</>
action={
<SegmentedControl
onChange={id => {
triggerHaptic('crisp')
setMode(id)
}}
options={modeOptions}
value={mode}
/>
}
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
description={a.colorModeDesc}
title={a.colorMode}
/>
<ListRow
@ -411,6 +211,80 @@ 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
@ -427,10 +301,6 @@ export function AppearanceSettings() {
/>
</div>
</div>
<div className="mt-6">
<PetSettings />
</div>
</SettingsContent>
)
}

View file

@ -16,6 +16,8 @@ const getAuxiliaryModels = vi.fn()
const setModelAssignment = vi.fn()
const getRecommendedDefaultModel = vi.fn()
const setEnvVar = vi.fn()
const getHermesConfigRecord = vi.fn()
const saveHermesConfig = vi.fn()
const startManualProviderOAuth = vi.fn()
vi.mock('@/hermes', () => ({
@ -24,7 +26,9 @@ vi.mock('@/hermes', () => ({
getAuxiliaryModels: () => getAuxiliaryModels(),
setModelAssignment: (body: unknown) => setModelAssignment(body),
getRecommendedDefaultModel: (slug: string) => getRecommendedDefaultModel(slug),
setEnvVar: (key: string, value: string) => setEnvVar(key, value)
setEnvVar: (key: string, value: string) => setEnvVar(key, value),
getHermesConfigRecord: () => getHermesConfigRecord(),
saveHermesConfig: (config: unknown) => saveHermesConfig(config)
}))
vi.mock('@/store/onboarding', () => ({
@ -35,7 +39,13 @@ beforeEach(() => {
getGlobalModelInfo.mockResolvedValue({ provider: 'nous', model: 'hermes-4' })
getGlobalModelOptions.mockResolvedValue({
providers: [
{ name: 'Nous', slug: 'nous', models: ['hermes-4', 'hermes-4-mini'], authenticated: true },
{
name: 'Nous',
slug: 'nous',
models: ['hermes-4', 'hermes-4-mini'],
authenticated: true,
capabilities: { 'hermes-4': { reasoning: true, fast: true } }
},
// An unconfigured api_key provider — surfaced by the full-universe payload.
{ name: 'DeepSeek', slug: 'deepseek', models: [], authenticated: false, auth_type: 'api_key', key_env: 'DEEPSEEK_API_KEY' }
]
@ -47,6 +57,8 @@ beforeEach(() => {
setModelAssignment.mockResolvedValue({ provider: 'nous', model: 'hermes-4', gateway_tools: [] })
getRecommendedDefaultModel.mockResolvedValue({ provider: 'deepseek', model: 'deepseek-chat', free_tier: null })
setEnvVar.mockResolvedValue({ ok: true })
getHermesConfigRecord.mockResolvedValue({ agent: { reasoning_effort: 'medium', service_tier: 'normal' } })
saveHermesConfig.mockResolvedValue({ ok: true })
})
afterEach(() => {
@ -100,6 +112,31 @@ describe('ModelSettings', () => {
await waitFor(() => expect(setEnvVar).toHaveBeenCalledWith('DEEPSEEK_API_KEY', 'sk-test-123'))
})
it('writes the profile default speed (service_tier) when the fast switch is toggled', async () => {
await renderModelSettings()
await waitFor(() => expect(getHermesConfigRecord).toHaveBeenCalled())
const fastSwitch = await screen.findByRole('switch')
fireEvent.click(fastSwitch)
await waitFor(() =>
expect(saveHermesConfig).toHaveBeenCalledWith(
expect.objectContaining({ agent: expect.objectContaining({ service_tier: 'fast' }) })
)
)
})
it('hides the reasoning/speed defaults when the main model reports no capabilities', async () => {
getGlobalModelOptions.mockResolvedValueOnce({
providers: [{ name: 'Nous', slug: 'nous', models: ['hermes-4'], authenticated: true, capabilities: { 'hermes-4': { reasoning: false, fast: false } } }]
})
await renderModelSettings()
await waitFor(() => expect(getHermesConfigRecord).toHaveBeenCalled())
expect(screen.queryByRole('switch')).toBeNull()
})
it('renders the auxiliary task rows', async () => {
await renderModelSettings()

View file

@ -3,11 +3,14 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import {
getAuxiliaryModels,
getGlobalModelInfo,
getGlobalModelOptions,
getHermesConfigRecord,
getRecommendedDefaultModel,
saveHermesConfig,
setEnvVar,
setModelAssignment
} from '@/hermes'
@ -15,11 +18,26 @@ import type { AuxiliaryModelsResponse, ModelOptionProvider, StaleAuxAssignment }
import { useI18n } from '@/i18n'
import { AlertTriangle, Cpu, Loader2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notifyError } from '@/store/notifications'
import { startManualLocalEndpoint, startManualProviderOAuth } from '@/store/onboarding'
import type { HermesConfigRecord } from '@/types/hermes'
import { CONTROL_TEXT } from './constants'
import { getNested, setNested } from './helpers'
import { ListRow, LoadingState, Pill, SectionHeading } from './primitives'
// Hermes' reasoning levels (VALID_REASONING_EFFORTS); `none` = thinking off.
// Empty config = Hermes default (medium), shown as Medium.
const EFFORT_VALUES = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh'] as const
// agent.service_tier stores "fast"/"priority"/"on" for fast; anything else is
// normal (mirrors tui_gateway _load_service_tier).
const isFastTier = (tier: unknown): boolean =>
['fast', 'priority', 'on'].includes(String(tier ?? '').trim().toLowerCase())
// Reuse the composer's effort labels (`xhigh` shows as "Max", else 1:1).
const effortLabelKey = (v: string) => (v === 'xhigh' ? 'max' : v) as 'high' | 'low' | 'max' | 'medium' | 'minimal'
// A provider row is "ready" to pick a model from when it reports models. The
// backend now surfaces the full `hermes model` universe (every canonical
// provider), so unconfigured providers come back with `authenticated:false`
@ -97,6 +115,9 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
const [selectedProvider, setSelectedProvider] = useState('')
const [selectedModel, setSelectedModel] = useState('')
const [auxiliary, setAuxiliary] = useState<AuxiliaryModelsResponse | null>(null)
// Full profile config, kept so the reasoning/speed defaults round-trip
// (read agent.* → write back the whole record) like the generic config page.
const [config, setConfig] = useState<HermesConfigRecord | null>(null)
const [applying, setApplying] = useState(false)
const [editingAuxTask, setEditingAuxTask] = useState<null | string>(null)
const [auxDraft, setAuxDraft] = useState<{ model: string; provider: string }>({ model: '', provider: '' })
@ -113,10 +134,11 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
setError('')
try {
const [modelInfo, modelOptions, auxiliaryModels] = await Promise.all([
const [modelInfo, modelOptions, auxiliaryModels, cfg] = await Promise.all([
getGlobalModelInfo(),
getGlobalModelOptions(),
getAuxiliaryModels()
getAuxiliaryModels(),
getHermesConfigRecord()
])
setMainModel({ model: modelInfo.model, provider: modelInfo.provider })
@ -124,6 +146,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
setSelectedProvider(prev => prev || modelInfo.provider)
setSelectedModel(prev => prev || modelInfo.model)
setAuxiliary(auxiliaryModels)
setConfig(cfg)
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
@ -181,6 +204,42 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
.map(entry => ({ task: entry.task, provider: entry.provider, model: entry.model }))
}, [auxiliary, mainModel])
// Capabilities of the APPLIED main model — gates the profile-default
// reasoning/speed controls the same way the composer picker gates per-model
// edits (reasoning defaults on, fast defaults off when unreported).
const mainCaps = useMemo(() => {
const row = providers.find(provider => provider.slug === mainModel?.provider)
return mainModel ? row?.capabilities?.[mainModel.model] : undefined
}, [providers, mainModel])
const reasoningSupported = mainCaps?.reasoning ?? true
const fastSupported = mainCaps?.fast ?? false
const effortValue = String(getNested(config ?? {}, 'agent.reasoning_effort') ?? '').trim().toLowerCase() || 'medium'
const fastOn = isFastTier(getNested(config ?? {}, 'agent.service_tier'))
// Persist a single agent.* default by round-tripping the whole config record
// (PUT /api/config replaces it) — optimistic, with rollback on failure.
const writeAgentDefault = useCallback(
async (key: string, value: string) => {
if (!config) {
return
}
const prev = config
const next = setNested(config, key, value)
setConfig(next)
try {
await saveHermesConfig(next)
} catch (err) {
setConfig(prev)
notifyError(err, m.defaultsFailed)
}
},
[config, m.defaultsFailed]
)
// Paste an API key for the selected `api_key` provider, persist it, then
// refresh so the now-authenticated provider's models populate. Auto-selects
// the recommended default model so the user can Apply in one more click.
@ -433,6 +492,38 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
: `${selectedProviderRow?.name} signs in through your browser — Hermes runs the flow for you.`}
</p>
)}
{config && mainModel && (reasoningSupported || fastSupported) && (
<div className="mt-3 flex flex-wrap items-center gap-x-6 gap-y-3">
<span className="text-xs text-muted-foreground">{m.defaultsLabel}</span>
{reasoningSupported && (
<div className="flex items-center gap-2 text-xs">
{m.reasoning}
<Select onValueChange={value => void writeAgentDefault('agent.reasoning_effort', value)} value={effortValue}>
<SelectTrigger className={cn('min-w-28', CONTROL_TEXT)}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{EFFORT_VALUES.map(value => (
<SelectItem key={value} value={value}>
{value === 'none' ? m.reasoningOff : t.shell.modelOptions[effortLabelKey(value)]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{fastSupported && (
<label className="flex items-center gap-2 text-xs">
{t.shell.modelOptions.fast}
<Switch
checked={fastOn}
onCheckedChange={checked => void writeAgentDefault('agent.service_tier', checked ? 'fast' : 'normal')}
size="xs"
/>
</label>
)}
</div>
)}
{error && <div className="mt-2 text-xs text-destructive">{error}</div>}
{switchStaleAux.length > 0 && (
<div className="mt-2">

View file

@ -1,231 +0,0 @@
import { useStore } from '@nanostores/react'
import { useEffect, useState } from 'react'
import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
import { PetThumb } from '@/components/pet/pet-thumb'
import { SegmentedControl } from '@/components/ui/segmented-control'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { Loader2, PawPrint, Trash2 } from '@/lib/icons'
import { selectableCardClass } from '@/lib/selectable-card'
import { cn } from '@/lib/utils'
import { $petInfo } from '@/store/pet'
import {
$petBusy,
$petGallery,
$petGalleryError,
$petGalleryStatus,
adoptPet,
loadPetGallery,
loadPetThumb,
PET_SCALE_DEFAULT,
PET_SCALE_MAX,
PET_SCALE_MIN,
rankedGalleryPets,
removePet as removePetAction,
setPetEnabled,
setPetScale
} from '@/store/pet-gallery'
import { $gatewayState } from '@/store/session'
import { ListRow, SectionHeading } from './primitives'
/**
* Appearance opt-in for the floating petdex mascot. A thin view over the shared
* `pet-gallery` store it subscribes to the atoms and calls the store actions,
* so the gallery is fetched once + cached and adopt/toggle/remove patch local
* state instead of re-pulling the network gallery. The floating mascot polls
* `pet.info`, so picking a pet here lights it up within a couple seconds.
*/
export function PetSettings() {
const { t } = useI18n()
const copy = t.settings.appearance.pet
const { requestGateway } = useGatewayRequest()
const gatewayState = useStore($gatewayState)
const gallery = useStore($petGallery)
const status = useStore($petGalleryStatus)
const error = useStore($petGalleryError)
const busySlug = useStore($petBusy)
const petInfo = useStore($petInfo)
const [query, setQuery] = useState('')
const scale = petInfo.scale ?? PET_SCALE_DEFAULT
useEffect(() => {
if (gatewayState !== 'open') {
return
}
void loadPetGallery(requestGateway)
}, [gatewayState, requestGateway])
const enabled = gallery?.enabled ?? false
const active = gallery?.active ?? ''
const pets = gallery?.pets ?? []
const staleBackend = status === 'stale'
const selectPet = (slug: string) => {
void adoptPet(requestGateway, slug, copy.adoptFailed(slug)).then(ok => ok && triggerHaptic('crisp'))
}
const removePet = (slug: string) => {
void removePetAction(requestGateway, slug, copy.uninstallFailed(slug)).then(ok => ok && triggerHaptic('crisp'))
}
const toggle = (on: boolean) => {
void setPetEnabled(requestGateway, on, {
noneAvailable: copy.noneAvailable,
fallback: on ? copy.turnOnFailed : copy.turnOffFailed
}).then(ok => ok && triggerHaptic('crisp'))
}
// The petdex catalog is thousands of entries, so rank + cap how many render.
const RENDER_CAP = 60
const sorted = rankedGalleryPets(gallery, query)
const shown = sorted.slice(0, RENDER_CAP)
return (
<div>
<SectionHeading icon={PawPrint} title={copy.title} />
<p className="max-w-2xl text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{copy.intro}
</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)">
{copy.restartHint}
</p>
)}
<div className="mt-2">
<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={copy.searchPlaceholder}
spellCheck={false}
value={query}
/>
{/* Fixed-height scroll area so filtering never grows/shrinks the
page (no layout thrash); the grid scrolls inside it. */}
<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)">
{copy.unreachable}
</p>
) : shown.length === 0 ? (
<p className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{copy.noMatch(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={(slug, url) => loadPetThumb(requestGateway, slug, url)}
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 ? ` · ${copy.installedTag}` : ''}
</span>
</span>
{isBusy && <Loader2 className="size-4 shrink-0 animate-spin text-(--ui-text-tertiary)" />}
</button>
{pet.installed && !isBusy && (
<button
aria-label={copy.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={copy.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 ? (
copy.countCapped(RENDER_CAP, sorted.length)
) : (
copy.count(sorted.length)
)}
</p>
</>
}
description={copy.chooseDesc}
title={
<div className="flex items-center justify-between gap-3">
<span>{copy.chooseTitle}</span>
<SegmentedControl
onChange={id => void toggle(id === 'on')}
options={[
{ id: 'off', label: copy.off },
{ id: 'on', label: copy.on }
]}
value={enabled ? 'on' : 'off'}
/>
</div>
}
wide
/>
{enabled && (
<ListRow
action={
<div className="flex items-center gap-3">
<input
aria-label={copy.scaleTitle}
className="h-1 w-40 cursor-pointer appearance-none rounded-full bg-(--ui-stroke-tertiary)"
max={PET_SCALE_MAX}
min={PET_SCALE_MIN}
onChange={event => {
triggerHaptic('selection')
setPetScale(requestGateway, Number(event.target.value))
}}
step={0.05}
style={{ accentColor: 'var(--dt-primary)' }}
type="range"
value={scale}
/>
<span className="w-9 text-right text-[length:var(--conversation-caption-font-size)] tabular-nums text-(--ui-text-tertiary)">
{`${Math.round(scale * 100)}%`}
</span>
</div>
}
description={copy.scaleDesc}
title={copy.scaleTitle}
/>
)}
</div>
</div>
)
}

View file

@ -4,7 +4,6 @@ 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 {
@ -203,10 +202,6 @@ 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>
)
}

View file

@ -18,7 +18,7 @@ import { Skeleton } from '@/components/ui/skeleton'
import type { HermesGateway } from '@/hermes'
import { getGlobalModelOptions } from '@/hermes'
import { useI18n } from '@/i18n'
import { displayModelName, modelDisplayParts, reasoningEffortLabel } from '@/lib/model-status-label'
import { currentPickerSelection, displayModelName, modelDisplayParts, reasoningEffortLabel } from '@/lib/model-status-label'
import { cn } from '@/lib/utils'
import { $modelPresets, applyModelPreset, modelPresetKey } from '@/store/model-presets'
import {
@ -84,8 +84,12 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
}
})
const optionsModel = String(modelOptions.data?.model ?? currentModel ?? '')
const optionsProvider = String(modelOptions.data?.provider ?? currentProvider ?? '')
const { model: optionsModel, provider: optionsProvider } = currentPickerSelection(
!!activeSessionId,
{ model: currentModel, provider: currentProvider },
modelOptions.data
)
const loading = modelOptions.isPending && !modelOptions.data
const error = modelOptions.error

View file

@ -2,6 +2,7 @@ import { useQuery } from '@tanstack/react-query'
import { useState } from 'react'
import { useI18n } from '@/i18n'
import { currentPickerSelection } from '@/lib/model-status-label'
import type { ModelOptionProvider, ModelOptionsResponse, ModelPricing } from '@/types/hermes'
import type { HermesGateway } from '../hermes'
@ -66,8 +67,13 @@ export function ModelPickerDialog({
})
const providers = modelOptions.data?.providers ?? []
const optionsModel = String(modelOptions.data?.model ?? currentModel ?? '')
const optionsProvider = String(modelOptions.data?.provider ?? currentProvider ?? '')
const { model: optionsModel, provider: optionsProvider } = currentPickerSelection(
!!sessionId,
{ model: currentModel, provider: currentProvider },
modelOptions.data
)
const loading = modelOptions.isPending && !modelOptions.data
const error = modelOptions.error

View file

@ -1,234 +0,0 @@
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 { useTheme } from '@/themes/context'
import { PetSprite } from './pet-sprite'
// v2: positions are now top/left anchored (v1 stored bottom-anchored values,
// which dragged inverted). Bumping the key discards stale v1 coordinates.
const POSITION_KEY = 'hermes.desktop.pet-position.v2'
interface Point {
x: number
y: number
}
function clampToViewport({ x, y }: Point): Point {
const maxX = Math.max(0, (window.innerWidth || 800) - 80)
const maxY = Math.max(0, (window.innerHeight || 600) - 80)
return { x: Math.min(Math.max(0, x), maxX), y: Math.min(Math.max(0, y), maxY) }
}
// The sprite art faces left by default, so mirror it when the pet's center sits
// on the left half of the window — it always faces inward, toward the content.
function facing(leftX: number, petW: number): string {
return leftX + petW / 2 < (window.innerWidth || 800) / 2 ? 'scaleX(-1)' : 'none'
}
function loadPosition(): Point {
try {
const raw = storedString(POSITION_KEY)
if (raw) {
const parsed = JSON.parse(raw) as Point
if (typeof parsed.x === 'number' && typeof parsed.y === 'number') {
return clampToViewport(parsed)
}
}
} catch {
// fall through to default
}
// Default: lower-left corner (top/left anchored).
return clampToViewport({ x: 24, y: (window.innerHeight || 600) - 220 })
}
/**
* In-window floating petdex mascot. Always-on-top within the app, draggable,
* and reactive to agent activity via `$petState`. Fetches the active pet via
* the shared `pet.info` RPC; renders nothing until a pet is installed +
* enabled.
*
* Adopting a pet is fully in-app: type `/pet boba` in the composer. That
* writes `display.pet.*` from the slash worker, so we keep polling `pet.info`
* while no pet is active and the mascot pops in within a few seconds no
* reload, no CLI. Once a pet is live we stop polling.
*
* Promotion to a separate frameless OS-level window is a follow-up the
* sprite + state logic here is reused as-is, only the host changes.
*/
const PET_POLL_MS = 3000
export function FloatingPet() {
const { requestGateway } = useGatewayRequest()
const { resolvedMode } = useTheme()
const gatewayState = useStore($gatewayState)
const info = useStore($petInfo)
const [position, setPosition] = useState<Point>(loadPosition)
const containerRef = useRef<HTMLDivElement | null>(null)
const petW = (info.frameW ?? 192) * (info.scale ?? 0.33)
// Soft contact shadow, sized off the pet so every scale/species grounds the
// same way (cf. lairp's per-actor feet ellipse). Lighter on light backgrounds.
const shadowW = Math.round(petW * 0.55)
const shadowH = Math.max(3, Math.round(shadowW * 0.28))
const shadowAlpha = resolvedMode === 'light' ? 0.2 : 0.55
// Live drag offset (pointer → element top-left). Drag updates the DOM
// directly to avoid a React re-render (and canvas reflow) per pointermove —
// state is only committed on release.
const dragRef = useRef<{ dx: number; dy: number; x: number; y: number } | null>(null)
// Fetch pet.info on connect, then keep polling while no pet is active so an
// in-app `/pet <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])
// A window resize must never strand the pet off-screen — re-clamp the
// committed position (and persist it) whenever the viewport shrinks.
useEffect(() => {
const onResize = () =>
setPosition(prev => {
const next = clampToViewport(prev)
if (next.x === prev.x && next.y === prev.y) {
return prev
}
persistString(POSITION_KEY, JSON.stringify(next))
return next
})
window.addEventListener('resize', onResize)
return () => window.removeEventListener('resize', onResize)
}, [])
const onPointerDown = useCallback((e: React.PointerEvent) => {
const el = containerRef.current
if (!el) {
return
}
const rect = el.getBoundingClientRect()
dragRef.current = { dx: e.clientX - rect.left, dy: e.clientY - rect.top, x: rect.left, y: rect.top }
el.setPointerCapture(e.pointerId)
el.style.cursor = 'grabbing'
}, [])
const onPointerMove = useCallback(
(e: React.PointerEvent) => {
const drag = dragRef.current
const el = containerRef.current
if (!drag || !el) {
return
}
const next = clampToViewport({ x: e.clientX - drag.dx, y: e.clientY - drag.dy })
drag.x = next.x
drag.y = next.y
// Mutate the DOM directly — no setState, so no re-render while dragging. The
// mirror follows the pointer across the midline for the same reason.
el.style.left = `${next.x}px`
el.style.top = `${next.y}px`
el.style.transform = facing(next.x, petW)
},
[petW]
)
const onPointerUp = useCallback((e: React.PointerEvent) => {
const drag = dragRef.current
if (drag) {
dragRef.current = null
const committed = { x: drag.x, y: drag.y }
setPosition(committed)
persistString(POSITION_KEY, JSON.stringify(committed))
}
const el = containerRef.current
if (el) {
el.style.cursor = 'grab'
el.releasePointerCapture?.(e.pointerId)
}
}, [])
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',
transform: facing(position.x, petW),
userSelect: 'none',
zIndex: 60
}}
title={info.displayName || 'pet'}
>
<div
aria-hidden
style={{
background: `radial-gradient(ellipse at center, rgba(0,0,0,${shadowAlpha}) 0%, rgba(0,0,0,0) 70%)`,
bottom: -shadowH * 0.4,
height: shadowH,
left: '50%',
pointerEvents: 'none',
position: 'absolute',
transform: 'translateX(-50%)',
width: shadowW,
zIndex: 0
}}
/>
<div style={{ lineHeight: 0, position: 'relative', zIndex: 1 }}>
<PetSprite info={info} />
</div>
</div>
)
}

View file

@ -1,153 +0,0 @@
import { memo, useEffect, useMemo, useRef } from 'react'
import { $petState, type PetInfo, type PetState } from '@/store/pet'
const DEFAULT_FRAME_W = 192
const DEFAULT_FRAME_H = 208
const DEFAULT_FRAMES = 6
const DEFAULT_LOOP_MS = 1100
// Mirrors agent.pet.constants.DEFAULT_SCALE — fallback only; the gateway sends
// the configured scale.
const DEFAULT_SCALE = 0.33
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 framesByState = info.framesByState
const loopMs = info.loopMs ?? DEFAULT_LOOP_MS
const scale = (info.scale ?? DEFAULT_SCALE) * zoom
const rows = info.stateRows ?? DEFAULT_STATE_ROWS
const drawW = Math.round(frameW * scale)
const drawH = Math.round(frameH * scale)
const image = useMemo(() => {
if (!info.spritesheetBase64) {
return null
}
const img = new Image()
img.src = `data:${info.mime ?? 'image/webp'};base64,${info.spritesheetBase64}`
return img
}, [info.spritesheetBase64, info.mime])
useEffect(() => {
const canvas = canvasRef.current
if (!canvas || !image) {
return
}
const ctx = canvas.getContext('2d')
if (!ctx) {
return
}
// Track state via subscription, not a prop — no re-render on activity ticks.
stateRef.current = $petState.get()
const unsubState = $petState.listen(next => {
stateRef.current = next
})
let raf = 0
let frame = 0
let lastStep = performance.now()
let drawnFrame = -1
let drawnRow = -1
const rowIndex = (s: string) => {
const idx = rows.indexOf(s)
return idx >= 0 ? idx : 0
}
// Resolve a state to the row it draws and its real frame count. A state
// with no real frames (ragged sheet, empty row) falls back to idle rather
// than flashing blank padding.
const resolve = (s: PetState): { row: number; count: number } => {
const real = framesByState?.[s] ?? frames
if (real > 0) {
return { row: rowIndex(s), count: real }
}
return { row: rowIndex('idle'), count: Math.max(1, framesByState?.idle ?? frames) }
}
const render = (now: number) => {
const { row, count } = resolve(stateRef.current)
// Per-state step keeps every state's loop ~loopMs even when frame counts
// differ; counts vary per row so derive the cadence here, not once.
const stepMs = loopMs / count
if (now - lastStep >= stepMs) {
frame += 1
lastStep = now
}
frame %= count
// Only touch the canvas when the visible cell actually changes. The RAF
// ticks at ~60Hz but the sprite only steps ~5Hz, so this skips ~90% of
// the clear+draw work and keeps the main thread free.
if ((frame !== drawnFrame || row !== drawnRow) && image.complete && image.naturalWidth > 0) {
const sx = frame * frameW
const sy = row * frameH
ctx.clearRect(0, 0, canvas.width, canvas.height)
ctx.imageSmoothingEnabled = false
ctx.drawImage(image, sx, sy, frameW, frameH, 0, 0, drawW, drawH)
drawnFrame = frame
drawnRow = row
}
raf = requestAnimationFrame(render)
}
raf = requestAnimationFrame(render)
return () => {
cancelAnimationFrame(raf)
unsubState()
}
}, [image, frameW, frameH, frames, framesByState, loopMs, drawW, drawH, rows])
return (
<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)

View file

@ -1,79 +0,0 @@
import { useEffect, useRef, useState } from 'react'
import { PawPrint } from '@/lib/icons'
// petdex frames are a fixed 192×208 grid; the box matches that aspect.
const THUMB_W = 40
const THUMB_H = Math.round((THUMB_W * 208) / 192)
export type PetThumbLoader = (slug: string, url?: string) => Promise<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>`.
*/
export function PetThumb({
slug,
url,
alt,
load,
size = THUMB_W
}: {
slug: string
url?: string
alt: string
load: PetThumbLoader
/** Width in px; height follows the petdex frame aspect. */
size?: number
}) {
const [src, setSrc] = useState<string | null>(null)
const boxRef = useRef<HTMLSpanElement | null>(null)
const height = Math.round((size * 208) / 192)
useEffect(() => {
const el = boxRef.current
if (!el || src) {
return
}
const observer = new IntersectionObserver(
entries => {
if (entries.some(entry => entry.isIntersecting)) {
observer.disconnect()
void load(slug, url).then(uri => {
if (uri) {
setSrc(uri)
}
})
}
},
{ rootMargin: '120px' }
)
observer.observe(el)
return () => observer.disconnect()
}, [slug, url, src, load])
return (
<span
className="grid shrink-0 place-items-center overflow-hidden rounded-md bg-(--ui-bg-tertiary) text-(--ui-text-tertiary)"
ref={boxRef}
style={{ height, width: size }}
>
{src ? (
<img
alt={alt}
aria-hidden
className="pointer-events-none size-full object-contain"
src={src}
style={{ imageRendering: 'pixelated' }}
/>
) : (
<PawPrint className="size-4" />
)}
</span>
)
}

View file

@ -17,12 +17,7 @@ function Command({ className, ...props }: React.ComponentProps<typeof CommandPri
)
}
interface CommandInputProps extends React.ComponentProps<typeof CommandPrimitive.Input> {
/** Inline trailing slot, rendered on the right of the search row. */
right?: React.ReactNode
}
function CommandInput({ className, right, ...props }: CommandInputProps) {
function CommandInput({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div className="flex h-11 items-center gap-2 border-b border-border px-3" data-slot="command-input-wrapper">
<SearchIcon className="size-4 shrink-0 text-muted-foreground" />
@ -34,7 +29,6 @@ function CommandInput({ className, right, ...props }: CommandInputProps) {
data-slot="command-input"
{...props}
/>
{right}
</div>
)
}

View file

@ -366,32 +366,7 @@ export const en: Translations = {
installError: 'Could not install that theme.',
installed: name => `Installed “${name}”.`,
removeTheme: 'Remove theme',
importedBadge: 'Imported',
pet: {
title: 'Pet',
intro:
'Adopt an animated petdex mascot that floats over the app and reacts to what Hermes is doing — running while tools execute, celebrating on success, sulking on errors.',
restartHint:
'Pets need a quick restart — the running app started before this feature was added. Quit and reopen Hermes, then come back here.',
on: 'On',
off: 'Off',
scaleTitle: 'Size',
scaleDesc: 'Resize the floating mascot. Applies everywhere instantly.',
chooseTitle: 'Choose a pet',
chooseDesc: 'Picking one installs it (if needed) and makes it active.',
searchPlaceholder: 'Search pets…',
unreachable: "Couldn't reach the petdex gallery. Check your connection and reopen this page.",
noMatch: query => `No pets match "${query}".`,
installedTag: 'installed',
countCapped: (cap, total) => `Showing ${cap} of ${total} — type to narrow it down.`,
count: n => `${n} pet${n === 1 ? '' : 's'}.`,
uninstall: name => `Uninstall ${name}`,
adoptFailed: slug => `Could not adopt ${slug}`,
uninstallFailed: slug => `Could not uninstall ${slug}`,
noneAvailable: 'No pets available to turn on right now.',
turnOnFailed: 'Could not turn the pet on.',
turnOffFailed: 'Could not turn the pet off.'
}
importedBadge: 'Imported'
},
fieldLabels: FIELD_LABELS,
fieldDescriptions: FIELD_DESCRIPTIONS,
@ -563,6 +538,10 @@ export const en: Translations = {
provider: 'Provider',
model: 'Model',
applying: 'Applying...',
defaultsLabel: 'Defaults',
reasoning: 'Reasoning',
reasoningOff: 'Off',
defaultsFailed: 'Failed to save model defaults',
auxiliaryTitle: 'Auxiliary models',
resetAllToMain: 'Reset all to main',
auxiliaryDesc: 'Helper tasks run on the main model by default. Assign a dedicated model to any task to override.',
@ -735,22 +714,8 @@ export const en: Translations = {
commandCenter: 'Command Center',
appearance: 'Appearance',
settings: 'Settings',
changeTheme: 'Change theme',
changeTheme: 'Change theme...',
changeColorMode: 'Change color mode...',
pets: {
title: 'Pets',
placeholder: 'Search pets…',
loading: 'Loading petdex gallery…',
error: 'Could not reach the petdex gallery.',
staleBackend: 'Restart Hermes to use pets — the backend predates this feature.',
empty: 'No matching pets.',
turnOff: 'Turn off',
turnOn: 'Turn on',
installed: 'Installed',
adoptFailed: 'Could not adopt that pet.',
toggleFailed: 'Could not toggle the pet.',
noneAvailable: 'No pets available — pick one below to install.'
},
installTheme: {
title: 'Install theme...',
placeholder: 'Search the VS Code Marketplace...',

View file

@ -281,32 +281,7 @@ export const ja = defineLocale({
installError: 'そのテーマをインストールできませんでした。',
installed: name => `${name}」をインストールしました。`,
removeTheme: 'テーマを削除',
importedBadge: 'インポート済み',
pet: {
title: 'ペット',
intro:
'アプリ上に浮かぶ petdex のアニメーションマスコットを採用しましょう。ツール実行中は走り、成功すると喜び、エラーでしょんぼりと、Hermes の状態に反応します。',
restartHint:
'ペット機能には再起動が必要です。この機能が追加される前に起動したアプリが動作中です。Hermes を終了して再度開き、このページに戻ってください。',
scaleTitle: 'サイズ',
scaleDesc: '浮遊マスコットの大きさを変更します。すべての画面に即時反映されます。',
on: 'オン',
off: 'オフ',
chooseTitle: 'ペットを選ぶ',
chooseDesc: '選ぶと(必要に応じて)インストールされ、アクティブになります。',
searchPlaceholder: 'ペットを検索…',
unreachable: 'petdex ギャラリーに接続できませんでした。接続を確認してこのページを開き直してください。',
noMatch: query => `${query}」に一致するペットがありません。`,
installedTag: 'インストール済み',
countCapped: (cap, total) => `${total} 件中 ${cap} 件を表示中——入力して絞り込めます。`,
count: n => `${n} 件のペット。`,
uninstall: name => `${name} をアンインストール`,
adoptFailed: slug => `${slug} を採用できませんでした`,
uninstallFailed: slug => `${slug} をアンインストールできませんでした`,
noneAvailable: 'オンにできるペットがありません。',
turnOnFailed: 'ペットをオンにできませんでした。',
turnOffFailed: 'ペットをオフにできませんでした。'
}
importedBadge: 'インポート済み'
},
fieldLabels: defineFieldCopy({
model: 'デフォルトモデル',
@ -859,22 +834,8 @@ export const ja = defineLocale({
commandCenter: 'コマンドセンター',
appearance: '外観',
settings: '設定',
changeTheme: 'テーマを変更',
changeTheme: 'テーマを変更...',
changeColorMode: 'カラーモードを変更...',
pets: {
title: 'ペット',
placeholder: 'ペットを検索…',
loading: 'petdex ギャラリーを読み込み中…',
error: 'petdex ギャラリーに接続できません。',
staleBackend: 'ペット機能を使うには Hermes を再起動してください。',
empty: '一致するペットがありません。',
turnOff: 'オフ',
turnOn: 'オン',
installed: 'インストール済み',
adoptFailed: 'ペットを採用できませんでした。',
toggleFailed: 'ペットを切り替えできませんでした。',
noneAvailable: '利用可能なペットがありません。'
},
installTheme: {
title: 'テーマをインストール...',
placeholder: 'VS Code Marketplace を検索...',

View file

@ -265,29 +265,6 @@ export interface Translations {
installed: (name: string) => string
removeTheme: string
importedBadge: string
pet: {
title: string
intro: string
restartHint: string
on: string
off: string
scaleTitle: string
scaleDesc: string
chooseTitle: string
chooseDesc: string
searchPlaceholder: string
unreachable: string
noMatch: (query: string) => string
installedTag: string
countCapped: (cap: number, total: number) => string
count: (n: number) => string
uninstall: (name: string) => string
adoptFailed: (slug: string) => string
uninstallFailed: (slug: string) => string
noneAvailable: string
turnOnFailed: string
turnOffFailed: string
}
}
fieldLabels: Record<string, string>
fieldDescriptions: Record<string, string>
@ -453,6 +430,10 @@ export interface Translations {
provider: string
model: string
applying: string
defaultsLabel: string
reasoning: string
reasoningOff: string
defaultsFailed: string
auxiliaryTitle: string
resetAllToMain: string
auxiliaryDesc: string
@ -613,20 +594,6 @@ export interface Translations {
settings: string
changeTheme: string
changeColorMode: string
pets: {
title: string
placeholder: string
loading: string
error: string
staleBackend: string
empty: string
turnOff: string
turnOn: string
installed: string
adoptFailed: string
toggleFailed: string
noneAvailable: string
}
installTheme: {
title: string
placeholder: string

View file

@ -271,30 +271,7 @@ export const zhHant = defineLocale({
installError: '無法安裝該主題。',
installed: name => `已安裝「${name}」。`,
removeTheme: '移除主題',
importedBadge: '已匯入',
pet: {
title: '寵物',
intro: '領養一隻懸浮在應用上的 petdex 動畫寵物,它會根據 Hermes 的狀態做出反應——工具執行時奔跑、成功時歡呼、出錯時沮喪。',
restartHint: '寵物功能需要重新啟動——目前執行的應用在此功能加入前啟動。請結束並重新開啟 Hermes然後回到此處。',
scaleTitle: '大小',
scaleDesc: '調整懸浮寵物的大小,所有介面即時生效。',
on: '開啟',
off: '關閉',
chooseTitle: '選擇寵物',
chooseDesc: '選擇後會自動安裝(如需)並設為目前寵物。',
searchPlaceholder: '搜尋寵物…',
unreachable: '無法連線至 petdex 畫廊。請檢查網路連線並重新開啟此頁面。',
noMatch: query => `沒有符合「${query}」的寵物。`,
installedTag: '已安裝',
countCapped: (cap, total) => `顯示 ${total} 個中的 ${cap} 個——輸入關鍵字以縮小範圍。`,
count: n => `${n} 個寵物。`,
uninstall: name => `解除安裝 ${name}`,
adoptFailed: slug => `無法領養 ${slug}`,
uninstallFailed: slug => `無法解除安裝 ${slug}`,
noneAvailable: '目前沒有可開啟的寵物。',
turnOnFailed: '無法開啟寵物。',
turnOffFailed: '無法關閉寵物。'
}
importedBadge: '已匯入'
},
fieldLabels: defineFieldCopy({
model: '預設模型',
@ -830,22 +807,8 @@ export const zhHant = defineLocale({
commandCenter: '命令中心',
appearance: '外觀',
settings: '設定',
changeTheme: '變更主題',
changeTheme: '變更主題...',
changeColorMode: '變更色彩模式...',
pets: {
title: '寵物',
placeholder: '搜尋寵物…',
loading: '正在載入 petdex 畫廊…',
error: '無法連線至 petdex 畫廊。',
staleBackend: '請重新啟動 Hermes 以使用寵物功能。',
empty: '沒有符合的寵物。',
turnOff: '關閉',
turnOn: '開啟',
installed: '已安裝',
adoptFailed: '無法領養該寵物。',
toggleFailed: '無法切換寵物顯示。',
noneAvailable: '尚無可用寵物——請在下方選擇一個安裝。'
},
installTheme: {
title: '安裝主題...',
placeholder: '搜尋 VS Code Marketplace...',

View file

@ -359,30 +359,7 @@ export const zh: Translations = {
installError: '无法安装该主题。',
installed: name => `已安装「${name}」。`,
removeTheme: '移除主题',
importedBadge: '已导入',
pet: {
title: '宠物',
intro: '领养一只悬浮在应用上的 petdex 动画宠物,它会根据 Hermes 的状态做出反应——工具执行时奔跑、成功时欢呼、出错时沮丧。',
restartHint: '宠物功能需要重启——当前运行的应用在此功能加入前启动。请退出并重新打开 Hermes然后回到此处。',
scaleTitle: '大小',
scaleDesc: '调整悬浮宠物的大小,所有界面即时生效。',
on: '开启',
off: '关闭',
chooseTitle: '选择宠物',
chooseDesc: '选择后会自动安装(如需)并设为当前宠物。',
searchPlaceholder: '搜索宠物…',
unreachable: '无法连接到 petdex 画廊。请检查网络连接并重新打开此页面。',
noMatch: query => `没有匹配「${query}」的宠物。`,
installedTag: '已安装',
countCapped: (cap, total) => `显示 ${total} 个中的 ${cap} 个——输入关键词以缩小范围。`,
count: n => `${n} 个宠物。`,
uninstall: name => `卸载 ${name}`,
adoptFailed: slug => `无法领养 ${slug}`,
uninstallFailed: slug => `无法卸载 ${slug}`,
noneAvailable: '当前没有可开启的宠物。',
turnOnFailed: '无法开启宠物。',
turnOffFailed: '无法关闭宠物。'
}
importedBadge: '已导入'
},
fieldLabels: defineFieldCopy({
model: '默认模型',
@ -756,6 +733,10 @@ export const zh: Translations = {
provider: '提供方',
model: '模型',
applying: '应用中...',
defaultsLabel: '默认值',
reasoning: '推理',
reasoningOff: '关闭',
defaultsFailed: '保存模型默认值失败',
auxiliaryTitle: '辅助模型',
resetAllToMain: '全部重置为主模型',
auxiliaryDesc: '辅助任务默认使用主模型。你可以为任意任务指定专用模型。',
@ -923,22 +904,8 @@ export const zh: Translations = {
commandCenter: '命令中心',
appearance: '外观',
settings: '设置',
changeTheme: '更改主题',
changeTheme: '更改主题...',
changeColorMode: '更改颜色模式...',
pets: {
title: '宠物',
placeholder: '搜索宠物…',
loading: '正在加载 petdex 画廊…',
error: '无法连接到 petdex 画廊。',
staleBackend: '请重启 Hermes 以使用宠物功能——当前后端版本过旧。',
empty: '没有匹配的宠物。',
turnOff: '关闭',
turnOn: '开启',
installed: '已安装',
adoptFailed: '无法领养该宠物。',
toggleFailed: '无法切换宠物显示。',
noneAvailable: '暂无可用宠物——请在下方选择一个安装。'
},
installTheme: {
title: '安装主题...',
placeholder: '搜索 VS Code Marketplace...',

View file

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

View file

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

View file

@ -67,7 +67,6 @@ import {
IconLayoutBottombar as PanelBottom,
IconLayoutSidebar as PanelLeftIcon,
IconPlayerPause as Pause,
IconPaw as PawPrint,
IconPencil as Pencil,
IconPencil as PencilIcon,
IconPencil as PencilLine,
@ -170,7 +169,6 @@ export {
PanelBottom,
PanelLeftIcon,
Pause,
PawPrint,
Pencil,
PencilIcon,
PencilLine,

View file

@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { displayModelName, formatModelStatusLabel, reasoningEffortLabel } from './model-status-label'
import { currentPickerSelection, displayModelName, formatModelStatusLabel, reasoningEffortLabel } from './model-status-label'
describe('model-status-label', () => {
it('formats display names consistently', () => {
@ -35,4 +35,25 @@ describe('model-status-label', () => {
it('returns just the placeholder name when there is no model', () => {
expect(formatModelStatusLabel('')).toBe('No model')
})
describe('currentPickerSelection', () => {
const store = { model: 'opus', provider: 'anthropic' }
const options = { model: 'hermes-4', provider: 'nous' }
it('prefers the sticky composer pick over the profile default pre-session', () => {
expect(currentPickerSelection(false, store, options)).toEqual(store)
})
it('lets the live session model.options win when a session exists', () => {
expect(currentPickerSelection(true, store, options)).toEqual(options)
})
it('falls back to options when the store is empty', () => {
expect(currentPickerSelection(false, { model: '', provider: '' }, options)).toEqual(options)
})
it('falls back to the store while options are still loading', () => {
expect(currentPickerSelection(true, store, undefined)).toEqual(store)
})
})
})

View file

@ -17,6 +17,22 @@ export function reasoningEffortLabel(effort: string): string {
return REASONING_LABELS[key] ?? effort
}
/** Which model/provider a picker should mark "current". With a live session the
* gateway's `model.options` is authoritative; pre-session there is no server
* "current", so the sticky composer pick wins over the profile default the
* global options query returns else the checkmark snaps back to the default
* and the pick looks ignored. */
export function currentPickerSelection(
hasSession: boolean,
store: { model: string; provider: string },
options?: { model?: string; provider?: string }
): { model: string; provider: string } {
return {
model: String((hasSession && options?.model) || store.model || options?.model || ''),
provider: String((hasSession && options?.provider) || store.provider || options?.provider || '')
}
}
/** Strip provider prefix and normalize for display. */
export function modelBaseId(model: string): string {
const trimmed = model.trim()

View file

@ -1,31 +0,0 @@
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)'
)
}

View file

@ -3,30 +3,16 @@ import { atom } from 'nanostores'
/** Whether the global command palette (Cmd/Ctrl+K) is currently open. */
export const $commandPaletteOpen = atom(false)
/** Optional nested page to open when the palette next opens (e.g. `pets`). */
export const $commandPalettePage = atom<string | null>(null)
export function openCommandPalette(): void {
$commandPaletteOpen.set(true)
}
/** Open the palette directly on a nested page (`theme`, `pets`, …). */
export function openCommandPalettePage(page: string): void {
$commandPalettePage.set(page)
$commandPaletteOpen.set(true)
}
export function closeCommandPalette(): void {
$commandPaletteOpen.set(false)
$commandPalettePage.set(null)
}
export function setCommandPaletteOpen(open: boolean): void {
$commandPaletteOpen.set(open)
if (!open) {
$commandPalettePage.set(null)
}
}
export function toggleCommandPalette(): void {

View file

@ -1,298 +0,0 @@
import { atom } from 'nanostores'
import { $petInfo, type PetInfo, setPetInfo } from '@/store/pet'
/**
* Feature store for the petdex gallery picker (Cmd+K "Pets…" + Settings).
*
* Why this exists: `pet.gallery` does a *network* manifest fetch on the gateway,
* so re-pulling it after every adopt/toggle made the picker feel laggy and made
* two components (palette + settings) each carry their own copy of the same
* fetch / thumb-cache / optimistic-mutation logic. This store centralizes it:
*
* - The gallery is fetched once and cached; reopening the picker is instant.
* - Mutations (adopt / enable / remove) patch local state and only re-pull the
* cheap, local `pet.info` never the network manifest again.
* - Thumbnails are deduped in a process-global cache (the backend disk-caches
* too, so a slug is fetched at most once per session).
*
* Consumers just `useStore($petGallery)` and call the actions; no component
* owns gallery state anymore.
*/
export interface GalleryPet {
slug: string
displayName: string
installed: boolean
spritesheetUrl?: string
/** petdex's hand-picked set — used only to rank "popular" pets first. */
curated?: boolean
}
export interface PetGallery {
enabled: boolean
active: string
pets: GalleryPet[]
}
export type PetGalleryStatus = 'idle' | 'loading' | 'ready' | 'stale' | 'error'
/** The recovering `requestGateway` from `useGatewayRequest` passed in so the
* store reuses the hook's reconnect/reauth handling instead of duplicating it. */
export type GatewayRequest = <T>(method: string, params?: Record<string, unknown>) => Promise<T>
/** A JSON-RPC "method not found" — the backend predates the pet RPCs. */
function isMissingMethod(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error)
return /method not found|-32601|unknown method|no such method/i.test(message)
}
export const $petGallery = atom<PetGallery | null>(null)
export const $petGalleryStatus = atom<PetGalleryStatus>('idle')
export const $petGalleryError = atom<string | null>(null)
// Which action is in flight, so rows/buttons can show a spinner. A slug for a
// per-pet mutation; the `TOGGLE_*` sentinels for the on/off switch.
export const TOGGLE_ON = '\u0000on'
export const TOGGLE_OFF = '\u0000off'
export const $petBusy = atom<string | null>(null)
// Process-global caches (survive component unmount → instant reopen).
const thumbCache = new Map<string, Promise<string | null>>()
let galleryLoad: Promise<void> | null = null
export function loadPetThumb(request: GatewayRequest, slug: string, url?: string): Promise<string | null> {
let pending = thumbCache.get(slug)
if (!pending) {
pending = request<{ ok: boolean; dataUri?: string }>('pet.thumb', { slug, url: url ?? '' })
.then(result => (result?.ok && result.dataUri ? result.dataUri : null))
.catch(() => null)
thumbCache.set(slug, pending)
}
return pending
}
/**
* Fetch the gallery once and cache it. Subsequent calls are no-ops while a
* ready snapshot is held; pass `{ force: true }` to bypass the cache (e.g. a
* manual refresh). Concurrent callers share a single in-flight request.
*/
export function loadPetGallery(request: GatewayRequest, options: { force?: boolean } = {}): Promise<void> {
if (!options.force && $petGallery.get() && $petGalleryStatus.get() === 'ready') {
return Promise.resolve()
}
if (galleryLoad) {
return galleryLoad
}
galleryLoad = (async () => {
if (!$petGallery.get()) {
$petGalleryStatus.set('loading')
}
try {
const [next, info] = await Promise.all([request<PetGallery>('pet.gallery'), request<PetInfo>('pet.info')])
if (next) {
$petGallery.set(next)
$petGalleryStatus.set('ready')
$petGalleryError.set(null)
}
if (info) {
setPetInfo(info)
}
} catch (e) {
if (isMissingMethod(e)) {
$petGalleryStatus.set('stale')
} else if (!$petGallery.get()) {
// Only surface a hard error when we have nothing to show; a transient
// hiccup mid-session leaves the cached gallery intact.
$petGalleryStatus.set('error')
$petGalleryError.set(e instanceof Error ? e.message : 'Could not reach the petdex gallery.')
}
} finally {
galleryLoad = null
}
})()
return galleryLoad
}
// Push the live mascot state (cheap, local config read) without re-pulling the
// network gallery — the floating pet repaints, the picker keeps its cache.
async function syncInfo(request: GatewayRequest): Promise<void> {
try {
const info = await request<PetInfo>('pet.info')
if (info) {
setPetInfo(info)
}
} catch {
// The mutation already succeeded; a stale mascot self-heals on its poll.
}
}
/**
* Filter (drop the internal `clawd*` pets + apply a search query) and rank the
* gallery for a picker. Ranking has no popularity data, so it leans on the
* signals we do have: active pet first, then installed, then curated. Shared by
* the Cmd-K palette and the Settings grid so the two can't drift each caller
* applies its own cap and reads `.length` for the total.
*/
export function rankedGalleryPets(gallery: PetGallery | null, query = ''): GalleryPet[] {
if (!gallery) {
return []
}
const needle = query.trim().toLowerCase()
const rank = (p: GalleryPet) =>
Number(gallery.enabled && p.slug === gallery.active) * 4 + Number(p.installed) * 2 + Number(p.curated)
return gallery.pets
.filter(
p =>
!/^clawd(-|$)/i.test(p.slug) &&
(!needle || p.slug.toLowerCase().includes(needle) || p.displayName.toLowerCase().includes(needle))
)
.sort((a, b) => rank(b) - rank(a))
}
function patchGallery(fn: (gallery: PetGallery) => PetGallery): void {
const current = $petGallery.get()
if (current) {
$petGallery.set(fn(current))
}
}
/** Shared mutation wrapper: spin, fire, patch on success, surface failures. */
async function mutate(
busyKey: string,
fallback: string,
request: GatewayRequest,
run: () => Promise<void>
): Promise<boolean> {
$petBusy.set(busyKey)
$petGalleryError.set(null)
try {
await run()
await syncInfo(request)
return true
} catch (e) {
if (isMissingMethod(e)) {
$petGalleryStatus.set('stale')
} else {
$petGalleryError.set(e instanceof Error ? e.message : fallback)
}
return false
} finally {
$petBusy.set(null)
}
}
/** Install (if needed) + activate a pet. Optimistically marks it active. */
export function adoptPet(request: GatewayRequest, slug: string, fallback: string): Promise<boolean> {
return mutate(slug, fallback, request, async () => {
await request('pet.select', { slug })
patchGallery(g => ({
...g,
enabled: true,
active: slug,
pets: g.pets.map(p => (p.slug === slug ? { ...p, installed: true } : p))
}))
})
}
/**
* Turn the floating mascot on/off. On enable, activates the current pet (or the
* first installed one). Returns false without firing if there's nothing to show.
*/
export function setPetEnabled(
request: GatewayRequest,
on: boolean,
copy: { noneAvailable: string; fallback: string }
): Promise<boolean> {
const gallery = $petGallery.get()
if (!on && !(gallery?.enabled ?? false)) {
return Promise.resolve(true)
}
let slug = gallery?.active || ''
if (on) {
slug = slug || gallery?.pets.find(p => p.installed)?.slug || ''
if (!slug) {
$petGalleryError.set(copy.noneAvailable)
return Promise.resolve(false)
}
}
return mutate(on ? TOGGLE_ON : TOGGLE_OFF, copy.fallback, request, async () => {
if (on) {
await request('pet.select', { slug })
} else {
await request('pet.disable')
}
patchGallery(g => ({ ...g, enabled: on, active: on ? slug : g.active }))
})
}
// Pet scale bounds — mirror `agent/pet/constants.py` (MIN_SCALE / MAX_SCALE) so
// the slider and the server clamp to the same range.
export const PET_SCALE_MIN = 0.1
export const PET_SCALE_MAX = 3.0
export const PET_SCALE_DEFAULT = 0.33
export const clampPetScale = (n: number) => Math.max(PET_SCALE_MIN, Math.min(PET_SCALE_MAX, n))
let scalePersist: ReturnType<typeof setTimeout> | undefined
/**
* Resize the floating pet. Updates `$petInfo` synchronously so the on-screen pet
* (and the slider) react on the same frame, then debounce-persists to
* `display.pet.scale` so a slider drag fires one RPC, not one per pixel. No poll
* or event needed the pet already renders from `$petInfo.scale`.
*/
export function setPetScale(request: GatewayRequest, scale: number): void {
const next = clampPetScale(scale)
setPetInfo({ ...$petInfo.get(), scale: next })
clearTimeout(scalePersist)
scalePersist = setTimeout(() => {
request<{ ok: boolean; scale?: number }>('pet.scale', { scale: next })
.then(result => {
// Reconcile with the server's clamp (cheap; only matters at the bounds).
if (typeof result?.scale === 'number' && result.scale !== $petInfo.get().scale) {
setPetInfo({ ...$petInfo.get(), scale: result.scale })
}
})
.catch(() => {
// Cosmetic — the pet already resized; persistence self-heals next write.
})
}, 200)
}
/** Uninstall a pet; turns the mascot off if it was the active one. */
export function removePet(request: GatewayRequest, slug: string, fallback: string): Promise<boolean> {
return mutate(slug, fallback, request, async () => {
await request('pet.remove', { slug })
patchGallery(g => ({
...g,
enabled: g.active === slug ? false : g.enabled,
pets: g.pets.map(p => (p.slug === slug ? { ...p, installed: false } : p))
}))
})
}

View file

@ -1,27 +0,0 @@
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')
})
})

View file

@ -1,123 +0,0 @@
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
// Real (padding-trimmed) frame count per state row, from the engine. Lets the
// canvas step only frames that exist instead of a fixed framesPerState, which
// would animate into the transparent padding of ragged sheets (blank flash).
framesByState?: Record<string, 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>({})
/** Steady activity flags (toolRunning / reasoning) set + cleared by the stream. */
export const setPetActivity = (next: Partial<PetActivity>) =>
$petActivity.set({ ...$petActivity.get(), ...next })
let flashTimer: ReturnType<typeof setTimeout> | undefined
/** Fire a transient reaction beat (error / celebrate / justCompleted) that
* decays back to the steady state after `ms`. */
export const flashPetActivity = (next: Partial<PetActivity>, ms = 1600) => {
setPetActivity(next)
clearTimeout(flashTimer)
flashTimer = setTimeout(
() => setPetActivity({ celebrate: false, error: false, justCompleted: false }),
ms
)
}
export const setPetInfo = (info: PetInfo) => $petInfo.set(info)
/**
* The live pet state. Derives from the dedicated activity atom 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 => {
const live = activity.busy ?? busy
return derivePetState({
busy: live,
awaitingInput: activity.awaitingInput ?? awaiting,
// Steady flags only count mid-turn — ignore stale ones once at rest so an
// interrupted turn can't pin the pet on `run`/`review`.
toolRunning: live && activity.toolRunning,
reasoning: live && activity.reasoning,
error: activity.error,
justCompleted: activity.justCompleted,
celebrate: activity.celebrate
})
}
)

250
cli.py
View file

@ -60,7 +60,7 @@ from prompt_toolkit.history import FileHistory
from prompt_toolkit.styles import Style as PTStyle
from prompt_toolkit.patch_stdout import patch_stdout
from prompt_toolkit.application import Application
from prompt_toolkit.layout import Layout, HSplit, Window, FormattedTextControl, ConditionalContainer, WindowAlign
from prompt_toolkit.layout import Layout, HSplit, Window, FormattedTextControl, ConditionalContainer
from prompt_toolkit.layout.processors import Processor, Transformation, PasswordProcessor, ConditionalProcessor
from prompt_toolkit.filters import Condition
from prompt_toolkit.layout.dimension import Dimension
@ -3572,25 +3572,6 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
self._last_scrollback_tool: str = "" # last tool name printed to scrollback (for "new" dedup)
self._command_running = False
self._command_status = ""
# Petdex mascot (opt-in via display.pet). The base CLI mirrors the TUI's
# PetPane: a half-block sprite above the prompt that reacts to agent
# activity. Lazily resolved; an invalidate timer drives the animation.
self._pet_renderer = None # agent.pet.render.PetRenderer | None
self._pet_slug: str = ""
self._pet_enabled: bool = False
self._pet_cols: int = 18
self._pet_scale: float = 0.7
self._pet_frames_cache: dict = {} # state -> list[grid]
self._pet_frame_idx: int = 0
self._pet_lock = threading.Lock()
self._pet_cfg_checked: float = 0.0
self._pet_anim_running: bool = False
self._pet_anim_thread = None
# Transient reaction beats (wave/jump/failed) + steady reasoning flag.
self._pet_event: str = ""
self._pet_event_until: float = 0.0
self._pet_reasoning: bool = False
self._pet_turn_error: bool = False
self._attached_images: list[Path] = []
self._image_counter = 0
self.preloaded_skills: list[str] = []
@ -4131,206 +4112,6 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
return f" {txt} ({elapsed_str})"
return f" {txt}"
# ── Petdex mascot (base-CLI pet pane) ───────────────────────────────
#
# Parity with the TUI: a half-block sprite rendered as a prompt_toolkit
# window above the prompt, reacting to agent state and animated by a timer
# that calls ``app.invalidate()``. Half-blocks only — the crisp Kitty image
# protocol can't coexist with prompt_toolkit's patch_stdout output layer
# (raw image escapes get swallowed/mangled), so we use truecolor styled
# text, which prompt_toolkit renders natively in any 24-bit terminal.
_PET_FRAME_INTERVAL = 0.16
_PET_CFG_INTERVAL = 2.5
def _pet_resolve_config(self) -> None:
"""(Re)resolve the active pet from config — picks up live enable/disable/
switch made via ``/pet`` or ``hermes pets`` without a restart, mirroring
the TUI's steady poll. Cheap and fail-open: any problem disables the pet.
"""
try:
from agent.pet import constants, store
from agent.pet.render import PetRenderer
from hermes_cli.config import load_config
cfg = load_config()
display = cfg.get("display", {}) if isinstance(cfg.get("display"), dict) else {}
pet_cfg = display.get("pet", {}) if isinstance(display.get("pet"), dict) else {}
enabled = bool(pet_cfg.get("enabled"))
slug = str(pet_cfg.get("slug", "") or "")
scale = float(pet_cfg.get("scale", constants.DEFAULT_SCALE) or constants.DEFAULT_SCALE)
cols = constants.resolve_cols(scale, pet_cfg.get("unicode_cols", 0))
if not enabled:
with self._pet_lock:
self._pet_enabled = False
self._pet_renderer = None
self._pet_frames_cache.clear()
return
pet = store.resolve_active_pet(slug)
if pet is None or not pet.exists:
with self._pet_lock:
self._pet_enabled = False
self._pet_renderer = None
self._pet_frames_cache.clear()
return
with self._pet_lock:
# Rebuild only when the resolved pet or geometry changes.
if (
self._pet_renderer is None
or self._pet_slug != pet.slug
or self._pet_cols != cols
or self._pet_scale != scale
):
self._pet_renderer = PetRenderer(
str(pet.spritesheet), mode="unicode", scale=scale, unicode_cols=cols
)
self._pet_slug = pet.slug
self._pet_cols = cols
self._pet_scale = scale
self._pet_frames_cache.clear()
self._pet_frame_idx = 0
self._pet_enabled = True
except Exception:
with self._pet_lock:
self._pet_enabled = False
self._pet_renderer = None
def _pet_flash(self, state: str, secs: float = 1.6) -> None:
"""Briefly force a transient reaction (wave/jump/failed) before resting."""
self._pet_event = state
self._pet_event_until = time.monotonic() + secs
def _pet_react_turn_end(self) -> None:
"""Flash the end-of-turn beat: failed on error, jump on a finished plan, else wave."""
if not self._pet_enabled:
return
from agent.pet.state import todos_all_done
if self._pet_turn_error:
self._pet_flash("failed")
return
try:
store = getattr(self.agent, "_todo_store", None)
done = todos_all_done(store.read()) if store else False
except Exception:
done = False
self._pet_flash("jump" if done else "wave")
def _derive_pet_state(self) -> str:
"""Map current CLI activity to a pet animation state.
A transient reaction beat (wave/jump/failed) wins while it's live;
otherwise the steady state comes from the shared
:func:`agent.pet.state.derive_pet_state` so the CLI can't drift from the
TUI/desktop priority order.
"""
if self._pet_event and time.monotonic() < self._pet_event_until:
return self._pet_event
self._pet_event = ""
from agent.pet.state import derive_pet_state
return derive_pet_state(
busy=getattr(self, "_agent_running", False),
reasoning=self._pet_reasoning,
).value
def _pet_frames_for(self, state: str) -> list:
"""Return (and cache) the half-block grids for one state."""
cached = self._pet_frames_cache.get(state)
if cached is not None:
return cached
renderer = self._pet_renderer
if renderer is None:
return []
try:
count = renderer.frame_count(state) or 1
grids = [renderer.cells(state, i, cols=self._pet_cols) for i in range(count)]
except Exception:
grids = []
self._pet_frames_cache[state] = grids
return grids
def _pet_fragments(self):
"""Return prompt_toolkit FormattedText for the current pet frame, or []."""
with self._pet_lock:
if not self._pet_enabled or self._pet_renderer is None:
return []
state = self._derive_pet_state()
grids = self._pet_frames_for(state)
if not grids:
return []
grid = grids[self._pet_frame_idx % len(grids)]
frags = []
for y, row in enumerate(grid):
if y:
frags.append(("", "\n"))
for top, bottom in row:
tr, tg, tb, ta = top
br, bg, bb, ba = bottom
top_op = ta >= 32
bot_op = ba >= 32
if not top_op and not bot_op:
frags.append(("", " "))
elif top_op and bot_op:
frags.append((f"fg:#{tr:02x}{tg:02x}{tb:02x} bg:#{br:02x}{bg:02x}{bb:02x}", ""))
elif top_op:
# Upper half only — leave the lower half the terminal's bg
# instead of painting it black (cleaner on light themes).
frags.append((f"fg:#{tr:02x}{tg:02x}{tb:02x}", ""))
else:
frags.append((f"fg:#{br:02x}{bg:02x}{bb:02x}", ""))
return frags
def _pet_widget_height(self) -> int:
"""Visible rows for the pet window — 0 collapses it when no pet shows."""
with self._pet_lock:
if not self._pet_enabled or self._pet_renderer is None:
return 0
grids = self._pet_frames_for(self._derive_pet_state())
if not grids or not grids[0]:
return 0
return len(grids[0])
def _pet_anim_loop(self) -> None:
"""Advance the frame + invalidate on a timer while a pet is enabled."""
while self._pet_anim_running:
time.sleep(self._PET_FRAME_INTERVAL)
now = time.monotonic()
if now - self._pet_cfg_checked >= self._PET_CFG_INTERVAL:
self._pet_cfg_checked = now
self._pet_resolve_config()
if not self._pet_enabled:
continue
with self._pet_lock:
self._pet_frame_idx += 1
app = getattr(self, "_app", None)
if app is not None:
try:
app.invalidate()
except Exception:
pass
def _pet_start_anim(self) -> None:
if self._pet_anim_running:
return
self._pet_resolve_config()
self._pet_anim_running = True
self._pet_anim_thread = threading.Thread(target=self._pet_anim_loop, daemon=True)
self._pet_anim_thread.start()
def _pet_stop_anim(self) -> None:
self._pet_anim_running = False
thread = self._pet_anim_thread
if thread is not None:
thread.join(timeout=0.3)
self._pet_anim_thread = None
def _voice_record_key_label(self) -> str:
"""Return the configured voice push-to-talk key formatted for UI.
@ -7653,8 +7434,6 @@ 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'):
@ -9194,15 +8973,6 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
stacked line to scrollback on tool.completed so users can see the
full history of tool calls (not just the current one in the spinner).
"""
# Feed the pet: tools mean "running" (not reasoning); a failed tool
# latches the turn so it ends on a sulk.
if event_type == "tool.started":
self._pet_reasoning = False
elif event_type == "tool.completed" and kwargs.get("is_error"):
self._pet_turn_error = True
elif event_type and event_type.startswith("reasoning"):
self._pet_reasoning = True
if event_type == "tool.completed":
self._tool_start_time = 0.0
# Print stacked scrollback line for "all" / "new" modes
@ -11155,7 +10925,6 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
spinner_widget,
spacer,
*self._get_extra_tui_widgets(),
getattr(self, "_pet_widget", None),
status_bar,
input_rule_top,
image_bar,
@ -12497,16 +12266,6 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
wrap_lines=True,
)
# Petdex mascot — right-aligned half-block sprite above the prompt,
# mirroring the TUI's PetPane. Collapses to height 0 when no pet is
# enabled, so it's a no-op for everyone else. The _pet_anim_loop thread
# advances frames + invalidates; align=RIGHT pins it to the edge.
self._pet_widget = Window(
content=FormattedTextControl(self._pet_fragments),
height=self._pet_widget_height,
align=WindowAlign.RIGHT,
)
spacer = Window(
content=FormattedTextControl(get_hint_text),
height=get_hint_height,
@ -13277,8 +13036,6 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
# Regular chat - run agent
self._agent_running = True
self._pet_turn_error = False
self._pet_reasoning = False
app.invalidate() # Refresh status line
try:
@ -13289,8 +13046,6 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
self._tool_start_time = 0.0
self._pending_tool_info.clear()
self._last_scrollback_tool = ""
self._pet_reasoning = False
self._pet_react_turn_end()
app.invalidate() # Refresh status line
@ -13523,8 +13278,6 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
# The app enables focus reporting + mouse tracking; record that
# so _run_cleanup resets them on exit (#36823).
_mark_tui_input_modes_active()
# Drive the petdex mascot animation (no-op when no pet enabled).
self._pet_start_anim()
app.run()
except (EOFError, KeyboardInterrupt, BrokenPipeError):
pass
@ -13551,7 +13304,6 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
raise
finally:
self._should_exit = True
self._pet_stop_anim()
# Interrupt the agent immediately so its daemon thread stops making
# API calls and exits promptly (agent_thread is daemon, so the
# process will exit once the main thread finishes, but interrupting

View file

@ -0,0 +1,166 @@
"""Helpers for rendering gateway message timestamps exactly once.
Gateway messages need timestamps in the LLM context for temporal awareness, but
persisted message content should stay clean so replay does not accumulate
``[timestamp] [timestamp] ...`` prefixes across turns.
"""
from __future__ import annotations
import re
from datetime import datetime
from typing import Any, Optional, Tuple
# Current gateway format: [Tue 2026-04-28 13:40:53 CEST]
_HUMAN_TIMESTAMP_RE = re.compile(
r"^\[(?P<dow>[A-Z][a-z]{2}) "
r"(?P<date>\d{4}-\d{2}-\d{2}) "
r"(?P<time>\d{2}:\d{2}:\d{2})"
r"(?: (?P<tz>[A-Za-z0-9_+\-/:]+))?\]\s*"
)
# Older gateway format: [2026-04-13T17:02:06+0200] or [+02:00]
_ISO_TIMESTAMP_RE = re.compile(
r"^\[(?P<iso>\d{4}-\d{2}-\d{2}T[^\]]+)\]\s*"
)
def coerce_message_timestamp(ts_value: Any, tz=None) -> Optional[float]:
"""Coerce a timestamp-like value to Unix epoch seconds.
Accepts Unix epoch numbers, datetime objects, ISO strings, and the gateway's
bracketed human-readable timestamp format. Returns ``None`` when the value
cannot be interpreted.
"""
if ts_value is None:
return None
if isinstance(ts_value, (int, float)):
return float(ts_value)
if hasattr(ts_value, "timestamp"):
try:
return float(ts_value.timestamp())
except Exception:
return None
if isinstance(ts_value, str):
text = ts_value.strip()
if not text:
return None
parsed = _parse_timestamp_prefix(text, tz=tz)
if parsed is not None:
return parsed
try:
return float(text)
except (TypeError, ValueError):
pass
try:
dt = datetime.fromisoformat(text)
except (TypeError, ValueError):
try:
dt = datetime.strptime(text, "%Y-%m-%dT%H:%M:%S%z")
except (TypeError, ValueError):
return None
if dt.tzinfo is None:
if tz is not None:
dt = dt.replace(tzinfo=tz)
else:
dt = dt.astimezone()
return float(dt.timestamp())
return None
def format_message_timestamp(ts_value: Any, tz=None) -> str:
"""Format a timestamp value as ``[Tue 2026-04-28 13:40:53 CEST]``."""
epoch = coerce_message_timestamp(ts_value, tz=tz)
if epoch is None:
return ""
if tz is not None:
dt = datetime.fromtimestamp(epoch, tz=tz)
else:
dt = datetime.fromtimestamp(epoch).astimezone()
return "[" + dt.strftime("%a %Y-%m-%d %H:%M:%S %Z") + "]"
def strip_leading_message_timestamps(content: str, tz=None) -> Tuple[str, Optional[float]]:
"""Strip one or more leading gateway timestamp prefixes from ``content``.
Returns ``(clean_content, embedded_epoch)``. If multiple timestamp prefixes
are present, the timestamp closest to the actual message text wins. That
preserves the original platform-send time for legacy contaminated rows like
``[processing time] [platform time] [sender] message``.
"""
if not isinstance(content, str) or not content:
return content, None
text = content
embedded_epoch: Optional[float] = None
while True:
match = _HUMAN_TIMESTAMP_RE.match(text) or _ISO_TIMESTAMP_RE.match(text)
if not match:
break
parsed = _parse_timestamp_match(match, tz=tz)
if parsed is not None:
embedded_epoch = parsed
text = text[match.end():]
return text, embedded_epoch
def render_user_content_with_timestamp(content: str, ts_value: Any = None, tz=None) -> str:
"""Render a user message for LLM context with exactly one timestamp prefix.
Existing leading timestamp prefixes are removed first. If such a prefix was
present, its parsed time wins over ``ts_value``; otherwise ``ts_value`` is
formatted and prepended. If no timestamp is available, the cleaned content is
returned unchanged.
"""
clean_content, embedded_epoch = strip_leading_message_timestamps(content, tz=tz)
effective_ts = embedded_epoch if embedded_epoch is not None else ts_value
prefix = format_message_timestamp(effective_ts, tz=tz)
if not prefix:
return clean_content
if clean_content:
return f"{prefix} {clean_content}"
return prefix
def _parse_timestamp_prefix(text: str, tz=None) -> Optional[float]:
match = _HUMAN_TIMESTAMP_RE.match(text) or _ISO_TIMESTAMP_RE.match(text)
if not match:
return None
return _parse_timestamp_match(match, tz=tz)
def _parse_timestamp_match(match: re.Match, tz=None) -> Optional[float]:
if "iso" in match.groupdict() and match.group("iso"):
iso_text = match.group("iso")
try:
dt = datetime.fromisoformat(iso_text)
except ValueError:
try:
dt = datetime.strptime(iso_text, "%Y-%m-%dT%H:%M:%S%z")
except ValueError:
return None
if dt.tzinfo is None:
if tz is not None:
dt = dt.replace(tzinfo=tz)
else:
dt = dt.astimezone()
return float(dt.timestamp())
date_part = match.group("date")
time_part = match.group("time")
try:
dt = datetime.strptime(f"{date_part} {time_part}", "%Y-%m-%d %H:%M:%S")
except ValueError:
return None
if tz is not None:
dt = dt.replace(tzinfo=tz)
else:
dt = dt.astimezone()
return float(dt.timestamp())

View file

@ -692,10 +692,31 @@ def _uses_telegram_observed_group_context(channel_prompt: Optional[str]) -> bool
return bool(channel_prompt and _TELEGRAM_OBSERVED_CONTEXT_PROMPT_MARKER in channel_prompt)
def _message_timestamps_enabled(user_config: Optional[dict]) -> bool:
"""True when gateway.message_timestamps.enabled is opted in.
Default OFF: injecting a ``[Tue 2026-04-28 13:40:53 CEST]`` prefix onto
every user message changes what the model sees for all gateway users, so
it must be explicitly enabled in config.yaml under
``gateway.message_timestamps.enabled``.
"""
if not isinstance(user_config, dict):
return False
gw = user_config.get("gateway")
if not isinstance(gw, dict):
return False
mt = gw.get("message_timestamps")
if isinstance(mt, dict):
return bool(mt.get("enabled", False))
# Allow a bare ``message_timestamps: true`` shorthand.
return bool(mt)
def _build_gateway_agent_history(
history: List[Dict[str, Any]],
*,
channel_prompt: Optional[str] = None,
inject_timestamps: bool = False,
) -> tuple[List[Dict[str, Any]], Optional[str]]:
"""Convert stored gateway transcript rows into agent replay messages.
@ -704,8 +725,18 @@ def _build_gateway_agent_history(
turns. Keeping that context out of ``conversation_history`` avoids
consecutive-user repair merging it with the live user turn and then hiding
the current message behind ``history_offset`` during persistence.
When ``inject_timestamps`` is True (gateway.message_timestamps.enabled),
each replayed user message is rendered with a single human-readable
timestamp prefix from its stored metadata.
"""
from hermes_time import get_timezone as _get_msg_tz
from gateway.message_timestamps import (
render_user_content_with_timestamp as _render_msg_ts,
)
_msg_tz = _get_msg_tz()
agent_history: List[Dict[str, Any]] = []
observed_group_context: List[str] = []
separate_observed_context = _uses_telegram_observed_group_context(channel_prompt)
@ -725,6 +756,8 @@ def _build_gateway_agent_history(
continue
content = msg.get("content")
if inject_timestamps and role == "user" and isinstance(content, str):
content = _render_msg_ts(content, msg.get("timestamp"), tz=_msg_tz)
if separate_observed_context and msg.get("observed") and role == "user" and content:
observed_group_context.append(str(content).strip())
continue
@ -8378,6 +8411,8 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
# Read privacy.redact_pii from config (re-read per message)
_redact_pii = False
persist_user_message = None
persist_user_timestamp = None
try:
_pcfg = _load_gateway_config()
_redact_pii = bool((_pcfg.get("privacy") or {}).get("redact_pii", False))
@ -8902,6 +8937,42 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
if message_text is None:
return
# Capture the platform event time as message metadata and keep the
# persisted transcript clean (strip any leading timestamp prefix).
# This runs regardless of the toggle so storage stays clean and the
# send-time is preserved. Only the in-context RENDER (prepending the
# human-readable prefix the model sees) is gated behind
# gateway.message_timestamps.enabled — default OFF.
try:
from hermes_time import get_timezone as _get_evt_tz
from gateway.message_timestamps import (
coerce_message_timestamp as _coerce_msg_ts,
render_user_content_with_timestamp as _render_msg_ts,
strip_leading_message_timestamps as _strip_msg_ts,
)
_evt_tz = _get_evt_tz()
_evt_ts = getattr(event, "timestamp", None)
if message_text and isinstance(message_text, str):
_clean_message_text, _embedded_ts = _strip_msg_ts(
message_text, tz=_evt_tz)
persist_user_message = _clean_message_text
_event_epoch = _coerce_msg_ts(_evt_ts, tz=_evt_tz)
persist_user_timestamp = (
_event_epoch if _event_epoch is not None else _embedded_ts
)
if _message_timestamps_enabled(_load_gateway_config()):
message_text = _render_msg_ts(
_clean_message_text,
persist_user_timestamp,
tz=_evt_tz,
)
else:
# Toggle off: model sees the clean message; the timestamp
# is still stored as metadata for later opt-in.
message_text = _clean_message_text
except Exception as _ts_err:
logger.debug("Message timestamp injection failed (non-fatal): %s", _ts_err)
# Bind this gateway run generation to the adapter's active-session
# event so deferred post-delivery callbacks can be released by the
# same run that registered them.
@ -8935,6 +9006,8 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
run_generation=run_generation,
event_message_id=self._reply_anchor_for_event(event),
channel_prompt=event.channel_prompt,
persist_user_message=persist_user_message,
persist_user_timestamp=persist_user_timestamp,
)
# Stop persistent typing indicator now that the agent is done
@ -9226,7 +9299,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
"Your next message will start a fresh session."
)
ts = datetime.now().isoformat()
ts = time.time() # Unix epoch float — consistent with DB storage
# If this is a fresh session (no history), write the full tool
# definitions as the first entry so the transcript is self-describing
@ -9262,7 +9335,19 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
# message so the next message can load a transcript that
# reflects what was said. Skip the assistant error text since
# it's a gateway-generated hint, not model output. (#7100)
_user_entry = {"role": "user", "content": message_text, "timestamp": ts}
_user_entry = {
"role": "user",
"content": (
persist_user_message
if persist_user_message is not None
else message_text
),
"timestamp": (
persist_user_timestamp
if persist_user_timestamp is not None
else ts
),
}
if event.message_id:
_user_entry["message_id"] = str(event.message_id)
self.session_store.append_to_transcript(
@ -9276,7 +9361,19 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
# If no new messages found (edge case), fall back to simple user/assistant
if not new_messages:
_user_entry = {"role": "user", "content": message_text, "timestamp": ts}
_user_entry = {
"role": "user",
"content": (
persist_user_message
if persist_user_message is not None
else message_text
),
"timestamp": (
persist_user_timestamp
if persist_user_timestamp is not None
else ts
),
}
if event.message_id:
_user_entry["message_id"] = str(event.message_id)
self.session_store.append_to_transcript(
@ -9401,13 +9498,26 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
_recent_transcript = []
for _msg in reversed(_recent_transcript[-10:]):
if _msg.get("role") == "user":
_already_persisted = (_msg.get("content") == message_text)
_expected_user_content = (
persist_user_message
if persist_user_message is not None
else message_text
)
_already_persisted = (_msg.get("content") == _expected_user_content)
break
if not _already_persisted:
_user_entry = {
"role": "user",
"content": message_text,
"timestamp": datetime.now().isoformat(),
"content": (
persist_user_message
if persist_user_message is not None
else message_text
),
"timestamp": (
persist_user_timestamp
if persist_user_timestamp is not None
else time.time()
),
}
if getattr(event, "message_id", None):
_user_entry["message_id"] = str(event.message_id)
@ -13602,6 +13712,8 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
_interrupt_depth: int = 0,
event_message_id: Optional[str] = None,
channel_prompt: Optional[str] = None,
persist_user_message: Optional[str] = None,
persist_user_timestamp: Optional[float] = None,
) -> Dict[str, Any]:
"""
Run the agent with the given message and context.
@ -14912,6 +15024,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
agent_history, observed_group_context = _build_gateway_agent_history(
history,
channel_prompt=channel_prompt,
inject_timestamps=_message_timestamps_enabled(_load_gateway_config()),
)
# Collect MEDIA paths already in history so we can exclude them
@ -15028,7 +15141,8 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
# Keep real user text separate from API-only recovery guidance. If
# an auto-continue note is prepended below, persist the original
# message so stale guidance never replays as user-authored text.
_persist_user_message_override: Optional[Any] = None
_persist_user_message_override: Optional[Any] = persist_user_message
_persist_user_timestamp_override: Optional[float] = persist_user_timestamp
# Prepend pending model switch note so the model knows about the switch
_pending_notes = getattr(self, '_pending_model_notes', {})
@ -15168,6 +15282,8 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
_conversation_kwargs["persist_user_message"] = _persist_user_message_override
elif observed_group_context:
_conversation_kwargs["persist_user_message"] = message
if _persist_user_timestamp_override is not None:
_conversation_kwargs["persist_user_timestamp"] = _persist_user_timestamp_override
result = agent.run_conversation(_api_run_message, **_conversation_kwargs)
finally:
unregister_gateway_notify(_approval_session_key)

View file

@ -1322,6 +1322,7 @@ class SessionStore:
message.get("platform_message_id") or message.get("message_id")
),
observed=bool(message.get("observed")),
timestamp=message.get("timestamp"),
)
except Exception as e:
logger.debug("Session DB operation failed: %s", e)

View file

@ -1039,64 +1039,6 @@ class CLICommandsMixin:
print(" Usage: /personality <name>")
print()
def _handle_pet_command(self, cmd: str):
"""Toggle, browse, or adopt a petdex mascot.
``/pet`` / ``/pet toggle`` flip ``display.pet.enabled`` on/off
``/pet list`` browse the petdex gallery
``/pet scale <n>`` resize the pet everywhere (e.g. 0.5)
``/pet <slug>`` adopt (install if needed) + make active
``/pet off`` disable (alias for toggle-off)
Writes ``display.pet.*`` to config; the CLI/TUI/desktop pet surfaces
pick the change up on their next poll, so the pet appears shortly.
"""
from agent.pet import store
from agent.pet.manifest import ManifestError
from hermes_cli.pets import _set_active, _set_enabled, print_pet_gallery, set_pet_scale, toggle_pet_display
parts = cmd.split(maxsplit=1)
arg = parts[1].strip() if len(parts) > 1 else ""
low = arg.lower()
if not arg or low == "toggle":
enabled, name, err = toggle_pet_display()
if err:
print(f"(x_x) {err}")
return
if enabled:
print(f"(^_^)b {name} is out — it'll pop in shortly.")
else:
print(f"(-_-)zzZ {name} put away." if name else "(-_-)zzZ Pet put away.")
return
if low in ("list", "gallery", "browse", "all"):
print_pet_gallery()
return
if low == "scale" or low.startswith("scale "):
value = arg[len("scale"):].strip()
if not value:
print("(o_o) Usage: /pet scale <factor> (e.g. /pet scale 0.5)")
return
scale, err = set_pet_scale(value)
print(f"(x_x) {err}" if err else f"(^_^) Pet scale → {scale:g}.")
return
if low == "off":
_set_enabled(False)
print("(-_-)zzZ Pet put away.")
return
print(f"(o_o) Fetching '{arg}' from petdex…")
try:
pet = store.install_pet(arg)
except (store.PetStoreError, ManifestError) as exc:
print(f"(x_x) Couldn't adopt '{arg}': {exc}")
return
_set_active(arg)
print(f"(^_^)b {pet.display_name} is out — it'll pop in shortly.")
def _handle_cron_command(self, cmd: str):
"""Handle the /cron command to manage scheduled tasks."""
from cli import get_job

View file

@ -176,8 +176,6 @@ COMMAND_REGISTRY: list[CommandDef] = [
subcommands=("pending", "approve", "reject", "approval")),
CommandDef("bundles", "List skill bundles (aliases /<name> for multiple skills)",
"Tools & Skills"),
CommandDef("pet", "Toggle or adopt a petdex mascot (/pet, /pet list, /pet <slug>)", "Tools & Skills",
cli_only=True, args_hint="[toggle|list|scale <n>|<slug>]", subcommands=("toggle", "list", "scale", "off")),
CommandDef("cron", "Manage scheduled tasks", "Tools & Skills",
cli_only=True, args_hint="[subcommand]",
subcommands=("list", "add", "create", "edit", "pause", "resume", "run", "remove")),

View file

@ -1534,31 +1534,6 @@ DEFAULT_CONFIG = {
"fields": ["model", "context_pct", "cwd"], # Order shown; drop any to hide
},
"copy_shortcut": "auto", # "auto" (platform default) | "ctrl_c" | "ctrl_shift_c" | "disabled"
# Petdex animated mascot (https://github.com/crafter-station/petdex).
# A purely cosmetic sprite that reacts to agent activity across the
# CLI, TUI, and desktop app. Manage with `hermes pets`. Disabled until
# a pet is installed + selected (no effect on prompt caching — this is
# a display concern only).
"pet": {
"enabled": False,
# Active pet slug; resolved against installed pets in
# get_hermes_home()/pets/. Empty → first installed pet.
"slug": "",
# Terminal render protocol for CLI/TUI:
# auto — detect kitty/iTerm2/sixel, else unicode half-blocks
# kitty | iterm | sixel | unicode | off
"render_mode": "auto",
# Master size scalar (relative to native 192×208 frames). One knob
# shrinks every surface: the desktop canvas scales its pixels by it
# and the CLI/TUI derive their terminal column width from it. The
# half-block fallback clamps to a legibility floor (it can't shrink
# as far as true-pixel kitty/GUI without turning to mush).
"scale": 0.33,
# Hard override for terminal column width. 0 = auto (derive from
# scale); set a positive int only to pin the half-block/kitty width
# independently of scale.
"unicode_cols": 0,
},
},
# Web dashboard settings
@ -2295,6 +2270,17 @@ DEFAULT_CONFIG = {
# Gateway settings — control how messaging platforms (Telegram, Discord,
# Slack, etc.) deliver agent-produced files as native attachments.
"gateway": {
# Inject a human-readable timestamp prefix (e.g.
# "[Tue 2026-04-28 13:40:53 CEST]") onto user messages IN THE MODEL'S
# CONTEXT so the agent has temporal awareness of when each message was
# sent. Off by default — when off, the model sees clean message text.
# Persisted transcripts always stay clean (the timestamp is stored as
# message metadata regardless of this toggle), so turning it on later
# surfaces send-times for past messages too.
"message_timestamps": {
"enabled": False,
},
# When false (default), any file path the agent emits is delivered
# as a native attachment as long as it isn't under the credential /
# system-path denylist (/etc, /proc, ~/.ssh, ~/.aws, ~/.hermes/.env,

View file

@ -11065,7 +11065,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", "pets", "plugins", "portal", "postinstall", "profile", "proxy",
"model", "pairing", "plugins", "portal", "postinstall", "profile", "proxy",
"prompt-size",
"send", "sessions", "setup",
"skills", "slack", "status", "tools", "uninstall", "update",
@ -11955,26 +11955,6 @@ 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)
# =========================================================================

View file

@ -1,482 +0,0 @@
"""CLI subcommand: ``hermes pets <subcommand>``.
Thin shell around :mod:`agent.pet`. Browses the public petdex gallery,
installs pets into the profile's ``pets/`` directory, selects the active
mascot (writes ``display.pet.*`` to config.yaml), and runs a doctor check.
No side effects at import time ``main.py`` wires the argparse subparsers on
demand via :func:`register_cli`.
"""
from __future__ import annotations
import argparse
import sys
def _print(msg: str = "") -> None:
print(msg)
def _err(msg: str) -> None:
print(msg, file=sys.stderr)
def _cmd_list(args) -> int:
"""List gallery pets (or only installed ones with ``--installed``)."""
from agent.pet import store
if getattr(args, "installed", False):
pets = store.installed_pets()
if not pets:
_print("No pets installed. Try: hermes pets install boba")
return 0
_print(f"Installed pets ({len(pets)}):")
for pet in pets:
_print(f" {pet.slug:<24} {pet.display_name}")
return 0
from agent.pet.manifest import ManifestError, fetch_manifest
try:
entries = fetch_manifest()
except ManifestError as exc:
_err(f"{exc}")
return 1
query = (getattr(args, "query", "") or "").strip().lower()
if query:
entries = [
e
for e in entries
if query in e.slug.lower() or query in e.display_name.lower()
]
limit = getattr(args, "limit", 0) or 0
shown = entries[:limit] if limit > 0 else entries
installed = {p.slug for p in store.installed_pets()}
_print(f"petdex gallery — {len(entries)} pet(s){' matching ' + repr(query) if query else ''}:")
for entry in shown:
mark = "" if entry.slug in installed else " "
_print(f" {mark} {entry.slug:<28} {entry.display_name} ({entry.kind})")
if limit and len(entries) > limit:
_print(f"{len(entries) - limit} more (use --limit 0 or --query to filter)")
_print("\nInstall one with: hermes pets install <slug>")
return 0
def _cmd_install(args) -> int:
from agent.pet import store
from agent.pet.manifest import ManifestError
slug = args.slug.strip()
try:
pet = store.install_pet(slug, force=getattr(args, "force", False))
except (store.PetStoreError, ManifestError) as exc:
_err(f"✗ install failed: {exc}")
return 1
_print(f"✓ installed {pet.display_name}{pet.directory}")
if getattr(args, "select", False) or not _has_active_pet():
_set_active(slug)
_print(f"{pet.display_name} is now the active pet (display.pet.slug={slug}, enabled)")
else:
_print(f" Make it active with: hermes pets select {slug}")
return 0
def _cmd_remove(args) -> int:
from agent.pet import store
slug = args.slug.strip()
if store.remove_pet(slug):
_print(f"✓ removed {slug}")
return 0
_err(f"'{slug}' is not installed")
return 1
def _cmd_select(args) -> int:
from agent.pet import store
slug = (getattr(args, "slug", "") or "").strip()
if not slug:
pets = store.installed_pets()
if not pets:
_err("✗ no pets installed — run: hermes pets install boba")
return 1
slug = _interactive_pick(pets)
if not slug:
return 1
pet = store.load_pet(slug)
if pet is None or not pet.exists:
_err(f"'{slug}' is not installed — run: hermes pets install {slug}")
return 1
_set_active(slug)
_print(f"✓ active pet set to {pet.display_name} (display.pet.slug={slug}, enabled)")
return 0
def _cmd_off(args) -> int:
_set_enabled(False)
_print("✓ pet disabled (display.pet.enabled=false)")
return 0
def _cmd_scale(args) -> int:
"""Persist ``display.pet.scale`` — one knob resizes every surface."""
scale, err = set_pet_scale(args.factor)
if err:
_err(f"{err}")
return 1
_print(f"✓ pet scale set to {scale:g} (display.pet.scale)")
return 0
def _cmd_show(args) -> int:
"""Animate the active (or named) pet in the terminal.
Uses the shared :class:`~agent.pet.render.PetRenderer` full graphics
protocol (kitty/iTerm2/sixel) when the terminal supports it, else a
truecolor Unicode half-block fallback. Ctrl+C to stop.
"""
import time
from agent.pet import store
from agent.pet.constants import DEFAULT_SCALE, LOOP_MS, STATE_ROWS, PetState, resolve_cols
from agent.pet.render import build_renderer
cfg = _pet_config()
slug = (getattr(args, "slug", "") or "").strip() or str(cfg.get("slug", "") or "")
pet = store.resolve_active_pet(slug)
if pet is None:
_err("✗ no pet to show — run: hermes pets install boba")
return 1
mode_cfg = getattr(args, "mode", None) or str(cfg.get("render_mode", "auto") or "auto")
scale = float(getattr(args, "scale", 0) or cfg.get("scale", DEFAULT_SCALE) or DEFAULT_SCALE)
cols = resolve_cols(scale, cfg.get("unicode_cols", 0))
renderer = build_renderer(
pet.spritesheet,
configured_mode=mode_cfg,
scale=scale,
unicode_cols=cols,
)
if not renderer.available:
_err(
"✗ cannot render here (no TTY / graphics disabled). "
f"Effective mode: {renderer.mode}."
)
return 1
# Which states to play: one named state, or cycle the driveable rows.
requested = (getattr(args, "state", "") or "").strip().lower()
if requested:
states = [requested]
elif getattr(args, "cycle", False):
states = [s for s in STATE_ROWS if s in {e.value for e in PetState}]
else:
states = [PetState.IDLE.value]
is_unicode = renderer.mode == "unicode"
frame_delay = max(0.05, (LOOP_MS / 1000.0) / max(1, renderer.frame_count(states[0]) or 1))
# Right-align the sprite against the terminal's right edge — half-blocks by
# indenting each row, graphics protocols by padding the cursor to the right
# column before the image draws (kitty/iTerm/sixel all render at the cursor).
import shutil
term_cols = shutil.get_terminal_size((80, 24)).columns
indent = ""
g_indent = ""
if is_unicode:
indent = " " * max(0, term_cols - cols - 1)
else:
cell_cols = max(1, int(renderer.frame_w * renderer.scale) // 8)
g_indent = " " * max(0, term_cols - cell_cols - 1)
out = sys.stdout
out.write("\x1b[?25l") # hide cursor
out.flush()
prev_lines = 0
try:
_print(f"{pet.display_name} — mode={renderer.mode} (Ctrl+C to stop)")
loops = 0
while True:
for state in states:
count = renderer.frame_count(state) or 1
for i in range(count):
encoded = renderer.frame(state, i)
if is_unicode:
if indent:
encoded = "\n".join(indent + ln for ln in encoded.split("\n"))
if prev_lines:
out.write(f"\x1b[{prev_lines}F") # cursor up to redraw in place
out.write(encoded)
out.write("\x1b[0m\n")
# Lines drawn = sprite rows + the trailing newline; move
# back up exactly that many so the next frame overwrites.
prev_lines = encoded.count("\n") + 1
else:
out.write("\x1b[2J\x1b[3J\x1b[H") # clear for image protocols
out.write(f"{pet.display_name} [{state}]\n")
if g_indent:
out.write(g_indent)
out.write(encoded)
out.write("\n")
out.flush()
time.sleep(frame_delay)
loops += 1
if getattr(args, "once", False) and loops >= len(states):
break
except KeyboardInterrupt:
pass
finally:
out.write("\x1b[?25h") # show cursor
out.write("\x1b[0m\n")
out.flush()
return 0
def _cmd_doctor(args) -> int:
"""Report install state, active pet, config, and terminal capability."""
from agent.pet import store
from agent.pet.render import detect_terminal_graphics, resolve_mode
cfg = _pet_config()
enabled = bool(cfg.get("enabled"))
configured_slug = str(cfg.get("slug", "") or "")
mode_cfg = str(cfg.get("render_mode", "auto") or "auto")
pets = store.installed_pets()
active = store.resolve_active_pet(configured_slug)
_print("petdex doctor")
_print(f" pets dir: {store.pets_dir()}")
_print(f" installed: {len(pets)} ({', '.join(p.slug for p in pets) or 'none'})")
_print(f" display.pet.enabled: {enabled}")
_print(f" display.pet.slug: {configured_slug or '(unset)'}")
_print(f" active (resolved): {active.slug if active else '(none)'}")
_print(f" display.pet.render_mode: {mode_cfg}")
_print(f" detected graphics: {detect_terminal_graphics()}")
_print(f" effective mode (TTY): {resolve_mode(mode_cfg)}")
ok = True
if not pets:
_print(" → no pets installed. Run: hermes pets install boba")
ok = False
elif active is None:
_print(" → active pet unresolved. Run: hermes pets select <slug>")
ok = False
elif not enabled:
_print(" → pet display is disabled. Run: hermes pets select " + active.slug)
try:
import PIL # noqa: F401
except ImportError:
_print(" ✗ Pillow not importable — sprite decoding will be unavailable")
ok = False
_print(" ✓ ready" if ok and enabled else " (run the suggestions above to finish setup)")
return 0
# ─────────────────────────────────────────────────────────────────────────
# config helpers
# ─────────────────────────────────────────────────────────────────────────
def _pet_config() -> dict:
from hermes_cli.config import load_config
cfg = load_config()
display = cfg.get("display", {}) if isinstance(cfg.get("display"), dict) else {}
pet = display.get("pet", {})
return pet if isinstance(pet, dict) else {}
def _has_active_pet() -> bool:
return bool(_pet_config().get("enabled")) and bool(_pet_config().get("slug"))
def _set_active(slug: str) -> None:
from hermes_cli.config import load_config, save_config
cfg = load_config()
display = cfg.setdefault("display", {})
pet = display.setdefault("pet", {})
pet["slug"] = slug
pet["enabled"] = True
save_config(cfg)
def _set_enabled(enabled: bool) -> None:
from hermes_cli.config import load_config, save_config
cfg = load_config()
display = cfg.setdefault("display", {})
pet = display.setdefault("pet", {})
pet["enabled"] = enabled
save_config(cfg)
def _set_scale(scale: float) -> None:
from hermes_cli.config import load_config, save_config
cfg = load_config()
display = cfg.setdefault("display", {})
pet = display.setdefault("pet", {})
pet["scale"] = scale
save_config(cfg)
def set_pet_scale(value: float | str) -> tuple[float, str | None]:
"""Set ``display.pet.scale`` (clamped to bounds). Returns ``(applied, error)``.
The single write path behind ``/pet scale`` and the desktop slider, so every
surface that resolves scale from config picks it up identically. *error* is
set (and nothing written) only when *value* isn't a number.
"""
from agent.pet.constants import clamp_scale
try:
scale = clamp_scale(float(value))
except (TypeError, ValueError):
return 0.0, f"not a number: {value!r} — try a value like 0.5"
_set_scale(scale)
return scale, None
def toggle_pet_display() -> tuple[bool, str | None, str | None]:
"""Toggle ``display.pet.enabled``.
Returns ``(enabled, display_name, error_message)``. *error_message* is set
when turning on but nothing is installed to show.
"""
from agent.pet import store
cfg = _pet_config()
slug = str(cfg.get("slug", "") or "")
pet = store.resolve_active_pet(slug)
if bool(cfg.get("enabled")):
_set_enabled(False)
return False, pet.display_name if pet else None, None
if pet is None:
installed = store.installed_pets()
if not installed:
return False, None, "no pets installed — /pet list to browse, or /pet <slug> to adopt"
pet = installed[0]
_set_active(pet.slug)
else:
_set_enabled(True)
return True, pet.display_name, None
def print_pet_gallery(*, limit: int = 20) -> None:
"""Print a slice of the public petdex gallery (CLI/TUI text fallback)."""
from agent.pet import store
from agent.pet.manifest import ManifestError, fetch_manifest
try:
entries = fetch_manifest()
except ManifestError as exc:
print(f"(._.) Couldn't reach the petdex gallery: {exc}")
return
installed = {p.slug for p in store.installed_pets()}
shown = entries[:limit] if limit > 0 else entries
print(f"(^o^)/ petdex gallery — first {len(shown)} of {len(entries)}:")
for entry in shown:
mark = "" if entry.slug in installed else ""
print(f" {mark} {entry.slug:<24} {entry.display_name}")
print(" /pet <slug> to adopt · /pet to toggle")
def _clear_active_if(slug: str) -> bool:
"""Disable + unset the active pet iff it's ``slug`` (e.g. after removal).
Returns whether anything changed, so callers don't write config needlessly.
"""
from hermes_cli.config import load_config, save_config
cfg = load_config()
pet = cfg.setdefault("display", {}).setdefault("pet", {})
if not isinstance(pet, dict) or str(pet.get("slug", "") or "") != slug:
return False
pet["slug"] = ""
pet["enabled"] = False
save_config(cfg)
return True
def _interactive_pick(pets) -> str:
"""Minimal numbered picker (avoids curses dep for a tiny list)."""
_print("Installed pets:")
for i, pet in enumerate(pets, 1):
_print(f" {i}. {pet.slug:<24} {pet.display_name}")
try:
choice = input("Select a pet [1]: ").strip() or "1"
idx = int(choice) - 1
except (EOFError, KeyboardInterrupt, ValueError):
_err("✗ cancelled")
return ""
if 0 <= idx < len(pets):
return pets[idx].slug
_err("✗ invalid selection")
return ""
# ─────────────────────────────────────────────────────────────────────────
# argparse wiring
# ─────────────────────────────────────────────────────────────────────────
def register_cli(parent: argparse.ArgumentParser) -> None:
"""Attach ``pets`` subcommands to *parent* (called by main.py)."""
parent.set_defaults(func=lambda a: (parent.print_help(), 0)[1])
subs = parent.add_subparsers(dest="pets_command")
p_list = subs.add_parser("list", help="Browse the petdex gallery")
p_list.add_argument("query", nargs="?", default="", help="Filter by slug/name substring")
p_list.add_argument("--installed", action="store_true", help="Only show installed pets")
p_list.add_argument("--limit", type=int, default=40, help="Max rows (0 = all)")
p_list.set_defaults(func=_cmd_list)
p_install = subs.add_parser("install", help="Install a pet from the gallery")
p_install.add_argument("slug", help="Pet slug (e.g. boba)")
p_install.add_argument("--force", action="store_true", help="Re-download even if present")
p_install.add_argument("--select", action="store_true", help="Make it the active pet")
p_install.set_defaults(func=_cmd_install)
p_select = subs.add_parser("select", help="Set the active pet (writes display.pet.*)")
p_select.add_argument("slug", nargs="?", default="", help="Pet slug (omit for picker)")
p_select.set_defaults(func=_cmd_select)
p_show = subs.add_parser("show", help="Animate the active pet in the terminal")
p_show.add_argument("slug", nargs="?", default="", help="Pet slug (default: active)")
p_show.add_argument("--state", default="", help="Single state: idle/run/review/failed/wave/jump")
p_show.add_argument("--cycle", action="store_true", help="Cycle through all states")
p_show.add_argument("--once", action="store_true", help="Play once instead of looping")
p_show.add_argument("--mode", default=None, help="Override render mode (kitty/iterm/sixel/unicode/auto)")
p_show.add_argument("--scale", type=float, default=0, help="Override scale (0 = config)")
p_show.set_defaults(func=_cmd_show)
subs.add_parser("off", help="Disable the pet display").set_defaults(func=_cmd_off)
p_scale = subs.add_parser("scale", help="Resize the pet everywhere (display.pet.scale)")
p_scale.add_argument("factor", help="Scale factor, e.g. 0.5 (clamped 0.13.0)")
p_scale.set_defaults(func=_cmd_scale)
p_remove = subs.add_parser("remove", help="Delete an installed pet")
p_remove.add_argument("slug", help="Pet slug")
p_remove.set_defaults(func=_cmd_remove)
subs.add_parser("doctor", help="Check pet setup + terminal graphics support").set_defaults(
func=_cmd_doctor
)

View file

@ -2379,6 +2379,7 @@ class SessionDB:
codex_message_items: Any = None,
platform_message_id: str = None,
observed: bool = False,
timestamp: Any = None,
) -> int:
"""
Append a message to a session. Returns the message row ID.
@ -2410,6 +2411,16 @@ class SessionDB:
# cannot bind list/dict parameters directly.
stored_content = self._encode_content(content)
message_timestamp = time.time()
if timestamp is not None:
try:
if hasattr(timestamp, "timestamp"):
message_timestamp = float(timestamp.timestamp())
else:
message_timestamp = float(timestamp)
except (TypeError, ValueError):
logger.debug("Ignoring invalid explicit message timestamp: %r", timestamp)
# Pre-compute tool call count
num_tool_calls = 0
if tool_calls is not None:
@ -2429,7 +2440,7 @@ class SessionDB:
tool_call_id,
tool_calls_json,
tool_name,
time.time(),
message_timestamp,
token_count,
finish_reason,
reasoning,
@ -2482,6 +2493,16 @@ class SessionDB:
for msg in messages:
role = msg.get("role", "unknown")
tool_calls = msg.get("tool_calls")
message_timestamp = now_ts
if msg.get("timestamp") is not None:
try:
ts_value = msg.get("timestamp")
if hasattr(ts_value, "timestamp"):
message_timestamp = float(ts_value.timestamp())
else:
message_timestamp = float(ts_value)
except (TypeError, ValueError):
logger.debug("Ignoring invalid explicit message timestamp: %r", msg.get("timestamp"))
reasoning_details = msg.get("reasoning_details") if role == "assistant" else None
codex_reasoning_items = (
msg.get("codex_reasoning_items") if role == "assistant" else None
@ -2519,7 +2540,7 @@ class SessionDB:
msg.get("tool_call_id"),
tool_calls_json,
msg.get("tool_name"),
now_ts,
message_timestamp,
msg.get("token_count"),
msg.get("finish_reason"),
msg.get("reasoning") if role == "assistant" else None,
@ -2536,7 +2557,7 @@ class SessionDB:
total_tool_calls += (
len(tool_calls) if isinstance(tool_calls, list) else 1
)
now_ts += 1e-6
now_ts = max(now_ts + 1e-6, message_timestamp + 1e-6)
conn.execute(
"UPDATE sessions SET message_count = ?, tool_call_count = ? WHERE id = ?",
@ -2867,9 +2888,9 @@ class SessionDB:
rows = self._conn.execute(
"SELECT role, content, tool_call_id, tool_calls, tool_name, "
"finish_reason, reasoning, reasoning_content, reasoning_details, "
"codex_reasoning_items, codex_message_items, platform_message_id, observed "
"codex_reasoning_items, codex_message_items, platform_message_id, observed, timestamp "
f"FROM messages WHERE session_id IN ({placeholders})"
f"{active_clause} ORDER BY id",
f"{active_clause} ORDER BY timestamp, id",
tuple(session_ids),
).fetchall()
@ -2879,6 +2900,8 @@ class SessionDB:
if row["role"] in {"user", "assistant"} and isinstance(content, str):
content = sanitize_context(content).strip()
msg = {"role": row["role"], "content": content}
if row["timestamp"]:
msg["timestamp"] = row["timestamp"]
if row["tool_call_id"]:
msg["tool_call_id"] = row["tool_call_id"]
if row["tool_name"]:

View file

@ -1472,16 +1472,21 @@ class AIAgent:
that synthetic text leak into persisted transcripts or resumed session
history. When an override is configured for the active turn, mutate the
in-memory messages list in place so both persistence and returned
history stay clean.
history stay clean. A paired timestamp override preserves the platform
event time as message metadata, rather than embedding it in content.
"""
idx = getattr(self, "_persist_user_message_idx", None)
override = getattr(self, "_persist_user_message_override", None)
if override is None or idx is None:
timestamp = getattr(self, "_persist_user_message_timestamp", None)
if idx is None or (override is None and timestamp is None):
return
if 0 <= idx < len(messages):
msg = messages[idx]
if isinstance(msg, dict) and msg.get("role") == "user":
msg["content"] = override
if override is not None:
msg["content"] = override
if timestamp is not None:
msg["timestamp"] = timestamp
def _persist_session(self, messages: List[Dict], conversation_history: List[Dict] = None):
"""Save session state to both JSON log and SQLite on any exit path.
@ -1639,6 +1644,7 @@ class AIAgent:
reasoning_details=msg.get("reasoning_details") if role == "assistant" else None,
codex_reasoning_items=msg.get("codex_reasoning_items") if role == "assistant" else None,
codex_message_items=msg.get("codex_message_items") if role == "assistant" else None,
timestamp=msg.get("timestamp"),
)
flushed_ids.add(msg_id)
self._last_flushed_db_idx = len(messages)
@ -5218,10 +5224,20 @@ class AIAgent:
task_id: str = None,
stream_callback: Optional[callable] = None,
persist_user_message: Optional[str] = None,
persist_user_timestamp: Optional[float] = None,
) -> Dict[str, Any]:
"""Forwarder — see ``agent.conversation_loop.run_conversation``."""
from agent.conversation_loop import run_conversation
return run_conversation(self, user_message, system_message, conversation_history, task_id, stream_callback, persist_user_message)
return run_conversation(
self,
user_message,
system_message,
conversation_history,
task_id,
stream_callback,
persist_user_message,
persist_user_timestamp,
)
def chat(self, message: str, stream_callback: Optional[callable] = None) -> str:
"""

View file

@ -1,89 +0,0 @@
---
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) |
| Resize the pet everywhere | `hermes pets scale <factor>` (e.g. `0.5`, clamped 0.13.0) |
| 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 size of the native 192×208 frames (default 0.33,
clamped 0.13.0). One knob resizes every surface; set it with
`hermes pets scale <factor>`, the `/pet scale` slash command, or the desktop
Appearance slider.
- `unicode_cols` (int) — width in columns for the Unicode fallback.
## Pitfalls
- A pet only shows once one is installed AND selected (`enabled: true`).
- Inside a pipe/redirect (no TTY) terminal rendering is disabled by design.
- The petdex npm CLI installs to `~/.codex/pets`; Hermes uses its own
profile-scoped `<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.

View file

@ -211,7 +211,10 @@ class TestListAndCleanup:
db = manager._get_db()
messages = db.get_messages_as_conversation(state.session_id)
assert messages == [{"role": "user", "content": "original"}]
assert len(messages) == 1
assert messages[0]["role"] == "user"
assert messages[0]["content"] == "original"
assert isinstance(messages[0].get("timestamp"), (int, float))
def test_cleanup_clears_all(self, manager):
s1 = manager.create_session()
@ -501,6 +504,8 @@ class TestPersistence:
restored = manager.get_session(state.session_id)
assert restored is not None
msg = restored.history[0]
assert isinstance(msg.pop("timestamp", None), (int, float))
assert restored.history == [{
"role": "assistant",
"content": "hello",

View file

@ -1,337 +0,0 @@
"""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_todos_all_done():
# empty / falsy → not done (no plan to celebrate)
assert state.todos_all_done(None) is False
assert state.todos_all_done([]) is False
# any open item → not done
assert state.todos_all_done([{"status": "completed"}, {"status": "pending"}]) is False
assert state.todos_all_done([{"status": "in_progress"}]) is False
# every item terminal → done (completed and/or cancelled)
assert state.todos_all_done([{"status": "completed"}, {"status": "cancelled"}]) is True
# objects with a .status attr work too (mirrors dict + attr access)
class _T:
def __init__(self, status):
self.status = status
assert state.todos_all_done([_T("completed")]) is True
assert state.todos_all_done([_T("completed"), _T("pending")]) is False
def test_state_row_index_maps_to_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
def test_cols_for_scale_is_monotonic_and_floored():
# scale is the master size knob: smaller scale never yields more columns,
# and half-blocks clamp to a legibility floor rather than devolving to mush.
sizes = [constants.cols_for_scale(s) for s in (0.1, 0.3, 0.5, 0.7, 1.0, 1.5)]
assert sizes == sorted(sizes)
assert all(c >= constants.UNICODE_MIN_COLS for c in sizes)
# tiny scales pin to the floor; large scales grow past it.
assert constants.cols_for_scale(0.05) == constants.UNICODE_MIN_COLS
assert constants.cols_for_scale(0.33) == constants.UNICODE_MIN_COLS
assert constants.cols_for_scale(2.0) > constants.UNICODE_MIN_COLS
def test_resolve_cols_override_else_scale():
# 0 / falsy → derive from scale; a positive int hard-overrides scale.
assert constants.resolve_cols(0.7, 0) == constants.cols_for_scale(0.7)
assert constants.resolve_cols(0.7, None) == constants.cols_for_scale(0.7)
assert constants.resolve_cols(2.0, 12) == 12
assert constants.resolve_cols(0.1, -5) == constants.cols_for_scale(0.1)
# ─────────────────────────────────────────────────────────────────────────
# synthetic spritesheet fixture
# ─────────────────────────────────────────────────────────────────────────
@pytest.fixture
def boba_like(tmp_path, monkeypatch):
"""Install a synthetic 8-col × 9-row pet into a temp HERMES_HOME."""
from PIL import Image
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
cols, rows = 8, 9
sheet = Image.new("RGBA", (FRAME_W * cols, FRAME_H * rows), (0, 0, 0, 0))
# paint each row a distinct opaque color so frames are non-empty
for r in range(rows):
color = (20 + r * 25, 60, 120, 255)
for c in range(cols):
block = Image.new("RGBA", (FRAME_W, FRAME_H), color)
sheet.paste(block, (c * FRAME_W, r * FRAME_H))
pet_dir = store.pets_dir() / "boba"
pet_dir.mkdir(parents=True, exist_ok=True)
sheet.save(pet_dir / "spritesheet.webp")
(pet_dir / "pet.json").write_text(
'{"id":"boba","displayName":"Boba","description":"d","spritesheetPath":"spritesheet.webp"}'
)
return pet_dir
def test_store_install_resolution(boba_like):
pets = store.installed_pets()
assert [p.slug for p in pets] == ["boba"]
assert store.installed_pets()[0].exists
# configured slug wins when installed
assert store.resolve_active_pet("boba").slug == "boba"
# bogus slug falls back to first installed
assert store.resolve_active_pet("does-not-exist").slug == "boba"
# display metadata flows from pet.json
assert store.load_pet("boba").display_name == "Boba"
def test_store_remove(boba_like):
assert store.remove_pet("boba") is True
assert store.installed_pets() == []
assert store.remove_pet("boba") is False # idempotent
# ─────────────────────────────────────────────────────────────────────────
# render — decode + every encoder produces output
# ─────────────────────────────────────────────────────────────────────────
def test_renderer_decodes_frames(boba_like):
sprite = store.load_pet("boba").spritesheet
r = render.PetRenderer(str(sprite), mode="unicode", scale=0.5, unicode_cols=12)
assert r.available
# standard sheet yields FRAMES_PER_STATE frames per state
assert r.frame_count("idle") == constants.FRAMES_PER_STATE
assert r.frame_count(PetState.RUN) == constants.FRAMES_PER_STATE
def test_trims_trailing_blank_frames(tmp_path):
"""Ragged state rows (real frames + transparent padding) trim to real count.
petdex sheets are left-packed: a state with fewer than FRAMES_PER_STATE real
frames pads the trailing columns transparent. Stepping into one flashes the
pet blank, so the engine must stop the row at the first gap.
"""
from PIL import Image
cols, rows = 8, 9
sheet = Image.new("RGBA", (FRAME_W * cols, FRAME_H * rows), (0, 0, 0, 0))
# row index → number of real (opaque) frames; the rest stay transparent.
real = {0: 6, 1: 8, 2: 8, 3: 4, 4: 5, 5: 8} # idle wave run failed review jump
for r, k in real.items():
for c in range(k):
block = Image.new("RGBA", (FRAME_W, FRAME_H), (200, 80, 80, 255))
sheet.paste(block, (c * FRAME_W, r * FRAME_H))
sprite = tmp_path / "ragged.webp"
sheet.save(sprite)
r = render.PetRenderer(str(sprite), mode="unicode", scale=0.5)
# Full rows cap at FRAMES_PER_STATE; ragged rows trim to their real count.
assert r.frame_count("idle") == constants.FRAMES_PER_STATE
assert r.frame_count("run") == constants.FRAMES_PER_STATE
assert r.frame_count("failed") == 4
assert r.frame_count("review") == 5
# Every stepped frame is non-empty — no blank flash for the trimmed states.
for state in ("failed", "review"):
for i in range(r.frame_count(state)):
assert r.frame(state, i), f"{state}[{i}] rendered blank"
counts = render.state_frame_counts(str(sprite))
assert counts == {
"idle": 6, "wave": 6, "run": 6, "failed": 4, "review": 5, "jump": 6,
}
@pytest.mark.parametrize("mode", ["unicode", "kitty", "iterm", "sixel"])
def test_every_encoder_emits(boba_like, mode):
sprite = store.load_pet("boba").spritesheet
r = render.PetRenderer(str(sprite), mode=mode, scale=0.4)
frame = r.frame("run", 1)
assert isinstance(frame, str) and frame, f"{mode} produced no frame"
if mode == "unicode":
assert "\x1b[" in frame # has color escapes
elif mode == "kitty":
assert frame.startswith("\x1b_G")
elif mode == "iterm":
assert frame.startswith("\x1b]1337;File=")
elif mode == "sixel":
assert frame.startswith("\x1bP")
def test_frame_index_wraps(boba_like):
sprite = store.load_pet("boba").spritesheet
r = render.PetRenderer(str(sprite), mode="unicode", scale=0.4)
# index beyond count wraps rather than indexing out of range
assert r.frame("idle", 999) == r.frame("idle", 999 % r.frame_count("idle"))
def test_cells_grid_shape(boba_like):
sprite = store.load_pet("boba").spritesheet
r = render.PetRenderer(str(sprite), mode="unicode", scale=0.4, unicode_cols=14)
grid = r.cells("run", 0, cols=14)
assert grid, "no cells produced"
# every row is the requested width; every cell is (top, bottom) RGBA pairs
assert all(len(row) == 14 for row in grid)
(top, bottom) = grid[0][0]
assert len(top) == 4 and len(bottom) == 4
# missing-sheet renderer yields no cells, never raises
assert render.PetRenderer(str(sprite.parent / "missing.webp"), mode="unicode").cells("idle", 0) == []
# ─────────────────────────────────────────────────────────────────────────
# render — kitty Unicode placeholders (TUI graphics path)
# ─────────────────────────────────────────────────────────────────────────
def test_kitty_image_id_stable_bounded_nonzero():
# Deterministic per slug so re-renders reuse the same terminal-side image,
# and always a valid 24-bit-encodable, non-zero id.
a = render.kitty_image_id("boba")
assert a == render.kitty_image_id("boba")
assert 1 <= a <= 0x7FFF
def test_kitty_color_hex_decodes_to_id():
# The placeholder's foreground color IS the image id (24-bit). The terminal
# reconstructs id = (r<<16)|(g<<8)|b, so the hex must round-trip.
for slug in ("boba", "clawd", "pixel-fox"):
image_id = render.kitty_image_id(slug)
h = render.kitty_color_hex(image_id)
assert h.startswith("#") and len(h) == 7
assert int(h[1:], 16) == image_id
def test_kitty_placeholder_rows_grid_contract():
cols, rows = 18, 10
grid = render.kitty_placeholder_rows(cols, rows)
assert len(grid) == rows
placeholder = "\U0010eeee"
for r, row in enumerate(grid):
# Each line is exactly `cols` placeholder cells (combining diacritics
# are zero-width, so this is the rendered width Ink must measure).
assert row.count(placeholder) == cols
# First cell carries this row's diacritic; the rest inherit row + col.
assert row.startswith(placeholder + chr(render._ROWCOL_DIACRITICS[r]))
def test_kitty_payload_structure(boba_like):
sprite = store.load_pet("boba").spritesheet
image_id = render.kitty_image_id("boba")
scale = 0.4
r = render.PetRenderer(str(sprite), mode="kitty", scale=scale, unicode_cols=18)
payload = r.kitty_payload("run", image_id=image_id)
assert payload is not None
# placement box must follow scaled pixels, not unicode_cols (kitty upscales to c×r).
frames = r._frames("run")
expect_cols, expect_rows = r._cell_box(frames[0])
assert payload["cols"] == expect_cols
assert payload["rows"] == expect_rows
assert expect_cols < 18 # 0.4 scale is much smaller than a pinned 18-col box
# placeholder grid matches the requested geometry
assert len(payload["placeholder"]) == payload["rows"]
# one transmit escape per animation frame, each a kitty virtual placement
assert len(payload["frames"]) == r.frame_count("run")
for esc in payload["frames"]:
assert esc.startswith("\x1b_G")
assert esc.endswith("\x1b\\")
assert f"i={image_id}" in esc
assert "a=T" in esc and "U=1" in esc
assert f"c={payload['cols']}" in esc and f"r={payload['rows']}" in esc
def test_kitty_payload_none_when_no_frames(tmp_path):
r = render.PetRenderer(str(tmp_path / "missing.webp"), mode="kitty")
assert r.kitty_payload("idle", image_id=1) is None
def test_off_mode_and_missing_sheet_degrade(tmp_path):
# off mode never emits
r_off = render.PetRenderer(str(tmp_path / "nope.webp"), mode="off")
assert r_off.frame("idle", 0) == ""
# missing sheet → not available, empty frames, no raise
r_missing = render.PetRenderer(str(tmp_path / "nope.webp"), mode="unicode")
assert not r_missing.available
assert r_missing.frame("idle", 0) == ""
def test_resolve_mode_non_tty_is_off():
# a non-tty stream forces 'off' regardless of configured mode
assert render.resolve_mode("kitty", stream=io.StringIO()) == "off"
assert render.resolve_mode("auto", stream=io.StringIO()) == "off"
def test_detect_terminal_graphics_env(monkeypatch):
for key in ("KITTY_WINDOW_ID", "TERM_PROGRAM", "ITERM_SESSION_ID", "WEZTERM_PANE", "TERM"):
monkeypatch.delenv(key, raising=False)
monkeypatch.setenv("KITTY_WINDOW_ID", "1")
assert render.detect_terminal_graphics() == "kitty"
monkeypatch.delenv("KITTY_WINDOW_ID")
monkeypatch.setenv("TERM_PROGRAM", "iTerm.app")
assert render.detect_terminal_graphics() == "iterm"
monkeypatch.delenv("TERM_PROGRAM")
monkeypatch.setenv("TERM", "xterm-256color")
assert render.detect_terminal_graphics() == "unicode"
def test_vscode_terminal_ignores_leaked_graphics_env(monkeypatch):
# The VS Code / Cursor integrated terminal can't show inline images by
# default, yet inherits ITERM_SESSION_ID/KITTY_WINDOW_ID when launched from
# those terminals. TERM_PROGRAM=vscode must win → unicode, never a protocol
# whose escapes the embedded terminal would silently drop.
for key in ("KITTY_WINDOW_ID", "TERM_PROGRAM", "ITERM_SESSION_ID", "WEZTERM_PANE", "TERM"):
monkeypatch.delenv(key, raising=False)
monkeypatch.setenv("TERM_PROGRAM", "vscode")
assert render.detect_terminal_graphics() == "unicode"
for leaked in ("ITERM_SESSION_ID", "KITTY_WINDOW_ID", "WEZTERM_PANE"):
monkeypatch.setenv(leaked, "1")
assert render.detect_terminal_graphics() == "unicode"
monkeypatch.delenv(leaked)

View file

@ -1,121 +0,0 @@
"""The base-CLI petdex pane: reactive half-block sprite above the prompt.
Mirrors the TUI's PetPane. The methods are tested in isolation via __new__ so
we don't pay the full HermesCLI.__init__ cost; a synthetic spritesheet exercises
the real engine decode + half-block fragment building.
"""
from __future__ import annotations
import threading
import pytest
from agent.pet import store
from agent.pet.constants import FRAME_H, FRAME_W
from agent.pet.render import PetRenderer
from cli import HermesCLI
@pytest.fixture
def boba_like(tmp_path, monkeypatch):
"""Install a synthetic pet into a temp HERMES_HOME and return its slug."""
from PIL import Image
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
cols, rows = 8, 9
sheet = Image.new("RGBA", (FRAME_W * cols, FRAME_H * rows), (0, 0, 0, 0))
for r in range(rows):
color = (20 + r * 25, 60, 120, 255)
for c in range(cols):
block = Image.new("RGBA", (FRAME_W, FRAME_H), color)
sheet.paste(block, (c * FRAME_W, r * FRAME_H))
pet_dir = store.pets_dir() / "boba"
pet_dir.mkdir(parents=True, exist_ok=True)
sheet.save(pet_dir / "spritesheet.webp")
(pet_dir / "pet.json").write_text(
'{"id":"boba","displayName":"Boba","description":"d","spritesheetPath":"spritesheet.webp"}'
)
return "boba"
def _make_cli():
cli_obj = HermesCLI.__new__(HermesCLI)
cli_obj._app = None
cli_obj._pet_lock = threading.Lock()
cli_obj._pet_enabled = False
cli_obj._pet_renderer = None
cli_obj._pet_slug = ""
cli_obj._pet_cols = 18
cli_obj._pet_scale = 0.7
cli_obj._pet_frames_cache = {}
cli_obj._pet_frame_idx = 0
cli_obj._agent_running = False
# Transient-beat + reasoning state (set by HermesCLI.__init__ in production).
cli_obj._pet_event = ""
cli_obj._pet_event_until = 0.0
cli_obj._pet_reasoning = False
return cli_obj
def test_pet_state_tracks_agent_running():
cli_obj = _make_cli()
assert cli_obj._derive_pet_state() == "idle"
cli_obj._agent_running = True
assert cli_obj._derive_pet_state() == "run"
def test_pet_pane_collapsed_when_disabled():
# No renderer resolved → the window reports zero height and no fragments,
# so it's invisible for users without a pet.
cli_obj = _make_cli()
assert cli_obj._pet_widget_height() == 0
assert cli_obj._pet_fragments() == []
def test_pet_fragments_render_half_blocks(boba_like):
cli_obj = _make_cli()
cli_obj._pet_renderer = PetRenderer(
str(store.load_pet("boba").spritesheet), mode="unicode", scale=0.4, unicode_cols=14
)
cli_obj._pet_cols = 14
cli_obj._pet_enabled = True
height = cli_obj._pet_widget_height()
assert height > 0
frags = cli_obj._pet_fragments()
assert frags, "expected fragments for an enabled pet"
# Each fragment is a (style, text) pair; glyphs are half-blocks or blanks.
glyphs = {text for _, text in frags}
assert glyphs <= {"", "", " ", "\n"}
# Opaque cells carry a truecolor foreground style.
assert any(text == "" and "fg:#" in style for style, text in frags)
# Row count in the fragment stream matches the reported window height.
assert sum(1 for _, text in frags if text == "\n") == height - 1
def test_pet_resolve_config_enables_and_disables(boba_like):
from hermes_cli.config import load_config, save_config
cli_obj = _make_cli()
cfg = load_config()
cfg.setdefault("display", {}).setdefault("pet", {})
cfg["display"]["pet"].update({"enabled": True, "slug": "boba"})
save_config(cfg)
cli_obj._pet_resolve_config()
assert cli_obj._pet_enabled is True
assert cli_obj._pet_renderer is not None
assert cli_obj._pet_slug == "boba"
cfg["display"]["pet"]["enabled"] = False
save_config(cfg)
cli_obj._pet_resolve_config()
assert cli_obj._pet_enabled is False
assert cli_obj._pet_renderer is None

View file

@ -23,12 +23,20 @@ class _CapturingAgent:
type(self).last_init = dict(kwargs)
self.tools = []
def run_conversation(self, user_message, conversation_history=None, task_id=None, persist_user_message=None):
def run_conversation(
self,
user_message,
conversation_history=None,
task_id=None,
persist_user_message=None,
persist_user_timestamp=None,
):
type(self).last_run = {
"user_message": user_message,
"conversation_history": conversation_history,
"task_id": task_id,
"persist_user_message": persist_user_message,
"persist_user_timestamp": persist_user_timestamp,
}
return {
"final_response": "ok",

View file

@ -0,0 +1,137 @@
from datetime import datetime
from zoneinfo import ZoneInfo
from gateway.message_timestamps import (
coerce_message_timestamp,
render_user_content_with_timestamp,
strip_leading_message_timestamps,
)
from run_agent import AIAgent
BERLIN = ZoneInfo("Europe/Berlin")
def _epoch(year, month, day, hour, minute, second):
return datetime(year, month, day, hour, minute, second, tzinfo=BERLIN).timestamp()
def test_render_user_content_adds_single_context_timestamp():
ts = _epoch(2026, 4, 28, 13, 40, 53)
rendered = render_user_content_with_timestamp(
"[Example User] Timestamp should be in context",
ts,
tz=BERLIN,
)
assert rendered == (
"[Tue 2026-04-28 13:40:53 CEST] "
"[Example User] Timestamp should be in context"
)
def test_render_user_content_deduplicates_existing_timestamp_and_preserves_embedded_time():
db_processing_ts = _epoch(2026, 4, 27, 15, 55, 36)
stored_content = (
"[Mon 2026-04-27 15:54:44 CEST] "
"[Example User] This should go on our todo list"
)
rendered = render_user_content_with_timestamp(
stored_content,
db_processing_ts,
tz=BERLIN,
)
assert rendered == stored_content
assert rendered.count("2026-04-27") == 1
def test_strip_leading_message_timestamps_removes_multiple_prefixes_and_prefers_inner_time():
content = (
"[Mon 2026-04-27 15:55:36 CEST] "
"[Mon 2026-04-27 15:54:44 CEST] "
"[Example User] This should go on our todo list"
)
stripped, embedded_ts = strip_leading_message_timestamps(content, tz=BERLIN)
assert stripped == "[Example User] This should go on our todo list"
assert embedded_ts == _epoch(2026, 4, 27, 15, 54, 44)
def test_coerce_message_timestamp_accepts_datetime_and_epoch():
dt = datetime(2026, 4, 28, 13, 40, 53, tzinfo=BERLIN)
assert coerce_message_timestamp(dt, tz=BERLIN) == dt.timestamp()
assert coerce_message_timestamp(dt.timestamp(), tz=BERLIN) == dt.timestamp()
def test_persist_user_message_override_keeps_clean_content_and_timestamp_metadata():
agent = AIAgent.__new__(AIAgent)
agent._persist_user_message_idx = 0
agent._persist_user_message_override = "[Example User] Clean content"
agent._persist_user_message_timestamp = _epoch(2026, 4, 28, 13, 40, 53)
messages = [
{
"role": "user",
"content": "[Tue 2026-04-28 13:40:53 CEST] [Example User] Clean content",
}
]
agent._apply_persist_user_message_override(messages)
assert messages == [
{
"role": "user",
"content": "[Example User] Clean content",
"timestamp": _epoch(2026, 4, 28, 13, 40, 53),
}
]
# ---------------------------------------------------------------------------
# Opt-in gate: gateway.message_timestamps.enabled (default OFF)
# ---------------------------------------------------------------------------
def test_message_timestamps_enabled_defaults_off():
from gateway.run import _message_timestamps_enabled
assert _message_timestamps_enabled(None) is False
assert _message_timestamps_enabled({}) is False
assert _message_timestamps_enabled({"gateway": {}}) is False
assert (
_message_timestamps_enabled({"gateway": {"message_timestamps": {}}}) is False
)
def test_message_timestamps_enabled_when_opted_in():
from gateway.run import _message_timestamps_enabled
assert _message_timestamps_enabled(
{"gateway": {"message_timestamps": {"enabled": True}}}
) is True
# Bare shorthand also accepted.
assert _message_timestamps_enabled({"gateway": {"message_timestamps": True}}) is True
def test_build_history_injects_only_when_enabled():
from gateway.run import _build_gateway_agent_history
history = [
{"role": "user", "content": "hello", "timestamp": _epoch(2026, 4, 28, 13, 40, 53)},
{"role": "assistant", "content": "hi"},
]
# Default (off): user content stays clean, no timestamp prefix.
agent_history, _ = _build_gateway_agent_history(history)
assert agent_history[0]["content"] == "hello"
# Enabled: user content gets exactly one timestamp prefix.
agent_history, _ = _build_gateway_agent_history(history, inject_timestamps=True)
assert agent_history[0]["content"].startswith("[")
assert agent_history[0]["content"].endswith("hello")
# Assistant message is never timestamped.
assert agent_history[1]["content"] == "hi"

View file

@ -241,7 +241,11 @@ async def test_session_chat_loads_history_and_preserves_session_headers(auth_ada
assert kwargs["session_id"] == session_id
assert kwargs["gateway_session_key"] == "client-42"
assert kwargs["ephemeral_system_prompt"] == "stay focused"
assert kwargs["conversation_history"] == [
history = kwargs["conversation_history"]
assert len(history) == 2
assert isinstance(history[0].pop("timestamp"), (int, float))
assert isinstance(history[1].pop("timestamp"), (int, float))
assert history == [
{"role": "user", "content": "earlier"},
{"role": "assistant", "content": "prior answer"},
]

View file

@ -1,104 +0,0 @@
"""Tests for pet slash-command config helpers."""
from __future__ import annotations
import pytest
from agent.pet import store
from agent.pet.constants import FRAME_H, FRAME_W
@pytest.fixture
def boba_installed(tmp_path, monkeypatch):
from PIL import Image
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
sheet = Image.new("RGBA", (FRAME_W * 8, FRAME_H * 9), (0, 0, 0, 0))
pet_dir = store.pets_dir() / "boba"
pet_dir.mkdir(parents=True, exist_ok=True)
sheet.save(pet_dir / "spritesheet.webp")
(pet_dir / "pet.json").write_text(
'{"id":"boba","displayName":"Boba","description":"d","spritesheetPath":"spritesheet.webp"}'
)
return home
def _write_config(home, *, enabled: bool, slug: str = "") -> None:
import yaml
cfg = {"display": {"pet": {"enabled": enabled, "slug": slug, "scale": 0.33}}}
(home / "config.yaml").write_text(yaml.dump(cfg), encoding="utf-8")
def test_toggle_pet_display_turns_off_when_enabled(boba_installed):
from hermes_cli.pets import _pet_config, toggle_pet_display
_write_config(boba_installed, enabled=True, slug="boba")
enabled, name, err = toggle_pet_display()
assert err is None
assert enabled is False
assert name == "Boba"
assert _pet_config()["enabled"] is False
def test_toggle_pet_display_turns_on_resolved_pet(boba_installed):
from hermes_cli.pets import _pet_config, toggle_pet_display
_write_config(boba_installed, enabled=False, slug="boba")
enabled, name, err = toggle_pet_display()
assert err is None
assert enabled is True
assert name == "Boba"
assert _pet_config()["enabled"] is True
def test_toggle_pet_display_errors_with_no_installed_pets(tmp_path, monkeypatch):
from hermes_cli.pets import toggle_pet_display
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
_write_config(home, enabled=False, slug="")
enabled, name, err = toggle_pet_display()
assert enabled is False
assert name is None
assert err is not None
@pytest.fixture
def empty_home(tmp_path, monkeypatch):
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
return home
def test_set_pet_scale_writes_clamped_value(empty_home):
from agent.pet.constants import MAX_SCALE, MIN_SCALE
from hermes_cli.pets import _pet_config, set_pet_scale
applied, err = set_pet_scale("0.5")
assert err is None
assert applied == 0.5
assert _pet_config()["scale"] == 0.5
# Out-of-range values clamp to the bounds rather than erroring.
assert set_pet_scale(99) == (MAX_SCALE, None)
assert set_pet_scale(0) == (MIN_SCALE, None)
def test_set_pet_scale_rejects_non_numbers(empty_home):
from hermes_cli.pets import set_pet_scale
applied, err = set_pet_scale("huge")
assert applied == 0.0
assert err is not None

View file

@ -347,6 +347,15 @@ class TestMessageStorage:
assert messages[0]["content"] == "Hello"
assert messages[1]["role"] == "assistant"
def test_append_message_accepts_explicit_timestamp(self, db):
db.create_session(session_id="s1", source="telegram")
event_ts = 1777383653.0
db.append_message("s1", role="user", content="Hello", timestamp=event_ts)
messages = db.get_messages_as_conversation("s1")
assert messages[0]["timestamp"] == event_ts
def test_message_increments_session_count(self, db):
db.create_session(session_id="s1", source="cli")
db.append_message("s1", role="user", content="Hello")
@ -370,11 +379,10 @@ class TestMessageStorage:
assert messages[1]["observed"] == 0
conversation = db.get_messages_as_conversation("s1")
assert conversation[0] == {
"role": "user",
"content": "[Alice|111]\nside chatter",
"observed": True,
}
assert conversation[0]["role"] == "user"
assert conversation[0]["content"] == "[Alice|111]\nside chatter"
assert conversation[0]["observed"] is True
assert isinstance(conversation[0].get("timestamp"), float)
assert "observed" not in conversation[1]
def test_tool_response_does_not_increment_tool_count(self, db):
@ -458,7 +466,9 @@ class TestMessageStorage:
# get_messages_as_conversation decodes back to the original list
conv = db.get_messages_as_conversation("s1")
assert len(conv) == 1
assert conv[0] == {"role": "user", "content": content}
assert conv[0]["role"] == "user"
assert conv[0]["content"] == content
assert isinstance(conv[0].get("timestamp"), float)
def test_dict_content_round_trip(self, db):
"""Dict-shaped content (e.g. provider wrappers) also round-trips."""
@ -529,8 +539,12 @@ class TestMessageStorage:
conv = db.get_messages_as_conversation("s1")
assert len(conv) == 2
assert conv[0] == {"role": "user", "content": "Hello"}
assert conv[1] == {"role": "assistant", "content": "Hi!"}
assert conv[0]["role"] == "user"
assert conv[0]["content"] == "Hello"
assert isinstance(conv[0]["timestamp"], float)
assert conv[1]["role"] == "assistant"
assert conv[1]["content"] == "Hi!"
assert isinstance(conv[1]["timestamp"], float)
def test_platform_message_id_round_trips(self, db):
"""Platform-side message ids (yuanbao msg_id, telegram update_id, …)
@ -620,7 +634,10 @@ class TestMessageStorage:
)
conv = db.get_messages_as_conversation("s1")
assert conv == [{"role": "assistant", "content": "Visible answer"}]
assert len(conv) == 1
assert conv[0]["role"] == "assistant"
assert conv[0]["content"] == "Visible answer"
assert isinstance(conv[0].get("timestamp"), float)
def test_reasoning_persisted_and_restored(self, db):
"""Reasoning text is stored for assistant messages and restored by

View file

@ -176,14 +176,6 @@ _LONG_HANDLERS = frozenset(
{
"browser.manage",
"cli.exec",
# Pet RPCs hit the network (manifest fetch / spritesheet download) or do
# per-frame PNG decode/encode (pet.cells): inline they serialize on the
# reader thread, so picker previews trickle in one at a time and the
# animation poll stutters. On the pool they run concurrently.
"pet.cells",
"pet.gallery",
"pet.select",
"pet.thumb",
"plugins.manage",
"session.branch",
"session.compress",
@ -4993,370 +4985,6 @@ def _(rid, params: dict) -> dict:
return _ok(rid, usage)
def _pet_frame_counts(spritesheet) -> dict:
"""Real (padding-trimmed) frame count per state, for the desktop canvas.
Fail-open: a decode hiccup returns ``{}`` and the canvas falls back to its
static ``framesPerState`` rather than breaking the (cosmetic) pet.
"""
try:
from agent.pet import render
return render.state_frame_counts(str(spritesheet))
except Exception: # noqa: BLE001 - cosmetic, never break the surface
return {}
@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
activitystate decision is mirrored from ``agent.pet.state`` client-side.
Agent-independent (reads config + disk), so it works on any session and
before the agent finishes building. Fail-open: returns ``enabled=False``
on any error rather than erroring the surface.
"""
import base64
try:
from agent.pet import constants, store
try:
from hermes_cli.config import load_config
cfg = load_config()
display = cfg.get("display", {}) if isinstance(cfg.get("display"), dict) else {}
pet_cfg = display.get("pet", {}) if isinstance(display.get("pet"), dict) else {}
except Exception:
pet_cfg = {}
enabled = bool(pet_cfg.get("enabled"))
configured_slug = str(pet_cfg.get("slug", "") or "")
pet = store.resolve_active_pet(configured_slug) if enabled else None
if not enabled or pet is None or not pet.exists:
return _ok(rid, {"enabled": False})
raw = pet.spritesheet.read_bytes()
suffix = pet.spritesheet.suffix.lower()
mime = "image/png" if suffix == ".png" else "image/webp"
return _ok(
rid,
{
"enabled": True,
"slug": pet.slug,
"displayName": pet.display_name,
"mime": mime,
"spritesheetBase64": base64.standard_b64encode(raw).decode("ascii"),
"frameW": constants.FRAME_W,
"frameH": constants.FRAME_H,
"framesPerState": constants.FRAMES_PER_STATE,
"framesByState": _pet_frame_counts(pet.spritesheet),
"loopMs": constants.LOOP_MS,
"scale": float(pet_cfg.get("scale", constants.DEFAULT_SCALE) or constants.DEFAULT_SCALE),
"stateRows": 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, render, store
from agent.pet.render import PetRenderer
try:
from hermes_cli.config import load_config
cfg = load_config()
display = cfg.get("display", {}) if isinstance(cfg.get("display"), dict) else {}
pet_cfg = display.get("pet", {}) if isinstance(display.get("pet"), dict) else {}
except Exception:
pet_cfg = {}
if not bool(pet_cfg.get("enabled")):
return _ok(rid, {"enabled": False})
pet = store.resolve_active_pet(str(pet_cfg.get("slug", "") or ""))
if pet is None or not pet.exists:
return _ok(rid, {"enabled": False})
state = str(params.get("state") or constants.PetState.IDLE.value)
scale = float(pet_cfg.get("scale", constants.DEFAULT_SCALE) or constants.DEFAULT_SCALE)
cols = int(params.get("cols") or 0) or constants.resolve_cols(scale, pet_cfg.get("unicode_cols", 0))
# Graphics path: when the TUI is attached to a real TTY (``graphics``)
# and the terminal speaks the kitty protocol, return a Unicode-
# placeholder payload for a crisp image instead of half-blocks. Env
# detection (KITTY_WINDOW_ID / TERM / TERM_PROGRAM) is shared with the
# Ink process since it spawns us; the dashboard PTY (xterm.js) has no
# such env, so it falls through to half-blocks automatically. Only
# kitty is grid-safe in Ink — iTerm/sixel stay on the fallback.
if params.get("graphics"):
configured = str(pet_cfg.get("render_mode", "auto") or "auto").lower()
gmode = render.detect_terminal_graphics() if configured in ("", "auto") else configured
if gmode == "kitty":
image_id = render.kitty_image_id(pet.slug)
# kitty sizes from scaled pixels (_cell_box), so unicode_cols is moot here.
payload = PetRenderer(
str(pet.spritesheet), mode="kitty", scale=scale
).kitty_payload(state, image_id=image_id)
if payload:
kcount = len(payload["frames"]) or 1
return _ok(
rid,
{
"enabled": True,
"slug": pet.slug,
"displayName": pet.display_name,
"state": state,
"graphics": "kitty",
"imageId": image_id,
"color": render.kitty_color_hex(image_id),
"cols": payload["cols"],
"rows": payload["rows"],
"placeholder": payload["placeholder"],
"frames": payload["frames"],
"frameMs": constants.LOOP_MS / max(1, kcount),
"scale": scale,
},
)
renderer = PetRenderer(
str(pet.spritesheet),
mode="unicode",
scale=scale,
unicode_cols=cols,
)
count = renderer.frame_count(state) or 1
frames = []
for i in range(count):
grid = renderer.cells(state, i, cols=cols)
frames.append(
[[[*top, *bottom] for (top, bottom) in row] for row in grid]
)
return _ok(
rid,
{
"enabled": True,
"slug": pet.slug,
"displayName": pet.display_name,
"state": state,
"cols": cols,
"frameMs": constants.LOOP_MS / max(1, count),
"frames": frames,
"scale": scale,
},
)
except Exception as exc: # noqa: BLE001
logger.debug("pet.cells failed: %s", exc)
return _ok(rid, {"enabled": False})
@method("pet.gallery")
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("pet.scale")
def _(rid, params: dict) -> dict:
"""Persist ``display.pet.scale`` from the desktop slider. Params: ``scale``.
Clamped to the engine bounds. The renderer updates its own ``$petInfo`` for
instant feedback; this just makes the change durable + visible to the other
terminal surfaces on their next read.
"""
try:
from hermes_cli.pets import set_pet_scale
scale, err = set_pet_scale(params.get("scale"))
if err:
return _err(rid, 4004, err)
return _ok(rid, {"ok": True, "scale": scale})
except Exception as exc: # noqa: BLE001
logger.debug("pet.scale failed: %s", exc)
return _err(rid, 5031, f"pet.scale failed: {exc}")
@method("credits.view")
def _(rid, params: dict) -> dict:
"""Structured Nous credit view for the TUI /credits command.

View file

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

View file

@ -9,7 +9,6 @@ import type {
GatewaySkin,
SessionMostRecentResponse
} from '../gatewayTypes.js'
import { isTodoDone } from '../lib/liveProgress.js'
import { rpcErrorMessage } from '../lib/rpc.js'
import { topLevelSubagents } from '../lib/subagentTree.js'
import { formatAbandonedClarify, formatToolCall, stripAnsi } from '../lib/text.js'
@ -19,9 +18,7 @@ import type { Msg, SubagentProgress, SubagentStatus } from '../types.js'
import { applyDelegationStatus, getDelegationState } from './delegationStore.js'
import type { GatewayEventHandlerContext } from './interfaces.js'
import { getOverlayState, patchOverlayState } from './overlayStore.js'
import { flashPet } from './petFlashStore.js'
import { turnController } from './turnController.js'
import { getTurnState } from './turnStore.js'
import { getUiState, patchUiState } from './uiStore.js'
const NO_PROVIDER_RE = /\bNo (?:LLM|inference) provider configured\b/i
@ -887,9 +884,6 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
const msgs: Msg[] = finalMessages.length ? finalMessages : [{ role: 'assistant', text: finalText }]
msgs.forEach(appendMessage)
// Pet beat: celebrate a finished plan, otherwise a clean-finish wave.
flashPet(isTodoDone(getTurnState().todos) ? 'jump' : 'wave')
if (bellOnComplete && stdout?.isTTY) {
stdout.write('\x07')
}
@ -906,7 +900,6 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
case 'error':
turnController.recordError()
flashPet('failed')
{
const message = String(ev.payload?.message || 'unknown error')

View file

@ -93,7 +93,6 @@ export interface OverlayState {
confirm: ConfirmReq | null
modelPicker: boolean
pager: null | PagerState
petPicker: boolean
pluginsHub: boolean
secret: null | SecretReq
sessions: boolean

View file

@ -10,7 +10,6 @@ const buildOverlayState = (): OverlayState => ({
confirm: null,
modelPicker: false,
pager: null,
petPicker: false,
pluginsHub: false,
secret: null,
sessions: false,
@ -22,9 +21,9 @@ export const $overlayState = atom<OverlayState>(buildOverlayState())
export const $isBlocked = computed(
$overlayState,
({ agents, approval, clarify, confirm, modelPicker, pager, petPicker, pluginsHub, secret, sessions, skillsHub, sudo }) =>
({ agents, approval, clarify, confirm, modelPicker, pager, pluginsHub, secret, sessions, skillsHub, sudo }) =>
Boolean(
agents || approval || clarify || confirm || modelPicker || pager || petPicker || pluginsHub || secret || sessions || skillsHub || sudo
agents || approval || clarify || confirm || modelPicker || pager || pluginsHub || secret || sessions || skillsHub || sudo
)
)
@ -50,7 +49,6 @@ export const resetFlowOverlays = () =>
agents: $overlayState.get().agents,
agentsInitialHistoryIndex: $overlayState.get().agentsInitialHistoryIndex,
modelPicker: $overlayState.get().modelPicker,
petPicker: $overlayState.get().petPicker,
pluginsHub: $overlayState.get().pluginsHub,
sessions: $overlayState.get().sessions,
skillsHub: $overlayState.get().skillsHub

View file

@ -1,16 +0,0 @@
import { atom } from 'nanostores'
import type { PetState } from './usePet.js'
interface PetFlash {
state: PetState
until: number
}
// Transient reaction beats (wave/jump/failed) the pet shows for a moment at
// turn end before falling back to its steady state. The gateway event handler
// sets these; usePet reads them with priority over the derived state.
export const $petFlash = atom<PetFlash | null>(null)
export const flashPet = (state: PetState, ms = 1600) =>
$petFlash.set({ state, until: Date.now() + ms })

View file

@ -8,7 +8,6 @@ import type {
SessionBranchResponse,
SessionCompressResponse,
SessionUsageResponse,
SlashExecResponse,
VoiceToggleResponse
} from '../../../gatewayTypes.js'
import { formatVoiceRecordKey, parseVoiceRecordKey } from '../../../lib/platform.js'
@ -341,31 +340,6 @@ export const sessionCommands: SlashCommand[] = [
}
},
{
help: 'toggle / adopt / resize an animated pet',
name: 'pet',
usage: '/pet [toggle | list | scale <n> | <slug>]',
run: (arg, ctx, cmd) => {
const sub = arg.trim().toLowerCase()
// Gallery picker — the interactive browse surface.
if (sub === 'list') {
return patchOverlayState({ petPicker: true })
}
// Bare /pet and /pet toggle flip display.pet.enabled via the slash worker.
ctx.gateway.gw
.request<SlashExecResponse>('slash.exec', { command: cmd.slice(1), session_id: ctx.sid })
.then(
ctx.guarded<SlashExecResponse>(r => {
const body = r.output || '/pet: no output'
ctx.transcript.sys(r.warning ? `warning: ${r.warning}\n${body}` : body)
})
)
.catch(ctx.guardedErr)
}
},
{
help: 'switch theme skin (fires skin.changed)',
name: 'skin',
@ -579,7 +553,6 @@ export const sessionCommands: SlashCommand[] = [
// even with zero API calls or on a resumed session. Render it whenever
// present, before the token panel.
const creditsLines = r?.credits_lines ?? []
if (creditsLines.length) {
ctx.transcript.panel('Nous credits', [{ text: creditsLines.join('\n') }])
}
@ -588,7 +561,6 @@ export const sessionCommands: SlashCommand[] = [
if (!creditsLines.length) {
ctx.transcript.sys('no API calls yet')
}
return
}

View file

@ -147,10 +147,6 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
return patchOverlayState({ modelPicker: false })
}
if (overlay.petPicker) {
return patchOverlayState({ petPicker: false })
}
if (overlay.skillsHub) {
return patchOverlayState({ skillsHub: false })
}

View file

@ -1,288 +0,0 @@
import { useStdout } from '@hermes/ink'
import { useCallback, useEffect, useRef, useState } from 'react'
import type { PetGrid } from '../components/petSprite.js'
import { useGateway } from './gatewayContext.js'
import { $petFlash } from './petFlashStore.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'
}
// A kitty Unicode-placeholder frame set: a static placeholder grid (painted by
// Ink in the image-id color) plus per-frame transmit escapes written straight
// to the terminal out-of-band.
interface KittyView {
color: string
placeholder: string[]
}
interface PetCellsResult {
color?: string
enabled?: boolean
frameMs?: number
// unicode mode: cell grids; kitty mode: transmit-escape strings.
frames?: PetGrid[] | string[]
graphics?: string
imageId?: number
placeholder?: string[]
scale?: number
slug?: string
state?: string
}
type CacheEntry =
| { kind: 'cells'; frameMs: number; frames: PetGrid[] }
| { kind: 'kitty'; frameMs: number; frames: string[]; placeholder: string[]; color: string }
const FRAME_MS = 160
const POLL_MS = 2500
// Only the standalone TUI owns a real terminal it can splat image escapes into;
// when piped (or running under the dashboard PTY the gateway resolves to
// half-blocks anyway) we never ask for graphics.
const IS_TTY = Boolean(process.stdout?.isTTY)
export interface PetRender {
enabled: boolean
grid: PetGrid | null
kitty: KittyView | null
}
/**
* Drives the TUI pet. Fetches each (slug, state)'s frames via the `pet.cells`
* RPC (cached) and animates the frame index. Two render paths:
*
* - **kitty** (Ghostty/kitty): the engine returns a static placeholder grid +
* per-frame transmit escapes. We paint the placeholder with Ink and write the
* current frame's escape to the terminal out-of-band, so the image animates
* underneath without Ink ever repainting.
* - **cells** (everywhere else): truecolor half-block grids painted by Ink.
*
* A steady poll keeps it reactive to config changes made elsewhere (`/pet`, the
* picker, `hermes pets select`) so adopting/switching/disabling takes effect
* live. The frame cache is keyed by `slug:state` so a switch re-pulls cleanly.
*/
export function usePet(): PetRender {
const { rpc } = useGateway()
const { write } = useStdout()
const [enabled, setEnabled] = useState(false)
const [grid, setGrid] = useState<PetGrid | null>(null)
const [kitty, setKitty] = useState<KittyView | null>(null)
const cache = useRef<Map<string, CacheEntry>>(new Map())
const slugRef = useRef('')
const scaleRef = useRef(0)
const imageIdRef = useRef(0)
const stateRef = useRef<PetState>('idle')
const frameRef = useRef(0)
const [petState, setPetState] = useState<PetState>('idle')
// Recompute the desired state on every turn/ui/flash change. A transient
// flash (wave/jump/failed) wins until it expires; a timer re-runs at expiry.
useEffect(() => {
let expiry: ReturnType<typeof setTimeout> | undefined
const apply = (next: PetState) => {
if (next !== stateRef.current) {
stateRef.current = next
frameRef.current = 0
setPetState(next)
}
}
const recompute = () => {
clearTimeout(expiry)
const flash = $petFlash.get()
const now = Date.now()
if (flash && now < flash.until) {
apply(flash.state)
expiry = setTimeout(recompute, flash.until - now)
return
}
const turn = $turnState.get()
const ui = $uiState.get()
apply(derivePetState({ busy: ui.busy, toolRunning: turn.tools.length > 0, reasoning: turn.reasoningActive }))
}
recompute()
const unsubTurn = $turnState.listen(recompute)
const unsubUi = $uiState.listen(recompute)
const unsubFlash = $petFlash.listen(recompute)
return () => {
clearTimeout(expiry)
unsubTurn()
unsubUi()
unsubFlash()
}
}, [])
// Free the terminal-side image when the pet goes away or the hook unmounts.
const releaseKitty = useCallback(() => {
if (imageIdRef.current) {
try {
write(`\x1b_Ga=d,d=i,i=${imageIdRef.current},q=2\x1b\\`)
} catch {
// best-effort cleanup
}
imageIdRef.current = 0
}
}, [write])
// Fetch + cache one (slug, state). `pet.cells` resolves the active pet from
// config, so its `slug`/`enabled` are the source of truth.
const sync = useCallback(
async (state: PetState) => {
try {
const res = (await rpc('pet.cells', { graphics: IS_TTY, state })) as PetCellsResult | null
if (!res) {
return
}
if (!res.enabled) {
releaseKitty()
slugRef.current = ''
cache.current.clear()
setGrid(null)
setKitty(null)
setEnabled(false)
return
}
const slug = res.slug ?? ''
const scale = res.scale ?? 0
// A switch OR a live `/pet scale` change invalidates the cached frames
// (they're rendered at the old size), so the steady poll repaints at the
// new scale without a restart.
if (slug !== slugRef.current || (scale > 0 && scale !== scaleRef.current)) {
releaseKitty()
slugRef.current = slug
scaleRef.current = scale
cache.current.clear()
frameRef.current = 0
}
if (res.graphics === 'kitty' && res.frames?.length && res.placeholder?.length) {
imageIdRef.current = res.imageId ?? 0
cache.current.set(`${slug}:${state}`, {
color: res.color ?? '#000001',
frameMs: res.frameMs ?? FRAME_MS,
frames: res.frames as string[],
kind: 'kitty',
placeholder: res.placeholder
})
} else if (res.frames?.length) {
cache.current.set(`${slug}:${state}`, {
frameMs: res.frameMs ?? FRAME_MS,
frames: res.frames as PetGrid[],
kind: 'cells'
})
}
setEnabled(true)
} catch {
// cosmetic — ignore RPC failures
}
},
[rpc, releaseKitty]
)
// Pull frames whenever the state changes (if not already cached for the
// active pet), plus a steady poll that catches adopt/switch/disable.
useEffect(() => {
if (!cache.current.has(`${slugRef.current}:${petState}`)) {
void sync(petState)
}
const timer = setInterval(() => void sync(stateRef.current), POLL_MS)
return () => clearInterval(timer)
}, [petState, sync])
useEffect(() => releaseKitty, [releaseKitty])
// Animation timer.
useEffect(() => {
if (!enabled) {
return
}
const tick = () => {
const entry = cache.current.get(`${slugRef.current}:${stateRef.current}`)
if (!entry?.frames.length) {
return // keep the last frame painted while the new state loads
}
const idx = frameRef.current % entry.frames.length
frameRef.current = idx + 1
if (entry.kind === 'kitty') {
// Transmit this frame's image under the shared id; the static
// placeholder cells (set below) render it. No Ink repaint needed.
try {
write(entry.frames[idx] ?? '')
} catch {
// ignore transmit failures
}
setGrid(null)
setKitty(prev =>
prev && prev.color === entry.color && prev.placeholder === entry.placeholder
? prev
: { color: entry.color, placeholder: entry.placeholder }
)
return
}
setKitty(null)
setGrid(entry.frames[idx] ?? null)
}
tick()
const interval = setInterval(tick, FRAME_MS)
return () => clearInterval(interval)
}, [enabled, petState, write])
return { enabled, grid, kitty }
}

View file

@ -6,7 +6,6 @@ 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,29 +25,10 @@ import { Banner, Panel, SessionPanel } from './branding.js'
import { FpsOverlay } from './fpsOverlay.js'
import { HelpHint } from './helpHint.js'
import { MessageLine } from './messageLine.js'
import { PetKitty, PetSprite } from './petSprite.js'
import { QueuedMessages } from './queuedMessages.js'
import { LiveTodoPanel, StreamingAssistant } from './streamingAssistant.js'
import { TextInput, type TextInputMouseApi } from './textInput.js'
// Petdex mascot — sits just above the composer, right-aligned. Renders
// nothing unless a pet is installed + enabled (`hermes pets select <slug>`),
// so it's a no-op for everyone else.
const PetPane = memo(function PetPane() {
const { enabled, grid, kitty } = usePet()
if (!enabled || (!grid && !kitty)) {
return null
}
return (
<NoSelect flexShrink={0} justifyContent="flex-end" paddingX={1} width="100%">
{kitty ? <PetKitty color={kitty.color} placeholder={kitty.placeholder} /> : null}
{!kitty && grid ? <PetSprite grid={grid} /> : null}
</NoSelect>
)
})
const PromptPrefix = memo(function PromptPrefix({
bold = false,
color,
@ -440,8 +420,6 @@ export const AppLayout = memo(function AppLayout({
{!overlay.agents && (
<>
<PetPane />
<PerfPane id="prompt">
<PromptZone
cols={composer.cols}

View file

@ -11,7 +11,6 @@ import { FloatBox } from './appChrome.js'
import { MaskedPrompt } from './maskedPrompt.js'
import { ModelPicker } from './modelPicker.js'
import { OverlayHint } from './overlayControls.js'
import { PetPicker } from './petPicker.js'
import { PluginsHub } from './pluginsHub.js'
import { ApprovalPrompt, ClarifyPrompt, ConfirmPrompt } from './prompts.js'
import { SkillsHub } from './skillsHub.js'
@ -125,7 +124,6 @@ export function FloatingOverlays({
const hasAny =
overlay.modelPicker ||
overlay.pager ||
overlay.petPicker ||
overlay.sessions ||
overlay.skillsHub ||
overlay.pluginsHub ||
@ -172,12 +170,6 @@ export function FloatingOverlays({
</FloatBox>
)}
{overlay.petPicker && (
<FloatBox color={theme.color.border}>
<PetPicker gw={gw} onClose={() => patchOverlayState({ petPicker: false })} t={theme} />
</FloatBox>
)}
{overlay.skillsHub && (
<FloatBox color={theme.color.border}>
<SkillsHub gw={gw} onClose={() => patchOverlayState({ skillsHub: false })} t={theme} />

View file

@ -1,183 +0,0 @@
import { Box, Text, useInput, useStdout } from '@hermes/ink'
import { useEffect, useMemo, useState } from 'react'
import type { GatewayClient } from '../gatewayClient.js'
import { rpcErrorMessage } from '../lib/rpc.js'
import type { Theme } from '../theme.js'
import { OverlayHint, windowItems } from './overlayControls.js'
const VISIBLE = 10
const MIN_WIDTH = 40
const MAX_WIDTH = 90
interface GalleryPet {
slug: string
displayName: string
installed: boolean
curated?: boolean
}
interface Gallery {
enabled: boolean
active: string
pets: GalleryPet[]
}
/**
* Interactive petdex picker overlay. Pulls the gallery via `pet.gallery`,
* filters as you type, and adopts the highlighted pet with `pet.select`
* (install-on-demand). The mascot lights up live once `usePet` next polls
* no restart. This is the interactive sibling of the text `/pet <slug>` path.
*/
export function PetPicker({ gw, onClose, t }: PetPickerProps) {
const [gallery, setGallery] = useState<Gallery | null>(null)
const [query, setQuery] = useState('')
const [idx, setIdx] = useState(0)
const [busy, setBusy] = useState(false)
const [err, setErr] = useState('')
const [loading, setLoading] = useState(true)
const { stdout } = useStdout()
const width = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (stdout?.columns ?? 80) - 6))
useEffect(() => {
gw.request<Gallery>('pet.gallery')
.then(r => {
setGallery(r)
setErr('')
})
.catch((e: unknown) => setErr(rpcErrorMessage(e)))
.finally(() => setLoading(false))
}, [gw])
const enabled = gallery?.enabled ?? false
const active = gallery?.active ?? ''
// Rank by the signals petdex gives us — active, then installed, then curated
// (its official set), then the rest — and hide the clawd placeholders.
const view = useMemo(() => {
const pets = (gallery?.pets ?? []).filter(p => !/^clawd(-|$)/i.test(p.slug))
const needle = query.trim().toLowerCase()
const matched = needle
? pets.filter(p => p.slug.toLowerCase().includes(needle) || p.displayName.toLowerCase().includes(needle))
: pets
const rank = (p: GalleryPet) => (enabled && p.slug === active ? 4 : 0) + (p.installed ? 2 : 0) + (p.curated ? 1 : 0)
return [...matched].sort((a, b) => rank(b) - rank(a))
}, [gallery, query, enabled, active])
const adopt = (slug: string) => {
setBusy(true)
setErr('')
gw.request('pet.select', { slug })
.then(() => onClose())
.catch((e: unknown) => {
setErr(rpcErrorMessage(e))
setBusy(false)
})
}
useInput((input, key) => {
if (busy) {
return
}
if (key.escape) {
return onClose()
}
if (key.upArrow) {
return setIdx(i => Math.max(0, i - 1))
}
if (key.downArrow) {
return setIdx(i => Math.min(view.length - 1, i + 1))
}
if (key.return) {
const pet = view[idx]
return pet ? adopt(pet.slug) : undefined
}
if (key.backspace || key.delete) {
setQuery(q => q.slice(0, -1))
return setIdx(0)
}
// Printable char → extend the filter (ignore control/chorded keys).
if (input && input.length === 1 && input >= ' ' && !key.ctrl && !key.meta) {
setQuery(q => q + input)
setIdx(0)
}
})
if (loading) {
return <Text color={t.color.muted}>loading pets</Text>
}
if (err && !gallery) {
return (
<Box flexDirection="column" width={width}>
<Text color={t.color.label}>error: {err}</Text>
<OverlayHint t={t}>Esc cancel</OverlayHint>
</Box>
)
}
const { items, offset } = windowItems(view, idx, VISIBLE)
return (
<Box flexDirection="column" width={width}>
<Text bold color={t.color.accent}>
Pets
</Text>
<Text color={t.color.muted} wrap="truncate-end">
{query ? `filter: ${query}` : 'type to filter'} · {view.length} pet{view.length === 1 ? '' : 's'}
</Text>
{offset > 0 && <Text color={t.color.muted}> {offset} more</Text>}
{view.length === 0 ? (
<Text color={t.color.muted}>{query ? `no pets match "${query}"` : 'no pets available'}</Text>
) : (
items.map((pet, i) => {
const at = offset + i === idx
const isActive = enabled && pet.slug === active
const mark = isActive ? '●' : pet.installed ? '✓' : ' '
const tag = pet.installed ? '' : pet.curated ? ' · official' : ''
return (
<Text bold={at} color={at ? t.color.accent : t.color.muted} inverse={at} key={pet.slug} wrap="truncate-end">
{at ? '▸ ' : ' '}
{mark} {pet.displayName}
<Text color={at ? t.color.accent : t.color.muted}>
{' '}
({pet.slug}
{tag})
</Text>
</Text>
)
})
)}
{offset + VISIBLE < view.length && <Text color={t.color.muted}> {view.length - offset - VISIBLE} more</Text>}
{err ? <Text color={t.color.label}>error: {err}</Text> : null}
{busy ? <Text color={t.color.accent}>adopting</Text> : null}
<OverlayHint t={t}>/ select · Enter adopt · type to filter · Esc cancel</OverlayHint>
</Box>
)
}
interface PetPickerProps {
gw: GatewayClient
onClose: () => void
t: Theme
}

View file

@ -1,93 +0,0 @@
import { Box, Text } from '@hermes/ink'
import { memo } from 'react'
// A cell is [tr,tg,tb,ta, br,bg,bb,ba] — the top + bottom pixel of one
// half-block, as produced by the `pet.cells` gateway RPC.
export type PetCell = number[]
export type PetGrid = PetCell[][]
const UPPER_HALF = '▀'
const LOWER_HALF = '▄'
const hex = (r: number, g: number, b: number) =>
`#${[r, g, b].map(v => Math.max(0, Math.min(255, v | 0)).toString(16).padStart(2, '0')).join('')}`
/**
* Renders one petdex frame as truecolor half-blocks using native Ink color
* props (no raw ANSI, so width measurement stays correct). The engine
* (`agent/pet/render.py`) does the decode + downscale; this is a thin painter.
*/
export const PetSprite = memo(function PetSprite({ grid }: { grid: PetGrid }) {
if (!grid.length) {
return null
}
return (
<Box flexDirection="column">
{grid.map((row, y) => (
<Box key={y}>
{row.map((cell, x) => {
const [tr, tg, tb, ta, br, bg, bb, ba] = cell
const top = (ta ?? 0) >= 32
const bot = (ba ?? 0) >= 32
if (!top && !bot) {
return <Text key={x}> </Text>
}
// Both halves opaque → fg=top over bg=bottom. One half opaque →
// draw it fg-only so the other stays the terminal bg (no black
// boxes bleeding around transparent sprite edges).
if (top && bot) {
return (
<Text backgroundColor={hex(br, bg, bb)} color={hex(tr, tg, tb)} key={x}>
{UPPER_HALF}
</Text>
)
}
return top ? (
<Text color={hex(tr, tg, tb)} key={x}>
{UPPER_HALF}
</Text>
) : (
<Text color={hex(br, bg, bb)} key={x}>
{LOWER_HALF}
</Text>
)
})}
</Box>
))}
</Box>
)
})
/**
* Renders a kitty Unicode-placeholder grid: each line is a row of U+10EEEE
* cells whose foreground color encodes the image id. The actual pixels are
* drawn by the terminal (the frame image is transmitted out-of-band by
* `usePet`); this only emits the placeholder text Ink can measure as width-1
* cells. Truecolor-only the color must reach the terminal verbatim for the
* id to decode, which Ghostty/kitty support.
*/
export const PetKitty = memo(function PetKitty({
color,
placeholder
}: {
color: string
placeholder: string[]
}) {
if (!placeholder.length) {
return null
}
return (
<Box flexDirection="column">
{placeholder.map((row, y) => (
<Text color={color} key={y}>
{row}
</Text>
))}
</Box>
)
})

View file

@ -156,10 +156,7 @@ declare module '@hermes/ink' {
readonly setSelectionBgColor: (color: string) => void
}
export function useHasSelection(): boolean
export function useStdout(): {
readonly stdout?: NodeJS.WriteStream
readonly write: (data: string) => boolean
}
export function useStdout(): { readonly stdout?: NodeJS.WriteStream }
export function useTerminalFocus(): boolean
export function useTerminalTitle(title: string | null): void
export function useDeclaredCursor(args: {

View file

@ -125,7 +125,6 @@ If a skill is missing from this list but present in the repo, the catalog is reg
| [`nano-pdf`](/docs/user-guide/skills/bundled/productivity/productivity-nano-pdf) | Edit PDF text/typos/titles via nano-pdf CLI (NL prompts). | `productivity/nano-pdf` |
| [`notion`](/docs/user-guide/skills/bundled/productivity/productivity-notion) | Notion API + ntn CLI: pages, databases, markdown, Workers. | `productivity/notion` |
| [`ocr-and-documents`](/docs/user-guide/skills/bundled/productivity/productivity-ocr-and-documents) | Extract text from PDFs/scans (pymupdf, marker-pdf). | `productivity/ocr-and-documents` |
| [`petdex`](/docs/user-guide/skills/bundled/productivity/productivity-petdex) | Install and select animated petdex mascots for Hermes. | `productivity/petdex` |
| [`powerpoint`](/docs/user-guide/skills/bundled/productivity/productivity-powerpoint) | Create, read, edit .pptx decks, slides, notes, templates. | `productivity/powerpoint` |
| [`teams-meeting-pipeline`](/docs/user-guide/skills/bundled/productivity/productivity-teams-meeting-pipeline) | Operate the Teams meeting summary pipeline via Hermes CLI — summarize meetings, inspect pipeline status, replay jobs, manage Microsoft Graph subscriptions. | `productivity/teams-meeting-pipeline` |

View file

@ -1,166 +0,0 @@
---
sidebar_position: 11
title: "Pets (Petdex Mascots)"
description: "Adopt an animated mascot that reacts to agent activity across the CLI, TUI, and desktop app"
---
# Pets
Hermes can show an animated **pet** — a small mascot sprite that reacts to what
the agent is doing (idle, running a tool, thinking, finishing, failing) across
the **CLI**, **TUI**, and **desktop app**. Pets come from the public
[petdex](https://github.com/crafter-station/petdex) gallery.
Pets are purely cosmetic. They have **no effect on prompt caching, tokens, or
the agent's behavior** — the sprite is a display concern only. The feature is
**off by default** and stays dormant until you install and select a pet.
## How it works
- Pets are installed into your profile's `pets/` directory
(`<HERMES_HOME>/pets/<slug>/`), so each [profile](../profiles.md) keeps its
own set.
- Selecting a pet writes `display.pet.slug` and `display.pet.enabled` to
`config.yaml` — nothing is stored as a secret or env var.
- Each surface watches the activity it already tracks and maps it to one of six
animation states. The mapping lives in one place so every surface behaves the
same:
| Agent activity | Pet state |
| --- | --- |
| A tool/turn just failed | `failed` |
| A plan finished (all todos done) | `jump` (celebrate) |
| A turn finished cleanly | `wave` |
| A tool is executing | `run` |
| The model is thinking/reading | `review` |
| Turn in flight (unspecified) | `run` |
| Waiting on you / nothing happening | `idle` |
## Rendering
In the terminal (CLI/TUI), Hermes renders the sprite at full fidelity when your
terminal supports a graphics protocol (**kitty**, **Ghostty**, **WezTerm**,
**iTerm2**, or **sixel**). Otherwise it falls back automatically to a truecolor
Unicode **half-block** rendering. Inside a pipe or redirect (no TTY), terminal
rendering is disabled by design.
The desktop app draws the pet as a floating sprite on a canvas and toggles it
from **Settings → Appearance**.
## Quick start (CLI)
```bash
# Browse the gallery (filter by substring)
hermes pets list
hermes pets list cat
# Install a pet and make it active in one step
hermes pets install boba --select
# Preview / animate it in your terminal (Ctrl+C to stop)
hermes pets show
# Check your setup
hermes pets doctor
```
## `hermes pets` commands
| Goal | Command |
| --- | --- |
| Browse the gallery | `hermes pets list [query] [--limit N]` |
| List installed pets | `hermes pets list --installed` |
| Install a pet | `hermes pets install <slug> [--select] [--force]` |
| Set the active pet | `hermes pets select [slug]` (omit slug for a picker) |
| Resize the pet everywhere | `hermes pets scale <factor>` (e.g. `0.5`, clamped 0.13.0) |
| Preview/animate | `hermes pets show [slug] [--state <s>] [--cycle] [--once] [--mode <m>] [--scale <f>]` |
| Disable the pet | `hermes pets off` |
| Remove an installed pet | `hermes pets remove <slug>` |
| Diagnose setup | `hermes pets doctor` |
`hermes pets show` flags:
- `--state` — play a single state (`idle`, `wave`, `run`, `failed`, `review`,
`jump`).
- `--cycle` — cycle through every state.
- `--once` — play once instead of looping.
- `--mode` — override the render protocol (`kitty`, `iterm`, `sixel`,
`unicode`, `auto`).
- `--scale` — override the on-screen scale (`0` = use config).
## `/pet` slash command
Inside the CLI and TUI you can manage the pet without leaving the session:
- `/pet` — toggle the pet on/off (adopts the first installed pet if none is
active).
- `/pet list` — browse the gallery.
- `/pet scale <factor>` — resize the pet everywhere (e.g. `/pet scale 0.5`).
- `/pet <slug>` — adopt a specific pet.
- `/pet off` — disable the pet.
In the TUI, `/pet list` opens an interactive picker overlay; in the desktop app
it opens the Cmd+K pet palette.
## Desktop app
In the desktop app you can manage the pet two ways:
- **Cmd+K → "Pets…"** — browse, search, adopt, and toggle pets without leaving
the keyboard (mirrors the theme picker).
- **Settings → Appearance** — the same gallery plus a **size slider** that
resizes the floating mascot live as you drag.
Both adopt/toggle/resize the floating mascot in place — size changes apply
instantly; adopting a new pet lights it up within a moment.
## Configuration
All settings live under `display.pet` in `config.yaml`:
```yaml
display:
pet:
enabled: false # master on/off (true once you select a pet)
slug: "" # active pet; empty = first installed
render_mode: auto # auto | kitty | iterm | sixel | unicode | off
scale: 0.33 # master size knob (relative to native 192x208 frames)
unicode_cols: 0 # hard override for terminal width (0 = derive from scale)
```
- **`scale`** is the single master size knob. One number shrinks every surface:
the desktop canvas scales its pixels by it, and the CLI/TUI derive their
terminal column width from it. The half-block fallback clamps to a legibility
floor — it can't shrink as far as true-pixel kitty/GUI rendering without
turning to mush, so the same `scale` looks crisp under kitty but is floored in
half-blocks.
- **`render_mode: auto`** detects kitty/iTerm2/sixel and falls back to unicode
half-blocks. Set it explicitly to force a protocol or `off` to disable
terminal rendering while keeping the pet on the desktop.
- **`unicode_cols`** pins the terminal column width independently of `scale`;
leave it at `0` to derive width from `scale`.
## Troubleshooting
Run `hermes pets doctor` — it reports:
- the pets directory and which pets are installed,
- `display.pet.enabled`, `display.pet.slug`, and the resolved active pet,
- the configured `render_mode`, the detected terminal graphics protocol, and the
effective mode for a TTY,
- whether Pillow (used for sprite decoding) is importable.
It prints `✓ ready` once a pet is installed, selected, enabled, and Pillow is
available.
Common gotchas:
- A pet only shows once one is **installed AND selected** (`enabled: true`).
- Inside a pipe/redirect (no TTY), terminal rendering is disabled by design.
- The petdex npm CLI installs to `~/.codex/pets`; Hermes uses its own
profile-scoped `<HERMES_HOME>/pets/` instead — install through `hermes pets`.
## See also
- The [`petdex` skill](../skills/bundled/productivity/productivity-petdex.md)
lets the agent install and switch pets for you on request.

View file

@ -327,6 +327,24 @@ display:
tool_progress_grouping: accumulate # accumulate | separate
```
### Message timestamps in model context
Off by default. When enabled, Hermes prepends a human-readable timestamp
(e.g. `[Tue 2026-04-28 13:40:53 CEST]`) onto each **user** message *in the
model's context* so the agent knows when messages were sent — useful for
temporal reasoning ("you asked this morning…", noticing a long gap). It is
**not** added to assistant messages or the system prompt.
```yaml
gateway:
message_timestamps:
enabled: false # set true to show send-times to the model
```
Persisted transcripts always stay clean — the timestamp is stored as message
metadata regardless of this toggle, so enabling it later also surfaces
send-times for past messages, and replay never accumulates duplicate prefixes.
When enabled, the bot sends status messages as it works:
```text

View file

@ -1,105 +0,0 @@
---
title: "Petdex — Install and select animated petdex mascots for Hermes"
sidebar_label: "Petdex"
description: "Install and select animated petdex mascots for Hermes"
---
{/* This page is auto-generated from the skill's SKILL.md by website/scripts/generate-skill-docs.py. Edit the source SKILL.md, not this page. */}
# Petdex
Install and select animated petdex mascots for Hermes.
## Skill metadata
| | |
|---|---|
| Source | Bundled (installed by default) |
| Path | `skills/productivity/petdex` |
| Version | `1.0.0` |
| Author | Hermes Agent |
| License | MIT |
| Platforms | linux, macos, windows |
| Tags | `petdex`, `mascot`, `display`, `cli`, `tui`, `desktop` |
## Reference: full SKILL.md
:::info
The following is the complete skill definition that Hermes loads when this skill is triggered. This is what the agent sees as instructions when the skill is active.
:::
# Petdex Skill
Browse, install, and select animated "pet" mascots from the public
[petdex](https://github.com/crafter-station/petdex) gallery. An installed pet
reacts to agent activity (idle, running a tool, reviewing, error, done) across
the Hermes CLI, TUI, and desktop app. This skill drives the `hermes pets` CLI
and the `display.pet` config — it does not generate sprites.
## When to Use
- The user wants a desktop/terminal mascot or asks about "pets" / petdex.
- The user wants to change, preview, or disable the active pet.
- Diagnosing why a pet isn't showing (terminal graphics support, config).
## Prerequisites
- Network access to `petdex.dev` for the gallery/manifest (read-only, no auth).
- Pillow (a core Hermes dependency) for sprite decoding — already installed.
- For full-fidelity terminal rendering: a graphics-capable terminal (kitty,
Ghostty, WezTerm, iTerm2, or sixel). Otherwise a truecolor Unicode
half-block fallback is used automatically.
## How to Run
Use the `terminal` tool to run `hermes pets <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) |
| Resize the pet everywhere | `hermes pets scale <factor>` (e.g. `0.5`, clamped 0.13.0) |
| 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 size of the native 192×208 frames (default 0.33,
clamped 0.13.0). One knob resizes every surface; set it with
`hermes pets scale <factor>`, the `/pet scale` slash command, or the desktop
Appearance slider.
- `unicode_cols` (int) — width in columns for the Unicode fallback.
## Pitfalls
- A pet only shows once one is installed AND selected (`enabled: true`).
- Inside a pipe/redirect (no TTY) terminal rendering is disabled by design.
- The petdex npm CLI installs to `~/.codex/pets`; Hermes uses its own
profile-scoped `<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.

View file

@ -102,7 +102,6 @@ const sidebars: SidebarsConfig = {
'user-guide/features/vision',
'user-guide/features/image-generation',
'user-guide/features/spotify',
'user-guide/features/pets',
'user-guide/features/tts',
'user-guide/features/deliverable-mode',
],
@ -278,7 +277,6 @@ const sidebars: SidebarsConfig = {
'user-guide/skills/bundled/productivity/productivity-nano-pdf',
'user-guide/skills/bundled/productivity/productivity-notion',
'user-guide/skills/bundled/productivity/productivity-ocr-and-documents',
'user-guide/skills/bundled/productivity/productivity-petdex',
'user-guide/skills/bundled/productivity/productivity-powerpoint',
'user-guide/skills/bundled/productivity/productivity-teams-meeting-pipeline',
],