Compare commits
24 Commits
main
...
bb/pets-ge
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a210cfc33c | ||
|
|
8f3ea8a148 | ||
|
|
6ce6054dfa | ||
|
|
e942321cd7 | ||
|
|
365c23c554 | ||
|
|
8c48be90ac | ||
|
|
25e78f129f | ||
|
|
b5152ec846 | ||
|
|
7bd3715f1d | ||
|
|
9f27f159cc | ||
|
|
eceb91cffd | ||
|
|
52134078e3 | ||
|
|
b6abd39ca5 | ||
|
|
d1b1308e2a | ||
|
|
a0aa1bd11c | ||
|
|
5cd2f6bd36 | ||
|
|
4bc20abbd0 | ||
|
|
0586c8073e | ||
|
|
c176054c33 | ||
|
|
fdcfa44584 | ||
|
|
2572617d5a | ||
|
|
4db86e349c | ||
|
|
db00cbfd56 | ||
|
|
6681bef707 |
51
agent/pet/__init__.py
Normal file
51
agent/pet/__init__.py
Normal file
@ -0,0 +1,51 @@
|
||||
"""Petdex pet engine — shared core for the CLI, TUI, and desktop surfaces.
|
||||
|
||||
Petdex (https://github.com/crafter-station/petdex) is a public gallery of
|
||||
animated sprite "pets" for coding agents. Each pet is a ``pet.json`` plus a
|
||||
``spritesheet.{webp,png}`` of 192×208 px cells. Current Codex/petdex sheets use
|
||||
an 8-column × 9-row atlas; older Hermes/petdex sheets used an 8-row atlas.
|
||||
Hermes infers the row taxonomy from the sheet and maps agent activity onto
|
||||
idle/run/review/failed/wave/jump.
|
||||
|
||||
This package is the **single source of truth** for the feature so the base
|
||||
CLI (Python) and TUI (Ink, via ``tui_gateway``) never duplicate the hard
|
||||
parts:
|
||||
|
||||
- :mod:`agent.pet.constants` — frame geometry + the :class:`PetState` enum.
|
||||
- :mod:`agent.pet.state` — map agent activity → a :class:`PetState`.
|
||||
- :mod:`agent.pet.manifest` — fetch the public petdex manifest.
|
||||
- :mod:`agent.pet.store` — install / list / resolve pets on disk
|
||||
(profile-aware via ``get_hermes_home()``).
|
||||
- :mod:`agent.pet.render` — decode a spritesheet and encode frames for a
|
||||
terminal (kitty / iTerm2 / sixel graphics
|
||||
protocols, with a Unicode half-block
|
||||
fallback).
|
||||
|
||||
Rendering in the Electron desktop is necessarily TypeScript (canvas), but it
|
||||
reuses the same on-disk store and the same state semantics.
|
||||
|
||||
The whole feature is a *display* concern: it adds no model tool, mutates no
|
||||
system prompt or toolset, and therefore has zero effect on prompt caching.
|
||||
"""
|
||||
|
||||
from agent.pet.constants import (
|
||||
DEFAULT_SCALE,
|
||||
FRAME_H,
|
||||
FRAME_W,
|
||||
FRAMES_PER_STATE,
|
||||
LOOP_MS,
|
||||
STATE_ROWS,
|
||||
PetState,
|
||||
)
|
||||
from agent.pet.state import derive_pet_state
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_SCALE",
|
||||
"FRAME_H",
|
||||
"FRAME_W",
|
||||
"FRAMES_PER_STATE",
|
||||
"LOOP_MS",
|
||||
"STATE_ROWS",
|
||||
"PetState",
|
||||
"derive_pet_state",
|
||||
]
|
||||
167
agent/pet/constants.py
Normal file
167
agent/pet/constants.py
Normal file
@ -0,0 +1,167 @@
|
||||
"""Pet sprite geometry + animation-state taxonomy.
|
||||
|
||||
These values are the common petdex/Codex pet geometry. The real ``pet.json``
|
||||
usually only carries ``id``/``displayName``/``description``/``spritesheetPath``;
|
||||
row taxonomy is inferred from the atlas shape so Hermes can render both legacy
|
||||
8-row sheets and current 9-row Codex sheets.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
|
||||
# Frame geometry (pixels). Current Codex/petdex spritesheets are 8 columns x 9
|
||||
# rows (1536x1872), while older Hermes/petdex sheets used 9 columns x 8 rows
|
||||
# (1728x1664). Renderers derive both row taxonomy and real column count from the
|
||||
# concrete sheet, so either shape works.
|
||||
FRAME_W = 192
|
||||
FRAME_H = 208
|
||||
|
||||
# Frames consumed per animation state (the petdex web app uses CSS
|
||||
# ``steps(6)``). A sheet may physically contain more columns; we only step
|
||||
# through the first ``FRAMES_PER_STATE``.
|
||||
FRAMES_PER_STATE = 6
|
||||
|
||||
# Full-loop duration for one state, milliseconds (petdex default).
|
||||
LOOP_MS = 1100
|
||||
|
||||
# Default on-screen scale relative to native frame size. ``display.pet.scale``
|
||||
# is the single master scalar: the desktop canvas multiplies its native pixels
|
||||
# by it and every terminal surface derives its half-block/kitty column width
|
||||
# from it (see :func:`cols_for_scale`), so one number shrinks all three
|
||||
# interfaces together. (petdex's own clients render at 0.7; we default smaller
|
||||
# so the kitty/GUI mascot stays a glanceable corner sprite. The half-block
|
||||
# fallback can't shrink as far — see ``UNICODE_MIN_COLS`` — and clamps to its
|
||||
# legibility floor instead.)
|
||||
DEFAULT_SCALE = 0.33
|
||||
|
||||
# User-settable scale bounds (``/pet scale``, desktop slider). Floor keeps the
|
||||
# pet clickable/visible; ceiling stops a fat-fingered value from filling the
|
||||
# screen. The unicode fallback additionally clamps to ``UNICODE_MIN_COLS``.
|
||||
MIN_SCALE = 0.1
|
||||
MAX_SCALE = 3.0
|
||||
|
||||
|
||||
def clamp_scale(scale: float) -> float:
|
||||
"""Clamp *scale* to ``[MIN_SCALE, MAX_SCALE]`` (the single validation point)."""
|
||||
return max(MIN_SCALE, min(MAX_SCALE, scale))
|
||||
|
||||
# Terminal cells one native frame spans at ``scale == 1.0``. A cell is ~8px
|
||||
# wide, a frame is ``FRAME_W`` (192) px → 24 cells. This mirrors the kitty
|
||||
# graphics placement (``scaled_px // 8``) so at full scale every renderer agrees.
|
||||
BASE_UNICODE_COLS = FRAME_W // 8
|
||||
|
||||
# Legibility floor for the half-block fallback. A half-block cell samples the
|
||||
# sprite at only 1 horizontal + 2 vertical taps, so below this width a 192×208
|
||||
# pet collapses into an unreadable blob *regardless* of scale. kitty/GUI draw
|
||||
# true pixels and have no such floor — that's why the same ``scale: 0.33`` is
|
||||
# crisp there but mush in half-blocks. ``scale`` shrinks the unicode pet down
|
||||
# TO this floor (and grows it above), instead of past it into noise.
|
||||
UNICODE_MIN_COLS = 16
|
||||
|
||||
|
||||
def cols_for_scale(scale: float) -> int:
|
||||
"""Half-block width implied by *scale*, clamped to the legibility floor.
|
||||
|
||||
Above the floor it tracks the kitty cell box (``scaled_px // 8``) so the two
|
||||
renderers converge at larger sizes; below it the floor keeps the sprite
|
||||
readable rather than letting it devolve into a blob.
|
||||
"""
|
||||
return max(UNICODE_MIN_COLS, round(BASE_UNICODE_COLS * (scale or DEFAULT_SCALE)))
|
||||
|
||||
|
||||
def resolve_cols(scale: float, unicode_cols: int = 0) -> int:
|
||||
"""Resolve terminal width: explicit *unicode_cols* override, else from *scale*."""
|
||||
return int(unicode_cols) if unicode_cols and int(unicode_cols) > 0 else cols_for_scale(scale)
|
||||
|
||||
|
||||
class PetState(str, Enum):
|
||||
"""Animation state a pet can be shown in.
|
||||
|
||||
These are Hermes' activity state names. They are not always identical to the
|
||||
source atlas row names: Codex-format pets use rows like ``jumping`` /
|
||||
``running`` while the UI keeps the shorter ``jump`` / ``run`` names.
|
||||
"""
|
||||
|
||||
IDLE = "idle"
|
||||
WAVE = "wave"
|
||||
RUN = "run"
|
||||
FAILED = "failed"
|
||||
REVIEW = "review"
|
||||
JUMP = "jump"
|
||||
WAITING = "waiting"
|
||||
|
||||
|
||||
# Legacy Hermes/petdex row order (top -> bottom) used by the older 8-row,
|
||||
# 9-column atlas shape.
|
||||
LEGACY_STATE_ROWS: list[str] = [
|
||||
PetState.IDLE.value,
|
||||
PetState.WAVE.value,
|
||||
PetState.RUN.value,
|
||||
PetState.FAILED.value,
|
||||
PetState.REVIEW.value,
|
||||
PetState.JUMP.value,
|
||||
"extra1",
|
||||
"extra2",
|
||||
]
|
||||
|
||||
# Current Petdex row order (top -> bottom) used by 1536x1872 atlases:
|
||||
# 8 columns x 9 rows of 192x208 cells.
|
||||
CODEX_STATE_ROWS: list[str] = [
|
||||
PetState.IDLE.value,
|
||||
"running-right",
|
||||
"running-left",
|
||||
"waving",
|
||||
"jumping",
|
||||
PetState.FAILED.value,
|
||||
PetState.WAITING.value,
|
||||
"running",
|
||||
PetState.REVIEW.value,
|
||||
]
|
||||
|
||||
# Default/fallback for callers without a sheet. Prefer the current 9-row Codex
|
||||
# format because generated pets and the public Codex pet contract use it.
|
||||
STATE_ROWS: list[str] = CODEX_STATE_ROWS
|
||||
|
||||
# Canonical Hermes activity names -> accepted row-name aliases in descending
|
||||
# preference. This keeps our internal state names stable (`wave`/`jump`/`run`)
|
||||
# while matching Petdex's current `waving`/`jumping`/`running` taxonomy.
|
||||
STATE_ALIASES: dict[str, tuple[str, ...]] = {
|
||||
PetState.IDLE.value: (PetState.IDLE.value,),
|
||||
PetState.WAVE.value: (PetState.WAVE.value, "waving"),
|
||||
PetState.JUMP.value: (PetState.JUMP.value, "jumping"),
|
||||
PetState.RUN.value: (PetState.RUN.value, "running"),
|
||||
PetState.FAILED.value: (PetState.FAILED.value,),
|
||||
PetState.REVIEW.value: (PetState.REVIEW.value,),
|
||||
PetState.WAITING.value: (PetState.WAITING.value,),
|
||||
}
|
||||
|
||||
|
||||
def state_aliases_for(state: "PetState | str") -> tuple[str, ...]:
|
||||
"""Return accepted row-name aliases for *state* (always non-empty)."""
|
||||
value = state.value if isinstance(state, PetState) else str(state)
|
||||
aliases = STATE_ALIASES.get(value)
|
||||
return aliases if aliases else (value,)
|
||||
|
||||
|
||||
def state_rows_for_grid(row_count: int | None) -> list[str]:
|
||||
"""Return the row taxonomy for a spritesheet with *row_count* rows."""
|
||||
try:
|
||||
rows = int(row_count or 0)
|
||||
except (TypeError, ValueError):
|
||||
rows = 0
|
||||
|
||||
if rows >= len(CODEX_STATE_ROWS):
|
||||
return CODEX_STATE_ROWS
|
||||
return LEGACY_STATE_ROWS
|
||||
|
||||
|
||||
def state_row_index(state: "PetState | str", row_count: int | None = None) -> int:
|
||||
"""Return the spritesheet row index for *state* (clamped, never raises)."""
|
||||
rows = state_rows_for_grid(row_count)
|
||||
for name in state_aliases_for(state):
|
||||
try:
|
||||
return rows.index(name)
|
||||
except ValueError:
|
||||
continue
|
||||
return 0 # fall back to the idle row
|
||||
29
agent/pet/generate/__init__.py
Normal file
29
agent/pet/generate/__init__.py
Normal file
@ -0,0 +1,29 @@
|
||||
"""Pet generation — base-draft → hatch pipeline.
|
||||
|
||||
Public surface used by the gateway RPCs, the CLI ``hermes pets generate``
|
||||
command, and tests:
|
||||
|
||||
- :func:`generate_base_drafts` / :func:`hatch_pet` — the two-step flow.
|
||||
- :class:`HatchResult`, :class:`GenerationError`.
|
||||
- :mod:`atlas` — deterministic frame extraction + atlas composition/validation.
|
||||
|
||||
Image generation is delegated to the active reference-capable
|
||||
:class:`~agent.image_gen_provider.ImageGenProvider` (OpenAI gpt-image-2 or Krea);
|
||||
atlas assembly is fully deterministic so it's testable without any API calls.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from agent.pet.generate.imagegen import GenerationError
|
||||
from agent.pet.generate.orchestrate import (
|
||||
HatchResult,
|
||||
generate_base_drafts,
|
||||
hatch_pet,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"GenerationError",
|
||||
"HatchResult",
|
||||
"generate_base_drafts",
|
||||
"hatch_pet",
|
||||
]
|
||||
400
agent/pet/generate/atlas.py
Normal file
400
agent/pet/generate/atlas.py
Normal file
@ -0,0 +1,400 @@
|
||||
"""Deterministic spritesheet assembly — generated row strips → Hermes atlas.
|
||||
|
||||
Image-generation models are good at *drawing* a row of poses but bad at exact
|
||||
grid geometry, so the model never owns the atlas layout: it produces one loose
|
||||
horizontal strip per state, and these deterministic ops slice that strip into
|
||||
clean, centered, transparent ``192x208`` cells and pack them into the sheet our
|
||||
renderer reads.
|
||||
|
||||
The atlas is **Hermes-native**, not the petdex/Codex format. Our renderer
|
||||
(:mod:`agent.pet.render`) keys frames as ``rows = states, cols = frames`` using
|
||||
:data:`agent.pet.constants.STATE_ROWS`, so we emit exactly the six states the
|
||||
engine drives — idle, wave, run, failed, review, jump — left-packed with
|
||||
trailing transparent cells (which the renderer trims). Sheet is
|
||||
``COLUMNS*192 x ROWS*208`` (1152x1248).
|
||||
|
||||
The frame-segmentation, fit-to-cell, and transparency-residue logic is adapted
|
||||
from OpenAI's ``hatch-pet`` skill (openai/skills, Apache-2.0).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import logging
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
from agent.pet.constants import FRAME_H, FRAME_W
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CELL_WIDTH = FRAME_W
|
||||
CELL_HEIGHT = FRAME_H
|
||||
|
||||
# (state, row index, frame count). Order/row indices MUST match
|
||||
# ``STATE_ROWS`` so the renderer crops the right row for each driven state.
|
||||
# Frame counts are the petdex-ish per-state lengths; the renderer trims any
|
||||
# trailing blank columns, so rows shorter than ``COLUMNS`` just leave the tail
|
||||
# transparent.
|
||||
ROW_SPECS: list[tuple[str, int, int]] = [
|
||||
("idle", 0, 6),
|
||||
("wave", 1, 4),
|
||||
("run", 2, 6),
|
||||
("failed", 3, 6),
|
||||
("review", 4, 6),
|
||||
("jump", 5, 5),
|
||||
]
|
||||
|
||||
ROWS = len(ROW_SPECS)
|
||||
COLUMNS = max(count for _, _, count in ROW_SPECS)
|
||||
ATLAS_WIDTH = COLUMNS * CELL_WIDTH
|
||||
ATLAS_HEIGHT = ROWS * CELL_HEIGHT
|
||||
|
||||
FRAME_COUNTS: dict[str, int] = {state: count for state, _, count in ROW_SPECS}
|
||||
|
||||
# Alpha at/below which a pixel is "background" for component detection.
|
||||
_ALPHA_FLOOR = 16
|
||||
# Cell padding kept around a fitted sprite so poses never touch the edge.
|
||||
_CELL_PAD = 10
|
||||
|
||||
|
||||
# ───────────────────────── background removal ─────────────────────────
|
||||
|
||||
|
||||
def _color_distance(r: int, g: int, b: int, key: tuple[int, int, int]) -> float:
|
||||
return math.sqrt((r - key[0]) ** 2 + (g - key[1]) ** 2 + (b - key[2]) ** 2)
|
||||
|
||||
|
||||
def _has_transparency(image) -> bool:
|
||||
"""True if the strip already carries a real alpha background."""
|
||||
extrema = image.getchannel("A").getextrema()
|
||||
# Min alpha 0 somewhere and a meaningful share of fully-transparent pixels.
|
||||
if extrema[0] > _ALPHA_FLOOR:
|
||||
return False
|
||||
hist = image.getchannel("A").histogram()
|
||||
transparent = sum(hist[: _ALPHA_FLOOR + 1])
|
||||
total = image.width * image.height
|
||||
return transparent > total * 0.05
|
||||
|
||||
|
||||
def _dominant_corner_color(image) -> tuple[int, int, int]:
|
||||
"""Sample the four corners and return the most common opaque color."""
|
||||
from collections import Counter
|
||||
|
||||
w, h = image.width, image.height
|
||||
px = image.load()
|
||||
counter: Counter = Counter()
|
||||
for x, y in ((0, 0), (w - 1, 0), (0, h - 1), (w - 1, h - 1)):
|
||||
r, g, b, a = px[x, y]
|
||||
if a > _ALPHA_FLOOR:
|
||||
counter[(r, g, b)] += 1
|
||||
if not counter:
|
||||
return (0, 255, 0)
|
||||
return counter.most_common(1)[0][0]
|
||||
|
||||
|
||||
def remove_background(image, *, chroma_key: tuple[int, int, int] | None = None, threshold: float = 110.0):
|
||||
"""Return *image* (RGBA) with its flat background keyed out to transparent.
|
||||
|
||||
If the strip already has a transparent background we leave it alone; else we
|
||||
key out *chroma_key* (or the dominant corner color when not given). This
|
||||
handles both providers that emit transparency natively and those that paint
|
||||
a solid backdrop.
|
||||
"""
|
||||
rgba = image.convert("RGBA")
|
||||
if _has_transparency(rgba):
|
||||
return rgba
|
||||
|
||||
key = chroma_key or _dominant_corner_color(rgba)
|
||||
px = rgba.load()
|
||||
for y in range(rgba.height):
|
||||
for x in range(rgba.width):
|
||||
r, g, b, a = px[x, y]
|
||||
if a > _ALPHA_FLOOR and _color_distance(r, g, b, key) <= threshold:
|
||||
px[x, y] = (0, 0, 0, 0)
|
||||
return rgba
|
||||
|
||||
|
||||
# ───────────────────────── frame extraction ─────────────────────────
|
||||
|
||||
|
||||
def _fit_to_cell(image):
|
||||
"""Crop to content, scale to fit a padded cell, and center on transparent."""
|
||||
from PIL import Image
|
||||
|
||||
target = Image.new("RGBA", (CELL_WIDTH, CELL_HEIGHT), (0, 0, 0, 0))
|
||||
bbox = image.getbbox()
|
||||
if bbox is None:
|
||||
return target
|
||||
|
||||
sprite = image.crop(bbox)
|
||||
max_w = CELL_WIDTH - _CELL_PAD
|
||||
max_h = CELL_HEIGHT - _CELL_PAD
|
||||
scale = min(max_w / sprite.width, max_h / sprite.height, 1.0)
|
||||
if scale != 1.0:
|
||||
sprite = sprite.resize(
|
||||
(max(1, round(sprite.width * scale)), max(1, round(sprite.height * scale))),
|
||||
Image.Resampling.LANCZOS,
|
||||
)
|
||||
left = (CELL_WIDTH - sprite.width) // 2
|
||||
top = (CELL_HEIGHT - sprite.height) // 2
|
||||
target.alpha_composite(sprite, (left, top))
|
||||
return target
|
||||
|
||||
|
||||
def _connected_components(image) -> list[dict]:
|
||||
"""Flood-fill the alpha mask into connected blobs (4-connectivity)."""
|
||||
alpha = image.getchannel("A")
|
||||
w, h = image.size
|
||||
data = alpha.tobytes()
|
||||
visited = bytearray(w * h)
|
||||
out: list[dict] = []
|
||||
|
||||
for start, a in enumerate(data):
|
||||
if a <= _ALPHA_FLOOR or visited[start]:
|
||||
continue
|
||||
stack = [start]
|
||||
visited[start] = 1
|
||||
pixels: list[int] = []
|
||||
min_x = w
|
||||
min_y = h
|
||||
max_x = 0
|
||||
max_y = 0
|
||||
while stack:
|
||||
cur = stack.pop()
|
||||
pixels.append(cur)
|
||||
x = cur % w
|
||||
y = cur // w
|
||||
min_x = min(min_x, x)
|
||||
min_y = min(min_y, y)
|
||||
max_x = max(max_x, x)
|
||||
max_y = max(max_y, y)
|
||||
for nb, ok in (
|
||||
(cur - 1, x > 0),
|
||||
(cur + 1, x + 1 < w),
|
||||
(cur - w, y > 0),
|
||||
(cur + w, y + 1 < h),
|
||||
):
|
||||
if ok and not visited[nb] and data[nb] > _ALPHA_FLOOR:
|
||||
visited[nb] = 1
|
||||
stack.append(nb)
|
||||
out.append(
|
||||
{
|
||||
"pixels": pixels,
|
||||
"area": len(pixels),
|
||||
"bbox": (min_x, min_y, max_x + 1, max_y + 1),
|
||||
"center_x": (min_x + max_x + 1) / 2,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _group_image(source, components: list[dict], padding: int = 4):
|
||||
from PIL import Image
|
||||
|
||||
w, h = source.size
|
||||
min_x = max(0, min(c["bbox"][0] for c in components) - padding)
|
||||
min_y = max(0, min(c["bbox"][1] for c in components) - padding)
|
||||
max_x = min(w, max(c["bbox"][2] for c in components) + padding)
|
||||
max_y = min(h, max(c["bbox"][3] for c in components) + padding)
|
||||
|
||||
out = Image.new("RGBA", (max_x - min_x, max_y - min_y), (0, 0, 0, 0))
|
||||
src_px = source.load()
|
||||
out_px = out.load()
|
||||
for c in components:
|
||||
for idx in c["pixels"]:
|
||||
x = idx % w
|
||||
y = idx // w
|
||||
out_px[x - min_x, y - min_y] = src_px[x, y]
|
||||
return out
|
||||
|
||||
|
||||
def _component_frames(strip, frame_count: int) -> list | None:
|
||||
"""Segment a strip into *frame_count* sprites by connected components.
|
||||
|
||||
Picks the ``frame_count`` largest blobs as seeds (left→right), attaches
|
||||
smaller blobs to the nearest seed, and returns one fitted cell per group.
|
||||
Returns ``None`` when it can't find enough distinct sprites (caller falls
|
||||
back to equal slicing).
|
||||
"""
|
||||
components = _connected_components(strip)
|
||||
if not components:
|
||||
return None
|
||||
|
||||
largest = max(c["area"] for c in components)
|
||||
seed_threshold = max(120, largest * 0.20)
|
||||
seeds = [c for c in components if c["area"] >= seed_threshold]
|
||||
if len(seeds) < frame_count:
|
||||
seeds = sorted(components, key=lambda c: c["area"], reverse=True)[:frame_count]
|
||||
if len(seeds) < frame_count:
|
||||
return None
|
||||
|
||||
seeds = sorted(
|
||||
sorted(seeds, key=lambda c: c["area"], reverse=True)[:frame_count],
|
||||
key=lambda c: c["center_x"],
|
||||
)
|
||||
seed_ids = {id(s) for s in seeds}
|
||||
groups: list[list[dict]] = [[s] for s in seeds]
|
||||
noise_threshold = max(12, largest * 0.002)
|
||||
for c in components:
|
||||
if id(c) in seed_ids or c["area"] < noise_threshold:
|
||||
continue
|
||||
nearest = min(range(len(seeds)), key=lambda i: abs(seeds[i]["center_x"] - c["center_x"]))
|
||||
groups[nearest].append(c)
|
||||
|
||||
return [_fit_to_cell(_group_image(strip, g)) for g in groups]
|
||||
|
||||
|
||||
def _slot_frames(strip, frame_count: int) -> list:
|
||||
"""Fallback: slice the strip into *frame_count* equal columns."""
|
||||
slot = strip.width / frame_count
|
||||
frames = []
|
||||
for i in range(frame_count):
|
||||
left = round(i * slot)
|
||||
right = round((i + 1) * slot)
|
||||
frames.append(_fit_to_cell(strip.crop((left, 0, right, strip.height))))
|
||||
return frames
|
||||
|
||||
|
||||
def extract_strip_frames(
|
||||
strip,
|
||||
frame_count: int,
|
||||
*,
|
||||
chroma_key: tuple[int, int, int] | None = None,
|
||||
method: str = "auto",
|
||||
) -> list:
|
||||
"""Turn one generated row strip into *frame_count* clean 192x208 cells.
|
||||
|
||||
*strip* is a PIL image (or path). Background is keyed out, then frames are
|
||||
found by connected components (``auto``) with an equal-slot fallback.
|
||||
"""
|
||||
from PIL import Image
|
||||
|
||||
if isinstance(strip, (str, Path)):
|
||||
with Image.open(strip) as opened:
|
||||
strip = opened.convert("RGBA")
|
||||
else:
|
||||
strip = strip.convert("RGBA")
|
||||
|
||||
strip = remove_background(strip, chroma_key=chroma_key)
|
||||
|
||||
if method in ("auto", "components"):
|
||||
frames = _component_frames(strip, frame_count)
|
||||
if frames is not None:
|
||||
return frames
|
||||
if method == "components":
|
||||
raise ValueError(f"could not segment {frame_count} sprites from strip")
|
||||
return _slot_frames(strip, frame_count)
|
||||
|
||||
|
||||
# ───────────────────────── atlas composition ─────────────────────────
|
||||
|
||||
|
||||
def single_frame(image):
|
||||
"""One fitted 192x208 cell from a standalone image (e.g. the base look).
|
||||
|
||||
Used as an idle fallback so a pet always renders even if the idle row
|
||||
generation failed.
|
||||
"""
|
||||
from PIL import Image
|
||||
|
||||
if isinstance(image, (str, Path)):
|
||||
with Image.open(image) as opened:
|
||||
image = opened.convert("RGBA")
|
||||
return _fit_to_cell(remove_background(image))
|
||||
|
||||
|
||||
def _clear_transparent_rgb(image):
|
||||
"""Zero the RGB of fully-transparent pixels (no colored-halo residue)."""
|
||||
from PIL import Image
|
||||
|
||||
rgba = image.convert("RGBA")
|
||||
data = bytearray(rgba.tobytes())
|
||||
for i in range(0, len(data), 4):
|
||||
if data[i + 3] == 0:
|
||||
data[i] = data[i + 1] = data[i + 2] = 0
|
||||
return Image.frombytes("RGBA", rgba.size, bytes(data))
|
||||
|
||||
|
||||
def compose_atlas(frames_by_state: dict[str, list]):
|
||||
"""Pack per-state frame lists into the Hermes atlas (RGBA, residue-cleared).
|
||||
|
||||
Missing/short states leave their trailing cells transparent; extra frames
|
||||
beyond a state's spec are dropped.
|
||||
"""
|
||||
from PIL import Image
|
||||
|
||||
atlas = Image.new("RGBA", (ATLAS_WIDTH, ATLAS_HEIGHT), (0, 0, 0, 0))
|
||||
for state, row, count in ROW_SPECS:
|
||||
frames = frames_by_state.get(state) or []
|
||||
for col, frame in enumerate(frames[:count]):
|
||||
cell = frame.convert("RGBA")
|
||||
if cell.size != (CELL_WIDTH, CELL_HEIGHT):
|
||||
cell = _fit_to_cell(cell)
|
||||
atlas.alpha_composite(cell, (col * CELL_WIDTH, row * CELL_HEIGHT))
|
||||
return _clear_transparent_rgb(atlas)
|
||||
|
||||
|
||||
def atlas_to_webp_bytes(atlas) -> bytes:
|
||||
"""Encode an atlas image to lossless WebP bytes (the on-disk pet format)."""
|
||||
buf = io.BytesIO()
|
||||
atlas.save(buf, format="WEBP", lossless=True, quality=100, method=6, exact=True)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def validate_atlas(atlas) -> dict:
|
||||
"""Check geometry, per-cell occupancy, and transparency invariants.
|
||||
|
||||
Returns ``{ok, width, height, errors, warnings, filled_states}``. Errors are
|
||||
blockers (wrong size, empty used cell, opaque/dirty transparency); warnings
|
||||
are soft (a whole state row blank — generation likely dropped a row).
|
||||
"""
|
||||
from PIL import Image
|
||||
|
||||
if isinstance(atlas, (str, Path)):
|
||||
with Image.open(atlas) as opened:
|
||||
atlas = opened.convert("RGBA")
|
||||
else:
|
||||
atlas = atlas.convert("RGBA")
|
||||
|
||||
errors: list[str] = []
|
||||
warnings: list[str] = []
|
||||
|
||||
if atlas.size != (ATLAS_WIDTH, ATLAS_HEIGHT):
|
||||
errors.append(f"expected {ATLAS_WIDTH}x{ATLAS_HEIGHT}, got {atlas.width}x{atlas.height}")
|
||||
return {"ok": False, "width": atlas.width, "height": atlas.height, "errors": errors, "warnings": warnings, "filled_states": []}
|
||||
|
||||
filled_states: list[str] = []
|
||||
for state, row, count in ROW_SPECS:
|
||||
row_pixels = 0
|
||||
for col in range(count):
|
||||
left = col * CELL_WIDTH
|
||||
top = row * CELL_HEIGHT
|
||||
cell = atlas.crop((left, top, left + CELL_WIDTH, top + CELL_HEIGHT))
|
||||
nonblank = sum(cell.getchannel("A").histogram()[1:])
|
||||
row_pixels += nonblank
|
||||
if row_pixels > 0:
|
||||
filled_states.append(state)
|
||||
else:
|
||||
warnings.append(f"state '{state}' has no frames")
|
||||
|
||||
if not filled_states:
|
||||
errors.append("atlas is empty — no state produced any frames")
|
||||
|
||||
# Transparent pixels must carry zero RGB (no halo residue).
|
||||
data = atlas.tobytes()
|
||||
residue = 0
|
||||
for i in range(0, len(data), 4):
|
||||
if data[i + 3] == 0 and (data[i] or data[i + 1] or data[i + 2]):
|
||||
residue += 1
|
||||
if residue:
|
||||
errors.append(f"{residue} transparent pixels retain RGB residue")
|
||||
|
||||
return {
|
||||
"ok": not errors,
|
||||
"width": atlas.width,
|
||||
"height": atlas.height,
|
||||
"errors": errors,
|
||||
"warnings": warnings,
|
||||
"filled_states": filled_states,
|
||||
}
|
||||
168
agent/pet/generate/imagegen.py
Normal file
168
agent/pet/generate/imagegen.py
Normal file
@ -0,0 +1,168 @@
|
||||
"""Thin image-generation layer for pet sprites.
|
||||
|
||||
Wraps the active :class:`~agent.image_gen_provider.ImageGenProvider` with the
|
||||
two things sprite generation needs that the agent-facing ``image_generate`` tool
|
||||
doesn't expose: **N variants** (loop) and **reference-image grounding** (so each
|
||||
animation row stays the same character as the chosen base).
|
||||
|
||||
Reference grounding only works on providers that support it — currently OpenAI
|
||||
``gpt-image-2`` (image edits) and Krea (style references). We resolve to one of
|
||||
those and surface a clear, actionable error otherwise rather than silently
|
||||
producing an ungrounded, drifting pet.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Providers that can ground generation on a reference image.
|
||||
_REF_CAPABLE = ("openai", "openai-codex", "krea")
|
||||
|
||||
|
||||
class GenerationError(RuntimeError):
|
||||
"""Raised on any image-generation failure (no provider, API error, IO)."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SpriteProvider:
|
||||
"""Resolved provider plus whether it can take reference images."""
|
||||
|
||||
name: str
|
||||
provider: object
|
||||
supports_references: bool
|
||||
|
||||
|
||||
def _discover() -> None:
|
||||
try:
|
||||
from hermes_cli.plugins import _ensure_plugins_discovered
|
||||
|
||||
_ensure_plugins_discovered()
|
||||
except Exception as exc: # noqa: BLE001 - discovery is best-effort
|
||||
logger.debug("image-gen plugin discovery failed: %s", exc)
|
||||
|
||||
|
||||
def resolve_provider(*, require_references: bool = True) -> SpriteProvider:
|
||||
"""Pick the image provider to use for sprite work.
|
||||
|
||||
Preference: the configured provider when it's reference-capable, else the
|
||||
first available reference-capable provider. With *require_references* off we
|
||||
fall back to any available provider (used for prompt-only base drafts).
|
||||
"""
|
||||
_discover()
|
||||
from agent.image_gen_registry import get_active_provider, get_provider
|
||||
|
||||
# Configured / active provider first.
|
||||
active = None
|
||||
try:
|
||||
active = get_active_provider()
|
||||
except Exception: # noqa: BLE001
|
||||
active = None
|
||||
if active is not None:
|
||||
name = getattr(active, "name", "")
|
||||
if name in _REF_CAPABLE and active.is_available():
|
||||
return SpriteProvider(name=name, provider=active, supports_references=True)
|
||||
|
||||
# Any available reference-capable provider.
|
||||
for name in _REF_CAPABLE:
|
||||
provider = get_provider(name)
|
||||
if provider is not None and provider.is_available():
|
||||
return SpriteProvider(name=name, provider=provider, supports_references=True)
|
||||
|
||||
if not require_references and active is not None and active.is_available():
|
||||
return SpriteProvider(
|
||||
name=getattr(active, "name", "unknown"), provider=active, supports_references=False
|
||||
)
|
||||
|
||||
raise GenerationError(
|
||||
"Pet generation needs a reference-capable image backend. "
|
||||
"Run `hermes tools` → Image Generation → OpenAI (gpt-image-2) and add an "
|
||||
"OpenAI API key (or configure Krea)."
|
||||
)
|
||||
|
||||
|
||||
def _save_local(image_ref: str, *, prefix: str) -> Path:
|
||||
"""Return a local path for *image_ref*, downloading it if it's a URL."""
|
||||
if image_ref.startswith(("http://", "https://")):
|
||||
from agent.image_gen_provider import save_url_image
|
||||
|
||||
return Path(save_url_image(image_ref, prefix=prefix))
|
||||
return Path(image_ref)
|
||||
|
||||
|
||||
def _rejected_background(error: str) -> bool:
|
||||
"""True when a provider error is specifically about the ``background`` param.
|
||||
|
||||
Transparent backgrounds are a per-model capability (e.g. some gpt-image tiers
|
||||
reject ``background=transparent`` outright). We detect that one rejection so
|
||||
we can retry without the flag rather than failing the whole pet — our chroma
|
||||
key pass makes the result transparent regardless.
|
||||
"""
|
||||
lowered = (error or "").lower()
|
||||
return "background" in lowered and ("not supported" in lowered or "transparent" in lowered)
|
||||
|
||||
|
||||
def generate(
|
||||
prompt: str,
|
||||
*,
|
||||
n: int = 1,
|
||||
reference_images: list[Path] | None = None,
|
||||
provider: SpriteProvider | None = None,
|
||||
prefix: str = "pet_gen",
|
||||
) -> list[Path]:
|
||||
"""Generate *n* square sprite images and return their local paths.
|
||||
|
||||
*reference_images* grounds the output on a base image (required for rows).
|
||||
We *ask* for a transparent background, but fall back to an opaque generation
|
||||
(cleaned up downstream by the chroma-key pass) on models that reject the
|
||||
flag. Raises :class:`GenerationError` if nothing usable comes back.
|
||||
"""
|
||||
sprite = provider or resolve_provider(require_references=bool(reference_images))
|
||||
if reference_images and not sprite.supports_references:
|
||||
raise GenerationError(
|
||||
f"image backend '{sprite.name}' cannot use reference images; "
|
||||
"configure OpenAI gpt-image-2 or Krea for pet generation"
|
||||
)
|
||||
|
||||
refs = [str(p) for p in (reference_images or [])]
|
||||
|
||||
def _run(extra: dict) -> tuple[Path | None, str]:
|
||||
kwargs: dict = {"aspect_ratio": "square", **extra}
|
||||
if refs:
|
||||
kwargs["reference_images"] = refs
|
||||
try:
|
||||
result = sprite.provider.generate(prompt, **kwargs)
|
||||
except Exception as exc: # noqa: BLE001 - normalize provider crashes
|
||||
logger.debug("provider.generate crashed: %s", exc)
|
||||
return None, str(exc)
|
||||
if not isinstance(result, dict) or not result.get("success"):
|
||||
return None, (result or {}).get("error", "unknown error") if isinstance(result, dict) else "no result"
|
||||
image_ref = result.get("image")
|
||||
if not image_ref:
|
||||
return None, "provider returned no image"
|
||||
try:
|
||||
return _save_local(str(image_ref), prefix=prefix), ""
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return None, f"could not save generated image: {exc}"
|
||||
|
||||
out: list[Path] = []
|
||||
last_error = ""
|
||||
allow_transparent = True
|
||||
for _ in range(max(1, n)):
|
||||
path, err = _run({"background": "transparent"} if allow_transparent else {})
|
||||
# Model doesn't support the transparent flag → drop it for this and every
|
||||
# remaining variant (no point re-probing a capability we just disproved).
|
||||
if path is None and allow_transparent and _rejected_background(err):
|
||||
allow_transparent = False
|
||||
path, err = _run({})
|
||||
if path is not None:
|
||||
out.append(path)
|
||||
else:
|
||||
last_error = err
|
||||
|
||||
if not out:
|
||||
raise GenerationError(last_error or "image generation produced no output")
|
||||
return out
|
||||
149
agent/pet/generate/orchestrate.py
Normal file
149
agent/pet/generate/orchestrate.py
Normal file
@ -0,0 +1,149 @@
|
||||
"""Pet generation orchestration — the base-draft → hatch flow.
|
||||
|
||||
Two steps, mirroring the UX across every surface:
|
||||
|
||||
1. :func:`generate_base_drafts` — a handful of prompt-only "what should this pet
|
||||
look like" variants. Cheap; the user picks one (or retries for a fresh set).
|
||||
2. :func:`hatch_pet` — takes the chosen base and generates one grounded row
|
||||
strip per Hermes state, slices each into frames, composes the atlas, validates
|
||||
it, and writes the pet into the store.
|
||||
|
||||
Splitting it this way bounds cost (4 cheap base calls per round; the ~6 row
|
||||
calls happen once, on the pet you actually keep) and gives each UI a natural
|
||||
preview/loading point.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
from agent.pet.generate import atlas, imagegen, prompts
|
||||
from agent.pet.generate.imagegen import GenerationError, SpriteProvider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# (event, detail) — e.g. ("row", "idle"), ("compose", ""), ("save", "<slug>").
|
||||
ProgressFn = Callable[[str, str], None]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HatchResult:
|
||||
"""Outcome of a successful :func:`hatch_pet`."""
|
||||
|
||||
slug: str
|
||||
display_name: str
|
||||
spritesheet: Path
|
||||
states: list[str]
|
||||
validation: dict
|
||||
|
||||
|
||||
def _harden_transparency(path: Path) -> Path:
|
||||
"""Key out any solid backdrop the provider painted; save as an RGBA PNG.
|
||||
|
||||
``background=transparent`` is requested on every call, but image models honor
|
||||
it inconsistently — some still paint a flat (often near-white) backdrop. We
|
||||
run the same chroma-key pass the row extractor uses so every base draft the
|
||||
user picks between (and the reference the rows are grounded on) is a clean
|
||||
cutout. Best-effort: a decode failure leaves the original untouched.
|
||||
"""
|
||||
from PIL import Image
|
||||
|
||||
try:
|
||||
with Image.open(path) as opened:
|
||||
keyed = atlas.remove_background(opened.convert("RGBA"))
|
||||
out = path.with_suffix(".png")
|
||||
keyed.save(out, format="PNG")
|
||||
return out
|
||||
except Exception as exc: # noqa: BLE001 - cosmetic; fall back to the raw image
|
||||
logger.debug("base draft transparency hardening failed for %s: %s", path, exc)
|
||||
return path
|
||||
|
||||
|
||||
def generate_base_drafts(
|
||||
concept: str,
|
||||
*,
|
||||
n: int = 4,
|
||||
style: str = "auto",
|
||||
provider: SpriteProvider | None = None,
|
||||
) -> list[Path]:
|
||||
"""Generate *n* candidate base looks for *concept*; returns image paths.
|
||||
|
||||
Each draft is hardened to a transparent cutout (see :func:`_harden_transparency`).
|
||||
"""
|
||||
prompt = prompts.build_base_prompt(concept, style=style)
|
||||
sprite = provider or imagegen.resolve_provider(require_references=False)
|
||||
raw = imagegen.generate(prompt, n=n, provider=sprite, prefix="pet_base")
|
||||
return [_harden_transparency(p) for p in raw]
|
||||
|
||||
|
||||
def hatch_pet(
|
||||
*,
|
||||
base_image: str | Path,
|
||||
slug: str,
|
||||
display_name: str = "",
|
||||
description: str = "",
|
||||
concept: str = "",
|
||||
style: str = "auto",
|
||||
on_progress: ProgressFn | None = None,
|
||||
provider: SpriteProvider | None = None,
|
||||
) -> HatchResult:
|
||||
"""Turn an approved base image into a full, installed Hermes pet.
|
||||
|
||||
Generates a grounded row strip per state, extracts frames, composes +
|
||||
validates the atlas, and registers it. The idle row falls back to the base
|
||||
look so the pet always renders. Raises :class:`GenerationError` on failure.
|
||||
"""
|
||||
base = Path(base_image)
|
||||
if not base.is_file():
|
||||
raise GenerationError(f"base image not found: {base}")
|
||||
|
||||
sprite = provider or imagegen.resolve_provider(require_references=True)
|
||||
progress = on_progress or (lambda *_: None)
|
||||
label = concept or display_name or slug
|
||||
|
||||
frames_by_state: dict[str, list] = {}
|
||||
for state, _row, count in atlas.ROW_SPECS:
|
||||
progress("row", state)
|
||||
row_prompt = prompts.build_row_prompt(state, count, label, style=style)
|
||||
try:
|
||||
strips = imagegen.generate(
|
||||
row_prompt,
|
||||
n=1,
|
||||
reference_images=[base],
|
||||
provider=sprite,
|
||||
prefix=f"pet_row_{state}",
|
||||
)
|
||||
frames_by_state[state] = atlas.extract_strip_frames(strips[0], count, method="auto")
|
||||
except Exception as exc: # noqa: BLE001 - a single row may fail; keep going
|
||||
logger.warning("pet row '%s' failed: %s", state, exc)
|
||||
|
||||
# Idle is the resting state the renderer falls back to — guarantee it.
|
||||
if not frames_by_state.get("idle"):
|
||||
progress("row", "idle-fallback")
|
||||
frames_by_state["idle"] = [atlas.single_frame(base)]
|
||||
|
||||
progress("compose", "")
|
||||
sheet = atlas.compose_atlas(frames_by_state)
|
||||
validation = atlas.validate_atlas(sheet)
|
||||
if not validation["ok"]:
|
||||
raise GenerationError("; ".join(validation["errors"]) or "atlas validation failed")
|
||||
|
||||
from agent.pet import store
|
||||
|
||||
progress("save", slug)
|
||||
pet = store.register_local_pet(
|
||||
sheet,
|
||||
slug=slug,
|
||||
display_name=display_name or slug,
|
||||
description=description,
|
||||
)
|
||||
return HatchResult(
|
||||
slug=pet.slug,
|
||||
display_name=pet.display_name,
|
||||
spritesheet=pet.spritesheet,
|
||||
states=validation["filled_states"],
|
||||
validation=validation,
|
||||
)
|
||||
74
agent/pet/generate/prompts.py
Normal file
74
agent/pet/generate/prompts.py
Normal file
@ -0,0 +1,74 @@
|
||||
"""Prompt builders for pet generation.
|
||||
|
||||
Two prompt shapes: a *base* prompt (prompt-only, produces the canonical look the
|
||||
user picks between) and per-*state* *row* prompts (grounded on the chosen base,
|
||||
produce one horizontal strip of N poses). Prompts stay concise and
|
||||
sprite-production oriented; the identity lock and "one transparent row" framing
|
||||
matter more than flowery description.
|
||||
|
||||
Hermes drives six states (see :data:`agent.pet.generate.atlas.ROW_SPECS`); these
|
||||
mirror that set rather than the petdex/Codex nine.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# What each Hermes state should depict (kept short — these go straight into the
|
||||
# row prompt). Phrased to avoid the common sprite-gen failure modes (detached
|
||||
# effects, motion lines, shadows).
|
||||
STATE_ACTIONS: dict[str, str] = {
|
||||
"idle": "a calm idle loop: subtle breathing, a tiny blink or gentle bob, no big gestures",
|
||||
"wave": "a friendly greeting: raising a paw/hand/limb to wave, clear up-and-down gesture",
|
||||
"run": "focused active work: leaning in, concentrating, busy 'thinking/processing' energy (NOT foot-running)",
|
||||
"failed": "a sad or deflated reaction: slumped, dejected, small frown — readable but not noisy",
|
||||
"review": "careful inspection: a focused lean, head tilt, studying something intently",
|
||||
"jump": "a happy celebration jump: anticipation, lift off the ground, peak, and land",
|
||||
}
|
||||
|
||||
_STYLE_HINTS: dict[str, str] = {
|
||||
"auto": "",
|
||||
"pixel": " Render in clean pixel-art style.",
|
||||
"plush": " Render as a soft plush toy.",
|
||||
"clay": " Render as a claymation / soft 3D clay figure.",
|
||||
"sticker": " Render as a glossy die-cut sticker.",
|
||||
"flat-vector": " Render in flat vector mascot style.",
|
||||
"3d-toy": " Render as a glossy 3D toy.",
|
||||
"painterly": " Render in a soft painterly style.",
|
||||
}
|
||||
|
||||
_BACKGROUND = (
|
||||
"Center one full-body character on a fully transparent background. "
|
||||
"No text, no labels, no shadow, no ground line, no scenery, no frame, no border."
|
||||
)
|
||||
|
||||
|
||||
def style_hint(style: str | None) -> str:
|
||||
return _STYLE_HINTS.get((style or "auto").strip().lower(), "")
|
||||
|
||||
|
||||
def build_base_prompt(concept: str, *, style: str | None = "auto") -> str:
|
||||
"""The base look: a single, clean, centered full-body mascot."""
|
||||
concept = (concept or "a cute friendly mascot creature").strip()
|
||||
return (
|
||||
f"A cute, characterful mascot pet: {concept}. "
|
||||
"Compact, whole-body silhouette that reads clearly at small size, "
|
||||
"appealing face, simple consistent palette. "
|
||||
f"{_BACKGROUND}{style_hint(style)}"
|
||||
)
|
||||
|
||||
|
||||
def build_row_prompt(state: str, frame_count: int, concept: str, *, style: str | None = "auto") -> str:
|
||||
"""A row strip: *frame_count* poses of the SAME character, left→right.
|
||||
|
||||
The attached base image is the identity source of truth; the prompt locks
|
||||
species, palette, face, and props to it.
|
||||
"""
|
||||
action = STATE_ACTIONS.get(state, "a simple idle pose")
|
||||
concept = (concept or "the mascot").strip()
|
||||
return (
|
||||
f"Using the attached reference image as the exact same character "
|
||||
f"(same species, face, colors, markings, proportions, and props), "
|
||||
f"draw a single horizontal strip of {frame_count} animation frames showing {action}. "
|
||||
f"The {frame_count} poses must be evenly spaced left to right, each fully separated "
|
||||
"(not overlapping), same size and baseline, forming a smooth loop. "
|
||||
f"Keep the character identical across all frames. {_BACKGROUND}{style_hint(style)}"
|
||||
)
|
||||
128
agent/pet/manifest.py
Normal file
128
agent/pet/manifest.py
Normal file
@ -0,0 +1,128 @@
|
||||
"""Fetch the public petdex manifest.
|
||||
|
||||
``https://petdex.dev/api/manifest`` 307-redirects to a JSON document on R2:
|
||||
|
||||
{
|
||||
"generatedAt": "...",
|
||||
"total": 2926,
|
||||
"pets": [
|
||||
{"slug": "boba", "displayName": "Boba", "kind": "creature",
|
||||
"submittedBy": "railly",
|
||||
"spritesheetUrl": "https://assets.petdex.dev/.../spritesheet.webp",
|
||||
"petJsonUrl": "https://assets.petdex.dev/.../pet.json",
|
||||
"zipUrl": "https://assets.petdex.dev/.../boba.zip"},
|
||||
...
|
||||
]
|
||||
}
|
||||
|
||||
Read-only and unauthenticated; no credentials involved.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MANIFEST_URL = "https://petdex.dev/api/manifest"
|
||||
|
||||
_DEFAULT_TIMEOUT = 20.0
|
||||
|
||||
# In-process cache for the (large, slow, identical-per-call) manifest. The list
|
||||
# is a static CDN object that barely changes, yet a single session can ask for
|
||||
# it many times — every gallery open, plus a full re-fetch per install/select
|
||||
# (``find_entry``). A short TTL collapses those into one network hit without
|
||||
# going stale for long. Cleared by :func:`clear_cache` (tests).
|
||||
_MANIFEST_TTL = 300.0
|
||||
_cache: tuple[float, list[ManifestEntry]] | None = None
|
||||
|
||||
|
||||
def clear_cache() -> None:
|
||||
"""Drop the cached manifest (forces the next fetch to hit the network)."""
|
||||
global _cache
|
||||
_cache = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ManifestEntry:
|
||||
"""A single pet's row in the manifest."""
|
||||
|
||||
slug: str
|
||||
display_name: str
|
||||
kind: str
|
||||
submitted_by: str
|
||||
spritesheet_url: str
|
||||
pet_json_url: str
|
||||
zip_url: str
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "ManifestEntry":
|
||||
return cls(
|
||||
slug=str(data.get("slug", "")).strip(),
|
||||
display_name=str(data.get("displayName", "") or data.get("slug", "")),
|
||||
kind=str(data.get("kind", "") or "pet"),
|
||||
submitted_by=str(data.get("submittedBy", "") or ""),
|
||||
spritesheet_url=str(data.get("spritesheetUrl", "") or ""),
|
||||
pet_json_url=str(data.get("petJsonUrl", "") or ""),
|
||||
zip_url=str(data.get("zipUrl", "") or ""),
|
||||
)
|
||||
|
||||
|
||||
class ManifestError(RuntimeError):
|
||||
"""Raised when the manifest can't be fetched or parsed."""
|
||||
|
||||
|
||||
def fetch_manifest(*, timeout: float = _DEFAULT_TIMEOUT, force: bool = False) -> list[ManifestEntry]:
|
||||
"""Return every approved pet from the public manifest.
|
||||
|
||||
Cached in-process for ``_MANIFEST_TTL`` seconds (pass ``force=True`` to
|
||||
bypass). Follows the 307 redirect to R2. Raises :class:`ManifestError` on
|
||||
any network/parse failure so callers can surface a clean message.
|
||||
"""
|
||||
global _cache
|
||||
|
||||
if not force and _cache is not None and time.monotonic() - _cache[0] < _MANIFEST_TTL:
|
||||
return _cache[1]
|
||||
|
||||
try:
|
||||
import httpx
|
||||
except ImportError as exc: # pragma: no cover - httpx is a core dep
|
||||
raise ManifestError("httpx is required to fetch the petdex manifest") from exc
|
||||
|
||||
try:
|
||||
resp = httpx.get(
|
||||
MANIFEST_URL,
|
||||
timeout=timeout,
|
||||
follow_redirects=True,
|
||||
headers={"User-Agent": "hermes-agent-petdex"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
payload = resp.json()
|
||||
except Exception as exc: # noqa: BLE001 - normalize to one error type
|
||||
raise ManifestError(f"could not fetch petdex manifest: {exc}") from exc
|
||||
|
||||
pets = payload.get("pets") if isinstance(payload, dict) else None
|
||||
if not isinstance(pets, list):
|
||||
raise ManifestError("petdex manifest had no 'pets' array")
|
||||
|
||||
entries: list[ManifestEntry] = []
|
||||
for raw in pets:
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
entry = ManifestEntry.from_dict(raw)
|
||||
if entry.slug and entry.spritesheet_url:
|
||||
entries.append(entry)
|
||||
|
||||
_cache = (time.monotonic(), entries)
|
||||
return entries
|
||||
|
||||
|
||||
def find_entry(slug: str, *, timeout: float = _DEFAULT_TIMEOUT) -> ManifestEntry | None:
|
||||
"""Return the manifest entry for *slug*, or ``None`` if not listed."""
|
||||
slug = slug.strip().lower()
|
||||
for entry in fetch_manifest(timeout=timeout):
|
||||
if entry.slug.lower() == slug:
|
||||
return entry
|
||||
return None
|
||||
618
agent/pet/render.py
Normal file
618
agent/pet/render.py
Normal file
@ -0,0 +1,618 @@
|
||||
"""Decode a pet spritesheet and encode frames for a terminal.
|
||||
|
||||
Shared by the base CLI (writes the escape bytes to its own stdout) and the
|
||||
TUI (``tui_gateway`` ships the encoded bytes to Ink, which writes them) so the
|
||||
decode + capability-detection + protocol-encoding logic exists exactly once.
|
||||
|
||||
Supported output modes, in fidelity order:
|
||||
|
||||
- ``kitty`` — the kitty graphics protocol (kitty, Ghostty, WezTerm).
|
||||
- ``iterm`` — iTerm2 inline images (iTerm2, WezTerm).
|
||||
- ``sixel`` — DEC sixel (xterm -ti vt340, foot, mlterm, WezTerm, …).
|
||||
- ``unicode`` — 24-bit half-block downscale; works in any truecolor terminal.
|
||||
|
||||
Frame decoding requires Pillow (a core Hermes dependency). If Pillow or the
|
||||
spritesheet is unavailable the renderer degrades to ``unicode`` text or an
|
||||
empty string rather than raising.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
from agent.pet.constants import (
|
||||
DEFAULT_SCALE,
|
||||
FRAME_H,
|
||||
FRAME_W,
|
||||
FRAMES_PER_STATE,
|
||||
PetState,
|
||||
state_row_index,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Public render-mode names accepted by ``display.pet.render_mode``.
|
||||
RENDER_MODES = ("auto", "kitty", "iterm", "sixel", "unicode", "off")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# Terminal capability detection
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def detect_terminal_graphics() -> str:
|
||||
"""Best-effort detection of the richest graphics protocol available.
|
||||
|
||||
Env-based (non-blocking — we never issue a DA1/terminal query that could
|
||||
hang a pipe). Returns one of ``kitty`` / ``iterm`` / ``sixel`` /
|
||||
``unicode``. Conservative: unknown terminals get ``unicode``, which works
|
||||
anywhere with truecolor.
|
||||
"""
|
||||
term = os.environ.get("TERM", "").lower()
|
||||
term_program = os.environ.get("TERM_PROGRAM", "").lower()
|
||||
|
||||
# The VS Code / Cursor integrated terminal sets TERM_PROGRAM=vscode
|
||||
# authoritatively but does NOT scrub the terminal env vars it inherits when
|
||||
# launched from another emulator (ITERM_SESSION_ID, KITTY_WINDOW_ID, …).
|
||||
# Trusting those leaks emits an image protocol the embedded xterm.js can't
|
||||
# display — you get a blank frame. Inline images there are opt-in
|
||||
# (terminal.integrated.enableImages), so default to half-blocks, which
|
||||
# always render in its truecolor grid. Users who enabled images can pin
|
||||
# display.pet.render_mode explicitly.
|
||||
if term_program == "vscode":
|
||||
return "unicode"
|
||||
|
||||
# kitty graphics protocol
|
||||
if os.environ.get("KITTY_WINDOW_ID") or "kitty" in term or "ghostty" in term:
|
||||
return "kitty"
|
||||
if term_program in {"ghostty"}:
|
||||
return "kitty"
|
||||
|
||||
# WezTerm speaks both kitty and iterm; prefer kitty (richer placement).
|
||||
if term_program == "wezterm" or os.environ.get("WEZTERM_PANE"):
|
||||
return "kitty"
|
||||
|
||||
# iTerm2 inline images
|
||||
if term_program == "iterm.app" or os.environ.get("ITERM_SESSION_ID"):
|
||||
return "iterm"
|
||||
|
||||
# sixel-capable terminals (env heuristics only)
|
||||
if term_program in {"mintty"} or "foot" in term or "mlterm" in term:
|
||||
return "sixel"
|
||||
if "sixel" in term:
|
||||
return "sixel"
|
||||
|
||||
return "unicode"
|
||||
|
||||
|
||||
def resolve_mode(configured: str | None, *, stream=None) -> str:
|
||||
"""Resolve the effective render mode from config + the environment.
|
||||
|
||||
``configured`` is ``display.pet.render_mode`` (``auto`` → detect). Returns
|
||||
``off`` when not attached to a TTY (no point emitting graphics into a pipe
|
||||
or logfile).
|
||||
"""
|
||||
mode = (configured or "auto").strip().lower()
|
||||
if mode not in RENDER_MODES:
|
||||
mode = "auto"
|
||||
if mode == "off":
|
||||
return "off"
|
||||
|
||||
stream = stream or sys.stdout
|
||||
try:
|
||||
if not (hasattr(stream, "isatty") and stream.isatty()):
|
||||
return "off"
|
||||
except (ValueError, OSError):
|
||||
return "off"
|
||||
|
||||
if mode == "auto":
|
||||
return detect_terminal_graphics()
|
||||
return mode
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# Frame decoding
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _open_sheet(path: Path):
|
||||
from PIL import Image
|
||||
|
||||
img = Image.open(path)
|
||||
return img.convert("RGBA")
|
||||
|
||||
|
||||
# Max alpha at/below which a frame counts as blank padding. petdex sheets are
|
||||
# left-packed: a state with fewer real frames than ``FRAMES_PER_STATE`` fills
|
||||
# the trailing columns with fully transparent cells. Animating into one flashes
|
||||
# the pet blank, so we stop the row at the first such gap.
|
||||
_BLANK_ALPHA = 8
|
||||
|
||||
|
||||
def _frame_is_blank(frame) -> bool:
|
||||
"""True if *frame* has no meaningfully opaque pixel (transparent padding)."""
|
||||
return frame.getchannel("A").getextrema()[1] <= _BLANK_ALPHA
|
||||
|
||||
|
||||
@lru_cache(maxsize=16)
|
||||
def _raw_frames(
|
||||
sheet_path: str,
|
||||
state_value: str,
|
||||
frame_w: int,
|
||||
frame_h: int,
|
||||
frames_per_state: int,
|
||||
) -> tuple:
|
||||
"""Cropped, padding-trimmed RGBA frames for one state row (unscaled).
|
||||
|
||||
Steps across the row until the first blank column so pets with ragged
|
||||
per-state frame counts never animate into empty padding. Cached; returns
|
||||
``()`` on any decode failure.
|
||||
"""
|
||||
try:
|
||||
sheet = _open_sheet(Path(sheet_path))
|
||||
cols = max(1, sheet.width // frame_w)
|
||||
rows = max(1, sheet.height // frame_h)
|
||||
row = state_row_index(state_value, rows)
|
||||
top = row * frame_h
|
||||
# Clamp the row to the sheet (some pets ship fewer rows than the 8 the
|
||||
# taxonomy reserves).
|
||||
if top + frame_h > sheet.height:
|
||||
top = max(0, sheet.height - frame_h)
|
||||
|
||||
frames = []
|
||||
for i in range(min(frames_per_state, cols)):
|
||||
left = i * frame_w
|
||||
frame = sheet.crop((left, top, left + frame_w, top + frame_h))
|
||||
if _frame_is_blank(frame):
|
||||
break # trailing transparent padding — real frames end here
|
||||
frames.append(frame)
|
||||
return tuple(frames)
|
||||
except Exception as exc: # noqa: BLE001 - cosmetic feature, never fatal
|
||||
logger.debug("pet frame decode failed (%s, %s): %s", sheet_path, state_value, exc)
|
||||
return ()
|
||||
|
||||
|
||||
@lru_cache(maxsize=8)
|
||||
def _frames_for(
|
||||
sheet_path: str,
|
||||
state_value: str,
|
||||
frame_w: int,
|
||||
frame_h: int,
|
||||
frames_per_state: int,
|
||||
scale_w: int,
|
||||
scale_h: int,
|
||||
):
|
||||
"""Return padding-trimmed RGBA frames for one state row, scaled.
|
||||
|
||||
Thin scaling layer over :func:`_raw_frames`; both are cached so repeated
|
||||
frame requests during animation are free.
|
||||
"""
|
||||
raw = _raw_frames(sheet_path, state_value, frame_w, frame_h, frames_per_state)
|
||||
if not raw or (scale_w, scale_h) == (frame_w, frame_h):
|
||||
return list(raw)
|
||||
from PIL import Image
|
||||
|
||||
return [f.resize((scale_w, scale_h), Image.LANCZOS) for f in raw]
|
||||
|
||||
|
||||
def state_frame_counts(
|
||||
sheet_path: str | Path,
|
||||
*,
|
||||
frame_w: int = FRAME_W,
|
||||
frame_h: int = FRAME_H,
|
||||
frames_per_state: int = FRAMES_PER_STATE,
|
||||
) -> dict[str, int]:
|
||||
"""Map each driven :class:`PetState` → its real (padding-trimmed) frame count.
|
||||
|
||||
The single source of truth for "how many frames does this state actually
|
||||
have?". The CLI/TUI consume the trimmed frame lists directly; the gateway
|
||||
ships this map to the desktop canvas, which steps its own loop.
|
||||
"""
|
||||
return {
|
||||
state.value: len(
|
||||
_raw_frames(str(sheet_path), state.value, frame_w, frame_h, frames_per_state)
|
||||
)
|
||||
for state in PetState
|
||||
}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# Encoders
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _png_bytes(frame) -> bytes:
|
||||
buf = io.BytesIO()
|
||||
frame.save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _kitty_apc(ctrl: str, data: str) -> str:
|
||||
"""Emit a kitty APC escape for *data*, chunked into ≤4096-byte ``m`` pieces."""
|
||||
chunk = 4096
|
||||
if len(data) <= chunk:
|
||||
return f"\x1b_G{ctrl},m=0;{data}\x1b\\"
|
||||
out = [f"\x1b_G{ctrl},m=1;{data[:chunk]}\x1b\\"]
|
||||
rest = data[chunk:]
|
||||
while rest:
|
||||
piece, rest = rest[:chunk], rest[chunk:]
|
||||
out.append(f"\x1b_Gm={1 if rest else 0};{piece}\x1b\\")
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def _encode_kitty(frame, *, cell_cols: int | None = None, cell_rows: int | None = None) -> str:
|
||||
"""Encode one frame via the kitty graphics protocol (transmit + display).
|
||||
|
||||
``a=T`` transmits & displays at the cursor; ``c``/``r`` request a display
|
||||
box in terminal cells so successive frames overwrite the same area.
|
||||
"""
|
||||
ctrl = "f=100,a=T,q=2"
|
||||
if cell_cols:
|
||||
ctrl += f",c={cell_cols}"
|
||||
if cell_rows:
|
||||
ctrl += f",r={cell_rows}"
|
||||
return _kitty_apc(ctrl, base64.standard_b64encode(_png_bytes(frame)).decode("ascii"))
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# kitty Unicode placeholders
|
||||
#
|
||||
# Ink (the TUI's React-for-terminal layer) owns the screen and measures every
|
||||
# cell's width, so it can't host raw kitty image escapes (no width to count,
|
||||
# clobbered on the next repaint). kitty's *Unicode placeholder* protocol is the
|
||||
# grid-safe path: transmit the image once (q=2, virtual placement U=1), then the
|
||||
# host app prints ordinary-width placeholder cells (U+10EEEE + diacritics) whose
|
||||
# foreground color encodes the image id. Ink counts those as width-1 text, so
|
||||
# layout stays correct and the terminal paints the image underneath.
|
||||
# https://sw.kovidgoyal.net/kitty/graphics-protocol/#unicode-placeholders
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
_KITTY_PLACEHOLDER = "\U0010eeee"
|
||||
|
||||
# Row/column diacritics, in order (index → diacritic). Verbatim from kitty's
|
||||
# gen/rowcolumn-diacritics.txt (Unicode 6.0.0, combining class 230). Index i is
|
||||
# the diacritic that encodes the number i; we only ever need the row index.
|
||||
_ROWCOL_DIACRITICS: tuple[int, ...] = (
|
||||
0x0305, 0x030D, 0x030E, 0x0310, 0x0312, 0x033D, 0x033E, 0x033F, 0x0346, 0x034A,
|
||||
0x034B, 0x034C, 0x0350, 0x0351, 0x0352, 0x0357, 0x035B, 0x0363, 0x0364, 0x0365,
|
||||
0x0366, 0x0367, 0x0368, 0x0369, 0x036A, 0x036B, 0x036C, 0x036D, 0x036E, 0x036F,
|
||||
0x0483, 0x0484, 0x0485, 0x0486, 0x0487, 0x0592, 0x0593, 0x0594, 0x0595, 0x0597,
|
||||
0x0598, 0x0599, 0x059C, 0x059D, 0x059E, 0x059F, 0x05A0, 0x05A1, 0x05A8, 0x05A9,
|
||||
0x05AB, 0x05AC, 0x05AF, 0x05C4, 0x0610, 0x0611, 0x0612, 0x0613, 0x0614, 0x0615,
|
||||
0x0616, 0x0617, 0x0657, 0x0658, 0x0659, 0x065A, 0x065B, 0x065D, 0x065E, 0x06D6,
|
||||
0x06D7, 0x06D8, 0x06D9, 0x06DA, 0x06DB, 0x06DC, 0x06DF, 0x06E0, 0x06E1, 0x06E2,
|
||||
0x06E4, 0x06E7, 0x06E8, 0x06EB, 0x06EC, 0x0730, 0x0732, 0x0733, 0x0735, 0x0736,
|
||||
0x073A, 0x073D, 0x073F, 0x0740, 0x0741, 0x0743, 0x0745, 0x0747, 0x0749, 0x074A,
|
||||
0x07EB, 0x07EC, 0x07ED, 0x07EE, 0x07EF, 0x07F0, 0x07F1, 0x07F3, 0x0816, 0x0817,
|
||||
0x0818, 0x0819, 0x081B, 0x081C, 0x081D, 0x081E, 0x081F, 0x0820, 0x0821, 0x0822,
|
||||
0x0823, 0x0825, 0x0826, 0x0827, 0x0829, 0x082A, 0x082B, 0x082C, 0x082D, 0x0951,
|
||||
0x0953, 0x0954, 0x0F82, 0x0F83, 0x0F86, 0x0F87, 0x135D, 0x135E, 0x135F, 0x17DD,
|
||||
0x193A, 0x1A17, 0x1A75, 0x1A76, 0x1A77, 0x1A78, 0x1A79, 0x1A7A, 0x1A7B, 0x1A7C,
|
||||
0x1B6B, 0x1B6D, 0x1B6E, 0x1B6F, 0x1B70, 0x1B71, 0x1B72, 0x1B73, 0x1CD0, 0x1CD1,
|
||||
0x1CD2, 0x1CDA, 0x1CDB, 0x1CE0, 0x1DC0, 0x1DC1, 0x1DC3, 0x1DC4, 0x1DC5, 0x1DC6,
|
||||
0x1DC7, 0x1DC8, 0x1DC9, 0x1DCB, 0x1DCC, 0x1DD1, 0x1DD2, 0x1DD3, 0x1DD4, 0x1DD5,
|
||||
0x1DD6, 0x1DD7, 0x1DD8, 0x1DD9, 0x1DDA, 0x1DDB, 0x1DDC, 0x1DDD, 0x1DDE, 0x1DDF,
|
||||
0x1DE0, 0x1DE1, 0x1DE2, 0x1DE3, 0x1DE4, 0x1DE5, 0x1DE6, 0x1DFE, 0x20D0, 0x20D1,
|
||||
0x20D4, 0x20D5, 0x20D6, 0x20D7, 0x20DB, 0x20DC, 0x20E1, 0x20E7, 0x20E9, 0x20F0,
|
||||
0x2CEF, 0x2CF0, 0x2CF1, 0x2DE0, 0x2DE1, 0x2DE2, 0x2DE3, 0x2DE4, 0x2DE5, 0x2DE6,
|
||||
0x2DE7, 0x2DE8, 0x2DE9, 0x2DEA, 0x2DEB, 0x2DEC, 0x2DED, 0x2DEE, 0x2DEF, 0x2DF0,
|
||||
0x2DF1, 0x2DF2, 0x2DF3, 0x2DF4, 0x2DF5, 0x2DF6, 0x2DF7, 0x2DF8, 0x2DF9, 0x2DFA,
|
||||
0x2DFB, 0x2DFC, 0x2DFD, 0x2DFE, 0x2DFF, 0xA66F, 0xA67C, 0xA67D, 0xA6F0, 0xA6F1,
|
||||
0xA8E0, 0xA8E1, 0xA8E2, 0xA8E3, 0xA8E4, 0xA8E5, 0xA8E6, 0xA8E7, 0xA8E8, 0xA8E9,
|
||||
0xA8EA, 0xA8EB, 0xA8EC, 0xA8ED, 0xA8EE, 0xA8EF, 0xA8F0, 0xA8F1, 0xAAB0, 0xAAB2,
|
||||
0xAAB3, 0xAAB7, 0xAAB8, 0xAABE, 0xAABF, 0xAAC1, 0xFE20, 0xFE21, 0xFE22, 0xFE23,
|
||||
0xFE24, 0xFE25, 0xFE26, 0x10A0F, 0x10A38, 0x1D185, 0x1D186, 0x1D187, 0x1D188,
|
||||
0x1D189, 0x1D1AA, 0x1D1AB, 0x1D1AC, 0x1D1AD, 0x1D242, 0x1D243, 0x1D244,
|
||||
)
|
||||
|
||||
|
||||
def kitty_image_id(slug: str) -> int:
|
||||
"""Stable per-pet image id in ``[1, 0x7FFF]``.
|
||||
|
||||
The id is encoded in the placeholder's 24-bit foreground color, so it must
|
||||
be non-zero and fit comfortably under ``0xFFFFFF``. A small CRC keeps it
|
||||
deterministic per slug (so re-renders reuse the same terminal-side image)
|
||||
while making collisions between two different pets unlikely.
|
||||
"""
|
||||
import zlib
|
||||
|
||||
return (zlib.crc32(slug.encode("utf-8")) % 0x7FFE) + 1
|
||||
|
||||
|
||||
def kitty_color_hex(image_id: int) -> str:
|
||||
"""Hex foreground color (``#rrggbb``) that encodes *image_id* for kitty."""
|
||||
return "#%06x" % (image_id & 0xFFFFFF)
|
||||
|
||||
|
||||
def kitty_placeholder_rows(cols: int, rows: int) -> list[str]:
|
||||
"""Build the placeholder text grid for an *rows*×*cols* image.
|
||||
|
||||
Each line is one row of the grid: the first cell carries the row diacritic
|
||||
(column defaults to 0), and the remaining ``cols-1`` bare placeholders let
|
||||
the terminal auto-increment the column. The foreground color (the image id)
|
||||
is applied by the caller / Ink, not embedded here.
|
||||
"""
|
||||
cols = max(1, cols)
|
||||
out: list[str] = []
|
||||
for r in range(max(1, rows)):
|
||||
idx = min(r, len(_ROWCOL_DIACRITICS) - 1)
|
||||
first = _KITTY_PLACEHOLDER + chr(_ROWCOL_DIACRITICS[idx])
|
||||
out.append(first + _KITTY_PLACEHOLDER * (cols - 1))
|
||||
return out
|
||||
|
||||
|
||||
def _encode_kitty_virtual(frame, *, image_id: int, cols: int, rows: int) -> str:
|
||||
"""Transmit a frame as a kitty *virtual* placement for Unicode placeholders.
|
||||
|
||||
``a=T`` transmits and creates the placement in one shot; ``U=1`` marks it
|
||||
virtual (no on-screen output, cursor untouched); ``q=2`` suppresses the
|
||||
terminal's OK/error replies that would otherwise corrupt the host app's
|
||||
output. Re-sending with the same ``i`` replaces the image, so the static
|
||||
placeholder cells animate underneath.
|
||||
"""
|
||||
ctrl = f"a=T,U=1,i={image_id},c={cols},r={rows},f=100,q=2"
|
||||
return _kitty_apc(ctrl, base64.standard_b64encode(_png_bytes(frame)).decode("ascii"))
|
||||
|
||||
|
||||
def _encode_iterm(frame, *, cell_cols: int | None = None, cell_rows: int | None = None) -> str:
|
||||
"""Encode one frame as an iTerm2 inline image (OSC 1337 File)."""
|
||||
payload = base64.standard_b64encode(_png_bytes(frame)).decode("ascii")
|
||||
size = len(payload)
|
||||
args = [f"inline=1", f"size={size}", "preserveAspectRatio=1"]
|
||||
if cell_cols:
|
||||
args.append(f"width={cell_cols}")
|
||||
if cell_rows:
|
||||
args.append(f"height={cell_rows}")
|
||||
return f"\x1b]1337;File={';'.join(args)}:{payload}\x07"
|
||||
|
||||
|
||||
def _encode_sixel(frame) -> str:
|
||||
"""Encode one frame as DEC sixel.
|
||||
|
||||
Quantizes to an adaptive palette (≤255 colors) and emits the sixel band
|
||||
stream. Pillow has no sixel writer, so this is a compact hand-rolled
|
||||
encoder. Transparent pixels render as background (color register skipped).
|
||||
"""
|
||||
from PIL import Image
|
||||
|
||||
rgba = frame
|
||||
# Composite onto transparent-as-skip: track alpha to decide background.
|
||||
pal = rgba.convert("RGB").quantize(colors=255, method=Image.MEDIANCUT)
|
||||
palette = pal.getpalette() or []
|
||||
px = pal.load()
|
||||
alpha = rgba.getchannel("A").load()
|
||||
w, h = pal.size
|
||||
|
||||
out = ["\x1bP0;1;0q", '"1;1;%d;%d' % (w, h)]
|
||||
# Color register definitions (sixel uses 0..100 scale).
|
||||
used = sorted({px[x, y] for y in range(h) for x in range(w)})
|
||||
for idx in used:
|
||||
r = palette[idx * 3] if idx * 3 < len(palette) else 0
|
||||
g = palette[idx * 3 + 1] if idx * 3 + 1 < len(palette) else 0
|
||||
b = palette[idx * 3 + 2] if idx * 3 + 2 < len(palette) else 0
|
||||
out.append("#%d;2;%d;%d;%d" % (idx, r * 100 // 255, g * 100 // 255, b * 100 // 255))
|
||||
|
||||
# Emit in 6-row bands.
|
||||
for band in range(0, h, 6):
|
||||
for color_idx in used:
|
||||
line = ["#%d" % color_idx]
|
||||
run_char = None
|
||||
run_len = 0
|
||||
|
||||
def flush():
|
||||
nonlocal run_char, run_len
|
||||
if run_char is None:
|
||||
return
|
||||
if run_len > 3:
|
||||
line.append("!%d%s" % (run_len, run_char))
|
||||
else:
|
||||
line.append(run_char * run_len)
|
||||
run_char, run_len = None, 0
|
||||
|
||||
for x in range(w):
|
||||
bits = 0
|
||||
for bit in range(6):
|
||||
y = band + bit
|
||||
if y < h and alpha[x, y] > 32 and px[x, y] == color_idx:
|
||||
bits |= 1 << bit
|
||||
ch = chr(63 + bits)
|
||||
if ch == run_char:
|
||||
run_len += 1
|
||||
else:
|
||||
flush()
|
||||
run_char, run_len = ch, 1
|
||||
flush()
|
||||
out.append("".join(line) + "$") # carriage return within band
|
||||
out.append("-") # next band
|
||||
out.append("\x1b\\")
|
||||
return "".join(out)
|
||||
|
||||
|
||||
_HALF_BLOCK = "▀"
|
||||
|
||||
# A single half-block cell: top pixel + bottom pixel as (r, g, b, a) tuples.
|
||||
Cell = tuple[tuple[int, int, int, int], tuple[int, int, int, int]]
|
||||
|
||||
|
||||
def _downscale_cells(frame, *, target_cols: int) -> list[list[Cell]]:
|
||||
"""Downscale a frame to a grid of half-block cells.
|
||||
|
||||
Each cell pairs a top and bottom pixel so one terminal row encodes two
|
||||
pixel rows. Returns rows of ``((tr,tg,tb,ta),(br,bg,bb,ba))`` — the
|
||||
framework-neutral representation shared by the ANSI encoder (CLI) and the
|
||||
structured ``cells`` API (Ink).
|
||||
"""
|
||||
from PIL import Image
|
||||
|
||||
target_cols = max(4, target_cols)
|
||||
aspect = frame.height / max(1, frame.width)
|
||||
target_rows = max(2, int(round(target_cols * aspect * 0.5)) * 2)
|
||||
small = frame.resize((target_cols, target_rows), Image.LANCZOS).convert("RGBA")
|
||||
px = small.load()
|
||||
|
||||
grid: list[list[Cell]] = []
|
||||
for y in range(0, target_rows, 2):
|
||||
row: list[Cell] = []
|
||||
for x in range(target_cols):
|
||||
top = px[x, y]
|
||||
bottom = px[x, y + 1] if y + 1 < target_rows else (0, 0, 0, 0)
|
||||
row.append((top, bottom))
|
||||
grid.append(row)
|
||||
return grid
|
||||
|
||||
|
||||
def _encode_unicode(frame, *, target_cols: int) -> str:
|
||||
"""Downscale to truecolor ANSI half-blocks (one char = 2 vertical pixels)."""
|
||||
lines: list[str] = []
|
||||
for row in _downscale_cells(frame, target_cols=target_cols):
|
||||
cells: list[str] = []
|
||||
for (tr, tg, tb, ta), (br, bg, bb, ba) in row:
|
||||
if ta < 32 and ba < 32:
|
||||
cells.append("\x1b[0m ") # fully transparent → blank
|
||||
continue
|
||||
cells.append(f"\x1b[38;2;{tr};{tg};{tb}m\x1b[48;2;{br};{bg};{bb}m{_HALF_BLOCK}")
|
||||
lines.append("".join(cells) + "\x1b[0m")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# Public renderer
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class PetRenderer:
|
||||
"""Holds a pet's spritesheet and yields encoded frames per (state, index).
|
||||
|
||||
Construct once per pet, then call :meth:`frame` on an animation timer.
|
||||
Cheap to call repeatedly — decoded frames are cached.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
spritesheet: str | Path,
|
||||
*,
|
||||
mode: str = "unicode",
|
||||
scale: float = DEFAULT_SCALE,
|
||||
unicode_cols: int = 20,
|
||||
frame_w: int = FRAME_W,
|
||||
frame_h: int = FRAME_H,
|
||||
frames_per_state: int = FRAMES_PER_STATE,
|
||||
) -> None:
|
||||
self.spritesheet = str(spritesheet)
|
||||
self.mode = mode if mode in RENDER_MODES else "unicode"
|
||||
self.scale = scale
|
||||
self.unicode_cols = unicode_cols
|
||||
self.frame_w = frame_w
|
||||
self.frame_h = frame_h
|
||||
self.frames_per_state = frames_per_state
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
return self.mode != "off" and Path(self.spritesheet).is_file()
|
||||
|
||||
def frame_count(self, state: PetState | str) -> int:
|
||||
return len(self._frames(state))
|
||||
|
||||
def _frames(self, state: PetState | str):
|
||||
value = state.value if isinstance(state, PetState) else str(state)
|
||||
scale_w = max(1, int(self.frame_w * self.scale))
|
||||
scale_h = max(1, int(self.frame_h * self.scale))
|
||||
return _frames_for(
|
||||
self.spritesheet,
|
||||
value,
|
||||
self.frame_w,
|
||||
self.frame_h,
|
||||
self.frames_per_state,
|
||||
scale_w,
|
||||
scale_h,
|
||||
)
|
||||
|
||||
def cells(self, state: PetState | str, index: int, *, cols: int | None = None) -> list[list[Cell]]:
|
||||
"""Return one frame as a half-block cell grid (framework-neutral).
|
||||
|
||||
Used by the TUI, which renders the grid with native Ink color props
|
||||
instead of raw ANSI. Returns ``[]`` when no frame is available.
|
||||
"""
|
||||
frames = self._frames(state)
|
||||
if not frames:
|
||||
return []
|
||||
frame = frames[index % len(frames)]
|
||||
return _downscale_cells(frame, target_cols=cols or self.unicode_cols)
|
||||
|
||||
def _cell_box(self, frame) -> tuple[int, int]:
|
||||
"""Terminal cell box for a scaled frame (~8×16 px per cell).
|
||||
|
||||
Must match :meth:`frame` graphics sizing — kitty stretches the image to
|
||||
fill ``c``×``r`` cells, so these must reflect the scaled pixel
|
||||
dimensions, not a native-aspect column count (that upscales small pets).
|
||||
"""
|
||||
return max(1, frame.width // 8), max(1, frame.height // 16)
|
||||
|
||||
def kitty_payload(self, state: PetState | str, *, image_id: int) -> dict | None:
|
||||
"""Build the kitty Unicode-placeholder payload for one state.
|
||||
|
||||
Returns ``{cols, rows, placeholder, frames}`` where ``frames`` is a
|
||||
list of transmit escapes (one per animation frame, all reusing
|
||||
``image_id``) and ``placeholder`` is the static text grid Ink paints.
|
||||
Placement geometry is derived from the scaled frame pixels (via
|
||||
:meth:`_cell_box`), not ``unicode_cols`` — kitty upscales to fill
|
||||
``c``×``r`` cells. ``None`` when no frame is available.
|
||||
"""
|
||||
frames = self._frames(state)
|
||||
if not frames:
|
||||
return None
|
||||
cols, rows = self._cell_box(frames[0])
|
||||
return {
|
||||
"cols": cols,
|
||||
"rows": rows,
|
||||
"placeholder": kitty_placeholder_rows(cols, rows),
|
||||
"frames": [
|
||||
_encode_kitty_virtual(f, image_id=image_id, cols=cols, rows=rows) for f in frames
|
||||
],
|
||||
}
|
||||
|
||||
def frame(self, state: PetState | str, index: int) -> str:
|
||||
"""Return the encoded escape string for one frame, or ``""``.
|
||||
|
||||
``index`` is taken modulo the available frame count so callers can pass
|
||||
a free-running counter.
|
||||
"""
|
||||
if self.mode == "off":
|
||||
return ""
|
||||
frames = self._frames(state)
|
||||
if not frames:
|
||||
return ""
|
||||
frame = frames[index % len(frames)]
|
||||
cell_cols, cell_rows = self._cell_box(frame)
|
||||
|
||||
try:
|
||||
if self.mode == "kitty":
|
||||
return _encode_kitty(frame, cell_cols=cell_cols, cell_rows=cell_rows)
|
||||
if self.mode == "iterm":
|
||||
return _encode_iterm(frame, cell_cols=cell_cols, cell_rows=cell_rows)
|
||||
if self.mode == "sixel":
|
||||
return _encode_sixel(frame)
|
||||
return _encode_unicode(frame, target_cols=self.unicode_cols)
|
||||
except Exception as exc: # noqa: BLE001 - degrade silently
|
||||
logger.debug("pet frame encode failed (mode=%s): %s", self.mode, exc)
|
||||
return ""
|
||||
|
||||
|
||||
def build_renderer(
|
||||
spritesheet: str | Path,
|
||||
*,
|
||||
configured_mode: str | None = None,
|
||||
scale: float = DEFAULT_SCALE,
|
||||
unicode_cols: int = 20,
|
||||
stream=None,
|
||||
) -> PetRenderer:
|
||||
"""Convenience factory: resolve the mode from config+env, then construct."""
|
||||
mode = resolve_mode(configured_mode, stream=stream)
|
||||
return PetRenderer(
|
||||
spritesheet,
|
||||
mode=mode,
|
||||
scale=scale,
|
||||
unicode_cols=unicode_cols,
|
||||
)
|
||||
81
agent/pet/state.py
Normal file
81
agent/pet/state.py
Normal file
@ -0,0 +1,81 @@
|
||||
"""Map agent activity → a :class:`PetState`.
|
||||
|
||||
This is the one place the "what is the agent doing right now?" → "which
|
||||
animation row?" decision lives. Each surface feeds it the signals it already
|
||||
tracks:
|
||||
|
||||
- CLI — ``KawaiiSpinner`` waiting/thinking state + tool outcomes.
|
||||
- TUI — gateway ``tool.start/complete`` + ``message.delta/complete`` events.
|
||||
- Desktop — the ``$busy``/``$awaitingResponse``/tool-event nanostores
|
||||
(re-implemented in TS, but mirroring this priority order).
|
||||
|
||||
Keeping the priority order here (and documenting it) lets the TypeScript
|
||||
mirror stay faithful without a second design.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from typing import Any
|
||||
|
||||
from agent.pet.constants import PetState
|
||||
|
||||
|
||||
def todos_all_done(todos: Iterable[Any] | None) -> bool:
|
||||
"""True iff there's ≥1 todo and every one is completed/cancelled.
|
||||
|
||||
The "celebrate" beat (``JUMP``) fires when a plan finishes; this mirrors
|
||||
the TUI's ``isTodoDone`` so the trigger is defined once across surfaces.
|
||||
Accepts dicts (``{"status": ...}``) or objects with a ``status`` attr.
|
||||
"""
|
||||
items = list(todos or [])
|
||||
if not items:
|
||||
return False
|
||||
|
||||
def _status(t: Any) -> Any:
|
||||
return t.get("status") if isinstance(t, dict) else getattr(t, "status", None)
|
||||
|
||||
return all(_status(t) in ("completed", "cancelled") for t in items)
|
||||
|
||||
|
||||
def derive_pet_state(
|
||||
*,
|
||||
busy: bool = False,
|
||||
awaiting_input: bool = False,
|
||||
error: bool = False,
|
||||
celebrate: bool = False,
|
||||
just_completed: bool = False,
|
||||
tool_running: bool = False,
|
||||
reasoning: bool = False,
|
||||
) -> PetState:
|
||||
"""Resolve the animation state from coarse activity signals.
|
||||
|
||||
Priority (highest first) — only one row can show at a time, so the most
|
||||
salient signal wins:
|
||||
|
||||
1. ``error`` → ``FAILED`` (a tool/turn just failed)
|
||||
2. ``celebrate`` → ``JUMP`` (explicit success beat, e.g. todos done)
|
||||
3. ``just_completed`` → ``WAVE`` (turn finished cleanly / greeting)
|
||||
4. ``awaiting_input`` → ``WAITING`` (blocked on the user — a clarify/approval
|
||||
prompt is open; this outranks the in-flight signals below because the turn
|
||||
is paused on *you*, even though a tool is technically mid-call)
|
||||
5. ``tool_running`` → ``RUN`` (a tool is executing)
|
||||
6. ``reasoning`` → ``REVIEW`` (model is thinking / reading)
|
||||
7. ``busy`` → ``RUN`` (turn in flight, unspecified work)
|
||||
8. otherwise → ``IDLE``
|
||||
"""
|
||||
if error:
|
||||
return PetState.FAILED
|
||||
if celebrate:
|
||||
return PetState.JUMP
|
||||
if just_completed:
|
||||
return PetState.WAVE
|
||||
if awaiting_input:
|
||||
return PetState.WAITING
|
||||
if tool_running:
|
||||
return PetState.RUN
|
||||
if reasoning:
|
||||
return PetState.REVIEW
|
||||
if busy:
|
||||
return PetState.RUN
|
||||
return PetState.IDLE
|
||||
388
agent/pet/store.py
Normal file
388
agent/pet/store.py
Normal file
@ -0,0 +1,388 @@
|
||||
"""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
|
||||
import re
|
||||
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
|
||||
|
||||
|
||||
def slugify(name: str) -> str:
|
||||
"""Lowercase, hyphenate, and strip a display name into a filesystem slug."""
|
||||
slug = re.sub(r"[^a-z0-9]+", "-", (name or "").strip().lower()).strip("-")
|
||||
return slug or "pet"
|
||||
|
||||
|
||||
def unique_slug(name: str) -> str:
|
||||
"""A :func:`slugify` result that doesn't collide with an existing pet dir."""
|
||||
base = slugify(name)
|
||||
slug = base
|
||||
counter = 2
|
||||
while (pets_dir() / slug).exists():
|
||||
slug = f"{base}-{counter}"
|
||||
counter += 1
|
||||
return slug
|
||||
|
||||
|
||||
def _write_spritesheet(source, dest: Path) -> None:
|
||||
"""Write *source* (PIL image, bytes, or path) as a lossless WebP at *dest*."""
|
||||
if isinstance(source, (bytes, bytearray)):
|
||||
dest.write_bytes(bytes(source))
|
||||
return
|
||||
|
||||
from PIL import Image
|
||||
|
||||
if isinstance(source, (str, Path)):
|
||||
with Image.open(source) as opened:
|
||||
image = opened.convert("RGBA")
|
||||
else:
|
||||
image = source.convert("RGBA")
|
||||
image.save(dest, format="WEBP", lossless=True, quality=100, method=6, exact=True)
|
||||
|
||||
|
||||
def register_local_pet(
|
||||
spritesheet,
|
||||
*,
|
||||
slug: str,
|
||||
display_name: str = "",
|
||||
description: str = "",
|
||||
) -> InstalledPet:
|
||||
"""Write a locally-generated pet into the store and return it.
|
||||
|
||||
*spritesheet* may be a PIL image, raw WebP/PNG bytes, or a path. The pet
|
||||
appears in :func:`installed_pets` immediately, and because :func:`install_pet`
|
||||
returns an already-on-disk pet before consulting the manifest, it can be
|
||||
adopted (``pet.select`` / ``/pet <slug>``) without a manifest entry.
|
||||
"""
|
||||
slug = slugify(slug)
|
||||
directory = pets_dir() / slug
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
sprite_path = directory / "spritesheet.webp"
|
||||
try:
|
||||
_write_spritesheet(spritesheet, sprite_path)
|
||||
except Exception as exc: # noqa: BLE001 - normalize to one error type
|
||||
raise PetStoreError(f"could not write spritesheet for '{slug}': {exc}") from exc
|
||||
|
||||
meta = {
|
||||
"id": slug,
|
||||
"displayName": display_name or slug,
|
||||
"description": description or "",
|
||||
"spritesheetPath": sprite_path.name,
|
||||
"createdBy": "generator",
|
||||
}
|
||||
(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"register of generated pet '{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 {}
|
||||
@ -5155,6 +5155,142 @@ function createNewSessionWindow() {
|
||||
return spawnSecondaryWindow({ newSession: true })
|
||||
}
|
||||
|
||||
// The pet overlay: a single transparent, frameless, always-on-top window that
|
||||
// hosts ONLY the floating mascot. Shift-clicking the in-window pet "pops it out"
|
||||
// here so it can leave the app's bounds and stay visible while Hermes is
|
||||
// minimized (Codex-style task-completion glance). It carries no gateway
|
||||
// connection of its own — the main renderer is the single source of truth and
|
||||
// pushes pet state over IPC (hermes:pet-overlay:state); the overlay just renders
|
||||
// it. Control flows back (pop-in, composer submit) via hermes:pet-overlay:control.
|
||||
let petOverlayWindow = null
|
||||
|
||||
function petOverlayUrl() {
|
||||
if (DEV_SERVER) {
|
||||
return `${DEV_SERVER.endsWith('/') ? DEV_SERVER.slice(0, -1) : DEV_SERVER}/?win=overlay#/`
|
||||
}
|
||||
|
||||
return `${pathToFileURL(resolveRendererIndex()).toString()}?win=overlay#/`
|
||||
}
|
||||
|
||||
function spawnPetOverlayWindow(bounds) {
|
||||
const win = new BrowserWindow({
|
||||
width: Math.max(80, Math.round(bounds?.width || 220)),
|
||||
height: Math.max(80, Math.round(bounds?.height || 220)),
|
||||
x: Number.isFinite(bounds?.x) ? Math.round(bounds.x) : undefined,
|
||||
y: Number.isFinite(bounds?.y) ? Math.round(bounds.y) : undefined,
|
||||
frame: false,
|
||||
transparent: true,
|
||||
resizable: false,
|
||||
movable: true,
|
||||
minimizable: false,
|
||||
maximizable: false,
|
||||
fullscreenable: false,
|
||||
// Windows/Linux need this so the helper window does not get its own
|
||||
// taskbar/alt-tab entry. On macOS, cmd-tab is app-level and this can make
|
||||
// the whole app look like it vanished when the only newly-created visible
|
||||
// window is a frameless overlay. Use NSPanel + Mission Control hiding below
|
||||
// instead, leaving the main Hermes app as the Dock/cmd-tab anchor.
|
||||
skipTaskbar: !IS_MAC,
|
||||
hasShadow: false,
|
||||
alwaysOnTop: true,
|
||||
// macOS panels are non-activating helper windows and can float over full
|
||||
// screen spaces without becoming the app's main switcher window.
|
||||
type: IS_MAC ? 'panel' : undefined,
|
||||
hiddenInMissionControl: IS_MAC,
|
||||
// Non-activating: the overlay must never become the app's key/main window,
|
||||
// or it (a frameless, taskbar-skipping panel) becomes the app's switcher
|
||||
// anchor and the Hermes icon drops out of cmd/alt-tab — especially when the
|
||||
// main window is minimized. We flip this on only while the composer needs
|
||||
// the keyboard (see hermes:pet-overlay:set-focusable).
|
||||
focusable: false,
|
||||
show: false,
|
||||
// Fully transparent — the renderer paints only the sprite + bubble.
|
||||
backgroundColor: '#00000000',
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.cjs'),
|
||||
contextIsolation: true,
|
||||
sandbox: true,
|
||||
nodeIntegration: false,
|
||||
devTools: true,
|
||||
// Keep the sprite animating + bubble updating while the main window is
|
||||
// minimized/blurred — the whole point of the overlay.
|
||||
backgroundThrottling: false
|
||||
}
|
||||
})
|
||||
|
||||
// Float above other apps and follow the user across desktops so the pet is
|
||||
// always reachable. `floating` + `type: panel` is the macOS NSPanel path; the
|
||||
// more aggressive `screen-saver` level can interfere with normal app/window
|
||||
// switching semantics.
|
||||
win.setAlwaysOnTop(true, IS_MAC ? 'floating' : 'screen-saver')
|
||||
win.setHiddenInMissionControl?.(true)
|
||||
try {
|
||||
// Electron docs: macOS may transform process type on each
|
||||
// setVisibleOnAllWorkspaces() call unless skipTransformProcessType=true,
|
||||
// which briefly hides the Dock/cmd-tab presence. Keep Hermes in the normal
|
||||
// ForegroundApplication class so shift-clicking the pet never drops the app
|
||||
// out of app switchers.
|
||||
win.setVisibleOnAllWorkspaces(
|
||||
true,
|
||||
IS_MAC ? { visibleOnFullScreen: true, skipTransformProcessType: true } : undefined
|
||||
)
|
||||
} catch {
|
||||
// Not supported everywhere — best effort.
|
||||
}
|
||||
|
||||
wireCommonWindowHandlers(win)
|
||||
|
||||
win.once('ready-to-show', () => {
|
||||
if (!win.isDestroyed()) win.showInactive()
|
||||
})
|
||||
|
||||
win.on('closed', () => {
|
||||
if (petOverlayWindow === win) {
|
||||
petOverlayWindow = null
|
||||
}
|
||||
|
||||
// If the overlay went away on its own (e.g. ⌘W), tell the main renderer to
|
||||
// pop the pet back in so it doesn't stay hidden. Harmless echo when we're
|
||||
// the ones who closed it (popInPet already cleared the active flag).
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('hermes:pet-overlay:control', { type: 'pop-in' })
|
||||
}
|
||||
})
|
||||
|
||||
win.loadURL(petOverlayUrl())
|
||||
|
||||
return win
|
||||
}
|
||||
|
||||
function openPetOverlay(bounds) {
|
||||
if (petOverlayWindow && !petOverlayWindow.isDestroyed()) {
|
||||
if (bounds) {
|
||||
petOverlayWindow.setBounds({
|
||||
x: Math.round(bounds.x),
|
||||
y: Math.round(bounds.y),
|
||||
width: Math.max(80, Math.round(bounds.width)),
|
||||
height: Math.max(80, Math.round(bounds.height))
|
||||
})
|
||||
}
|
||||
|
||||
petOverlayWindow.showInactive()
|
||||
|
||||
return petOverlayWindow
|
||||
}
|
||||
|
||||
petOverlayWindow = spawnPetOverlayWindow(bounds)
|
||||
|
||||
return petOverlayWindow
|
||||
}
|
||||
|
||||
function closePetOverlay() {
|
||||
if (petOverlayWindow && !petOverlayWindow.isDestroyed()) {
|
||||
petOverlayWindow.close()
|
||||
}
|
||||
|
||||
petOverlayWindow = null
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
const icon = getAppIconPath()
|
||||
mainWindow = new BrowserWindow({
|
||||
@ -5224,6 +5360,11 @@ function createWindow() {
|
||||
mainWindow.on('will-leave-full-screen', () => sendWindowStateChanged(false))
|
||||
mainWindow.on('leave-full-screen', () => sendWindowStateChanged(false))
|
||||
|
||||
// The overlay rides the main window — closing the app's primary window must
|
||||
// tear it down too (otherwise it strands as an orphan that blocks
|
||||
// window-all-closed from quitting on Windows/Linux).
|
||||
mainWindow.on('closed', () => closePetOverlay())
|
||||
|
||||
wireCommonWindowHandlers(mainWindow)
|
||||
|
||||
mainWindow.webContents.on('render-process-gone', (_event, details) => {
|
||||
@ -5344,6 +5485,116 @@ ipcMain.handle('hermes:window:openNewSession', async () => {
|
||||
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
// --- Pet overlay (pop-out mascot) -----------------------------------------
|
||||
// `request` is `{ bounds, screen }`. A fresh pop-out passes viewport-space
|
||||
// bounds (screen=false): convert to screen space by adding the main window's
|
||||
// content origin so the pet lands where it sat in-window. A remembered/dragged
|
||||
// spot passes screen-space bounds (screen=true) and is used as-is. We return the
|
||||
// resolved screen bounds so the renderer can persist exactly where it opened.
|
||||
ipcMain.handle('hermes:pet-overlay:open', async (_event, request) => {
|
||||
const bounds = request && request.bounds ? request.bounds : request
|
||||
const isScreen = Boolean(request && request.screen)
|
||||
let screenBounds = bounds
|
||||
|
||||
try {
|
||||
if (bounds && !isScreen && mainWindow && !mainWindow.isDestroyed()) {
|
||||
const content = mainWindow.getContentBounds()
|
||||
screenBounds = {
|
||||
x: content.x + (bounds.x || 0),
|
||||
y: content.y + (bounds.y || 0),
|
||||
width: bounds.width,
|
||||
height: bounds.height
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fall back to raw bounds if the window geometry is unavailable.
|
||||
}
|
||||
|
||||
openPetOverlay(screenBounds)
|
||||
|
||||
return { ok: true, bounds: screenBounds }
|
||||
})
|
||||
ipcMain.handle('hermes:pet-overlay:close', async () => {
|
||||
closePetOverlay()
|
||||
|
||||
return { ok: true }
|
||||
})
|
||||
// Drag: the overlay reports a new absolute screen position (it already knows the
|
||||
// pointer's screen coords), we just move the window.
|
||||
ipcMain.on('hermes:pet-overlay:set-bounds', (_event, bounds) => {
|
||||
if (!petOverlayWindow || petOverlayWindow.isDestroyed() || !bounds) {
|
||||
return
|
||||
}
|
||||
|
||||
petOverlayWindow.setBounds({
|
||||
x: Math.round(bounds.x),
|
||||
y: Math.round(bounds.y),
|
||||
width: Math.max(80, Math.round(bounds.width)),
|
||||
height: Math.max(80, Math.round(bounds.height))
|
||||
})
|
||||
})
|
||||
// Click-through: the overlay window is a full rectangle but only the pet pixels
|
||||
// should be interactive. The renderer toggles this as the cursor enters/leaves
|
||||
// the sprite so transparent margins pass clicks to whatever is behind.
|
||||
ipcMain.on('hermes:pet-overlay:ignore-mouse', (_event, ignore) => {
|
||||
if (petOverlayWindow && !petOverlayWindow.isDestroyed()) {
|
||||
petOverlayWindow.setIgnoreMouseEvents(Boolean(ignore), { forward: true })
|
||||
}
|
||||
})
|
||||
// The overlay is a non-activating panel (focusable:false) so it never steals
|
||||
// the app's cmd/alt-tab anchor from the main window. But the pop-up composer
|
||||
// needs the keyboard, so the renderer asks us to flip it focusable + focus it
|
||||
// while the composer is open, then back to non-activating when it closes.
|
||||
ipcMain.on('hermes:pet-overlay:set-focusable', (_event, focusable) => {
|
||||
if (!petOverlayWindow || petOverlayWindow.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
|
||||
petOverlayWindow.setFocusable(Boolean(focusable))
|
||||
if (focusable) {
|
||||
petOverlayWindow.focus()
|
||||
}
|
||||
})
|
||||
// Main renderer → overlay: forward the latest pet state for the overlay to render.
|
||||
ipcMain.on('hermes:pet-overlay:state', (_event, payload) => {
|
||||
if (petOverlayWindow && !petOverlayWindow.isDestroyed()) {
|
||||
petOverlayWindow.webContents.send('hermes:pet-overlay:state', payload)
|
||||
}
|
||||
})
|
||||
// Overlay → main renderer: control messages (pop back in, composer submit).
|
||||
ipcMain.on('hermes:pet-overlay:control', (_event, payload) => {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
|
||||
// Double-click toggles the app window: hide it away if it's up front, bring it
|
||||
// back if it's minimized/buried. Pure window control — nothing for the
|
||||
// renderer to do, so don't forward it.
|
||||
if (payload && payload.type === 'toggle-app') {
|
||||
if (mainWindow.isMinimized() || !mainWindow.isVisible()) {
|
||||
mainWindow.show()
|
||||
mainWindow.focus()
|
||||
} else {
|
||||
mainWindow.minimize()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// The mail icon means "take me to the app": raise the main window (it may be
|
||||
// minimized or buried) before the renderer navigates to the latest thread.
|
||||
if (payload && payload.type === 'open-app') {
|
||||
if (mainWindow.isMinimized()) {
|
||||
mainWindow.restore()
|
||||
}
|
||||
|
||||
mainWindow.show()
|
||||
mainWindow.focus()
|
||||
}
|
||||
|
||||
mainWindow.webContents.send('hermes:pet-overlay:control', payload)
|
||||
})
|
||||
ipcMain.handle('hermes:bootstrap:reset', async () => {
|
||||
// Renderer's "Reload and retry" path. Clear the latched failure and
|
||||
// reset connection state so the next startHermes() call restarts the
|
||||
@ -6548,6 +6799,10 @@ function configureSpellChecker() {
|
||||
}
|
||||
|
||||
app.on('before-quit', () => {
|
||||
// The always-on-top overlay isn't a "real" app window; close it so a stray
|
||||
// pet can't keep the process alive or float over a quit app.
|
||||
closePetOverlay()
|
||||
|
||||
// Quitting mid-install should stop the installer, not orphan it.
|
||||
if (bootstrapAbortController) {
|
||||
try {
|
||||
|
||||
@ -7,6 +7,32 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
getGatewayWsUrl: profile => ipcRenderer.invoke('hermes:gateway:ws-url', profile),
|
||||
openSessionWindow: (sessionId, opts) => ipcRenderer.invoke('hermes:window:openSession', sessionId, opts),
|
||||
openNewSessionWindow: () => ipcRenderer.invoke('hermes:window:openNewSession'),
|
||||
petOverlay: {
|
||||
// Main renderer → main process: window lifecycle + drag. `request` is
|
||||
// `{ bounds, screen }`; resolves with the screen bounds it actually used.
|
||||
open: request => ipcRenderer.invoke('hermes:pet-overlay:open', request),
|
||||
close: () => ipcRenderer.invoke('hermes:pet-overlay:close'),
|
||||
setBounds: bounds => ipcRenderer.send('hermes:pet-overlay:set-bounds', bounds),
|
||||
setIgnoreMouse: ignore => ipcRenderer.send('hermes:pet-overlay:ignore-mouse', ignore),
|
||||
// Flip the overlay focusable (and focus it) while the composer needs keys.
|
||||
setFocusable: focusable => ipcRenderer.send('hermes:pet-overlay:set-focusable', focusable),
|
||||
// Main renderer → overlay (forwarded by main): push the latest pet state.
|
||||
pushState: payload => ipcRenderer.send('hermes:pet-overlay:state', payload),
|
||||
// Overlay → main renderer (forwarded by main): pop back in / composer submit.
|
||||
control: payload => ipcRenderer.send('hermes:pet-overlay:control', payload),
|
||||
// Overlay subscribes to state pushes.
|
||||
onState: callback => {
|
||||
const listener = (_event, payload) => callback(payload)
|
||||
ipcRenderer.on('hermes:pet-overlay:state', listener)
|
||||
return () => ipcRenderer.removeListener('hermes:pet-overlay:state', listener)
|
||||
},
|
||||
// Main renderer subscribes to overlay control messages.
|
||||
onControl: callback => {
|
||||
const listener = (_event, payload) => callback(payload)
|
||||
ipcRenderer.on('hermes:pet-overlay:control', listener)
|
||||
return () => ipcRenderer.removeListener('hermes:pet-overlay:control', listener)
|
||||
}
|
||||
},
|
||||
getBootProgress: () => ipcRenderer.invoke('hermes:boot-progress:get'),
|
||||
getConnectionConfig: profile => ipcRenderer.invoke('hermes:connection-config:get', profile),
|
||||
saveConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:save', payload),
|
||||
|
||||
@ -5,6 +5,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { HUD_HEADING, HUD_ITEM, HUD_POSITION, HUD_SURFACE, HUD_TEXT } from '@/app/floating-hud'
|
||||
import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
|
||||
import { setTerminalTakeover } from '@/app/right-sidebar/store'
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
|
||||
import { KbdCombo } from '@/components/ui/kbd'
|
||||
@ -20,6 +21,7 @@ import {
|
||||
Clock,
|
||||
Cpu,
|
||||
Download,
|
||||
Egg,
|
||||
Globe,
|
||||
type IconComponent,
|
||||
Info,
|
||||
@ -29,6 +31,7 @@ import {
|
||||
Moon,
|
||||
Package,
|
||||
Palette,
|
||||
PawPrint,
|
||||
Plus,
|
||||
Settings,
|
||||
Settings2,
|
||||
@ -39,8 +42,9 @@ import {
|
||||
Zap
|
||||
} from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $commandPaletteOpen, closeCommandPalette, setCommandPaletteOpen } from '@/store/command-palette'
|
||||
import { $commandPaletteOpen, $commandPalettePage, closeCommandPalette, setCommandPaletteOpen } from '@/store/command-palette'
|
||||
import { $bindings } from '@/store/keybinds'
|
||||
import { $petGenStatus, cleanupPetGen, generateDrafts } from '@/store/pet-generate'
|
||||
import { luminance } from '@/themes/color'
|
||||
import { type ThemeMode, useTheme } from '@/themes/context'
|
||||
import { isUserTheme, resolveTheme } from '@/themes/user-themes'
|
||||
@ -62,6 +66,8 @@ import { fieldCopyForSchemaKey } from '../settings/field-copy'
|
||||
import { prettyName } from '../settings/helpers'
|
||||
|
||||
import { MarketplaceThemePage } from './marketplace-theme-page'
|
||||
import { PetGeneratePage } from './pet-generate-page'
|
||||
import { PetInlineToggle, PetPalettePage } from './pet-palette-page'
|
||||
|
||||
interface PaletteItem {
|
||||
/** Keybind action id — its live combo renders as a hotkey hint. */
|
||||
@ -87,7 +93,7 @@ interface PaletteGroup {
|
||||
|
||||
// Nested page → its parent, so Back / Esc step up one level instead of closing
|
||||
// the palette. Pages absent here go straight back to the root list.
|
||||
const PAGE_PARENTS: Record<string, string> = { 'install-theme': 'theme' }
|
||||
const PAGE_PARENTS: Record<string, string> = { 'generate-pet': 'pets', 'install-theme': 'theme' }
|
||||
|
||||
/** A nested page reachable from a root item via `to`. */
|
||||
interface PalettePage {
|
||||
@ -205,8 +211,10 @@ 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 { requestGateway } = useGatewayRequest()
|
||||
const { availableThemes, resolvedMode, setMode, setTheme, themeName } = useTheme()
|
||||
const [search, setSearch] = useState('')
|
||||
const [page, setPage] = useState<string | null>(null)
|
||||
@ -242,13 +250,23 @@ export function CommandPalette() {
|
||||
const sessions = useMemo(() => (sessionsQuery.data?.sessions ?? []).map(toSessionEntry), [sessionsQuery.data])
|
||||
const archivedSessions = useMemo(() => (archivedQuery.data?.sessions ?? []).map(toSessionEntry), [archivedQuery.data])
|
||||
|
||||
// Reset the query/sub-page on close so it reopens clean.
|
||||
// Reset the query/sub-page on close so it reopens clean. Cleanup also deletes
|
||||
// a hatched-but-unadopted preview pet so it doesn't linger in the gallery.
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setSearch('')
|
||||
setPage(null)
|
||||
cleanupPetGen(requestGateway)
|
||||
}
|
||||
}, [open])
|
||||
}, [open, requestGateway])
|
||||
|
||||
// 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])
|
||||
|
||||
@ -382,6 +400,20 @@ 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'
|
||||
},
|
||||
{
|
||||
icon: Egg,
|
||||
id: 'appearance-generate-pet',
|
||||
keywords: ['pet', 'generate', 'create', 'make', 'new pet', 'mascot', 'hatch', 'ai'],
|
||||
label: cc.generatePet.title,
|
||||
to: 'generate-pet'
|
||||
}
|
||||
]
|
||||
},
|
||||
@ -550,6 +582,18 @@ 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: describe → draft variants → hatch a custom pet.
|
||||
'generate-pet': {
|
||||
title: t.commandCenter.generatePet.title,
|
||||
placeholder: t.commandCenter.generatePet.placeholder,
|
||||
groups: []
|
||||
},
|
||||
// Server-driven page: items come from the Marketplace, rendered by
|
||||
// <MarketplaceThemePage> (loader + live search + per-row install).
|
||||
'install-theme': {
|
||||
@ -620,49 +664,77 @@ export function CommandPalette() {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
goBack()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// On the generate page, Enter (re)generates from the typed
|
||||
// concept — cmdk has no item to select there, so each Enter,
|
||||
// including a retype after drafts already exist, starts a fresh
|
||||
// round. The page's own Retry/Hatch buttons cover the rest.
|
||||
if (page === 'generate-pet' && event.key === 'Enter' && search.trim()) {
|
||||
const genStatus = $petGenStatus.get()
|
||||
|
||||
if (
|
||||
genStatus !== 'generating' &&
|
||||
genStatus !== 'hatching' &&
|
||||
genStatus !== 'preview' &&
|
||||
genStatus !== 'adopting'
|
||||
) {
|
||||
event.preventDefault()
|
||||
void generateDrafts(requestGateway, { prompt: search })
|
||||
}
|
||||
}
|
||||
}}
|
||||
onValueChange={setSearch}
|
||||
placeholder={placeholder}
|
||||
right={page === 'pets' ? <PetInlineToggle /> : undefined}
|
||||
value={search}
|
||||
/>
|
||||
<CommandList className="dt-portal-scrollbar max-h-[min(20rem,56vh)]">
|
||||
{page === 'install-theme' ? (
|
||||
{/* Server-driven pages render their own list; the rest show groups. */}
|
||||
{page === 'generate-pet' ? (
|
||||
<PetGeneratePage search={search} />
|
||||
) : page === 'pets' ? (
|
||||
<PetPalettePage onGenerate={() => { setSearch(''); setPage('generate-pet') }} search={search} />
|
||||
) : 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
|
||||
<>
|
||||
<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>
|
||||
))}
|
||||
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>
|
||||
|
||||
303
apps/desktop/src/app/command-palette/pet-generate-page.tsx
Normal file
303
apps/desktop/src/app/command-palette/pet-generate-page.tsx
Normal file
@ -0,0 +1,303 @@
|
||||
/**
|
||||
* Cmd-K → Pets → "Generate" page — describe a pet, pick a draft, hatch it.
|
||||
*
|
||||
* A thin view over the `pet-generate` store. The palette search box doubles as
|
||||
* the concept prompt; this page renders the variant grid, the selection, the
|
||||
* retry/hatch actions, and the loading states. The store owns the two-step
|
||||
* `pet.generate` → `pet.hatch` flow.
|
||||
*/
|
||||
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
|
||||
import { PetSprite } from '@/components/pet/pet-sprite'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Check, Egg, Loader2, PawPrint, RefreshCw } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { closeCommandPalette } from '@/store/command-palette'
|
||||
import { type PetInfo } from '@/store/pet'
|
||||
import {
|
||||
$petGenDrafts,
|
||||
$petGenError,
|
||||
$petGenPreview,
|
||||
$petGenSelected,
|
||||
$petGenStatus,
|
||||
adoptHatched,
|
||||
discardHatched,
|
||||
generateDrafts,
|
||||
hatchSelected
|
||||
} from '@/store/pet-generate'
|
||||
|
||||
const VARIANT_COUNT = 4
|
||||
|
||||
// Fixed render scale for the preview so it's a predictable size regardless of
|
||||
// the user's configured `display.pet.scale`.
|
||||
const PREVIEW_SCALE = 0.7
|
||||
|
||||
// Fallback row order if a backend doesn't return `stateRows`.
|
||||
const PREVIEW_ROWS = ['idle', 'waving', 'running-right', 'running-left', 'running', 'review', 'jumping', 'failed']
|
||||
const PREVIEW_STATE_MS = 1500
|
||||
|
||||
const ROW_TO_FRAME_KEY: Record<string, string> = {
|
||||
idle: 'idle',
|
||||
wave: 'wave',
|
||||
waving: 'wave',
|
||||
jump: 'jump',
|
||||
jumping: 'jump',
|
||||
run: 'run',
|
||||
running: 'run',
|
||||
'running-right': 'run',
|
||||
'running-left': 'run',
|
||||
failed: 'failed',
|
||||
review: 'review',
|
||||
waiting: 'waiting'
|
||||
}
|
||||
|
||||
function frameCountForRow(pet: PetInfo, row: string): number {
|
||||
const byState = pet.framesByState
|
||||
const mapped = ROW_TO_FRAME_KEY[row]
|
||||
return byState?.[row] ?? (mapped ? byState?.[mapped] : undefined) ?? pet.framesPerState ?? 0
|
||||
}
|
||||
|
||||
interface PetGeneratePageProps {
|
||||
search: string
|
||||
}
|
||||
|
||||
export function PetGeneratePage({ search }: PetGeneratePageProps) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.commandCenter.generatePet
|
||||
const { requestGateway } = useGatewayRequest()
|
||||
|
||||
const status = useStore($petGenStatus)
|
||||
const error = useStore($petGenError)
|
||||
const drafts = useStore($petGenDrafts)
|
||||
const selected = useStore($petGenSelected)
|
||||
const preview = useStore($petGenPreview)
|
||||
const [name, setName] = useState('')
|
||||
|
||||
const prompt = search.trim()
|
||||
const busy = status === 'generating' || status === 'hatching'
|
||||
|
||||
const generate = () => {
|
||||
if (prompt) {
|
||||
void generateDrafts(requestGateway, { prompt })
|
||||
}
|
||||
}
|
||||
|
||||
const hatch = () => {
|
||||
void hatchSelected(requestGateway, { name: name.trim() || prompt, prompt })
|
||||
}
|
||||
|
||||
const adopt = () => {
|
||||
void adoptHatched(requestGateway).then(out => {
|
||||
if (out.ok) {
|
||||
triggerHaptic('crisp')
|
||||
closeCommandPalette()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (status === 'stale') {
|
||||
return <Status text={copy.staleBackend} tone="error" />
|
||||
}
|
||||
|
||||
// Hatching is slow (several grounded image generations) — own the whole pane.
|
||||
if (status === 'hatching') {
|
||||
return <Status icon={<Loader2 className="size-4 animate-spin" />} text={copy.hatching} />
|
||||
}
|
||||
|
||||
// Preview: play every animation row before the user commits.
|
||||
if ((status === 'preview' || status === 'adopting') && preview) {
|
||||
return (
|
||||
<HatchPreview
|
||||
adopting={status === 'adopting'}
|
||||
error={error}
|
||||
onAdopt={adopt}
|
||||
onDiscard={() => void discardHatched(requestGateway)}
|
||||
pet={preview}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const hasDrafts = drafts.length > 0
|
||||
const generating = status === 'generating'
|
||||
const cells = generating ? Array.from({ length: VARIANT_COUNT }, (_, i) => ({ index: i, dataUri: '' })) : drafts
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 p-2">
|
||||
{error && <p className="px-1 text-[0.6875rem] text-(--ui-red)">{error}</p>}
|
||||
|
||||
{!hasDrafts && !generating && (
|
||||
<p className="px-1 py-1 text-xs text-muted-foreground">{prompt ? copy.readyHint : copy.promptHint}</p>
|
||||
)}
|
||||
|
||||
{(hasDrafts || generating) && (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{cells.map((draft, i) => {
|
||||
const isSelected = !generating && selected === draft.index
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'relative flex aspect-square items-center justify-center overflow-hidden rounded-lg border bg-(--ui-bg-quinary) transition-colors',
|
||||
isSelected
|
||||
? 'border-(--ui-accent) ring-2 ring-(--ui-accent)/40'
|
||||
: 'border-(--ui-stroke-tertiary) hover:border-foreground/40'
|
||||
)}
|
||||
disabled={generating || busy}
|
||||
key={generating ? i : draft.index}
|
||||
onClick={() => $petGenSelected.set(draft.index)}
|
||||
onMouseDown={event => event.preventDefault()}
|
||||
type="button"
|
||||
>
|
||||
{generating ? (
|
||||
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<img alt="" className="size-full object-contain" draggable={false} src={draft.dataUri} />
|
||||
)}
|
||||
{isSelected && (
|
||||
<span className="absolute right-1 top-1 rounded-full bg-(--ui-accent) p-0.5 text-(--ui-base)">
|
||||
<Check className="size-3" />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasDrafts ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<input
|
||||
className="w-full rounded-md border border-(--ui-stroke-tertiary) bg-transparent px-2 py-1.5 text-xs outline-none placeholder:text-muted-foreground focus:border-foreground/40"
|
||||
onChange={event => setName(event.target.value)}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
hatch()
|
||||
}
|
||||
}}
|
||||
placeholder={copy.namePlaceholder}
|
||||
value={name}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
className="flex flex-1 items-center justify-center gap-1.5 rounded-md border border-border px-2 py-1.5 text-xs font-medium transition-colors hover:bg-(--chrome-action-hover) disabled:opacity-50"
|
||||
disabled={busy || !prompt}
|
||||
onClick={generate}
|
||||
onMouseDown={event => event.preventDefault()}
|
||||
type="button"
|
||||
>
|
||||
<RefreshCw className="size-3.5" />
|
||||
{copy.retry}
|
||||
</button>
|
||||
<button
|
||||
className="flex flex-1 items-center justify-center gap-1.5 rounded-md bg-primary px-2 py-1.5 text-xs font-medium text-primary-foreground transition-opacity hover:opacity-90 disabled:opacity-50"
|
||||
disabled={busy || selected === null}
|
||||
onClick={hatch}
|
||||
onMouseDown={event => event.preventDefault()}
|
||||
type="button"
|
||||
>
|
||||
<PawPrint className="size-3.5" />
|
||||
{copy.hatch}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="flex items-center justify-center gap-1.5 rounded-md bg-primary px-2 py-2 text-xs font-medium text-primary-foreground transition-opacity hover:opacity-90 disabled:opacity-50"
|
||||
disabled={busy || !prompt}
|
||||
onClick={generate}
|
||||
onMouseDown={event => event.preventDefault()}
|
||||
type="button"
|
||||
>
|
||||
{generating ? <Loader2 className="size-3.5 animate-spin" /> : <Egg className="size-3.5" />}
|
||||
{generating ? copy.generating : copy.generate}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface HatchPreviewProps {
|
||||
pet: PetInfo
|
||||
adopting: boolean
|
||||
error: string | null
|
||||
onAdopt: () => void
|
||||
onDiscard: () => void
|
||||
}
|
||||
|
||||
function HatchPreview({ pet, adopting, error, onAdopt, onDiscard }: HatchPreviewProps) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.commandCenter.generatePet
|
||||
const [stateIndex, setStateIndex] = useState(0)
|
||||
const previewRows = (pet.stateRows?.length ? pet.stateRows : PREVIEW_ROWS).filter(row => frameCountForRow(pet, row) > 0)
|
||||
const rows = previewRows.length > 0 ? previewRows : ['idle']
|
||||
const activeRow = rows[stateIndex % rows.length] ?? 'idle'
|
||||
|
||||
// Cycle through the animation rows so the preview showcases all frames.
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => {
|
||||
setStateIndex(i => (i + 1) % rows.length)
|
||||
}, PREVIEW_STATE_MS)
|
||||
|
||||
return () => clearInterval(id)
|
||||
}, [rows.length])
|
||||
|
||||
useEffect(() => {
|
||||
setStateIndex(0)
|
||||
}, [pet.slug])
|
||||
|
||||
const previewInfo: PetInfo = { ...pet, scale: PREVIEW_SCALE }
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2 p-2">
|
||||
<div className="flex min-h-[9rem] w-full items-center justify-center rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) py-2">
|
||||
<PetSprite info={previewInfo} rowOverride={activeRow} />
|
||||
</div>
|
||||
|
||||
{pet.displayName && <p className="text-xs font-medium text-foreground">{pet.displayName}</p>}
|
||||
|
||||
{error && <p className="px-1 text-[0.6875rem] text-(--ui-red)">{error}</p>}
|
||||
|
||||
<div className="flex w-full gap-2">
|
||||
<button
|
||||
className="flex flex-1 items-center justify-center gap-1.5 rounded-md border border-border px-2 py-1.5 text-xs font-medium transition-colors hover:bg-(--chrome-action-hover) disabled:opacity-50"
|
||||
disabled={adopting}
|
||||
onClick={onDiscard}
|
||||
onMouseDown={event => event.preventDefault()}
|
||||
type="button"
|
||||
>
|
||||
<RefreshCw className="size-3.5" />
|
||||
{copy.startOver}
|
||||
</button>
|
||||
<button
|
||||
className="flex flex-1 items-center justify-center gap-1.5 rounded-md bg-primary px-2 py-1.5 text-xs font-medium text-primary-foreground transition-opacity hover:opacity-90 disabled:opacity-50"
|
||||
disabled={adopting}
|
||||
onClick={onAdopt}
|
||||
onMouseDown={event => event.preventDefault()}
|
||||
type="button"
|
||||
>
|
||||
{adopting ? <Loader2 className="size-3.5 animate-spin" /> : <PawPrint className="size-3.5" />}
|
||||
{copy.adopt}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
205
apps/desktop/src/app/command-palette/pet-palette-page.tsx
Normal file
205
apps/desktop/src/app/command-palette/pet-palette-page.tsx
Normal file
@ -0,0 +1,205 @@
|
||||
/**
|
||||
* 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, Egg, 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
|
||||
/** Navigate to the "generate a pet" page (rendered as a header action). */
|
||||
onGenerate?: () => void
|
||||
}
|
||||
|
||||
export function PetPalettePage({ search, onGenerate }: 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">
|
||||
{onGenerate && (
|
||||
<button
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-md text-left text-foreground transition-colors hover:bg-(--chrome-action-hover)',
|
||||
HUD_ITEM,
|
||||
HUD_TEXT
|
||||
)}
|
||||
onClick={onGenerate}
|
||||
onMouseDown={event => event.preventDefault()}
|
||||
type="button"
|
||||
>
|
||||
<span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-(--chrome-action-hover)">
|
||||
<Egg className="size-4" />
|
||||
</span>
|
||||
<span className="font-medium">{t.commandCenter.generatePet.title}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{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>
|
||||
)
|
||||
}
|
||||
@ -38,6 +38,8 @@ import {
|
||||
unpinSession
|
||||
} from '../store/layout'
|
||||
import { respondToApprovalAction } from '../store/native-notifications'
|
||||
import { setPetActivity } from '../store/pet'
|
||||
import { setPetOverlayOpenAppHandler, setPetOverlaySubmitHandler } from '../store/pet-overlay'
|
||||
import { $filePreviewTarget, $previewTarget, closeActiveRightRailTab } from '../store/preview'
|
||||
import {
|
||||
$activeGatewayProfile,
|
||||
@ -49,6 +51,7 @@ import {
|
||||
} from '../store/profile'
|
||||
import {
|
||||
$activeSessionId,
|
||||
$attentionSessionIds,
|
||||
$currentCwd,
|
||||
$freshDraftReady,
|
||||
$gatewayState,
|
||||
@ -786,6 +789,53 @@ export function DesktopController() {
|
||||
updateSessionState
|
||||
})
|
||||
|
||||
// The popped-out pet drives two actions back into the app: send a prompt, and
|
||||
// open the most recent thread. Both are registered ONCE through refs that track
|
||||
// the latest callbacks — re-registering on every `submitText`/`resumeSession`
|
||||
// identity change left a brief window where the handler was nulled (cleanup
|
||||
// before re-register), which could drop a submit fired from the overlay (e.g.
|
||||
// creating a session from the new-session screen). The ref form keeps a stable,
|
||||
// always-current handler. Primary window only — it owns the overlay.
|
||||
const submitTextRef = useRef(submitText)
|
||||
submitTextRef.current = submitText
|
||||
const resumeSessionRef = useRef(resumeSession)
|
||||
resumeSessionRef.current = resumeSession
|
||||
|
||||
useEffect(() => {
|
||||
if (isSecondaryWindow()) {
|
||||
return
|
||||
}
|
||||
|
||||
setPetOverlaySubmitHandler(text => void submitTextRef.current(text))
|
||||
// Mail icon: $sessions is ordered most-recent-first; the pet is global (not
|
||||
// per session) so "most recent" is the right target. main.cjs already raised
|
||||
// the window before forwarding this.
|
||||
setPetOverlayOpenAppHandler(() => {
|
||||
const recent = $sessions.get()[0]
|
||||
|
||||
if (recent?.id) {
|
||||
void resumeSessionRef.current(recent.id)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
setPetOverlaySubmitHandler(null)
|
||||
setPetOverlayOpenAppHandler(null)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Mirror "a session is blocked on the user" (clarify/approval) into the pet's
|
||||
// awaitingInput flag so it shows the `waiting` pose. Lives on $petActivity so
|
||||
// it rides the same atom the pop-out overlay mirrors — no session list needed
|
||||
// there. Every window keeps its own in-window pet in sync.
|
||||
useEffect(() => {
|
||||
const sync = () => setPetActivity({ awaitingInput: $attentionSessionIds.get().length > 0 })
|
||||
|
||||
sync()
|
||||
|
||||
return $attentionSessionIds.listen(sync)
|
||||
}, [])
|
||||
|
||||
useGatewayBoot({
|
||||
handleGatewayEvent: handleDesktopGatewayEvent,
|
||||
onConnectionReady: c => {
|
||||
|
||||
38
apps/desktop/src/app/pet-overlay/overlay-root.tsx
Normal file
38
apps/desktop/src/app/pet-overlay/overlay-root.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
|
||||
import { ErrorBoundary } from '@/components/error-boundary'
|
||||
import { ThemeProvider } from '@/themes/context'
|
||||
|
||||
import { PetOverlayApp } from './pet-overlay-app'
|
||||
|
||||
/**
|
||||
* Boot the pet-overlay window. Loaded by the same bundle as the main app but
|
||||
* via `?win=overlay`, so it shares CSS/atoms while mounting a minimal, transparent
|
||||
* surface (no app shell, no gateway, no I18n — the bubble strings are inline).
|
||||
*
|
||||
* The index.html boot script paints an OPAQUE themed background to avoid a flash
|
||||
* in normal windows; the overlay must be see-through, so we force every host
|
||||
* layer transparent with a late, high-specificity style tag.
|
||||
*/
|
||||
export function mountPetOverlay(): void {
|
||||
const style = document.createElement('style')
|
||||
style.textContent = 'html,body,#root{background:transparent !important;}'
|
||||
document.head.appendChild(style)
|
||||
|
||||
const root = document.getElementById('root')
|
||||
|
||||
if (!root) {
|
||||
return
|
||||
}
|
||||
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<ErrorBoundary label="pet-overlay">
|
||||
<ThemeProvider>
|
||||
<PetOverlayApp />
|
||||
</ThemeProvider>
|
||||
</ErrorBoundary>
|
||||
</StrictMode>
|
||||
)
|
||||
}
|
||||
345
apps/desktop/src/app/pet-overlay/pet-overlay-app.tsx
Normal file
345
apps/desktop/src/app/pet-overlay/pet-overlay-app.tsx
Normal file
@ -0,0 +1,345 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { PetBubble } from '@/components/pet/pet-bubble'
|
||||
import { PetSprite } from '@/components/pet/pet-sprite'
|
||||
import { Mail } from '@/lib/icons'
|
||||
import { $petActivity, $petInfo, setPetInfo } from '@/store/pet'
|
||||
import { setAwaitingResponse, setBusy } from '@/store/session'
|
||||
|
||||
/**
|
||||
* The pop-out overlay's only view: a transparent, draggable mascot with a mini
|
||||
* composer.
|
||||
*
|
||||
* This runs in a separate, gateway-less BrowserWindow (`?win=overlay`). It is a
|
||||
* pure puppet — the main renderer pushes the live pet state over IPC and we
|
||||
* mirror it into the same atoms the in-window pet reads, so `PetSprite` /
|
||||
* `PetBubble` render identically with zero extra logic.
|
||||
*
|
||||
* The window is a full rectangle but mostly transparent; we toggle OS-level
|
||||
* mouse click-through so only the sprite (or the open composer) is interactive
|
||||
* and the empty margins pass clicks through to whatever is behind.
|
||||
*
|
||||
* Gestures on the pet: drag to move it anywhere on screen (even outside the
|
||||
* app), shift-click to pop it back into the window, single-click to open a small
|
||||
* composer, double-click to toggle the app window (minimize ↔ restore). A mail
|
||||
* icon (shown only when a turn finished while you were away) raises the app on
|
||||
* the most recent thread.
|
||||
*/
|
||||
|
||||
// Below this much pointer travel, a press counts as a click, not a drag.
|
||||
const CLICK_SLOP_PX = 3
|
||||
// A second click within this window is a double-click (raise app) and cancels
|
||||
// the deferred single-click (open composer), so a double never flashes it open.
|
||||
const DOUBLE_CLICK_MS = 250
|
||||
|
||||
interface DragState {
|
||||
startX: number
|
||||
startY: number
|
||||
offX: number
|
||||
offY: number
|
||||
width: number
|
||||
height: number
|
||||
moved: boolean
|
||||
}
|
||||
|
||||
export function PetOverlayApp() {
|
||||
const info = useStore($petInfo)
|
||||
const [composerOpen, setComposerOpen] = useState(false)
|
||||
const [draft, setDraft] = useState('')
|
||||
// Mirrored from the main renderer: a finish landed while you were away.
|
||||
const [unread, setUnread] = useState(false)
|
||||
|
||||
const dragRef = useRef<DragState | null>(null)
|
||||
const petRef = useRef<HTMLDivElement | null>(null)
|
||||
const inputRef = useRef<HTMLInputElement | null>(null)
|
||||
const ignoreRef = useRef(true)
|
||||
const composerOpenRef = useRef(false)
|
||||
const clickTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
||||
|
||||
const setIgnore = (ignore: boolean) => {
|
||||
if (ignoreRef.current !== ignore) {
|
||||
ignoreRef.current = ignore
|
||||
window.hermesDesktop?.petOverlay?.setIgnoreMouse(ignore)
|
||||
}
|
||||
}
|
||||
|
||||
// Mirror pushed state into the shared atoms so PetSprite/PetBubble just work.
|
||||
useEffect(() => {
|
||||
const off = window.hermesDesktop?.petOverlay?.onState(payload => {
|
||||
setPetInfo(payload.info)
|
||||
$petActivity.set(payload.activity ?? {})
|
||||
setBusy(Boolean(payload.busy))
|
||||
setAwaitingResponse(Boolean(payload.awaiting))
|
||||
setUnread(Boolean(payload.unread))
|
||||
})
|
||||
|
||||
// Tell the main renderer we're mounted so it pushes the current frame (the
|
||||
// subscribe-time pushes during open() can land before this view exists).
|
||||
window.hermesDesktop?.petOverlay?.control({ type: 'ready' })
|
||||
|
||||
return off
|
||||
}, [])
|
||||
|
||||
// Click-through: make only the sprite (or an open composer) interactive. With
|
||||
// ignore+forward, the renderer still receives mousemove so we can re-enable
|
||||
// hit-testing the moment the cursor returns to the pet.
|
||||
useEffect(() => {
|
||||
setIgnore(true)
|
||||
|
||||
const onMove = (ev: MouseEvent) => {
|
||||
if (dragRef.current || composerOpenRef.current) {
|
||||
setIgnore(false)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const el = petRef.current
|
||||
|
||||
if (!el) {
|
||||
return
|
||||
}
|
||||
|
||||
const r = el.getBoundingClientRect()
|
||||
const over = ev.clientX >= r.left && ev.clientX <= r.right && ev.clientY >= r.top && ev.clientY <= r.bottom
|
||||
setIgnore(!over)
|
||||
}
|
||||
|
||||
window.addEventListener('mousemove', onMove)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', onMove)
|
||||
clearTimeout(clickTimerRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// The whole window must stay interactive while the composer is open (so the
|
||||
// input keeps focus); focus it on open. The overlay is a non-activating panel
|
||||
// (so it never steals the app's cmd/alt-tab anchor) — flip it focusable while
|
||||
// the composer needs the keyboard, then back to non-activating when it closes.
|
||||
useEffect(() => {
|
||||
composerOpenRef.current = composerOpen
|
||||
|
||||
window.hermesDesktop?.petOverlay?.setFocusable(composerOpen)
|
||||
|
||||
if (composerOpen) {
|
||||
setIgnore(false)
|
||||
// The OS window has to become key first (setFocusable + focus happen in
|
||||
// the main process), so focus the input on the next frame.
|
||||
requestAnimationFrame(() => inputRef.current?.focus())
|
||||
}
|
||||
}, [composerOpen])
|
||||
|
||||
const onPetPointerDown = (e: React.PointerEvent) => {
|
||||
if (e.button !== 0) {
|
||||
return
|
||||
}
|
||||
|
||||
;(e.target as Element).setPointerCapture?.(e.pointerId)
|
||||
dragRef.current = {
|
||||
height: window.outerHeight,
|
||||
moved: false,
|
||||
offX: e.screenX - window.screenX,
|
||||
offY: e.screenY - window.screenY,
|
||||
startX: e.screenX,
|
||||
startY: e.screenY,
|
||||
width: window.outerWidth
|
||||
}
|
||||
}
|
||||
|
||||
const onPetPointerMove = (e: React.PointerEvent) => {
|
||||
const drag = dragRef.current
|
||||
|
||||
if (!drag) {
|
||||
return
|
||||
}
|
||||
|
||||
if (Math.hypot(e.screenX - drag.startX, e.screenY - drag.startY) > CLICK_SLOP_PX) {
|
||||
drag.moved = true
|
||||
}
|
||||
|
||||
window.hermesDesktop?.petOverlay?.setBounds({
|
||||
height: drag.height,
|
||||
width: drag.width,
|
||||
x: e.screenX - drag.offX,
|
||||
y: e.screenY - drag.offY
|
||||
})
|
||||
}
|
||||
|
||||
const onPetPointerUp = (e: React.PointerEvent) => {
|
||||
const drag = dragRef.current
|
||||
dragRef.current = null
|
||||
;(e.target as Element).releasePointerCapture?.(e.pointerId)
|
||||
|
||||
if (!drag) {
|
||||
return
|
||||
}
|
||||
|
||||
if (drag.moved) {
|
||||
// A drag cancels any deferred single-click so the composer can't pop open
|
||||
// after you reposition the pet.
|
||||
clearTimeout(clickTimerRef.current)
|
||||
clickTimerRef.current = undefined
|
||||
|
||||
// Remember the spot on the desktop (screen coords) so the pet reopens here
|
||||
// next time / after a restart.
|
||||
window.hermesDesktop?.petOverlay?.control({
|
||||
bounds: { height: drag.height, width: drag.width, x: e.screenX - drag.offX, y: e.screenY - drag.offY },
|
||||
type: 'bounds'
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Shift-click always pops the pet back in (no double-click ambiguity).
|
||||
if (e.shiftKey) {
|
||||
window.hermesDesktop?.petOverlay?.control({ type: 'pop-in' })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Double-click toggles the app window (minimize ↔ restore); defer the
|
||||
// single-click composer toggle so a double never flashes the composer open.
|
||||
if (clickTimerRef.current) {
|
||||
clearTimeout(clickTimerRef.current)
|
||||
clickTimerRef.current = undefined
|
||||
window.hermesDesktop?.petOverlay?.control({ type: 'toggle-app' })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
clickTimerRef.current = setTimeout(() => {
|
||||
clickTimerRef.current = undefined
|
||||
setComposerOpen(open => !open)
|
||||
}, DOUBLE_CLICK_MS)
|
||||
}
|
||||
|
||||
const send = () => {
|
||||
const text = draft.trim()
|
||||
|
||||
if (text) {
|
||||
window.hermesDesktop?.petOverlay?.control({ text, type: 'submit' })
|
||||
}
|
||||
|
||||
setDraft('')
|
||||
setComposerOpen(false)
|
||||
}
|
||||
|
||||
const openApp = () => {
|
||||
// Hide the icon immediately; the main renderer also clears the source flag.
|
||||
setUnread(false)
|
||||
window.hermesDesktop?.petOverlay?.control({ type: 'open-app' })
|
||||
}
|
||||
|
||||
if (!info.enabled || !info.spritesheetBase64) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
onPointerDown={e => {
|
||||
// Click on the transparent backdrop (not the pet/composer) dismisses
|
||||
// the composer.
|
||||
if (composerOpen && e.target === e.currentTarget) {
|
||||
setComposerOpen(false)
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
alignItems: 'center',
|
||||
background: 'transparent',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100vh',
|
||||
justifyContent: 'flex-end',
|
||||
paddingBottom: 24,
|
||||
userSelect: 'none',
|
||||
width: '100vw'
|
||||
}}
|
||||
>
|
||||
{composerOpen && (
|
||||
<input
|
||||
onChange={e => setDraft(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
send()
|
||||
} else if (e.key === 'Escape') {
|
||||
setComposerOpen(false)
|
||||
}
|
||||
}}
|
||||
placeholder="Message…"
|
||||
ref={inputRef}
|
||||
style={{
|
||||
background: 'var(--ui-bg-elevated)',
|
||||
border: '1px solid var(--ui-stroke-secondary)',
|
||||
borderRadius: 2,
|
||||
boxShadow: '0 6px 18px rgba(0,0,0,0.28)',
|
||||
color: 'var(--foreground)',
|
||||
fontSize: 12,
|
||||
marginBottom: 8,
|
||||
outline: 'none',
|
||||
padding: '4px 8px',
|
||||
width: 184
|
||||
}}
|
||||
value={draft}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
onPointerDown={onPetPointerDown}
|
||||
onPointerMove={onPetPointerMove}
|
||||
onPointerUp={onPetPointerUp}
|
||||
ref={petRef}
|
||||
style={{
|
||||
alignItems: 'center',
|
||||
cursor: 'grab',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'relative',
|
||||
touchAction: 'none'
|
||||
}}
|
||||
>
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
<PetBubble />
|
||||
</div>
|
||||
<div style={{ lineHeight: 0, position: 'relative' }}>
|
||||
<PetSprite info={info} />
|
||||
|
||||
{/* Mail icon: only when a finish landed while you were away. Jumps to
|
||||
the app's most recent thread. Anchored to the sprite (kept inside
|
||||
its box so the overlay's click-through hit-test still catches it);
|
||||
stopPropagation keeps a click from starting a window drag. */}
|
||||
{unread && (
|
||||
<button
|
||||
aria-label="Open in Hermes"
|
||||
onClick={openApp}
|
||||
onPointerDown={e => e.stopPropagation()}
|
||||
onPointerUp={e => e.stopPropagation()}
|
||||
style={{
|
||||
alignItems: 'center',
|
||||
background: 'var(--ui-bg-elevated)',
|
||||
border: '1px solid var(--ui-stroke-secondary)',
|
||||
borderRadius: 999,
|
||||
boxShadow: '0 4px 14px rgba(0,0,0,0.22)',
|
||||
color: 'var(--foreground)',
|
||||
cursor: 'pointer',
|
||||
display: 'inline-flex',
|
||||
height: 24,
|
||||
justifyContent: 'center',
|
||||
padding: 0,
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 0,
|
||||
width: 24
|
||||
}}
|
||||
title="Open in Hermes"
|
||||
type="button"
|
||||
>
|
||||
<Mail style={{ height: 13, width: 13 }} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -33,6 +33,7 @@ import { $gateway } from '@/store/gateway'
|
||||
import { dispatchNativeNotification } from '@/store/native-notifications'
|
||||
import { notify } from '@/store/notifications'
|
||||
import { requestDesktopOnboarding } from '@/store/onboarding'
|
||||
import { flashPetActivity, markPetUnread, setPetActivity } from '@/store/pet'
|
||||
import { clearAllPrompts, setApprovalRequest, setSecretRequest, setSudoRequest } from '@/store/prompts'
|
||||
import {
|
||||
setCurrentBranch,
|
||||
@ -869,10 +870,18 @@ export function useMessageStream({
|
||||
if (sessionId) {
|
||||
appendReasoningDelta(sessionId, coerceThinkingText(payload?.text))
|
||||
}
|
||||
|
||||
if (isActiveEvent) {
|
||||
setPetActivity({ reasoning: true })
|
||||
}
|
||||
} else if (event.type === 'reasoning.available') {
|
||||
if (sessionId) {
|
||||
appendReasoningDelta(sessionId, coerceThinkingText(payload?.text), true)
|
||||
}
|
||||
|
||||
if (isActiveEvent) {
|
||||
setPetActivity({ reasoning: true })
|
||||
}
|
||||
} else if (event.type === 'message.complete') {
|
||||
if (!sessionId) {
|
||||
return
|
||||
@ -894,6 +903,20 @@ export function useMessageStream({
|
||||
|
||||
if (isActiveEvent) {
|
||||
setTurnStartedAt(null)
|
||||
|
||||
// Pet beat: a finished turn always celebrates — go straight to the
|
||||
// jump, never linger on the run/reason pose. One atom update (clears
|
||||
// toolRunning/reasoning AND sets celebrate together) so no stray "run"
|
||||
// frame leaks to the sprite — including the popped-out overlay, which
|
||||
// mirrors each activity change. The jump runs ~2 loops, then settles.
|
||||
flashPetActivity({ celebrate: true, reasoning: false, toolRunning: false }, 2200)
|
||||
|
||||
// Light up the pet's mail icon if the user wasn't looking when the turn
|
||||
// finished — a glanceable "new message" hint on the popped-out overlay.
|
||||
// Cleared when they open the app via the mail icon or refocus the window.
|
||||
if (typeof document !== 'undefined' && !document.hasFocus()) {
|
||||
markPetUnread()
|
||||
}
|
||||
}
|
||||
|
||||
if (payload?.usage) {
|
||||
@ -906,10 +929,19 @@ export function useMessageStream({
|
||||
|
||||
flushQueuedDeltas(sessionId)
|
||||
upsertToolCall(sessionId, toTodoPayload(payload) ?? payload, 'running', event.type)
|
||||
|
||||
if (isActiveEvent) {
|
||||
setPetActivity({ reasoning: false, toolRunning: true })
|
||||
}
|
||||
} else if (event.type === 'tool.complete') {
|
||||
if (sessionId) {
|
||||
flushQueuedDeltas(sessionId)
|
||||
upsertToolCall(sessionId, toTodoPayload(payload) ?? payload, 'complete', event.type)
|
||||
|
||||
if (isActiveEvent) {
|
||||
setPetActivity({ toolRunning: false })
|
||||
}
|
||||
|
||||
// A pending clarify blocks the turn, so the first tool.complete after
|
||||
// one is the clarify resolving — drop the "needs input" flag here so
|
||||
// the sidebar indicator clears as soon as it's answered, not only at
|
||||
@ -1093,6 +1125,11 @@ export function useMessageStream({
|
||||
compactedTurnRef.current.delete(sessionId)
|
||||
}
|
||||
|
||||
if (isActiveEvent) {
|
||||
setPetActivity({ reasoning: false, toolRunning: false })
|
||||
flashPetActivity({ error: true })
|
||||
}
|
||||
|
||||
dispatchNativeNotification({
|
||||
body: errorMessage,
|
||||
kind: 'turnError',
|
||||
|
||||
@ -27,6 +27,7 @@ import { triggerHaptic } from '@/lib/haptics'
|
||||
import { setMutableRef } from '@/lib/mutable-ref'
|
||||
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
|
||||
import { setSessionYolo } from '@/lib/yolo-session'
|
||||
import { openCommandPalettePage } from '@/store/command-palette'
|
||||
import {
|
||||
$composerAttachments,
|
||||
clearComposerAttachments,
|
||||
@ -38,6 +39,7 @@ import {
|
||||
import { resetSessionBackground } from '@/store/composer-status'
|
||||
import { clearNotifications, notify, notifyError } from '@/store/notifications'
|
||||
import { requestDesktopOnboarding } from '@/store/onboarding'
|
||||
import { setPetScale } from '@/store/pet-gallery'
|
||||
import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile'
|
||||
import {
|
||||
$busy,
|
||||
@ -57,8 +59,8 @@ import { clearSessionSubagents } from '@/store/subagents'
|
||||
import { clearSessionTodos } from '@/store/todos'
|
||||
|
||||
import type {
|
||||
ClientSessionState,
|
||||
BrowserManageResponse,
|
||||
ClientSessionState,
|
||||
FileAttachResponse,
|
||||
HandoffFailResponse,
|
||||
HandoffRequestResponse,
|
||||
@ -1143,6 +1145,35 @@ export function usePromptActions({
|
||||
renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
},
|
||||
pet: async ctx => {
|
||||
const [sub = '', rawValue = ''] = ctx.arg.trim().split(/\s+/)
|
||||
const lower = sub.toLowerCase()
|
||||
|
||||
if (lower === 'list' || lower === 'gallery' || lower === 'browse' || lower === 'all') {
|
||||
openCommandPalettePage('pets')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// `/pet scale <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
|
||||
@ -1359,6 +1390,7 @@ export function usePromptActions({
|
||||
|
||||
const cancelRun = useCallback(async () => {
|
||||
const sessionId = activeSessionId || activeSessionIdRef.current
|
||||
|
||||
const releaseBusy = () => {
|
||||
setMutableRef(busyRef, false)
|
||||
setBusy(false)
|
||||
|
||||
@ -1,30 +1,31 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { LanguageSwitcher } from '@/components/language-switcher'
|
||||
import { SegmentedControl } from '@/components/ui/segmented-control'
|
||||
import type { DesktopMarketplaceSearchItem } from '@/global'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Check, Download, Loader2, Palette, Trash2 } from '@/lib/icons'
|
||||
import { selectableCardClass } from '@/lib/selectable-card'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/profile'
|
||||
import { $toolViewMode, setToolViewMode } from '@/store/tool-view'
|
||||
import { $translucency, setTranslucency } from '@/store/translucency'
|
||||
import { useTheme } from '@/themes/context'
|
||||
import { getBaseColors, useTheme } from '@/themes/context'
|
||||
import { installVscodeThemeFromMarketplace } from '@/themes/install'
|
||||
import { isUserTheme, removeUserTheme, resolveTheme } from '@/themes/user-themes'
|
||||
import { isUserTheme, removeUserTheme } from '@/themes/user-themes'
|
||||
|
||||
import { MODE_OPTIONS } from './constants'
|
||||
import { PetSettings } from './pet-settings'
|
||||
import { ListRow, SectionHeading, SettingsContent } from './primitives'
|
||||
|
||||
function ThemePreview({ name }: { name: string }) {
|
||||
const t = resolveTheme(name)
|
||||
|
||||
if (!t) {
|
||||
return null
|
||||
}
|
||||
|
||||
const c = t.colors
|
||||
function ThemePreview({ name, mode }: { name: string; mode: 'light' | 'dark' }) {
|
||||
// Preview in the *current* mode: the dark palette in Dark, and the light
|
||||
// palette in Light — synthesizing one for dark-only themes — so every card
|
||||
// tracks the Light/Dark toggle, exactly like the app itself does.
|
||||
const c = getBaseColors(name, mode)
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -57,90 +58,200 @@ function ThemePreview({ name }: { name: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
function VscodeThemeInstaller() {
|
||||
function useDebounced<T>(value: T, delayMs: number): T {
|
||||
const [debounced, setDebounced] = useState(value)
|
||||
|
||||
useEffect(() => {
|
||||
const handle = setTimeout(() => setDebounced(value), delayMs)
|
||||
|
||||
return () => clearTimeout(handle)
|
||||
}, [value, delayMs])
|
||||
|
||||
return debounced
|
||||
}
|
||||
|
||||
const compactNumber = new Intl.NumberFormat(undefined, { notation: 'compact', maximumFractionDigits: 1 })
|
||||
|
||||
/**
|
||||
* Live VS Code Marketplace theme search (the same backend as the Cmd-K "Install
|
||||
* theme…" page). Renders below the local grid when there's a query: each row
|
||||
* downloads + converts + installs via `installVscodeThemeFromMarketplace` and
|
||||
* activates it. Extensions already imported locally are marked installed.
|
||||
*/
|
||||
function MarketplaceThemeResults({
|
||||
query,
|
||||
installedExtIds,
|
||||
onInstalled
|
||||
}: {
|
||||
query: string
|
||||
installedExtIds: Set<string>
|
||||
onInstalled: (name: string) => void
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const { setTheme } = useTheme()
|
||||
const a = t.settings.appearance
|
||||
const [id, setId] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [status, setStatus] = useState<{ kind: 'error' | 'success'; text: string } | null>(null)
|
||||
const copy = t.commandCenter.installTheme
|
||||
const debounced = useDebounced(query.trim(), 300)
|
||||
const [installingId, setInstallingId] = useState<string | null>(null)
|
||||
const [installedHere, setInstalledHere] = useState<Record<string, true>>({})
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const install = async () => {
|
||||
const trimmed = id.trim()
|
||||
const search = useQuery({
|
||||
enabled: debounced.length > 0,
|
||||
queryFn: () => window.hermesDesktop?.themes?.searchMarketplace(debounced) ?? Promise.resolve([]),
|
||||
queryKey: ['marketplace-themes-settings', debounced],
|
||||
staleTime: 5 * 60 * 1000
|
||||
})
|
||||
|
||||
if (!trimmed || busy) {
|
||||
const install = async (item: DesktopMarketplaceSearchItem) => {
|
||||
if (installingId) {
|
||||
return
|
||||
}
|
||||
|
||||
setBusy(true)
|
||||
setStatus(null)
|
||||
setInstallingId(item.extensionId)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const theme = await installVscodeThemeFromMarketplace(trimmed)
|
||||
const theme = await installVscodeThemeFromMarketplace(item.extensionId)
|
||||
|
||||
triggerHaptic('crisp')
|
||||
setTheme(theme.name)
|
||||
setStatus({ kind: 'success', text: a.installed(theme.label) })
|
||||
setId('')
|
||||
} catch (error) {
|
||||
setStatus({ kind: 'error', text: error instanceof Error ? error.message : a.installError })
|
||||
setInstalledHere(prev => ({ ...prev, [item.extensionId]: true }))
|
||||
onInstalled(theme.name)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : copy.error)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
setInstallingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
className="min-w-0 flex-1 rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) px-3 py-1.5 font-mono text-[length:var(--conversation-caption-font-size)] outline-none placeholder:text-(--ui-text-tertiary) focus:border-(--ui-stroke-secondary)"
|
||||
disabled={busy}
|
||||
onChange={event => {
|
||||
setId(event.target.value)
|
||||
setStatus(null)
|
||||
}}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter') {
|
||||
void install()
|
||||
}
|
||||
}}
|
||||
placeholder={a.installPlaceholder}
|
||||
spellCheck={false}
|
||||
value={id}
|
||||
/>
|
||||
<button
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary) px-3 py-1.5 text-[length:var(--conversation-caption-font-size)] font-medium transition hover:bg-(--chrome-action-hover) disabled:opacity-50"
|
||||
disabled={busy || !id.trim()}
|
||||
onClick={() => void install()}
|
||||
type="button"
|
||||
>
|
||||
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Download className="size-3.5" />}
|
||||
{busy ? a.installing : a.installButton}
|
||||
</button>
|
||||
</div>
|
||||
{status && (
|
||||
<p
|
||||
className={cn(
|
||||
'mt-2 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height)',
|
||||
status.kind === 'error' ? 'text-(--ui-red)' : 'text-(--ui-text-tertiary)'
|
||||
)}
|
||||
>
|
||||
{status.text}
|
||||
if (!debounced) {
|
||||
return null
|
||||
}
|
||||
|
||||
const header = (
|
||||
<p className="mb-2 mt-4 text-[length:var(--conversation-caption-font-size)] font-medium text-(--ui-text-tertiary)">
|
||||
From the VS Code Marketplace
|
||||
</p>
|
||||
)
|
||||
|
||||
if (search.isLoading) {
|
||||
return (
|
||||
<>
|
||||
{header}
|
||||
<p className="flex items-center gap-2 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
{copy.loading}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (search.isError) {
|
||||
return (
|
||||
<>
|
||||
{header}
|
||||
<p className="text-[length:var(--conversation-caption-font-size)] text-(--ui-red)">{copy.error}</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const results = search.data ?? []
|
||||
|
||||
if (results.length === 0) {
|
||||
return (
|
||||
<>
|
||||
{header}
|
||||
<p className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">{copy.empty}</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{header}
|
||||
{error && <p className="mb-2 text-[length:var(--conversation-caption-font-size)] text-(--ui-red)">{error}</p>}
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{results.map(item => {
|
||||
const busy = installingId === item.extensionId
|
||||
const done = installedHere[item.extensionId] || installedExtIds.has(item.extensionId)
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'flex items-center gap-2.5 px-2.5 py-2 text-left disabled:opacity-60',
|
||||
selectableCardClass({ prominent: done })
|
||||
)}
|
||||
disabled={Boolean(installingId) && !busy}
|
||||
key={item.extensionId}
|
||||
onClick={() => void install(item)}
|
||||
type="button"
|
||||
>
|
||||
<Palette className="size-4 shrink-0 text-(--ui-text-tertiary)" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[length:var(--conversation-text-font-size)] font-medium">
|
||||
{item.displayName}
|
||||
</span>
|
||||
<span className="block truncate text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
{item.publisher}
|
||||
{item.installs > 0 ? ` · ${copy.installs(compactNumber.format(item.installs))}` : ''}
|
||||
</span>
|
||||
</span>
|
||||
<span className="shrink-0 text-(--ui-text-tertiary)">
|
||||
{busy ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : done ? (
|
||||
<Check className="size-4 text-(--ui-green)" />
|
||||
) : (
|
||||
<Download className="size-4" />
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function AppearanceSettings() {
|
||||
const { t, isSavingLocale } = useI18n()
|
||||
const { themeName, mode, availableThemes, setTheme, setMode } = useTheme()
|
||||
const { themeName, mode, resolvedMode, availableThemes, setTheme, setMode } = useTheme()
|
||||
const toolViewMode = useStore($toolViewMode)
|
||||
const translucency = useStore($translucency)
|
||||
const profiles = useStore($profiles)
|
||||
const activeProfileKey = normalizeProfileKey(useStore($activeGatewayProfile))
|
||||
const a = t.settings.appearance
|
||||
|
||||
const [query, setQuery] = useState('')
|
||||
|
||||
// One box does double duty: filter installed themes live (below), and run a
|
||||
// name search against the VS Code Marketplace (the Cmd-K "Install theme…"
|
||||
// backend) for anything not already installed.
|
||||
const needle = query.trim().toLowerCase()
|
||||
|
||||
const filteredThemes = availableThemes
|
||||
.filter(
|
||||
theme =>
|
||||
!needle ||
|
||||
theme.label.toLowerCase().includes(needle) ||
|
||||
theme.name.toLowerCase().includes(needle) ||
|
||||
theme.description.toLowerCase().includes(needle)
|
||||
)
|
||||
// Active theme first; stable sort keeps the rest in their original order.
|
||||
.sort((a, b) => Number(b.name === themeName) - Number(a.name === themeName))
|
||||
|
||||
// Marketplace imports describe themselves as "VS Code · <publisher.extension>";
|
||||
// pull those ids back out so search results already imported show as installed.
|
||||
const MARKETPLACE_DESC_PREFIX = 'VS Code · '
|
||||
|
||||
const installedExtIds = new Set(
|
||||
availableThemes
|
||||
.map(theme =>
|
||||
theme.description.startsWith(MARKETPLACE_DESC_PREFIX)
|
||||
? theme.description.slice(MARKETPLACE_DESC_PREFIX.length)
|
||||
: ''
|
||||
)
|
||||
.filter(Boolean)
|
||||
)
|
||||
|
||||
// Themes save per profile. Surface that only when the user actually has more
|
||||
// than one profile (single-profile installs never see the distinction).
|
||||
const showProfileNote = profiles.length > 1
|
||||
@ -163,7 +274,7 @@ export function AppearanceSettings() {
|
||||
{a.intro}
|
||||
</p>
|
||||
|
||||
<div className="mt-2 divide-y divide-(--ui-stroke-tertiary)">
|
||||
<div className="mt-2">
|
||||
<ListRow
|
||||
action={<LanguageSwitcher />}
|
||||
description={isSavingLocale ? t.language.saving : t.language.description}
|
||||
@ -171,18 +282,107 @@ export function AppearanceSettings() {
|
||||
/>
|
||||
|
||||
<ListRow
|
||||
action={
|
||||
<SegmentedControl
|
||||
onChange={id => {
|
||||
triggerHaptic('crisp')
|
||||
setMode(id)
|
||||
}}
|
||||
options={modeOptions}
|
||||
value={mode}
|
||||
/>
|
||||
below={
|
||||
<>
|
||||
{/* One search box: filters your installed themes (the grid)
|
||||
and live-searches the VS Code Marketplace below. */}
|
||||
<div className="mt-3">
|
||||
<input
|
||||
className="w-full rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) px-3 py-1.5 text-[length:var(--conversation-caption-font-size)] outline-none placeholder:text-(--ui-text-tertiary) focus:border-(--ui-stroke-secondary)"
|
||||
onChange={event => setQuery(event.target.value)}
|
||||
placeholder="Search your themes or the VS Code Marketplace…"
|
||||
spellCheck={false}
|
||||
value={query}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Fixed-height scroll area so the (growing) theme list never
|
||||
runs the page long; the grid scrolls inside it. */}
|
||||
<div className="mt-3 max-h-96 overflow-y-auto pr-1">
|
||||
{filteredThemes.length === 0 ? (
|
||||
needle ? (
|
||||
<p className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
No installed themes match "{query.trim()}".
|
||||
</p>
|
||||
) : null
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{filteredThemes.map(theme => {
|
||||
const active = themeName === theme.name
|
||||
const removable = isUserTheme(theme.name)
|
||||
|
||||
return (
|
||||
<div className="group relative" key={theme.name}>
|
||||
<button
|
||||
className={cn('w-full p-2 text-left', selectableCardClass({ active, prominent: true }))}
|
||||
onClick={() => {
|
||||
triggerHaptic('crisp')
|
||||
setTheme(theme.name)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<ThemePreview mode={resolvedMode} name={theme.name} />
|
||||
<div className="mt-3 px-1">
|
||||
<div className="truncate text-[length:var(--conversation-text-font-size)] font-medium">
|
||||
{theme.label}
|
||||
</div>
|
||||
<div className="mt-0.5 line-clamp-2 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{theme.description}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{removable && (
|
||||
<button
|
||||
aria-label={a.removeTheme}
|
||||
className="absolute right-1.5 top-1.5 grid size-6 place-items-center rounded-md bg-(--ui-bg-elevated)/80 text-(--ui-text-tertiary) opacity-0 backdrop-blur-sm transition hover:text-(--ui-red) focus-visible:opacity-100 group-hover:opacity-100"
|
||||
onClick={() => {
|
||||
triggerHaptic('crisp')
|
||||
removeUserTheme(theme.name)
|
||||
|
||||
// Re-normalize off the now-missing skin → default.
|
||||
if (active) {
|
||||
setTheme(theme.name)
|
||||
}
|
||||
}}
|
||||
title={a.removeTheme}
|
||||
type="button"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<MarketplaceThemeResults
|
||||
installedExtIds={installedExtIds}
|
||||
onInstalled={name => setTheme(name)}
|
||||
query={query}
|
||||
/>
|
||||
</div>
|
||||
{showProfileNote && (
|
||||
<p className="mt-3 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{a.themeProfileNote(activeProfileName)}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
description={a.colorModeDesc}
|
||||
title={a.colorMode}
|
||||
description={a.themeDesc}
|
||||
title={
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span>{a.themeTitle}</span>
|
||||
<SegmentedControl
|
||||
onChange={id => {
|
||||
triggerHaptic('crisp')
|
||||
setMode(id)
|
||||
}}
|
||||
options={modeOptions}
|
||||
value={mode}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
wide
|
||||
/>
|
||||
|
||||
<ListRow
|
||||
@ -211,80 +411,6 @@ export function AppearanceSettings() {
|
||||
title={a.translucencyTitle}
|
||||
/>
|
||||
|
||||
<ListRow
|
||||
below={
|
||||
<>
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{availableThemes.map(theme => {
|
||||
const active = themeName === theme.name
|
||||
const removable = isUserTheme(theme.name)
|
||||
|
||||
return (
|
||||
<div className="group relative" key={theme.name}>
|
||||
<button
|
||||
className={cn(
|
||||
'w-full rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-2 text-left transition hover:bg-(--chrome-action-hover)',
|
||||
active && 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)'
|
||||
)}
|
||||
onClick={() => {
|
||||
triggerHaptic('crisp')
|
||||
setTheme(theme.name)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<ThemePreview name={theme.name} />
|
||||
<div className="mt-3 flex items-start justify-between gap-3 px-1">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[length:var(--conversation-text-font-size)] font-medium">
|
||||
{theme.label}
|
||||
</div>
|
||||
<div className="mt-0.5 line-clamp-2 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{theme.description}
|
||||
</div>
|
||||
</div>
|
||||
{active && (
|
||||
<span className="mt-0.5 grid size-5 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground">
|
||||
<Check className="size-3.5" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
{removable && (
|
||||
<button
|
||||
aria-label={a.removeTheme}
|
||||
className="absolute right-1.5 top-1.5 grid size-6 place-items-center rounded-md bg-(--ui-bg-elevated)/80 text-(--ui-text-tertiary) opacity-0 backdrop-blur-sm transition hover:text-(--ui-red) focus-visible:opacity-100 group-hover:opacity-100"
|
||||
onClick={() => {
|
||||
triggerHaptic('crisp')
|
||||
removeUserTheme(theme.name)
|
||||
|
||||
// Re-normalize off the now-missing skin → default.
|
||||
if (active) {
|
||||
setTheme(theme.name)
|
||||
}
|
||||
}}
|
||||
title={a.removeTheme}
|
||||
type="button"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<VscodeThemeInstaller />
|
||||
{showProfileNote && (
|
||||
<p className="mt-3 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{a.themeProfileNote(activeProfileName)}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
description={a.themeDesc}
|
||||
title={a.themeTitle}
|
||||
wide
|
||||
/>
|
||||
|
||||
<ListRow
|
||||
action={
|
||||
<SegmentedControl
|
||||
@ -301,6 +427,10 @@ export function AppearanceSettings() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<PetSettings />
|
||||
</div>
|
||||
</SettingsContent>
|
||||
)
|
||||
}
|
||||
|
||||
231
apps/desktop/src/app/settings/pet-settings.tsx
Normal file
231
apps/desktop/src/app/settings/pet-settings.tsx
Normal file
@ -0,0 +1,231 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
|
||||
import { PetThumb } from '@/components/pet/pet-thumb'
|
||||
import { SegmentedControl } from '@/components/ui/segmented-control'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Loader2, PawPrint, Trash2 } from '@/lib/icons'
|
||||
import { selectableCardClass } from '@/lib/selectable-card'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $petInfo } from '@/store/pet'
|
||||
import {
|
||||
$petBusy,
|
||||
$petGallery,
|
||||
$petGalleryError,
|
||||
$petGalleryStatus,
|
||||
adoptPet,
|
||||
loadPetGallery,
|
||||
loadPetThumb,
|
||||
PET_SCALE_DEFAULT,
|
||||
PET_SCALE_MAX,
|
||||
PET_SCALE_MIN,
|
||||
rankedGalleryPets,
|
||||
removePet as removePetAction,
|
||||
setPetEnabled,
|
||||
setPetScale
|
||||
} from '@/store/pet-gallery'
|
||||
import { $gatewayState } from '@/store/session'
|
||||
|
||||
import { ListRow, SectionHeading } from './primitives'
|
||||
|
||||
/**
|
||||
* Appearance opt-in for the floating petdex mascot. A thin view over the shared
|
||||
* `pet-gallery` store — it subscribes to the atoms and calls the store actions,
|
||||
* so the gallery is fetched once + cached and adopt/toggle/remove patch local
|
||||
* state instead of re-pulling the network gallery. The floating mascot polls
|
||||
* `pet.info`, so picking a pet here lights it up within a couple seconds.
|
||||
*/
|
||||
export function PetSettings() {
|
||||
const { t } = useI18n()
|
||||
const copy = t.settings.appearance.pet
|
||||
const { requestGateway } = useGatewayRequest()
|
||||
const gatewayState = useStore($gatewayState)
|
||||
const gallery = useStore($petGallery)
|
||||
const status = useStore($petGalleryStatus)
|
||||
const error = useStore($petGalleryError)
|
||||
const busySlug = useStore($petBusy)
|
||||
const petInfo = useStore($petInfo)
|
||||
const [query, setQuery] = useState('')
|
||||
const scale = petInfo.scale ?? PET_SCALE_DEFAULT
|
||||
|
||||
useEffect(() => {
|
||||
if (gatewayState !== 'open') {
|
||||
return
|
||||
}
|
||||
|
||||
void loadPetGallery(requestGateway)
|
||||
}, [gatewayState, requestGateway])
|
||||
|
||||
const enabled = gallery?.enabled ?? false
|
||||
const active = gallery?.active ?? ''
|
||||
const pets = gallery?.pets ?? []
|
||||
const staleBackend = status === 'stale'
|
||||
|
||||
const selectPet = (slug: string) => {
|
||||
void adoptPet(requestGateway, slug, copy.adoptFailed(slug)).then(ok => ok && triggerHaptic('crisp'))
|
||||
}
|
||||
|
||||
const removePet = (slug: string) => {
|
||||
void removePetAction(requestGateway, slug, copy.uninstallFailed(slug)).then(ok => ok && triggerHaptic('crisp'))
|
||||
}
|
||||
|
||||
const toggle = (on: boolean) => {
|
||||
void setPetEnabled(requestGateway, on, {
|
||||
noneAvailable: copy.noneAvailable,
|
||||
fallback: on ? copy.turnOnFailed : copy.turnOffFailed
|
||||
}).then(ok => ok && triggerHaptic('crisp'))
|
||||
}
|
||||
|
||||
// The petdex catalog is thousands of entries, so rank + cap how many render.
|
||||
const RENDER_CAP = 60
|
||||
const sorted = rankedGalleryPets(gallery, query)
|
||||
const shown = sorted.slice(0, RENDER_CAP)
|
||||
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@ -4,6 +4,7 @@ import { useSyncExternalStore } from 'react'
|
||||
|
||||
import { NotificationStack } from '@/components/notifications'
|
||||
import { PaneShell } from '@/components/pane-shell'
|
||||
import { FloatingPet } from '@/components/pet/floating-pet'
|
||||
import { SidebarProvider } from '@/components/ui/sidebar'
|
||||
import { useMediaQuery } from '@/hooks/use-media-query'
|
||||
import {
|
||||
@ -202,6 +203,10 @@ export function AppShell({
|
||||
{/* Mounted at the shell root (after overlays) so success/error toasts
|
||||
surface above every route and overlay — not just the chat view. */}
|
||||
<NotificationStack />
|
||||
|
||||
{/* Petdex floating mascot — in-window, always-on-top, reactive to agent
|
||||
activity. Renders nothing unless a pet is installed + enabled. */}
|
||||
<FloatingPet />
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
|
||||
291
apps/desktop/src/components/pet/floating-pet.tsx
Normal file
291
apps/desktop/src/components/pet/floating-pet.tsx
Normal file
@ -0,0 +1,291 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
|
||||
import { persistString, storedString } from '@/lib/storage'
|
||||
import { $petInfo, clearPetUnread, type PetInfo, setPetInfo } from '@/store/pet'
|
||||
import { $petOverlayActive, initPetOverlayBridge, popOutPet, restorePetOverlay } from '@/store/pet-overlay'
|
||||
import { $gatewayState } from '@/store/session'
|
||||
import { isSecondaryWindow } from '@/store/windows'
|
||||
import { useTheme } from '@/themes/context'
|
||||
|
||||
import { PetSprite } from './pet-sprite'
|
||||
|
||||
// v2: positions are now top/left anchored (v1 stored bottom-anchored values,
|
||||
// which dragged inverted). Bumping the key discards stale v1 coordinates.
|
||||
const POSITION_KEY = 'hermes.desktop.pet-position.v2'
|
||||
|
||||
interface Point {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
function clampToViewport({ x, y }: Point): Point {
|
||||
const maxX = Math.max(0, (window.innerWidth || 800) - 80)
|
||||
const maxY = Math.max(0, (window.innerHeight || 600) - 80)
|
||||
|
||||
return { x: Math.min(Math.max(0, x), maxX), y: Math.min(Math.max(0, y), maxY) }
|
||||
}
|
||||
|
||||
// The sprite art faces left by default, so mirror it when the pet's center sits
|
||||
// on the left half of the window — it always faces inward, toward the content.
|
||||
function facing(leftX: number, petW: number): string {
|
||||
return leftX + petW / 2 < (window.innerWidth || 800) / 2 ? 'scaleX(-1)' : 'none'
|
||||
}
|
||||
|
||||
function loadPosition(): Point {
|
||||
try {
|
||||
const raw = storedString(POSITION_KEY)
|
||||
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as Point
|
||||
|
||||
if (typeof parsed.x === 'number' && typeof parsed.y === 'number') {
|
||||
return clampToViewport(parsed)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// fall through to default
|
||||
}
|
||||
|
||||
// Default: lower-left corner (top/left anchored).
|
||||
return clampToViewport({ x: 24, y: (window.innerHeight || 600) - 220 })
|
||||
}
|
||||
|
||||
/**
|
||||
* In-window floating petdex mascot. Always-on-top within the app, draggable,
|
||||
* and reactive to agent activity via `$petState`. Fetches the active pet via
|
||||
* the shared `pet.info` RPC; renders nothing until a pet is installed +
|
||||
* enabled.
|
||||
*
|
||||
* Adopting a pet is fully in-app: type `/pet boba` in the composer. That
|
||||
* writes `display.pet.*` from the slash worker, so we keep polling `pet.info`
|
||||
* while no pet is active and the mascot pops in within a few seconds — no
|
||||
* reload, no CLI. Once a pet is live we stop polling.
|
||||
*
|
||||
* Promotion to a separate frameless OS-level window is a follow-up — the
|
||||
* sprite + state logic here is reused as-is, only the host changes.
|
||||
*/
|
||||
const PET_POLL_MS = 3000
|
||||
|
||||
export function FloatingPet() {
|
||||
const { requestGateway } = useGatewayRequest()
|
||||
const { resolvedMode } = useTheme()
|
||||
const gatewayState = useStore($gatewayState)
|
||||
const info = useStore($petInfo)
|
||||
const overlayActive = useStore($petOverlayActive)
|
||||
|
||||
const [position, setPosition] = useState<Point>(loadPosition)
|
||||
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||
// The facing mirror lives on the sprite wrapper, not the container, so the
|
||||
// speech bubble (a container child) never renders flipped/backwards.
|
||||
const spriteWrapRef = useRef<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])
|
||||
|
||||
// Wire the overlay control channel once, only in the primary window — the
|
||||
// pop-out overlay belongs to it (main.cjs positions it against the main
|
||||
// window and routes control messages back to it).
|
||||
useEffect(() => {
|
||||
if (isSecondaryWindow()) {
|
||||
return
|
||||
}
|
||||
|
||||
return initPetOverlayBridge()
|
||||
}, [])
|
||||
|
||||
// Returning to the app (by any route, not just the mail icon) clears the pet's
|
||||
// "new message" hint — you've seen it now.
|
||||
useEffect(() => {
|
||||
if (isSecondaryWindow()) {
|
||||
return
|
||||
}
|
||||
|
||||
const onFocus = () => clearPetUnread()
|
||||
window.addEventListener('focus', onFocus)
|
||||
|
||||
return () => window.removeEventListener('focus', onFocus)
|
||||
}, [])
|
||||
|
||||
// Restore a popped-out pet on boot, once the pet has loaded (so we never spawn
|
||||
// an empty overlay window). Primary window only; runs at most once.
|
||||
const restoredRef = useRef(false)
|
||||
useEffect(() => {
|
||||
if (isSecondaryWindow() || restoredRef.current || !active) {
|
||||
return
|
||||
}
|
||||
|
||||
restoredRef.current = true
|
||||
restorePetOverlay()
|
||||
}, [active])
|
||||
|
||||
// A window resize must never strand the pet off-screen — re-clamp the
|
||||
// committed position (and persist it) whenever the viewport shrinks.
|
||||
useEffect(() => {
|
||||
const onResize = () =>
|
||||
setPosition(prev => {
|
||||
const next = clampToViewport(prev)
|
||||
|
||||
if (next.x === prev.x && next.y === prev.y) {
|
||||
return prev
|
||||
}
|
||||
|
||||
persistString(POSITION_KEY, JSON.stringify(next))
|
||||
|
||||
return next
|
||||
})
|
||||
|
||||
window.addEventListener('resize', onResize)
|
||||
|
||||
return () => window.removeEventListener('resize', onResize)
|
||||
}, [])
|
||||
|
||||
const onPointerDown = useCallback((e: React.PointerEvent) => {
|
||||
const el = containerRef.current
|
||||
|
||||
if (!el) {
|
||||
return
|
||||
}
|
||||
|
||||
const rect = el.getBoundingClientRect()
|
||||
|
||||
// Shift-click pops the pet out into a free-floating desktop overlay (it can
|
||||
// leave the window and stays visible while Hermes is minimized) instead of
|
||||
// starting an in-window drag. Primary window only — the overlay is anchored
|
||||
// to it.
|
||||
if (e.shiftKey && !isSecondaryWindow()) {
|
||||
popOutPet({ height: rect.height, width: rect.width, x: rect.left, y: rect.top })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
dragRef.current = { dx: e.clientX - rect.left, dy: e.clientY - rect.top, x: rect.left, y: rect.top }
|
||||
el.setPointerCapture(e.pointerId)
|
||||
el.style.cursor = 'grabbing'
|
||||
}, [])
|
||||
|
||||
const onPointerMove = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
const drag = dragRef.current
|
||||
const el = containerRef.current
|
||||
|
||||
if (!drag || !el) {
|
||||
return
|
||||
}
|
||||
|
||||
const next = clampToViewport({ x: e.clientX - drag.dx, y: e.clientY - drag.dy })
|
||||
drag.x = next.x
|
||||
drag.y = next.y
|
||||
// Mutate the DOM directly — no setState, so no re-render while dragging. The
|
||||
// mirror follows the pointer across the midline for the same reason; it
|
||||
// rides the sprite wrapper so the bubble stays upright.
|
||||
el.style.left = `${next.x}px`
|
||||
el.style.top = `${next.y}px`
|
||||
|
||||
if (spriteWrapRef.current) {
|
||||
spriteWrapRef.current.style.transform = facing(next.x, petW)
|
||||
}
|
||||
},
|
||||
[petW]
|
||||
)
|
||||
|
||||
const onPointerUp = useCallback((e: React.PointerEvent) => {
|
||||
const drag = dragRef.current
|
||||
|
||||
if (drag) {
|
||||
dragRef.current = null
|
||||
const committed = { x: drag.x, y: drag.y }
|
||||
setPosition(committed)
|
||||
persistString(POSITION_KEY, JSON.stringify(committed))
|
||||
}
|
||||
|
||||
const el = containerRef.current
|
||||
|
||||
if (el) {
|
||||
el.style.cursor = 'grab'
|
||||
el.releasePointerCapture?.(e.pointerId)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// While popped out, the desktop overlay window owns the mascot — hide the
|
||||
// in-window one so there aren't two.
|
||||
if (!info.enabled || !info.spritesheetBase64 || overlayActive) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
ref={containerRef}
|
||||
style={{
|
||||
cursor: 'grab',
|
||||
left: position.x,
|
||||
pointerEvents: 'auto',
|
||||
position: 'fixed',
|
||||
top: position.y,
|
||||
touchAction: 'none',
|
||||
userSelect: 'none',
|
||||
zIndex: 60
|
||||
}}
|
||||
>
|
||||
<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 ref={spriteWrapRef} style={{ lineHeight: 0, position: 'relative', transform: facing(position.x, petW), zIndex: 1 }}>
|
||||
<PetSprite info={info} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
142
apps/desktop/src/components/pet/pet-bubble.tsx
Normal file
142
apps/desktop/src/components/pet/pet-bubble.tsx
Normal file
@ -0,0 +1,142 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { AlertCircle, Clock, type IconComponent } from '@/lib/icons'
|
||||
import { $petActivity, $petState, type PetState } from '@/store/pet'
|
||||
|
||||
/**
|
||||
* Speech bubble + status glyph for the popped-out pet overlay — the
|
||||
* "notification" half of the mascot. It externalizes what the agent is doing
|
||||
* (Codex-style) so a glance at the desktop pet replaces switching back to the
|
||||
* window. The in-window pet doesn't show it (the app itself is the surface);
|
||||
* only the overlay renders it.
|
||||
*
|
||||
* Text is derived purely from the same `$petState` / `$petActivity` the sprite
|
||||
* already reacts to, so it never drifts from the animation. The bubble is shown
|
||||
* only when there's something worth saying (working / reviewing / a transient
|
||||
* done/error beat / waiting on the user) and is hidden at plain idle.
|
||||
*/
|
||||
|
||||
type Tone = 'error' | 'wait'
|
||||
|
||||
interface Spec {
|
||||
lines: string[]
|
||||
glyph?: IconComponent
|
||||
tone?: Tone
|
||||
}
|
||||
|
||||
// Phrasings per mood, picked at random (no immediate repeat) for a bit of life.
|
||||
// Keep them short — the bubble is tiny and never wraps.
|
||||
const SPECS: Partial<Record<PetState, Spec>> = {
|
||||
run: {
|
||||
lines: ['working…', 'on it…', 'crunching…', 'tinkering…', 'cooking…', 'in the weeds…', 'wiring it up…', 'making moves…', 'heads down…', 'hammering away…']
|
||||
},
|
||||
review: {
|
||||
lines: ['thinking…', 'reading…', 'reviewing…', 'pondering…', 'connecting dots…', 'sizing it up…', 'tracing it…', 'mulling…', 'scheming…', 'hmm…']
|
||||
},
|
||||
failed: {
|
||||
glyph: AlertCircle,
|
||||
lines: ['hit a snag', 'welp', 'that broke', 'oof', 'snagged'],
|
||||
tone: 'error'
|
||||
},
|
||||
waiting: {
|
||||
glyph: Clock,
|
||||
lines: ['your turn', 'all yours', 'over to you', 'ball’s in your court', 'awaiting orders'],
|
||||
tone: 'wait'
|
||||
}
|
||||
}
|
||||
|
||||
const TONE_COLOR: Record<Tone, string> = {
|
||||
error: 'var(--ui-red)',
|
||||
wait: 'var(--ui-yellow)'
|
||||
}
|
||||
|
||||
// Random pick that avoids repeating the line we're already showing.
|
||||
function pick(lines: string[], prev: string): string {
|
||||
if (lines.length <= 1) {
|
||||
return lines[0] ?? ''
|
||||
}
|
||||
|
||||
let next = prev
|
||||
|
||||
while (next === prev) {
|
||||
next = lines[Math.floor(Math.random() * lines.length)]
|
||||
}
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
export function PetBubble() {
|
||||
const state = useStore($petState)
|
||||
const activity = useStore($petActivity)
|
||||
const [line, setLine] = useState('')
|
||||
|
||||
// Finish beats are carried by the sprite/mail icon; idle only speaks up when
|
||||
// it's actually the user's turn. Everything else maps to a mood spec.
|
||||
const specKey: null | PetState =
|
||||
state in SPECS ? state : state === 'idle' && activity.awaitingInput ? 'waiting' : null
|
||||
const rotating = specKey === 'run' || specKey === 'review'
|
||||
|
||||
// Pick a fresh line on every mood change, then keep rotating (random, no
|
||||
// repeat) only while the agent is actively working/thinking.
|
||||
useEffect(() => {
|
||||
const spec = specKey ? SPECS[specKey] : null
|
||||
|
||||
if (!spec) {
|
||||
setLine('')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setLine(prev => pick(spec.lines, prev))
|
||||
|
||||
if (!rotating || spec.lines.length <= 1) {
|
||||
return
|
||||
}
|
||||
|
||||
const id = window.setInterval(() => setLine(prev => pick(spec.lines, prev)), 2600)
|
||||
|
||||
return () => window.clearInterval(id)
|
||||
}, [specKey, rotating])
|
||||
|
||||
const spec = specKey ? SPECS[specKey] : null
|
||||
|
||||
if (!spec) {
|
||||
return null
|
||||
}
|
||||
|
||||
const Glyph = spec.glyph
|
||||
const text = line || spec.lines[0]
|
||||
const hasText = Boolean(text)
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
alignItems: 'center',
|
||||
// Solid, theme-driven surface (the prior --ui-bg-card mixes in
|
||||
// `transparent`, so the bubble was see-through).
|
||||
background: 'var(--ui-bg-elevated)',
|
||||
border: '1px solid var(--ui-stroke-secondary)',
|
||||
borderRadius: hasText ? 10 : 999,
|
||||
boxShadow: '0 4px 14px rgba(0,0,0,0.22)',
|
||||
color: 'var(--foreground)',
|
||||
display: 'inline-flex',
|
||||
fontSize: 11,
|
||||
fontWeight: 500,
|
||||
gap: hasText ? 5 : 0,
|
||||
lineHeight: 1,
|
||||
// Glyph-only bubbles collapse to a tight, symmetric badge.
|
||||
padding: hasText ? '5px 8px' : 5,
|
||||
pointerEvents: 'none',
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
>
|
||||
{Glyph && (
|
||||
<span style={{ display: 'inline-flex' }}>
|
||||
<Glyph style={{ color: spec.tone ? TONE_COLOR[spec.tone] : 'currentColor', height: 13, width: 13 }} />
|
||||
</span>
|
||||
)}
|
||||
{text}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
221
apps/desktop/src/components/pet/pet-sprite.tsx
Normal file
221
apps/desktop/src/components/pet/pet-sprite.tsx
Normal file
@ -0,0 +1,221 @@
|
||||
import { memo, useEffect, useMemo, useRef } from 'react'
|
||||
|
||||
import { $petState, type PetInfo, type PetState } from '@/store/pet'
|
||||
|
||||
const DEFAULT_FRAME_W = 192
|
||||
const DEFAULT_FRAME_H = 208
|
||||
const DEFAULT_FRAMES = 6
|
||||
const DEFAULT_LOOP_MS = 1100
|
||||
// Mirrors agent.pet.constants.DEFAULT_SCALE — fallback only; the gateway sends
|
||||
// the configured scale.
|
||||
const DEFAULT_SCALE = 0.33
|
||||
// Mirrors agent.pet.constants.CODEX_STATE_ROWS (Petdex current taxonomy).
|
||||
const DEFAULT_STATE_ROWS = [
|
||||
'idle',
|
||||
'running-right',
|
||||
'running-left',
|
||||
'waving',
|
||||
'jumping',
|
||||
'failed',
|
||||
'waiting',
|
||||
'running',
|
||||
'review'
|
||||
]
|
||||
|
||||
const STATE_ALIASES: Record<PetState, string[]> = {
|
||||
idle: ['idle'],
|
||||
wave: ['wave', 'waving'],
|
||||
jump: ['jump', 'jumping'],
|
||||
run: ['run', 'running'],
|
||||
failed: ['failed'],
|
||||
review: ['review'],
|
||||
waiting: ['waiting']
|
||||
}
|
||||
|
||||
const ROW_TO_STATE: Record<string, PetState> = {
|
||||
idle: 'idle',
|
||||
wave: 'wave',
|
||||
waving: 'wave',
|
||||
jump: 'jump',
|
||||
jumping: 'jump',
|
||||
run: 'run',
|
||||
running: 'run',
|
||||
'running-right': 'run',
|
||||
'running-left': 'run',
|
||||
failed: 'failed',
|
||||
review: 'review',
|
||||
waiting: 'waiting'
|
||||
}
|
||||
|
||||
interface PetSpriteProps {
|
||||
info: PetInfo
|
||||
/** On-screen scale multiplier applied on top of the pet's native scale. */
|
||||
zoom?: number
|
||||
/**
|
||||
* Force a specific animation state instead of reading the live `$petState`.
|
||||
* Used by the generate-flow preview to showcase every row without driving (or
|
||||
* being driven by) the real agent activity that moves the floating mascot.
|
||||
*/
|
||||
stateOverride?: PetState
|
||||
/** Force a concrete row name from `info.stateRows` (e.g. `running-right`). */
|
||||
rowOverride?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, stateOverride, rowOverride }: PetSpriteProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null)
|
||||
const stateRef = useRef<PetState>($petState.get())
|
||||
const overrideRef = useRef<PetState | undefined>(stateOverride)
|
||||
const rowOverrideRef = useRef<string | undefined>(rowOverride)
|
||||
|
||||
// Keep the override current without re-running the RAF setup effect.
|
||||
useEffect(() => {
|
||||
overrideRef.current = stateOverride
|
||||
}, [stateOverride])
|
||||
|
||||
useEffect(() => {
|
||||
rowOverrideRef.current = rowOverride
|
||||
}, [rowOverride])
|
||||
|
||||
const frameW = info.frameW ?? DEFAULT_FRAME_W
|
||||
const frameH = info.frameH ?? DEFAULT_FRAME_H
|
||||
const frames = info.framesPerState ?? DEFAULT_FRAMES
|
||||
const framesByState = info.framesByState
|
||||
const loopMs = info.loopMs ?? DEFAULT_LOOP_MS
|
||||
const scale = (info.scale ?? DEFAULT_SCALE) * zoom
|
||||
const rows = info.stateRows ?? DEFAULT_STATE_ROWS
|
||||
|
||||
const drawW = Math.round(frameW * scale)
|
||||
const drawH = Math.round(frameH * scale)
|
||||
|
||||
const image = useMemo(() => {
|
||||
if (!info.spritesheetBase64) {
|
||||
return null
|
||||
}
|
||||
|
||||
const img = new Image()
|
||||
img.src = `data:${info.mime ?? 'image/webp'};base64,${info.spritesheetBase64}`
|
||||
|
||||
return img
|
||||
}, [info.spritesheetBase64, info.mime])
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
|
||||
if (!canvas || !image) {
|
||||
return
|
||||
}
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
|
||||
if (!ctx) {
|
||||
return
|
||||
}
|
||||
|
||||
// Track state via subscription, not a prop — no re-render on activity ticks.
|
||||
stateRef.current = $petState.get()
|
||||
|
||||
const unsubState = $petState.listen(next => {
|
||||
stateRef.current = next
|
||||
})
|
||||
|
||||
let raf = 0
|
||||
let frame = 0
|
||||
let lastStep = performance.now()
|
||||
let drawnFrame = -1
|
||||
let drawnRow = -1
|
||||
|
||||
const rowIndexForState = (s: PetState): number => {
|
||||
for (const key of STATE_ALIASES[s] ?? [s]) {
|
||||
const idx = rows.indexOf(key)
|
||||
if (idx >= 0) {
|
||||
return idx
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// Resolve a state to the row it draws and its real frame count. A state
|
||||
// with no real frames (ragged sheet, empty row) falls back to idle rather
|
||||
// than flashing blank padding.
|
||||
const resolve = (s: PetState): { row: number; count: number } => {
|
||||
const real = framesByState?.[s] ?? frames
|
||||
|
||||
if (real > 0) {
|
||||
return { row: rowIndexForState(s), count: real }
|
||||
}
|
||||
|
||||
return { row: rowIndexForState('idle'), count: Math.max(1, framesByState?.idle ?? frames) }
|
||||
}
|
||||
|
||||
const resolveRow = (rowName: string): { row: number; count: number } => {
|
||||
const row = rows.indexOf(rowName)
|
||||
const state = ROW_TO_STATE[rowName]
|
||||
const count = Math.max(1, framesByState?.[rowName] ?? (state ? framesByState?.[state] : 0) ?? frames)
|
||||
return { row: row >= 0 ? row : rowIndexForState(state ?? 'idle'), count }
|
||||
}
|
||||
|
||||
const render = (now: number) => {
|
||||
const forcedRow = rowOverrideRef.current
|
||||
const { row, count } = forcedRow ? resolveRow(forcedRow) : resolve(overrideRef.current ?? 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)
|
||||
79
apps/desktop/src/components/pet/pet-thumb.tsx
Normal file
79
apps/desktop/src/components/pet/pet-thumb.tsx
Normal file
@ -0,0 +1,79 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { PawPrint } from '@/lib/icons'
|
||||
|
||||
// petdex frames are a fixed 192×208 grid; the box matches that aspect.
|
||||
const THUMB_W = 40
|
||||
const THUMB_H = Math.round((THUMB_W * 208) / 192)
|
||||
|
||||
export type PetThumbLoader = (slug: string, url?: string) => Promise<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>
|
||||
)
|
||||
}
|
||||
@ -17,7 +17,12 @@ function Command({ className, ...props }: React.ComponentProps<typeof CommandPri
|
||||
)
|
||||
}
|
||||
|
||||
function CommandInput({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||
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) {
|
||||
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" />
|
||||
@ -29,6 +34,7 @@ function CommandInput({ className, ...props }: React.ComponentProps<typeof Comma
|
||||
data-slot="command-input"
|
||||
{...props}
|
||||
/>
|
||||
{right}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
21
apps/desktop/src/global.d.ts
vendored
21
apps/desktop/src/global.d.ts
vendored
@ -1,3 +1,10 @@
|
||||
import type {
|
||||
PetOverlayBounds,
|
||||
PetOverlayControl,
|
||||
PetOverlayOpenRequest,
|
||||
PetOverlayStatePayload
|
||||
} from './store/pet-overlay'
|
||||
|
||||
export {}
|
||||
|
||||
declare global {
|
||||
@ -26,6 +33,20 @@ declare global {
|
||||
openSessionWindow: (sessionId: string, opts?: { watch?: boolean }) => Promise<{ ok: boolean; error?: string }>
|
||||
// Open (or focus) a compact secondary window on the new-session draft.
|
||||
openNewSessionWindow: () => Promise<{ ok: boolean; error?: string }>
|
||||
// The pop-out pet overlay: a transparent always-on-top window hosting only
|
||||
// the mascot. The main renderer drives it (open/close/drag + state push);
|
||||
// the overlay sends control messages back (pop-in, composer submit).
|
||||
petOverlay: {
|
||||
open: (request: PetOverlayOpenRequest) => Promise<{ ok: boolean; bounds?: PetOverlayBounds }>
|
||||
close: () => Promise<{ ok: boolean }>
|
||||
setBounds: (bounds: PetOverlayBounds) => void
|
||||
setIgnoreMouse: (ignore: boolean) => void
|
||||
setFocusable: (focusable: boolean) => void
|
||||
pushState: (payload: PetOverlayStatePayload) => void
|
||||
control: (payload: PetOverlayControl) => void
|
||||
onState: (callback: (payload: PetOverlayStatePayload) => void) => () => void
|
||||
onControl: (callback: (payload: PetOverlayControl) => void) => () => void
|
||||
}
|
||||
getBootProgress: () => Promise<DesktopBootProgress>
|
||||
getConnectionConfig: (profile?: null | string) => Promise<DesktopConnectionConfig>
|
||||
saveConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionConfig>
|
||||
|
||||
@ -366,7 +366,32 @@ export const en: Translations = {
|
||||
installError: 'Could not install that theme.',
|
||||
installed: name => `Installed “${name}”.`,
|
||||
removeTheme: 'Remove theme',
|
||||
importedBadge: 'Imported'
|
||||
importedBadge: 'Imported',
|
||||
pet: {
|
||||
title: 'Pet',
|
||||
intro:
|
||||
'Adopt an animated petdex mascot that floats over the app and reacts to what Hermes is doing — running while tools execute, celebrating on success, sulking on errors.',
|
||||
restartHint:
|
||||
'Pets need a quick restart — the running app started before this feature was added. Quit and reopen Hermes, then come back here.',
|
||||
on: 'On',
|
||||
off: 'Off',
|
||||
scaleTitle: 'Size',
|
||||
scaleDesc: 'Resize the floating mascot. Applies everywhere instantly.',
|
||||
chooseTitle: 'Choose a pet',
|
||||
chooseDesc: 'Picking one installs it (if needed) and makes it active.',
|
||||
searchPlaceholder: 'Search pets…',
|
||||
unreachable: "Couldn't reach the petdex gallery. Check your connection and reopen this page.",
|
||||
noMatch: query => `No pets match "${query}".`,
|
||||
installedTag: 'installed',
|
||||
countCapped: (cap, total) => `Showing ${cap} of ${total} — type to narrow it down.`,
|
||||
count: n => `${n} pet${n === 1 ? '' : 's'}.`,
|
||||
uninstall: name => `Uninstall ${name}`,
|
||||
adoptFailed: slug => `Could not adopt ${slug}`,
|
||||
uninstallFailed: slug => `Could not uninstall ${slug}`,
|
||||
noneAvailable: 'No pets available to turn on right now.',
|
||||
turnOnFailed: 'Could not turn the pet on.',
|
||||
turnOffFailed: 'Could not turn the pet off.'
|
||||
}
|
||||
},
|
||||
fieldLabels: FIELD_LABELS,
|
||||
fieldDescriptions: FIELD_DESCRIPTIONS,
|
||||
@ -710,8 +735,37 @@ 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.'
|
||||
},
|
||||
generatePet: {
|
||||
title: 'Generate a pet',
|
||||
placeholder: 'Describe a pet to generate…',
|
||||
promptHint: 'Type a description, then press Enter to draft four looks.',
|
||||
readyHint: 'Press Enter to draft four looks from your description.',
|
||||
generate: 'Generate',
|
||||
generating: 'Generating…',
|
||||
retry: 'Retry',
|
||||
hatch: 'Hatch',
|
||||
hatching: 'Hatching your pet…',
|
||||
namePlaceholder: 'Name your pet',
|
||||
staleBackend: 'Update Hermes to generate pets.',
|
||||
adopt: 'Adopt',
|
||||
startOver: 'Start over'
|
||||
},
|
||||
installTheme: {
|
||||
title: 'Install theme...',
|
||||
placeholder: 'Search the VS Code Marketplace...',
|
||||
|
||||
@ -281,7 +281,32 @@ export const ja = defineLocale({
|
||||
installError: 'そのテーマをインストールできませんでした。',
|
||||
installed: name => `「${name}」をインストールしました。`,
|
||||
removeTheme: 'テーマを削除',
|
||||
importedBadge: 'インポート済み'
|
||||
importedBadge: 'インポート済み',
|
||||
pet: {
|
||||
title: 'ペット',
|
||||
intro:
|
||||
'アプリ上に浮かぶ petdex のアニメーションマスコットを採用しましょう。ツール実行中は走り、成功すると喜び、エラーでしょんぼりと、Hermes の状態に反応します。',
|
||||
restartHint:
|
||||
'ペット機能には再起動が必要です。この機能が追加される前に起動したアプリが動作中です。Hermes を終了して再度開き、このページに戻ってください。',
|
||||
scaleTitle: 'サイズ',
|
||||
scaleDesc: '浮遊マスコットの大きさを変更します。すべての画面に即時反映されます。',
|
||||
on: 'オン',
|
||||
off: 'オフ',
|
||||
chooseTitle: 'ペットを選ぶ',
|
||||
chooseDesc: '選ぶと(必要に応じて)インストールされ、アクティブになります。',
|
||||
searchPlaceholder: 'ペットを検索…',
|
||||
unreachable: 'petdex ギャラリーに接続できませんでした。接続を確認してこのページを開き直してください。',
|
||||
noMatch: query => `「${query}」に一致するペットがありません。`,
|
||||
installedTag: 'インストール済み',
|
||||
countCapped: (cap, total) => `${total} 件中 ${cap} 件を表示中——入力して絞り込めます。`,
|
||||
count: n => `${n} 件のペット。`,
|
||||
uninstall: name => `${name} をアンインストール`,
|
||||
adoptFailed: slug => `${slug} を採用できませんでした`,
|
||||
uninstallFailed: slug => `${slug} をアンインストールできませんでした`,
|
||||
noneAvailable: 'オンにできるペットがありません。',
|
||||
turnOnFailed: 'ペットをオンにできませんでした。',
|
||||
turnOffFailed: 'ペットをオフにできませんでした。'
|
||||
}
|
||||
},
|
||||
fieldLabels: defineFieldCopy({
|
||||
model: 'デフォルトモデル',
|
||||
@ -834,8 +859,37 @@ 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: '利用可能なペットがありません。'
|
||||
},
|
||||
generatePet: {
|
||||
title: 'ペットを生成',
|
||||
placeholder: '生成するペットを説明…',
|
||||
promptHint: '説明を入力して Enter を押すと、4 つの見た目を生成します。',
|
||||
readyHint: 'Enter を押すと、説明から 4 つの見た目を生成します。',
|
||||
generate: '生成',
|
||||
generating: '生成中…',
|
||||
retry: '再試行',
|
||||
hatch: '孵化',
|
||||
hatching: 'ペットを孵化しています…',
|
||||
namePlaceholder: 'ペットに名前を付ける',
|
||||
staleBackend: 'ペットを生成するには Hermes を更新してください。',
|
||||
adopt: '迎え入れる',
|
||||
startOver: 'やり直す'
|
||||
},
|
||||
installTheme: {
|
||||
title: 'テーマをインストール...',
|
||||
placeholder: 'VS Code Marketplace を検索...',
|
||||
|
||||
@ -265,6 +265,29 @@ export interface Translations {
|
||||
installed: (name: string) => string
|
||||
removeTheme: string
|
||||
importedBadge: string
|
||||
pet: {
|
||||
title: string
|
||||
intro: string
|
||||
restartHint: string
|
||||
on: string
|
||||
off: string
|
||||
scaleTitle: string
|
||||
scaleDesc: string
|
||||
chooseTitle: string
|
||||
chooseDesc: string
|
||||
searchPlaceholder: string
|
||||
unreachable: string
|
||||
noMatch: (query: string) => string
|
||||
installedTag: string
|
||||
countCapped: (cap: number, total: number) => string
|
||||
count: (n: number) => string
|
||||
uninstall: (name: string) => string
|
||||
adoptFailed: (slug: string) => string
|
||||
uninstallFailed: (slug: string) => string
|
||||
noneAvailable: string
|
||||
turnOnFailed: string
|
||||
turnOffFailed: string
|
||||
}
|
||||
}
|
||||
fieldLabels: Record<string, string>
|
||||
fieldDescriptions: Record<string, string>
|
||||
@ -590,6 +613,35 @@ 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
|
||||
}
|
||||
generatePet: {
|
||||
title: string
|
||||
placeholder: string
|
||||
promptHint: string
|
||||
readyHint: string
|
||||
generate: string
|
||||
generating: string
|
||||
retry: string
|
||||
hatch: string
|
||||
hatching: string
|
||||
namePlaceholder: string
|
||||
staleBackend: string
|
||||
adopt: string
|
||||
startOver: string
|
||||
}
|
||||
installTheme: {
|
||||
title: string
|
||||
placeholder: string
|
||||
|
||||
@ -271,7 +271,30 @@ export const zhHant = defineLocale({
|
||||
installError: '無法安裝該主題。',
|
||||
installed: name => `已安裝「${name}」。`,
|
||||
removeTheme: '移除主題',
|
||||
importedBadge: '已匯入'
|
||||
importedBadge: '已匯入',
|
||||
pet: {
|
||||
title: '寵物',
|
||||
intro: '領養一隻懸浮在應用上的 petdex 動畫寵物,它會根據 Hermes 的狀態做出反應——工具執行時奔跑、成功時歡呼、出錯時沮喪。',
|
||||
restartHint: '寵物功能需要重新啟動——目前執行的應用在此功能加入前啟動。請結束並重新開啟 Hermes,然後回到此處。',
|
||||
scaleTitle: '大小',
|
||||
scaleDesc: '調整懸浮寵物的大小,所有介面即時生效。',
|
||||
on: '開啟',
|
||||
off: '關閉',
|
||||
chooseTitle: '選擇寵物',
|
||||
chooseDesc: '選擇後會自動安裝(如需)並設為目前寵物。',
|
||||
searchPlaceholder: '搜尋寵物…',
|
||||
unreachable: '無法連線至 petdex 畫廊。請檢查網路連線並重新開啟此頁面。',
|
||||
noMatch: query => `沒有符合「${query}」的寵物。`,
|
||||
installedTag: '已安裝',
|
||||
countCapped: (cap, total) => `顯示 ${total} 個中的 ${cap} 個——輸入關鍵字以縮小範圍。`,
|
||||
count: n => `${n} 個寵物。`,
|
||||
uninstall: name => `解除安裝 ${name}`,
|
||||
adoptFailed: slug => `無法領養 ${slug}`,
|
||||
uninstallFailed: slug => `無法解除安裝 ${slug}`,
|
||||
noneAvailable: '目前沒有可開啟的寵物。',
|
||||
turnOnFailed: '無法開啟寵物。',
|
||||
turnOffFailed: '無法關閉寵物。'
|
||||
}
|
||||
},
|
||||
fieldLabels: defineFieldCopy({
|
||||
model: '預設模型',
|
||||
@ -807,8 +830,37 @@ 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: '尚無可用寵物——請在下方選擇一個安裝。'
|
||||
},
|
||||
generatePet: {
|
||||
title: '生成寵物',
|
||||
placeholder: '描述要生成的寵物……',
|
||||
promptHint: '輸入描述,然後按 Enter 生成四種造型。',
|
||||
readyHint: '按 Enter 依描述生成四種造型。',
|
||||
generate: '生成',
|
||||
generating: '生成中……',
|
||||
retry: '重試',
|
||||
hatch: '孵化',
|
||||
hatching: '正在孵化你的寵物……',
|
||||
namePlaceholder: '為寵物命名',
|
||||
staleBackend: '請更新 Hermes 以生成寵物。',
|
||||
adopt: '領養',
|
||||
startOver: '重新開始'
|
||||
},
|
||||
installTheme: {
|
||||
title: '安裝主題...',
|
||||
placeholder: '搜尋 VS Code Marketplace...',
|
||||
|
||||
@ -359,7 +359,30 @@ export const zh: Translations = {
|
||||
installError: '无法安装该主题。',
|
||||
installed: name => `已安装「${name}」。`,
|
||||
removeTheme: '移除主题',
|
||||
importedBadge: '已导入'
|
||||
importedBadge: '已导入',
|
||||
pet: {
|
||||
title: '宠物',
|
||||
intro: '领养一只悬浮在应用上的 petdex 动画宠物,它会根据 Hermes 的状态做出反应——工具执行时奔跑、成功时欢呼、出错时沮丧。',
|
||||
restartHint: '宠物功能需要重启——当前运行的应用在此功能加入前启动。请退出并重新打开 Hermes,然后回到此处。',
|
||||
scaleTitle: '大小',
|
||||
scaleDesc: '调整悬浮宠物的大小,所有界面即时生效。',
|
||||
on: '开启',
|
||||
off: '关闭',
|
||||
chooseTitle: '选择宠物',
|
||||
chooseDesc: '选择后会自动安装(如需)并设为当前宠物。',
|
||||
searchPlaceholder: '搜索宠物…',
|
||||
unreachable: '无法连接到 petdex 画廊。请检查网络连接并重新打开此页面。',
|
||||
noMatch: query => `没有匹配「${query}」的宠物。`,
|
||||
installedTag: '已安装',
|
||||
countCapped: (cap, total) => `显示 ${total} 个中的 ${cap} 个——输入关键词以缩小范围。`,
|
||||
count: n => `${n} 个宠物。`,
|
||||
uninstall: name => `卸载 ${name}`,
|
||||
adoptFailed: slug => `无法领养 ${slug}`,
|
||||
uninstallFailed: slug => `无法卸载 ${slug}`,
|
||||
noneAvailable: '当前没有可开启的宠物。',
|
||||
turnOnFailed: '无法开启宠物。',
|
||||
turnOffFailed: '无法关闭宠物。'
|
||||
}
|
||||
},
|
||||
fieldLabels: defineFieldCopy({
|
||||
model: '默认模型',
|
||||
@ -900,8 +923,37 @@ 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: '暂无可用宠物——请在下方选择一个安装。'
|
||||
},
|
||||
generatePet: {
|
||||
title: '生成宠物',
|
||||
placeholder: '描述要生成的宠物……',
|
||||
promptHint: '输入描述,然后按 Enter 生成四种造型。',
|
||||
readyHint: '按 Enter 根据描述生成四种造型。',
|
||||
generate: '生成',
|
||||
generating: '生成中……',
|
||||
retry: '重试',
|
||||
hatch: '孵化',
|
||||
hatching: '正在孵化你的宠物……',
|
||||
namePlaceholder: '给宠物起个名字',
|
||||
staleBackend: '请更新 Hermes 以生成宠物。',
|
||||
adopt: '领养',
|
||||
startOver: '重新开始'
|
||||
},
|
||||
installTheme: {
|
||||
title: '安装主题...',
|
||||
placeholder: '搜索 VS Code Marketplace...',
|
||||
|
||||
@ -52,6 +52,16 @@ describe('desktop slash command curation', () => {
|
||||
expect(desktopSlashUnavailableMessage('/personality')).toBeNull()
|
||||
})
|
||||
|
||||
it('routes /pet through the desktop action handler and drops /pets', () => {
|
||||
expect(resolveDesktopCommand('/pet')?.surface).toEqual({ kind: 'action', action: 'pet' })
|
||||
expect(resolveDesktopCommand('/pet')?.args).toBe(true)
|
||||
expect(isDesktopSlashSuggestion('/pet')).toBe(true)
|
||||
expect(isDesktopSlashCommand('/pet')).toBe(true)
|
||||
expect(resolveDesktopCommand('/pets')?.surface).toEqual({ kind: 'unavailable', reason: 'settings' })
|
||||
expect(isDesktopSlashSuggestion('/pets')).toBe(false)
|
||||
expect(isDesktopSlashCommand('/pets')).toBe(false)
|
||||
})
|
||||
|
||||
it('treats /browser as an executable action command (local-gateway connect)', () => {
|
||||
// /browser used to be terminal-only; it now resolves to a desktop action
|
||||
// handler that routes browser.manage RPC when the gateway is local.
|
||||
|
||||
@ -34,6 +34,7 @@ export type DesktopActionId =
|
||||
| 'handoff'
|
||||
| 'help'
|
||||
| 'new'
|
||||
| 'pet'
|
||||
| 'profile'
|
||||
| 'skin'
|
||||
| 'title'
|
||||
@ -128,6 +129,7 @@ const DESKTOP_COMMAND_SPECS: readonly DesktopCommandSpec[] = [
|
||||
{ name: '/debug', description: 'Create a debug report', surface: exec() },
|
||||
{ name: '/goal', description: 'Manage the standing goal for this session', surface: exec() },
|
||||
{ name: '/personality', description: 'Switch personality for this session', surface: exec(), args: true },
|
||||
{ name: '/pet', description: 'Toggle or adopt a petdex mascot (/pet, /pet list, /pet boba)', surface: action('pet'), args: true },
|
||||
{ name: '/queue', description: 'Queue a prompt for the next turn', aliases: ['/q'], surface: exec() },
|
||||
{ name: '/retry', description: 'Retry the last user message', surface: exec() },
|
||||
{ name: '/rollback', description: 'List or restore filesystem checkpoints', surface: exec() },
|
||||
@ -155,7 +157,7 @@ const NO_DESKTOP_SURFACE: Record<DesktopUnavailableReason, readonly string[]> =
|
||||
'/sb', '/set-home', '/sethome', '/snap', '/snapshot', '/statusbar', '/toolsets', '/update', '/verbose'
|
||||
],
|
||||
messaging: ['/approve', '/deny'],
|
||||
settings: ['/skills'],
|
||||
settings: ['/skills', '/pets'],
|
||||
advanced: ['/curator', '/fast', '/insights', '/kanban', '/reasoning', '/voice']
|
||||
}
|
||||
|
||||
|
||||
@ -29,6 +29,7 @@ import {
|
||||
IconCopy as CopyIcon,
|
||||
IconCpu as Cpu,
|
||||
IconDownload as Download,
|
||||
IconEgg as Egg,
|
||||
IconExternalLink as ExternalLink,
|
||||
IconEye as Eye,
|
||||
IconEyeOff as EyeOff,
|
||||
@ -51,6 +52,7 @@ import {
|
||||
IconLoader2 as Loader2Icon,
|
||||
IconLock as Lock,
|
||||
IconLogin as LogIn,
|
||||
IconMail as Mail,
|
||||
IconMessageCircle as MessageCircle,
|
||||
IconMessage2 as MessageSquareText,
|
||||
IconMicrophone as Mic,
|
||||
@ -67,6 +69,7 @@ import {
|
||||
IconLayoutBottombar as PanelBottom,
|
||||
IconLayoutSidebar as PanelLeftIcon,
|
||||
IconPlayerPause as Pause,
|
||||
IconPaw as PawPrint,
|
||||
IconPencil as Pencil,
|
||||
IconPencil as PencilIcon,
|
||||
IconPencil as PencilLine,
|
||||
@ -131,6 +134,7 @@ export {
|
||||
CopyIcon,
|
||||
Cpu,
|
||||
Download,
|
||||
Egg,
|
||||
ExternalLink,
|
||||
Eye,
|
||||
EyeOff,
|
||||
@ -153,6 +157,7 @@ export {
|
||||
Loader2Icon,
|
||||
Lock,
|
||||
LogIn,
|
||||
Mail,
|
||||
MessageCircle,
|
||||
MessageSquareText,
|
||||
Mic,
|
||||
@ -169,6 +174,7 @@ export {
|
||||
PanelBottom,
|
||||
PanelLeftIcon,
|
||||
Pause,
|
||||
PawPrint,
|
||||
Pencil,
|
||||
PencilIcon,
|
||||
PencilLine,
|
||||
|
||||
31
apps/desktop/src/lib/selectable-card.ts
Normal file
31
apps/desktop/src/lib/selectable-card.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface SelectableCardState {
|
||||
/** Currently selected / active — the strongest emphasis. */
|
||||
active?: boolean
|
||||
/**
|
||||
* Configured / installed / "you have this" — solid surface + border. When
|
||||
* false the card renders muted (transparent, dimmed) until hovered, so the
|
||||
* eye lands on what you already have. Ignored when `active` is set.
|
||||
*/
|
||||
prominent?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared emphasis for selectable list cards across settings surfaces (theme
|
||||
* picker, pet picker, Marketplace results, provider rows…). Three tiers:
|
||||
* active > prominent > muted. Keeps the "installed = solid, not-installed =
|
||||
* quiet" pattern consistent everywhere instead of each picker rolling its own.
|
||||
*
|
||||
* Callers own layout (padding, flex, width); this owns only border + surface.
|
||||
*/
|
||||
export function selectableCardClass({ active, prominent }: SelectableCardState): string {
|
||||
return cn(
|
||||
'rounded-lg border transition-colors',
|
||||
active
|
||||
? 'border-primary bg-primary/[0.06] ring-2 ring-primary/20'
|
||||
: prominent
|
||||
? 'border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) hover:bg-(--chrome-action-hover)'
|
||||
: 'border-transparent bg-transparent text-(--ui-text-tertiary) hover:border-(--ui-stroke-tertiary) hover:bg-(--ui-bg-quinary)'
|
||||
)
|
||||
}
|
||||
@ -26,20 +26,27 @@ if (import.meta.env.MODE !== 'production') {
|
||||
import('./app/chat/perf-probe')
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<ErrorBoundary label="root">
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<I18nProvider>
|
||||
<ThemeProvider>
|
||||
<HapticsProvider>
|
||||
<HashRouter>
|
||||
<App />
|
||||
</HashRouter>
|
||||
</HapticsProvider>
|
||||
</ThemeProvider>
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>
|
||||
</ErrorBoundary>
|
||||
</StrictMode>
|
||||
)
|
||||
// The pet overlay rides this same bundle (`?win=overlay`) but mounts a tiny,
|
||||
// transparent, gateway-less surface instead of the full app. Branch before any
|
||||
// app-shell work so the overlay window stays cheap.
|
||||
if (new URLSearchParams(window.location.search).get('win') === 'overlay') {
|
||||
void import('./app/pet-overlay/overlay-root').then(({ mountPetOverlay }) => mountPetOverlay())
|
||||
} else {
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<ErrorBoundary label="root">
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<I18nProvider>
|
||||
<ThemeProvider>
|
||||
<HapticsProvider>
|
||||
<HashRouter>
|
||||
<App />
|
||||
</HashRouter>
|
||||
</HapticsProvider>
|
||||
</ThemeProvider>
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>
|
||||
</ErrorBoundary>
|
||||
</StrictMode>
|
||||
)
|
||||
}
|
||||
|
||||
@ -3,16 +3,30 @@ import { atom } from 'nanostores'
|
||||
/** Whether the global command palette (Cmd/Ctrl+K) is currently open. */
|
||||
export const $commandPaletteOpen = atom(false)
|
||||
|
||||
/** Optional nested page to open when the palette next opens (e.g. `pets`). */
|
||||
export const $commandPalettePage = atom<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 {
|
||||
|
||||
298
apps/desktop/src/store/pet-gallery.ts
Normal file
298
apps/desktop/src/store/pet-gallery.ts
Normal file
@ -0,0 +1,298 @@
|
||||
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))
|
||||
}))
|
||||
})
|
||||
}
|
||||
241
apps/desktop/src/store/pet-generate.ts
Normal file
241
apps/desktop/src/store/pet-generate.ts
Normal file
@ -0,0 +1,241 @@
|
||||
import { atom } from 'nanostores'
|
||||
|
||||
import { type PetInfo } from '@/store/pet'
|
||||
import { type GatewayRequest, loadPetGallery } from '@/store/pet-gallery'
|
||||
|
||||
/**
|
||||
* Feature store for the "generate a pet" flow (Cmd-K → Pets → Generate).
|
||||
*
|
||||
* Three backend steps, mirrored as state here:
|
||||
* - `pet.generate` produces N cheap base-look *drafts* keyed by a `token`.
|
||||
* - `pet.hatch` turns the chosen draft into a full animated pet — installed but
|
||||
* NOT active — and returns its renderer payload so we can preview all frames.
|
||||
* - the user then *adopts* (`pet.select`) or *discards* (`pet.remove`) it.
|
||||
*
|
||||
* The store owns the draft set, the selected variant, the hatched preview, and
|
||||
* the busy/error status so the page is a thin view. Retry == regenerate (new
|
||||
* token). Kept separate from `pet-gallery` because its lifecycle (ephemeral
|
||||
* drafts + an unadopted preview) is unrelated to the long-lived gallery cache.
|
||||
*/
|
||||
|
||||
export interface PetDraft {
|
||||
index: number
|
||||
/** Downscaled PNG data URI preview from the gateway. */
|
||||
dataUri: string
|
||||
}
|
||||
|
||||
export type PetGenStatus =
|
||||
| 'idle'
|
||||
| 'generating'
|
||||
| 'ready'
|
||||
| 'hatching'
|
||||
| 'preview'
|
||||
| 'adopting'
|
||||
| 'error'
|
||||
| 'stale'
|
||||
|
||||
export const $petGenStatus = atom<PetGenStatus>('idle')
|
||||
export const $petGenError = atom<string | null>(null)
|
||||
export const $petGenToken = atom<string | null>(null)
|
||||
/** Prompt that produced the current draft token; hatch uses this for consistency. */
|
||||
export const $petGenPrompt = atom<string>('')
|
||||
export const $petGenDrafts = atom<PetDraft[]>([])
|
||||
export const $petGenSelected = atom<number | null>(null)
|
||||
/** The hatched-but-unadopted pet: its renderer payload, played in the preview. */
|
||||
export const $petGenPreview = atom<PetInfo | null>(null)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
/** Clear all generation state (on close, or before a fresh run). */
|
||||
export function resetPetGen(): void {
|
||||
$petGenStatus.set('idle')
|
||||
$petGenError.set(null)
|
||||
$petGenToken.set(null)
|
||||
$petGenPrompt.set('')
|
||||
$petGenDrafts.set([])
|
||||
$petGenSelected.set(null)
|
||||
$petGenPreview.set(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset on palette close, deleting an unadopted preview pet first so a hatched-
|
||||
* but-never-adopted creature doesn't linger in the gallery. Fire-and-forget.
|
||||
*/
|
||||
export function cleanupPetGen(request: GatewayRequest): void {
|
||||
const preview = $petGenPreview.get()
|
||||
|
||||
if ($petGenStatus.get() === 'preview' && preview?.slug) {
|
||||
void request('pet.remove', { slug: preview.slug }).catch(() => {})
|
||||
}
|
||||
|
||||
resetPetGen()
|
||||
}
|
||||
|
||||
interface GenerateOptions {
|
||||
prompt: string
|
||||
style?: string
|
||||
count?: number
|
||||
}
|
||||
|
||||
/** Generate (or retry) a fresh set of base-look drafts for `prompt`. */
|
||||
export async function generateDrafts(request: GatewayRequest, options: GenerateOptions): Promise<boolean> {
|
||||
const prompt = options.prompt.trim()
|
||||
|
||||
if (!prompt) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Starting a fresh generation round supersedes any unadopted preview pet.
|
||||
const preview = $petGenPreview.get()
|
||||
if (preview?.slug) {
|
||||
await request('pet.remove', { slug: preview.slug }).catch(() => {})
|
||||
}
|
||||
|
||||
$petGenStatus.set('generating')
|
||||
$petGenError.set(null)
|
||||
$petGenPreview.set(null)
|
||||
$petGenDrafts.set([])
|
||||
$petGenSelected.set(null)
|
||||
|
||||
try {
|
||||
const result = await request<{ ok: boolean; token: string; drafts: PetDraft[] }>('pet.generate', {
|
||||
prompt,
|
||||
style: options.style ?? 'auto',
|
||||
count: options.count ?? 4
|
||||
})
|
||||
|
||||
if (!result?.ok || !result.drafts?.length) {
|
||||
throw new Error('generation produced no drafts')
|
||||
}
|
||||
|
||||
$petGenToken.set(result.token)
|
||||
$petGenPrompt.set(prompt)
|
||||
$petGenDrafts.set(result.drafts)
|
||||
$petGenSelected.set(result.drafts[0]?.index ?? 0)
|
||||
$petGenStatus.set('ready')
|
||||
|
||||
return true
|
||||
} catch (e) {
|
||||
if (isMissingMethod(e)) {
|
||||
$petGenStatus.set('stale')
|
||||
} else {
|
||||
$petGenStatus.set('error')
|
||||
$petGenError.set(e instanceof Error ? e.message : 'Could not generate pet drafts.')
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
interface HatchOptions {
|
||||
name: string
|
||||
description?: string
|
||||
prompt?: string
|
||||
style?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Hatch the selected draft into a full pet (installed but NOT yet active) and
|
||||
* load its renderer payload into the preview. Adoption is a separate, explicit
|
||||
* step (`adoptHatched`) so the user sees every frame play before committing.
|
||||
* Returns true when the preview is ready.
|
||||
*/
|
||||
export async function hatchSelected(request: GatewayRequest, options: HatchOptions): Promise<boolean> {
|
||||
const token = $petGenToken.get()
|
||||
const index = $petGenSelected.get()
|
||||
const name = options.name.trim()
|
||||
const concept = ($petGenPrompt.get() || options.prompt || name).trim()
|
||||
|
||||
if (token === null || index === null || !name) {
|
||||
return false
|
||||
}
|
||||
|
||||
$petGenStatus.set('hatching')
|
||||
$petGenError.set(null)
|
||||
|
||||
try {
|
||||
const result = await request<{ ok: boolean; slug: string; displayName: string; pet?: PetInfo }>('pet.hatch', {
|
||||
token,
|
||||
index,
|
||||
name,
|
||||
description: options.description ?? '',
|
||||
prompt: concept,
|
||||
style: options.style ?? 'auto'
|
||||
})
|
||||
|
||||
if (!result?.ok || !result.pet?.spritesheetBase64) {
|
||||
throw new Error('hatch produced no preview')
|
||||
}
|
||||
|
||||
$petGenPreview.set({ ...result.pet, enabled: true })
|
||||
$petGenStatus.set('preview')
|
||||
|
||||
return true
|
||||
} catch (e) {
|
||||
$petGenStatus.set('error')
|
||||
$petGenError.set(e instanceof Error ? e.message : 'Could not hatch the pet.')
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export interface AdoptOutcome {
|
||||
ok: boolean
|
||||
slug?: string
|
||||
displayName?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Adopt the previewed pet: activate it (`pet.select`), refresh the gallery + live
|
||||
* mascot, and clear generation state. No-op unless a preview exists.
|
||||
*/
|
||||
export async function adoptHatched(request: GatewayRequest): Promise<AdoptOutcome> {
|
||||
const preview = $petGenPreview.get()
|
||||
|
||||
if (!preview?.slug) {
|
||||
return { ok: false }
|
||||
}
|
||||
|
||||
$petGenStatus.set('adopting')
|
||||
$petGenError.set(null)
|
||||
|
||||
try {
|
||||
const result = await request<{ ok: boolean; slug: string; displayName: string }>('pet.select', {
|
||||
slug: preview.slug
|
||||
})
|
||||
|
||||
if (!result?.ok) {
|
||||
throw new Error('adopt failed')
|
||||
}
|
||||
|
||||
await loadPetGallery(request, { force: true })
|
||||
resetPetGen()
|
||||
|
||||
return { ok: true, slug: result.slug, displayName: result.displayName }
|
||||
} catch (e) {
|
||||
$petGenStatus.set('preview')
|
||||
$petGenError.set(e instanceof Error ? e.message : 'Could not adopt the pet.')
|
||||
|
||||
return { ok: false }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Throw away the previewed pet (`pet.remove`) and return to the draft picker so
|
||||
* the user can choose another base or regenerate. Best-effort on the delete.
|
||||
*/
|
||||
export async function discardHatched(request: GatewayRequest): Promise<void> {
|
||||
const preview = $petGenPreview.get()
|
||||
|
||||
if (preview?.slug) {
|
||||
await request('pet.remove', { slug: preview.slug }).catch(() => {})
|
||||
}
|
||||
|
||||
$petGenPreview.set(null)
|
||||
$petGenError.set(null)
|
||||
$petGenStatus.set($petGenDrafts.get().length > 0 ? 'ready' : 'idle')
|
||||
}
|
||||
260
apps/desktop/src/store/pet-overlay.ts
Normal file
260
apps/desktop/src/store/pet-overlay.ts
Normal file
@ -0,0 +1,260 @@
|
||||
import { atom } from 'nanostores'
|
||||
|
||||
import { persistBoolean, persistString, storedBoolean, storedString } from '@/lib/storage'
|
||||
import { $petActivity, $petInfo, $petUnread, clearPetUnread, type PetActivity, type PetInfo } from '@/store/pet'
|
||||
import { $awaitingResponse, $busy } from '@/store/session'
|
||||
|
||||
/**
|
||||
* Controller for the pop-out pet overlay (main-renderer side).
|
||||
*
|
||||
* Shift-clicking the in-window pet "pops it out" into a transparent,
|
||||
* always-on-top OS window (created in electron/main.cjs) that can leave the
|
||||
* app's bounds and stays visible while Hermes is minimized. That window carries
|
||||
* NO gateway connection — this renderer remains the single source of truth and
|
||||
* pushes the live pet state to it over IPC. Control flows back (pop the pet back
|
||||
* in, submit a composer message) via `onControl`.
|
||||
*
|
||||
* The overlay renders the same `PetSprite` / `PetBubble` as the in-window pet by
|
||||
* mirroring the four reactive inputs of `$petState` (`$petInfo`, `$petActivity`,
|
||||
* `$busy`, `$awaitingResponse`) into its own copies of those atoms — so the
|
||||
* popped-out mascot is pixel-identical and needs zero bespoke render logic.
|
||||
*/
|
||||
|
||||
export interface PetOverlayBounds {
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Request to open the overlay window. `screen` says whether `bounds` are already
|
||||
* in absolute screen coordinates (a remembered/dragged spot) or in the main
|
||||
* window's viewport space (a fresh shift-click pop-out, which main.cjs converts
|
||||
* by adding the content origin).
|
||||
*/
|
||||
export interface PetOverlayOpenRequest {
|
||||
bounds: PetOverlayBounds
|
||||
screen?: boolean
|
||||
}
|
||||
|
||||
/** Everything the overlay needs to reproduce the live mascot. */
|
||||
export interface PetOverlayStatePayload {
|
||||
info: PetInfo
|
||||
activity: PetActivity
|
||||
busy: boolean
|
||||
awaiting: boolean
|
||||
/** Drives the overlay's mail icon: a finish landed while you were away. */
|
||||
unread: boolean
|
||||
}
|
||||
|
||||
export type PetOverlayControl =
|
||||
| { type: 'pop-in' }
|
||||
| { type: 'ready' }
|
||||
| { type: 'submit'; text: string }
|
||||
| { type: 'bounds'; bounds: PetOverlayBounds }
|
||||
| { type: 'open-app' }
|
||||
| { type: 'toggle-app' }
|
||||
|
||||
// Persisted across restarts: was the pet popped out, and where on the desktop
|
||||
// did the user leave it. Keyed v1; bump if the bounds shape ever changes.
|
||||
const OVERLAY_ACTIVE_KEY = 'hermes.desktop.pet-overlay-active.v1'
|
||||
const OVERLAY_BOUNDS_KEY = 'hermes.desktop.pet-overlay-bounds.v1'
|
||||
|
||||
export const $petOverlayActive = atom(storedBoolean(OVERLAY_ACTIVE_KEY, false))
|
||||
|
||||
// Persist the in/out choice so a popped-out pet comes back popped out.
|
||||
$petOverlayActive.subscribe(active => persistBoolean(OVERLAY_ACTIVE_KEY, active))
|
||||
|
||||
function loadSavedBounds(): null | PetOverlayBounds {
|
||||
try {
|
||||
const raw = storedString(OVERLAY_BOUNDS_KEY)
|
||||
|
||||
if (!raw) {
|
||||
return null
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(raw) as Partial<PetOverlayBounds>
|
||||
|
||||
if (
|
||||
typeof parsed.x === 'number' &&
|
||||
typeof parsed.y === 'number' &&
|
||||
typeof parsed.width === 'number' &&
|
||||
typeof parsed.height === 'number'
|
||||
) {
|
||||
return { height: parsed.height, width: parsed.width, x: parsed.x, y: parsed.y }
|
||||
}
|
||||
} catch {
|
||||
// fall through to null
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function saveBounds(bounds: PetOverlayBounds): void {
|
||||
persistString(OVERLAY_BOUNDS_KEY, JSON.stringify(bounds))
|
||||
}
|
||||
|
||||
// The overlay window is padded around the sprite so the bubble (above), the
|
||||
// drag area, and the pop-up composer all have room; the pet sits near the
|
||||
// bottom and the rest of the rectangle is transparent + click-through.
|
||||
const OVERLAY_PAD_X = 100
|
||||
const OVERLAY_PAD_Y = 200
|
||||
const OVERLAY_MIN_W = 240
|
||||
const OVERLAY_MIN_H = 300
|
||||
|
||||
let stateUnsubs: Array<() => void> = []
|
||||
let controlUnsub: (() => void) | null = null
|
||||
let submitHandler: ((text: string) => void) | null = null
|
||||
let openAppHandler: (() => void) | null = null
|
||||
|
||||
function currentPayload(): PetOverlayStatePayload {
|
||||
return {
|
||||
info: $petInfo.get(),
|
||||
activity: $petActivity.get(),
|
||||
busy: $busy.get(),
|
||||
awaiting: $awaitingResponse.get(),
|
||||
unread: $petUnread.get()
|
||||
}
|
||||
}
|
||||
|
||||
function pushNow(): void {
|
||||
window.hermesDesktop?.petOverlay?.pushState(currentPayload())
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the overlay window and start mirroring live state into it. The main
|
||||
* process echoes back the actual screen bounds it used, which we persist so the
|
||||
* pet reopens exactly where the user left it.
|
||||
*/
|
||||
function openOverlay(request: PetOverlayOpenRequest): void {
|
||||
const api = window.hermesDesktop?.petOverlay
|
||||
|
||||
if (!api || stateUnsubs.length) {
|
||||
return
|
||||
}
|
||||
|
||||
$petOverlayActive.set(true)
|
||||
void api.open(request).then(res => {
|
||||
if (res?.bounds) {
|
||||
saveBounds(res.bounds)
|
||||
}
|
||||
|
||||
pushNow()
|
||||
})
|
||||
|
||||
// Mirror live state into the overlay. subscribe() fires immediately, so the
|
||||
// overlay also gets a first frame the moment it's ready (it asks via 'ready').
|
||||
stateUnsubs = [
|
||||
$petInfo.subscribe(pushNow),
|
||||
$petActivity.subscribe(pushNow),
|
||||
$busy.subscribe(pushNow),
|
||||
$awaitingResponse.subscribe(pushNow),
|
||||
$petUnread.subscribe(pushNow)
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Pop the pet out of the window. `petRect` is the in-window sprite's viewport
|
||||
* rect; we grow it to the padded overlay size and center the window on the
|
||||
* pet's old spot (main.cjs adds the window's screen origin). If the user has
|
||||
* popped out before, reopen at that remembered desktop spot instead.
|
||||
*/
|
||||
export function popOutPet(petRect: PetOverlayBounds): void {
|
||||
if ($petOverlayActive.get() || stateUnsubs.length) {
|
||||
return
|
||||
}
|
||||
|
||||
const saved = loadSavedBounds()
|
||||
|
||||
if (saved) {
|
||||
openOverlay({ bounds: saved, screen: true })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const width = Math.max(OVERLAY_MIN_W, Math.round(petRect.width + OVERLAY_PAD_X))
|
||||
const height = Math.max(OVERLAY_MIN_H, Math.round(petRect.height + OVERLAY_PAD_Y))
|
||||
const x = Math.round(petRect.x - (width - petRect.width) / 2)
|
||||
const y = Math.round(petRect.y - (height - petRect.height) / 2)
|
||||
|
||||
openOverlay({ bounds: { height, width, x, y }, screen: false })
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore the overlay on boot if the pet was popped out when the app last
|
||||
* closed. Requires a remembered desktop spot — without one we fall back to the
|
||||
* in-window pet rather than spawning an orphan window at the origin.
|
||||
*/
|
||||
export function restorePetOverlay(): void {
|
||||
if (!window.hermesDesktop?.petOverlay || !$petOverlayActive.get() || stateUnsubs.length) {
|
||||
return
|
||||
}
|
||||
|
||||
const saved = loadSavedBounds()
|
||||
|
||||
if (!saved) {
|
||||
$petOverlayActive.set(false)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
openOverlay({ bounds: saved, screen: true })
|
||||
}
|
||||
|
||||
/** Pop the pet back into the window (closes the overlay window). */
|
||||
export function popInPet(): void {
|
||||
for (const off of stateUnsubs) {
|
||||
off()
|
||||
}
|
||||
|
||||
stateUnsubs = []
|
||||
$petOverlayActive.set(false)
|
||||
void window.hermesDesktop?.petOverlay?.close()
|
||||
}
|
||||
|
||||
/** Register the handler that turns an overlay composer submit into a real send. */
|
||||
export function setPetOverlaySubmitHandler(fn: ((text: string) => void) | null): void {
|
||||
submitHandler = fn
|
||||
}
|
||||
|
||||
/** Register the handler that opens the app to the most recent thread (mail icon). */
|
||||
export function setPetOverlayOpenAppHandler(fn: (() => void) | null): void {
|
||||
openAppHandler = fn
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire the overlay→renderer control channel once. Returns a disposer. Idempotent
|
||||
* — a second call while already wired is a no-op.
|
||||
*/
|
||||
export function initPetOverlayBridge(): () => void {
|
||||
const api = window.hermesDesktop?.petOverlay
|
||||
|
||||
if (!api || controlUnsub) {
|
||||
return () => {}
|
||||
}
|
||||
|
||||
controlUnsub = api.onControl(payload => {
|
||||
if (payload?.type === 'pop-in') {
|
||||
popInPet()
|
||||
} else if (payload?.type === 'ready') {
|
||||
// The overlay just mounted — hand it the current frame.
|
||||
pushNow()
|
||||
} else if (payload?.type === 'submit' && typeof payload.text === 'string') {
|
||||
submitHandler?.(payload.text)
|
||||
} else if (payload?.type === 'bounds' && payload.bounds) {
|
||||
// The user dragged the overlay to a new desktop spot — remember it.
|
||||
saveBounds(payload.bounds)
|
||||
} else if (payload?.type === 'open-app') {
|
||||
// Mail icon: surface the app on the most recent thread (main.cjs already
|
||||
// focused the window before forwarding this) and mark it read.
|
||||
clearPetUnread()
|
||||
openAppHandler?.()
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
controlUnsub?.()
|
||||
controlUnsub = null
|
||||
}
|
||||
}
|
||||
48
apps/desktop/src/store/pet.test.ts
Normal file
48
apps/desktop/src/store/pet.test.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { $petActivity, $petState, derivePetState, flashPetActivity, setPetActivity } from './pet'
|
||||
|
||||
describe('derivePetState', () => {
|
||||
it('rests at idle by default and uses waiting when awaiting input', () => {
|
||||
expect(derivePetState({})).toBe('idle')
|
||||
expect(derivePetState({ awaitingInput: true })).toBe('waiting')
|
||||
})
|
||||
|
||||
it('runs when busy or a tool is executing', () => {
|
||||
expect(derivePetState({ busy: true })).toBe('run')
|
||||
expect(derivePetState({ toolRunning: true })).toBe('run')
|
||||
})
|
||||
|
||||
it('reviews while reasoning (below tool, above bare busy)', () => {
|
||||
expect(derivePetState({ reasoning: true })).toBe('review')
|
||||
expect(derivePetState({ reasoning: true, busy: true })).toBe('review')
|
||||
expect(derivePetState({ reasoning: true, toolRunning: true })).toBe('run')
|
||||
})
|
||||
|
||||
it('waits (blocked on the user) above the in-flight signals', () => {
|
||||
expect(derivePetState({ awaitingInput: true, toolRunning: true, busy: true })).toBe('waiting')
|
||||
// but a finish beat still wins over waiting
|
||||
expect(derivePetState({ justCompleted: true, awaitingInput: true })).toBe('wave')
|
||||
})
|
||||
|
||||
it('honors the full priority chain: error > celebrate > complete > tool', () => {
|
||||
expect(derivePetState({ error: true, celebrate: true, busy: true })).toBe('failed')
|
||||
expect(derivePetState({ celebrate: true, justCompleted: true, toolRunning: true })).toBe('jump')
|
||||
expect(derivePetState({ justCompleted: true, toolRunning: true })).toBe('wave')
|
||||
})
|
||||
})
|
||||
|
||||
describe('flashPetActivity', () => {
|
||||
it('clears stale sibling beats so a completion never inherits a prior error', () => {
|
||||
// A turn errors (sad), then the next turn finishes cleanly. The celebrate
|
||||
// beat must win — error is highest priority, so a merge-only flash would
|
||||
// keep the pet on the failed pose.
|
||||
setPetActivity({ error: true })
|
||||
flashPetActivity({ celebrate: true })
|
||||
|
||||
expect($petActivity.get().error).toBe(false)
|
||||
expect($petState.get()).toBe('jump')
|
||||
|
||||
setPetActivity({})
|
||||
})
|
||||
})
|
||||
147
apps/desktop/src/store/pet.ts
Normal file
147
apps/desktop/src/store/pet.ts
Normal file
@ -0,0 +1,147 @@
|
||||
import { atom, computed } from 'nanostores'
|
||||
|
||||
import { $busy } from '@/store/session'
|
||||
|
||||
/**
|
||||
* Petdex mascot state for the desktop floating pet.
|
||||
*
|
||||
* The spritesheet payload comes from the gateway `pet.info` RPC (shared with
|
||||
* the TUI). The animation *state* is derived here from the same activity
|
||||
* signals the chat already tracks, mirroring the priority order documented in
|
||||
* `agent/pet/state.py` so the Python and TS surfaces never drift.
|
||||
*/
|
||||
|
||||
export type PetState = 'idle' | 'wave' | 'run' | 'failed' | 'review' | 'jump' | 'waiting'
|
||||
|
||||
export interface PetInfo {
|
||||
enabled: boolean
|
||||
slug?: string
|
||||
displayName?: string
|
||||
mime?: string
|
||||
spritesheetBase64?: string
|
||||
frameW?: number
|
||||
frameH?: number
|
||||
framesPerState?: number
|
||||
// Real (padding-trimmed) frame count per state row, from the engine. Lets the
|
||||
// canvas step only frames that exist instead of a fixed framesPerState, which
|
||||
// would animate into the transparent padding of ragged sheets (blank flash).
|
||||
framesByState?: Record<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 → awaitingInput → toolRunning → reasoning →
|
||||
* busy → idle. `awaitingInput` (a clarify/approval blocking on the user) outranks
|
||||
* the in-flight signals because the turn is paused on you, not working.
|
||||
*/
|
||||
export function derivePetState(activity: PetActivity): PetState {
|
||||
if (activity.error) {
|
||||
return 'failed'
|
||||
}
|
||||
|
||||
if (activity.celebrate) {
|
||||
return 'jump'
|
||||
}
|
||||
|
||||
if (activity.justCompleted) {
|
||||
return 'wave'
|
||||
}
|
||||
|
||||
if (activity.awaitingInput) {
|
||||
return 'waiting'
|
||||
}
|
||||
|
||||
if (activity.toolRunning) {
|
||||
return 'run'
|
||||
}
|
||||
|
||||
if (activity.reasoning) {
|
||||
return 'review'
|
||||
}
|
||||
|
||||
if (activity.busy) {
|
||||
return 'run'
|
||||
}
|
||||
|
||||
return 'idle'
|
||||
}
|
||||
|
||||
export const $petInfo = atom<PetInfo>({ enabled: false })
|
||||
export const $petActivity = atom<PetActivity>({})
|
||||
|
||||
/**
|
||||
* Pet-local "you have a new message" flag, surfaced as the overlay's mail icon.
|
||||
* Deliberately not real unread tracking: it flips on when a turn finishes while
|
||||
* the app isn't focused, and off when the user opens the app via the mail icon
|
||||
* (or returns to the window). No persistence — it's a glance hint, not state.
|
||||
*/
|
||||
export const $petUnread = atom(false)
|
||||
export const markPetUnread = () => $petUnread.set(true)
|
||||
export const clearPetUnread = () => $petUnread.set(false)
|
||||
|
||||
/** Steady activity flags (toolRunning / reasoning) set + cleared by the stream. */
|
||||
export const setPetActivity = (next: Partial<PetActivity>) =>
|
||||
$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`.
|
||||
*
|
||||
* Each beat first clears its siblings so a stale one can't win the priority
|
||||
* race: without this, a completion beat (`celebrate`) would merge on top of a
|
||||
* lingering `error`, and `derivePetState` checks `error` first — so a clean
|
||||
* finish would render the sad/failed pose. */
|
||||
export const flashPetActivity = (next: Partial<PetActivity>, ms = 1600) => {
|
||||
setPetActivity({ celebrate: false, error: false, justCompleted: false, ...next })
|
||||
clearTimeout(flashTimer)
|
||||
flashTimer = setTimeout(
|
||||
() => setPetActivity({ celebrate: false, error: false, justCompleted: false }),
|
||||
ms
|
||||
)
|
||||
}
|
||||
|
||||
export const setPetInfo = (info: PetInfo) => $petInfo.set(info)
|
||||
|
||||
/**
|
||||
* The live pet state. Derives from the dedicated activity atom, falling back to
|
||||
* the always-present `$busy` chat signal so the pet reacts out of the box.
|
||||
*
|
||||
* `awaitingInput` (a clarify/approval blocking on the user) is an explicit flag
|
||||
* on `$petActivity` — set by the controller from `$attentionSessionIds` and
|
||||
* mirrored to the pop-out overlay through the same atom, so both surfaces agree
|
||||
* without the overlay needing the session list.
|
||||
*/
|
||||
export const $petState = computed(
|
||||
[$petActivity, $busy],
|
||||
(activity, busy): PetState => {
|
||||
const live = activity.busy ?? busy
|
||||
|
||||
return derivePetState({
|
||||
busy: live,
|
||||
awaitingInput: activity.awaitingInput,
|
||||
// Steady flags only count mid-turn — ignore stale ones once at rest so an
|
||||
// interrupted turn can't pin the pet on `run`/`review`.
|
||||
toolRunning: live && activity.toolRunning,
|
||||
reasoning: live && activity.reasoning,
|
||||
error: activity.error,
|
||||
justCompleted: activity.justCompleted,
|
||||
celebrate: activity.celebrate
|
||||
})
|
||||
}
|
||||
)
|
||||
262
cli.py
262
cli.py
@ -60,7 +60,7 @@ from prompt_toolkit.history import FileHistory
|
||||
from prompt_toolkit.styles import Style as PTStyle
|
||||
from prompt_toolkit.patch_stdout import patch_stdout
|
||||
from prompt_toolkit.application import Application
|
||||
from prompt_toolkit.layout import Layout, HSplit, Window, FormattedTextControl, ConditionalContainer
|
||||
from prompt_toolkit.layout import Layout, HSplit, Window, FormattedTextControl, ConditionalContainer, WindowAlign
|
||||
from prompt_toolkit.layout.processors import Processor, Transformation, PasswordProcessor, ConditionalProcessor
|
||||
from prompt_toolkit.filters import Condition
|
||||
from prompt_toolkit.layout.dimension import Dimension
|
||||
@ -3572,6 +3572,25 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||
self._last_scrollback_tool: str = "" # last tool name printed to scrollback (for "new" dedup)
|
||||
self._command_running = False
|
||||
self._command_status = ""
|
||||
# Petdex mascot (opt-in via display.pet). The base CLI mirrors the TUI's
|
||||
# PetPane: a half-block sprite above the prompt that reacts to agent
|
||||
# activity. Lazily resolved; an invalidate timer drives the animation.
|
||||
self._pet_renderer = None # agent.pet.render.PetRenderer | None
|
||||
self._pet_slug: str = ""
|
||||
self._pet_enabled: bool = False
|
||||
self._pet_cols: int = 18
|
||||
self._pet_scale: float = 0.7
|
||||
self._pet_frames_cache: dict = {} # state -> list[grid]
|
||||
self._pet_frame_idx: int = 0
|
||||
self._pet_lock = threading.Lock()
|
||||
self._pet_cfg_checked: float = 0.0
|
||||
self._pet_anim_running: bool = False
|
||||
self._pet_anim_thread = None
|
||||
# Transient reaction beats (wave/jump/failed) + steady reasoning flag.
|
||||
self._pet_event: str = ""
|
||||
self._pet_event_until: float = 0.0
|
||||
self._pet_reasoning: bool = False
|
||||
self._pet_turn_error: bool = False
|
||||
self._attached_images: list[Path] = []
|
||||
self._image_counter = 0
|
||||
self.preloaded_skills: list[str] = []
|
||||
@ -4112,6 +4131,218 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||
return f" {txt} ({elapsed_str})"
|
||||
return f" {txt}"
|
||||
|
||||
# ── Petdex mascot (base-CLI pet pane) ───────────────────────────────
|
||||
#
|
||||
# Parity with the TUI: a half-block sprite rendered as a prompt_toolkit
|
||||
# window above the prompt, reacting to agent state and animated by a timer
|
||||
# that calls ``app.invalidate()``. Half-blocks only — the crisp Kitty image
|
||||
# protocol can't coexist with prompt_toolkit's patch_stdout output layer
|
||||
# (raw image escapes get swallowed/mangled), so we use truecolor styled
|
||||
# text, which prompt_toolkit renders natively in any 24-bit terminal.
|
||||
|
||||
_PET_FRAME_INTERVAL = 0.16
|
||||
_PET_CFG_INTERVAL = 2.5
|
||||
|
||||
def _pet_resolve_config(self) -> None:
|
||||
"""(Re)resolve the active pet from config — picks up live enable/disable/
|
||||
|
||||
switch made via ``/pet`` or ``hermes pets`` without a restart, mirroring
|
||||
the TUI's steady poll. Cheap and fail-open: any problem disables the pet.
|
||||
"""
|
||||
try:
|
||||
from agent.pet import constants, store
|
||||
from agent.pet.render import PetRenderer
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
cfg = load_config()
|
||||
display = cfg.get("display", {}) if isinstance(cfg.get("display"), dict) else {}
|
||||
pet_cfg = display.get("pet", {}) if isinstance(display.get("pet"), dict) else {}
|
||||
|
||||
enabled = bool(pet_cfg.get("enabled"))
|
||||
slug = str(pet_cfg.get("slug", "") or "")
|
||||
scale = float(pet_cfg.get("scale", constants.DEFAULT_SCALE) or constants.DEFAULT_SCALE)
|
||||
cols = constants.resolve_cols(scale, pet_cfg.get("unicode_cols", 0))
|
||||
|
||||
if not enabled:
|
||||
with self._pet_lock:
|
||||
self._pet_enabled = False
|
||||
self._pet_renderer = None
|
||||
self._pet_frames_cache.clear()
|
||||
return
|
||||
|
||||
pet = store.resolve_active_pet(slug)
|
||||
if pet is None or not pet.exists:
|
||||
with self._pet_lock:
|
||||
self._pet_enabled = False
|
||||
self._pet_renderer = None
|
||||
self._pet_frames_cache.clear()
|
||||
return
|
||||
|
||||
with self._pet_lock:
|
||||
# Rebuild only when the resolved pet or geometry changes.
|
||||
if (
|
||||
self._pet_renderer is None
|
||||
or self._pet_slug != pet.slug
|
||||
or self._pet_cols != cols
|
||||
or self._pet_scale != scale
|
||||
):
|
||||
self._pet_renderer = PetRenderer(
|
||||
str(pet.spritesheet), mode="unicode", scale=scale, unicode_cols=cols
|
||||
)
|
||||
self._pet_slug = pet.slug
|
||||
self._pet_cols = cols
|
||||
self._pet_scale = scale
|
||||
self._pet_frames_cache.clear()
|
||||
self._pet_frame_idx = 0
|
||||
self._pet_enabled = True
|
||||
except Exception:
|
||||
with self._pet_lock:
|
||||
self._pet_enabled = False
|
||||
self._pet_renderer = None
|
||||
|
||||
def _pet_flash(self, state: str, secs: float = 1.6) -> None:
|
||||
"""Briefly force a transient reaction (wave/jump/failed) before resting."""
|
||||
self._pet_event = state
|
||||
self._pet_event_until = time.monotonic() + secs
|
||||
|
||||
def _pet_react_turn_end(self) -> None:
|
||||
"""Flash the end-of-turn beat: failed on error, jump on a finished plan, else wave."""
|
||||
if not self._pet_enabled:
|
||||
return
|
||||
from agent.pet.state import todos_all_done
|
||||
|
||||
if self._pet_turn_error:
|
||||
self._pet_flash("failed")
|
||||
return
|
||||
try:
|
||||
store = getattr(self.agent, "_todo_store", None)
|
||||
done = todos_all_done(store.read()) if store else False
|
||||
except Exception:
|
||||
done = False
|
||||
self._pet_flash("jump" if done else "wave")
|
||||
|
||||
def _derive_pet_state(self) -> str:
|
||||
"""Map current CLI activity to a pet animation state.
|
||||
|
||||
A transient reaction beat (wave/jump/failed) wins while it's live;
|
||||
otherwise the steady state comes from the shared
|
||||
:func:`agent.pet.state.derive_pet_state` so the CLI can't drift from the
|
||||
TUI/desktop priority order.
|
||||
"""
|
||||
if self._pet_event and time.monotonic() < self._pet_event_until:
|
||||
return self._pet_event
|
||||
self._pet_event = ""
|
||||
from agent.pet.state import derive_pet_state
|
||||
|
||||
# A live blocking modal (approval / clarify / sudo / secret / slash
|
||||
# confirm) means the agent is paused on the user → the `waiting` pose,
|
||||
# which outranks the in-flight signals in derive_pet_state.
|
||||
awaiting_input = bool(
|
||||
self._approval_state
|
||||
or self._clarify_state
|
||||
or self._sudo_state
|
||||
or self._secret_state
|
||||
or getattr(self, "_slash_confirm_state", None)
|
||||
)
|
||||
|
||||
return derive_pet_state(
|
||||
awaiting_input=awaiting_input,
|
||||
busy=getattr(self, "_agent_running", False),
|
||||
reasoning=self._pet_reasoning,
|
||||
).value
|
||||
|
||||
def _pet_frames_for(self, state: str) -> list:
|
||||
"""Return (and cache) the half-block grids for one state."""
|
||||
cached = self._pet_frames_cache.get(state)
|
||||
if cached is not None:
|
||||
return cached
|
||||
renderer = self._pet_renderer
|
||||
if renderer is None:
|
||||
return []
|
||||
try:
|
||||
count = renderer.frame_count(state) or 1
|
||||
grids = [renderer.cells(state, i, cols=self._pet_cols) for i in range(count)]
|
||||
except Exception:
|
||||
grids = []
|
||||
self._pet_frames_cache[state] = grids
|
||||
return grids
|
||||
|
||||
def _pet_fragments(self):
|
||||
"""Return prompt_toolkit FormattedText for the current pet frame, or []."""
|
||||
with self._pet_lock:
|
||||
if not self._pet_enabled or self._pet_renderer is None:
|
||||
return []
|
||||
state = self._derive_pet_state()
|
||||
grids = self._pet_frames_for(state)
|
||||
if not grids:
|
||||
return []
|
||||
grid = grids[self._pet_frame_idx % len(grids)]
|
||||
|
||||
frags = []
|
||||
for y, row in enumerate(grid):
|
||||
if y:
|
||||
frags.append(("", "\n"))
|
||||
for top, bottom in row:
|
||||
tr, tg, tb, ta = top
|
||||
br, bg, bb, ba = bottom
|
||||
top_op = ta >= 32
|
||||
bot_op = ba >= 32
|
||||
if not top_op and not bot_op:
|
||||
frags.append(("", " "))
|
||||
elif top_op and bot_op:
|
||||
frags.append((f"fg:#{tr:02x}{tg:02x}{tb:02x} bg:#{br:02x}{bg:02x}{bb:02x}", "▀"))
|
||||
elif top_op:
|
||||
# Upper half only — leave the lower half the terminal's bg
|
||||
# instead of painting it black (cleaner on light themes).
|
||||
frags.append((f"fg:#{tr:02x}{tg:02x}{tb:02x}", "▀"))
|
||||
else:
|
||||
frags.append((f"fg:#{br:02x}{bg:02x}{bb:02x}", "▄"))
|
||||
return frags
|
||||
|
||||
def _pet_widget_height(self) -> int:
|
||||
"""Visible rows for the pet window — 0 collapses it when no pet shows."""
|
||||
with self._pet_lock:
|
||||
if not self._pet_enabled or self._pet_renderer is None:
|
||||
return 0
|
||||
grids = self._pet_frames_for(self._derive_pet_state())
|
||||
if not grids or not grids[0]:
|
||||
return 0
|
||||
return len(grids[0])
|
||||
|
||||
def _pet_anim_loop(self) -> None:
|
||||
"""Advance the frame + invalidate on a timer while a pet is enabled."""
|
||||
while self._pet_anim_running:
|
||||
time.sleep(self._PET_FRAME_INTERVAL)
|
||||
now = time.monotonic()
|
||||
if now - self._pet_cfg_checked >= self._PET_CFG_INTERVAL:
|
||||
self._pet_cfg_checked = now
|
||||
self._pet_resolve_config()
|
||||
if not self._pet_enabled:
|
||||
continue
|
||||
with self._pet_lock:
|
||||
self._pet_frame_idx += 1
|
||||
app = getattr(self, "_app", None)
|
||||
if app is not None:
|
||||
try:
|
||||
app.invalidate()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _pet_start_anim(self) -> None:
|
||||
if self._pet_anim_running:
|
||||
return
|
||||
self._pet_resolve_config()
|
||||
self._pet_anim_running = True
|
||||
self._pet_anim_thread = threading.Thread(target=self._pet_anim_loop, daemon=True)
|
||||
self._pet_anim_thread.start()
|
||||
|
||||
def _pet_stop_anim(self) -> None:
|
||||
self._pet_anim_running = False
|
||||
thread = self._pet_anim_thread
|
||||
if thread is not None:
|
||||
thread.join(timeout=0.3)
|
||||
self._pet_anim_thread = None
|
||||
|
||||
def _voice_record_key_label(self) -> str:
|
||||
"""Return the configured voice push-to-talk key formatted for UI.
|
||||
|
||||
@ -7434,6 +7665,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||
elif canonical == "personality":
|
||||
# Use original case (handler lowercases the personality name itself)
|
||||
self._handle_personality_command(cmd_original)
|
||||
elif canonical == "pet":
|
||||
self._handle_pet_command(cmd_original)
|
||||
elif canonical == "retry":
|
||||
retry_msg = self.retry_last()
|
||||
if retry_msg and hasattr(self, '_pending_input'):
|
||||
@ -8973,6 +9206,15 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||
stacked line to scrollback on tool.completed so users can see the
|
||||
full history of tool calls (not just the current one in the spinner).
|
||||
"""
|
||||
# Feed the pet: tools mean "running" (not reasoning); a failed tool
|
||||
# latches the turn so it ends on a sulk.
|
||||
if event_type == "tool.started":
|
||||
self._pet_reasoning = False
|
||||
elif event_type == "tool.completed" and kwargs.get("is_error"):
|
||||
self._pet_turn_error = True
|
||||
elif event_type and event_type.startswith("reasoning"):
|
||||
self._pet_reasoning = True
|
||||
|
||||
if event_type == "tool.completed":
|
||||
self._tool_start_time = 0.0
|
||||
# Print stacked scrollback line for "all" / "new" modes
|
||||
@ -10925,6 +11167,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||
spinner_widget,
|
||||
spacer,
|
||||
*self._get_extra_tui_widgets(),
|
||||
getattr(self, "_pet_widget", None),
|
||||
status_bar,
|
||||
input_rule_top,
|
||||
image_bar,
|
||||
@ -12266,6 +12509,16 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||
wrap_lines=True,
|
||||
)
|
||||
|
||||
# Petdex mascot — right-aligned half-block sprite above the prompt,
|
||||
# mirroring the TUI's PetPane. Collapses to height 0 when no pet is
|
||||
# enabled, so it's a no-op for everyone else. The _pet_anim_loop thread
|
||||
# advances frames + invalidates; align=RIGHT pins it to the edge.
|
||||
self._pet_widget = Window(
|
||||
content=FormattedTextControl(self._pet_fragments),
|
||||
height=self._pet_widget_height,
|
||||
align=WindowAlign.RIGHT,
|
||||
)
|
||||
|
||||
spacer = Window(
|
||||
content=FormattedTextControl(get_hint_text),
|
||||
height=get_hint_height,
|
||||
@ -13036,6 +13289,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||
|
||||
# Regular chat - run agent
|
||||
self._agent_running = True
|
||||
self._pet_turn_error = False
|
||||
self._pet_reasoning = False
|
||||
app.invalidate() # Refresh status line
|
||||
|
||||
try:
|
||||
@ -13046,6 +13301,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||
self._tool_start_time = 0.0
|
||||
self._pending_tool_info.clear()
|
||||
self._last_scrollback_tool = ""
|
||||
self._pet_reasoning = False
|
||||
self._pet_react_turn_end()
|
||||
|
||||
app.invalidate() # Refresh status line
|
||||
|
||||
@ -13278,6 +13535,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||
# The app enables focus reporting + mouse tracking; record that
|
||||
# so _run_cleanup resets them on exit (#36823).
|
||||
_mark_tui_input_modes_active()
|
||||
# Drive the petdex mascot animation (no-op when no pet enabled).
|
||||
self._pet_start_anim()
|
||||
app.run()
|
||||
except (EOFError, KeyboardInterrupt, BrokenPipeError):
|
||||
pass
|
||||
@ -13304,6 +13563,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||
raise
|
||||
finally:
|
||||
self._should_exit = True
|
||||
self._pet_stop_anim()
|
||||
# Interrupt the agent immediately so its daemon thread stops making
|
||||
# API calls and exits promptly (agent_thread is daemon, so the
|
||||
# process will exit once the main thread finishes, but interrupting
|
||||
|
||||
@ -1039,6 +1039,64 @@ class CLICommandsMixin:
|
||||
print(" Usage: /personality <name>")
|
||||
print()
|
||||
|
||||
def _handle_pet_command(self, cmd: str):
|
||||
"""Toggle, browse, or adopt a petdex mascot.
|
||||
|
||||
``/pet`` / ``/pet toggle`` → flip ``display.pet.enabled`` on/off
|
||||
``/pet list`` → browse the petdex gallery
|
||||
``/pet scale <n>`` → resize the pet everywhere (e.g. 0.5)
|
||||
``/pet <slug>`` → adopt (install if needed) + make active
|
||||
``/pet off`` → disable (alias for toggle-off)
|
||||
|
||||
Writes ``display.pet.*`` to config; the CLI/TUI/desktop pet surfaces
|
||||
pick the change up on their next poll, so the pet appears shortly.
|
||||
"""
|
||||
from agent.pet import store
|
||||
from agent.pet.manifest import ManifestError
|
||||
from hermes_cli.pets import _set_active, _set_enabled, print_pet_gallery, set_pet_scale, toggle_pet_display
|
||||
|
||||
parts = cmd.split(maxsplit=1)
|
||||
arg = parts[1].strip() if len(parts) > 1 else ""
|
||||
low = arg.lower()
|
||||
|
||||
if not arg or low == "toggle":
|
||||
enabled, name, err = toggle_pet_display()
|
||||
if err:
|
||||
print(f"(x_x) {err}")
|
||||
return
|
||||
if enabled:
|
||||
print(f"(^_^)b {name} is out — it'll pop in shortly.")
|
||||
else:
|
||||
print(f"(-_-)zzZ {name} put away." if name else "(-_-)zzZ Pet put away.")
|
||||
return
|
||||
|
||||
if low in ("list", "gallery", "browse", "all"):
|
||||
print_pet_gallery()
|
||||
return
|
||||
|
||||
if low == "scale" or low.startswith("scale "):
|
||||
value = arg[len("scale"):].strip()
|
||||
if not value:
|
||||
print("(o_o) Usage: /pet scale <factor> (e.g. /pet scale 0.5)")
|
||||
return
|
||||
scale, err = set_pet_scale(value)
|
||||
print(f"(x_x) {err}" if err else f"(^_^) Pet scale → {scale:g}.")
|
||||
return
|
||||
|
||||
if low == "off":
|
||||
_set_enabled(False)
|
||||
print("(-_-)zzZ Pet put away.")
|
||||
return
|
||||
|
||||
print(f"(o_o) Fetching '{arg}' from petdex…")
|
||||
try:
|
||||
pet = store.install_pet(arg)
|
||||
except (store.PetStoreError, ManifestError) as exc:
|
||||
print(f"(x_x) Couldn't adopt '{arg}': {exc}")
|
||||
return
|
||||
_set_active(arg)
|
||||
print(f"(^_^)b {pet.display_name} is out — it'll pop in shortly.")
|
||||
|
||||
def _handle_cron_command(self, cmd: str):
|
||||
"""Handle the /cron command to manage scheduled tasks."""
|
||||
from cli import get_job
|
||||
|
||||
@ -176,6 +176,8 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
||||
subcommands=("pending", "approve", "reject", "approval")),
|
||||
CommandDef("bundles", "List skill bundles (aliases /<name> for multiple skills)",
|
||||
"Tools & Skills"),
|
||||
CommandDef("pet", "Toggle or adopt a petdex mascot (/pet, /pet list, /pet <slug>)", "Tools & Skills",
|
||||
cli_only=True, args_hint="[toggle|list|scale <n>|<slug>]", subcommands=("toggle", "list", "scale", "off")),
|
||||
CommandDef("cron", "Manage scheduled tasks", "Tools & Skills",
|
||||
cli_only=True, args_hint="[subcommand]",
|
||||
subcommands=("list", "add", "create", "edit", "pause", "resume", "run", "remove")),
|
||||
|
||||
@ -1534,6 +1534,31 @@ DEFAULT_CONFIG = {
|
||||
"fields": ["model", "context_pct", "cwd"], # Order shown; drop any to hide
|
||||
},
|
||||
"copy_shortcut": "auto", # "auto" (platform default) | "ctrl_c" | "ctrl_shift_c" | "disabled"
|
||||
# Petdex animated mascot (https://github.com/crafter-station/petdex).
|
||||
# A purely cosmetic sprite that reacts to agent activity across the
|
||||
# CLI, TUI, and desktop app. Manage with `hermes pets`. Disabled until
|
||||
# a pet is installed + selected (no effect on prompt caching — this is
|
||||
# a display concern only).
|
||||
"pet": {
|
||||
"enabled": False,
|
||||
# Active pet slug; resolved against installed pets in
|
||||
# get_hermes_home()/pets/. Empty → first installed pet.
|
||||
"slug": "",
|
||||
# Terminal render protocol for CLI/TUI:
|
||||
# auto — detect kitty/iTerm2/sixel, else unicode half-blocks
|
||||
# kitty | iterm | sixel | unicode | off
|
||||
"render_mode": "auto",
|
||||
# Master size scalar (relative to native 192×208 frames). One knob
|
||||
# shrinks every surface: the desktop canvas scales its pixels by it
|
||||
# and the CLI/TUI derive their terminal column width from it. The
|
||||
# half-block fallback clamps to a legibility floor (it can't shrink
|
||||
# as far as true-pixel kitty/GUI without turning to mush).
|
||||
"scale": 0.33,
|
||||
# Hard override for terminal column width. 0 = auto (derive from
|
||||
# scale); set a positive int only to pin the half-block/kitty width
|
||||
# independently of scale.
|
||||
"unicode_cols": 0,
|
||||
},
|
||||
},
|
||||
|
||||
# Web dashboard settings
|
||||
|
||||
@ -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", "plugins", "portal", "postinstall", "profile", "proxy",
|
||||
"model", "pairing", "pets", "plugins", "portal", "postinstall", "profile", "proxy",
|
||||
"prompt-size",
|
||||
"send", "sessions", "setup",
|
||||
"skills", "slack", "status", "tools", "uninstall", "update",
|
||||
@ -11955,6 +11955,26 @@ def main():
|
||||
except Exception as _exc:
|
||||
logging.getLogger(__name__).debug("curator CLI wiring failed: %s", _exc)
|
||||
|
||||
# =========================================================================
|
||||
# pets command — petdex animated mascots (CLI / TUI / desktop display)
|
||||
# =========================================================================
|
||||
pets_parser = subparsers.add_parser(
|
||||
"pets",
|
||||
help="Browse, install, and select petdex animated pets",
|
||||
description=(
|
||||
"Petdex (https://github.com/crafter-station/petdex) is a public "
|
||||
"gallery of animated sprite pets for coding agents. Install one "
|
||||
"and Hermes shows it reacting to agent activity across the CLI, "
|
||||
"TUI, and desktop app."
|
||||
),
|
||||
)
|
||||
try:
|
||||
from hermes_cli.pets import register_cli as _register_pets_cli
|
||||
|
||||
_register_pets_cli(pets_parser)
|
||||
except Exception as _exc:
|
||||
logging.getLogger(__name__).debug("pets CLI wiring failed: %s", _exc)
|
||||
|
||||
# =========================================================================
|
||||
# memory command (parser built in hermes_cli/subcommands/memory.py)
|
||||
# =========================================================================
|
||||
|
||||
482
hermes_cli/pets.py
Normal file
482
hermes_cli/pets.py
Normal file
@ -0,0 +1,482 @@
|
||||
"""CLI subcommand: ``hermes pets <subcommand>``.
|
||||
|
||||
Thin shell around :mod:`agent.pet`. Browses the public petdex gallery,
|
||||
installs pets into the profile's ``pets/`` directory, selects the active
|
||||
mascot (writes ``display.pet.*`` to config.yaml), and runs a doctor check.
|
||||
|
||||
No side effects at import time — ``main.py`` wires the argparse subparsers on
|
||||
demand via :func:`register_cli`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
|
||||
def _print(msg: str = "") -> None:
|
||||
print(msg)
|
||||
|
||||
|
||||
def _err(msg: str) -> None:
|
||||
print(msg, file=sys.stderr)
|
||||
|
||||
|
||||
def _cmd_list(args) -> int:
|
||||
"""List gallery pets (or only installed ones with ``--installed``)."""
|
||||
from agent.pet import store
|
||||
|
||||
if getattr(args, "installed", False):
|
||||
pets = store.installed_pets()
|
||||
if not pets:
|
||||
_print("No pets installed. Try: hermes pets install boba")
|
||||
return 0
|
||||
_print(f"Installed pets ({len(pets)}):")
|
||||
for pet in pets:
|
||||
_print(f" {pet.slug:<24} {pet.display_name}")
|
||||
return 0
|
||||
|
||||
from agent.pet.manifest import ManifestError, fetch_manifest
|
||||
|
||||
try:
|
||||
entries = fetch_manifest()
|
||||
except ManifestError as exc:
|
||||
_err(f"✗ {exc}")
|
||||
return 1
|
||||
|
||||
query = (getattr(args, "query", "") or "").strip().lower()
|
||||
if query:
|
||||
entries = [
|
||||
e
|
||||
for e in entries
|
||||
if query in e.slug.lower() or query in e.display_name.lower()
|
||||
]
|
||||
|
||||
limit = getattr(args, "limit", 0) or 0
|
||||
shown = entries[:limit] if limit > 0 else entries
|
||||
installed = {p.slug for p in store.installed_pets()}
|
||||
|
||||
_print(f"petdex gallery — {len(entries)} pet(s){' matching ' + repr(query) if query else ''}:")
|
||||
for entry in shown:
|
||||
mark = "✓" if entry.slug in installed else " "
|
||||
_print(f" {mark} {entry.slug:<28} {entry.display_name} ({entry.kind})")
|
||||
if limit and len(entries) > limit:
|
||||
_print(f" … {len(entries) - limit} more (use --limit 0 or --query to filter)")
|
||||
_print("\nInstall one with: hermes pets install <slug>")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_install(args) -> int:
|
||||
from agent.pet import store
|
||||
from agent.pet.manifest import ManifestError
|
||||
|
||||
slug = args.slug.strip()
|
||||
try:
|
||||
pet = store.install_pet(slug, force=getattr(args, "force", False))
|
||||
except (store.PetStoreError, ManifestError) as exc:
|
||||
_err(f"✗ install failed: {exc}")
|
||||
return 1
|
||||
|
||||
_print(f"✓ installed {pet.display_name} → {pet.directory}")
|
||||
|
||||
if getattr(args, "select", False) or not _has_active_pet():
|
||||
_set_active(slug)
|
||||
_print(f"✓ {pet.display_name} is now the active pet (display.pet.slug={slug}, enabled)")
|
||||
else:
|
||||
_print(f" Make it active with: hermes pets select {slug}")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_remove(args) -> int:
|
||||
from agent.pet import store
|
||||
|
||||
slug = args.slug.strip()
|
||||
if store.remove_pet(slug):
|
||||
_print(f"✓ removed {slug}")
|
||||
return 0
|
||||
_err(f"✗ '{slug}' is not installed")
|
||||
return 1
|
||||
|
||||
|
||||
def _cmd_select(args) -> int:
|
||||
from agent.pet import store
|
||||
|
||||
slug = (getattr(args, "slug", "") or "").strip()
|
||||
if not slug:
|
||||
pets = store.installed_pets()
|
||||
if not pets:
|
||||
_err("✗ no pets installed — run: hermes pets install boba")
|
||||
return 1
|
||||
slug = _interactive_pick(pets)
|
||||
if not slug:
|
||||
return 1
|
||||
|
||||
pet = store.load_pet(slug)
|
||||
if pet is None or not pet.exists:
|
||||
_err(f"✗ '{slug}' is not installed — run: hermes pets install {slug}")
|
||||
return 1
|
||||
|
||||
_set_active(slug)
|
||||
_print(f"✓ active pet set to {pet.display_name} (display.pet.slug={slug}, enabled)")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_off(args) -> int:
|
||||
_set_enabled(False)
|
||||
_print("✓ pet disabled (display.pet.enabled=false)")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_scale(args) -> int:
|
||||
"""Persist ``display.pet.scale`` — one knob resizes every surface."""
|
||||
scale, err = set_pet_scale(args.factor)
|
||||
if err:
|
||||
_err(f"✗ {err}")
|
||||
return 1
|
||||
_print(f"✓ pet scale set to {scale:g} (display.pet.scale)")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_show(args) -> int:
|
||||
"""Animate the active (or named) pet in the terminal.
|
||||
|
||||
Uses the shared :class:`~agent.pet.render.PetRenderer` — full graphics
|
||||
protocol (kitty/iTerm2/sixel) when the terminal supports it, else a
|
||||
truecolor Unicode half-block fallback. Ctrl+C to stop.
|
||||
"""
|
||||
import time
|
||||
|
||||
from agent.pet import store
|
||||
from agent.pet.constants import DEFAULT_SCALE, LOOP_MS, STATE_ROWS, PetState, resolve_cols
|
||||
from agent.pet.render import build_renderer
|
||||
|
||||
cfg = _pet_config()
|
||||
slug = (getattr(args, "slug", "") or "").strip() or str(cfg.get("slug", "") or "")
|
||||
pet = store.resolve_active_pet(slug)
|
||||
if pet is None:
|
||||
_err("✗ no pet to show — run: hermes pets install boba")
|
||||
return 1
|
||||
|
||||
mode_cfg = getattr(args, "mode", None) or str(cfg.get("render_mode", "auto") or "auto")
|
||||
scale = float(getattr(args, "scale", 0) or cfg.get("scale", DEFAULT_SCALE) or DEFAULT_SCALE)
|
||||
cols = resolve_cols(scale, cfg.get("unicode_cols", 0))
|
||||
|
||||
renderer = build_renderer(
|
||||
pet.spritesheet,
|
||||
configured_mode=mode_cfg,
|
||||
scale=scale,
|
||||
unicode_cols=cols,
|
||||
)
|
||||
if not renderer.available:
|
||||
_err(
|
||||
"✗ cannot render here (no TTY / graphics disabled). "
|
||||
f"Effective mode: {renderer.mode}."
|
||||
)
|
||||
return 1
|
||||
|
||||
# Which states to play: one named state, or cycle the driveable rows.
|
||||
requested = (getattr(args, "state", "") or "").strip().lower()
|
||||
if requested:
|
||||
states = [requested]
|
||||
elif getattr(args, "cycle", False):
|
||||
states = [s for s in STATE_ROWS if s in {e.value for e in PetState}]
|
||||
else:
|
||||
states = [PetState.IDLE.value]
|
||||
|
||||
is_unicode = renderer.mode == "unicode"
|
||||
frame_delay = max(0.05, (LOOP_MS / 1000.0) / max(1, renderer.frame_count(states[0]) or 1))
|
||||
|
||||
# Right-align the sprite against the terminal's right edge — half-blocks by
|
||||
# indenting each row, graphics protocols by padding the cursor to the right
|
||||
# column before the image draws (kitty/iTerm/sixel all render at the cursor).
|
||||
import shutil
|
||||
|
||||
term_cols = shutil.get_terminal_size((80, 24)).columns
|
||||
indent = ""
|
||||
g_indent = ""
|
||||
if is_unicode:
|
||||
indent = " " * max(0, term_cols - cols - 1)
|
||||
else:
|
||||
cell_cols = max(1, int(renderer.frame_w * renderer.scale) // 8)
|
||||
g_indent = " " * max(0, term_cols - cell_cols - 1)
|
||||
|
||||
out = sys.stdout
|
||||
out.write("\x1b[?25l") # hide cursor
|
||||
out.flush()
|
||||
prev_lines = 0
|
||||
try:
|
||||
_print(f"{pet.display_name} — mode={renderer.mode} (Ctrl+C to stop)")
|
||||
loops = 0
|
||||
while True:
|
||||
for state in states:
|
||||
count = renderer.frame_count(state) or 1
|
||||
for i in range(count):
|
||||
encoded = renderer.frame(state, i)
|
||||
if is_unicode:
|
||||
if indent:
|
||||
encoded = "\n".join(indent + ln for ln in encoded.split("\n"))
|
||||
if prev_lines:
|
||||
out.write(f"\x1b[{prev_lines}F") # cursor up to redraw in place
|
||||
out.write(encoded)
|
||||
out.write("\x1b[0m\n")
|
||||
# Lines drawn = sprite rows + the trailing newline; move
|
||||
# back up exactly that many so the next frame overwrites.
|
||||
prev_lines = encoded.count("\n") + 1
|
||||
else:
|
||||
out.write("\x1b[2J\x1b[3J\x1b[H") # clear for image protocols
|
||||
out.write(f"{pet.display_name} [{state}]\n")
|
||||
if g_indent:
|
||||
out.write(g_indent)
|
||||
out.write(encoded)
|
||||
out.write("\n")
|
||||
out.flush()
|
||||
time.sleep(frame_delay)
|
||||
loops += 1
|
||||
if getattr(args, "once", False) and loops >= len(states):
|
||||
break
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
out.write("\x1b[?25h") # show cursor
|
||||
out.write("\x1b[0m\n")
|
||||
out.flush()
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_doctor(args) -> int:
|
||||
"""Report install state, active pet, config, and terminal capability."""
|
||||
from agent.pet import store
|
||||
from agent.pet.render import detect_terminal_graphics, resolve_mode
|
||||
|
||||
cfg = _pet_config()
|
||||
enabled = bool(cfg.get("enabled"))
|
||||
configured_slug = str(cfg.get("slug", "") or "")
|
||||
mode_cfg = str(cfg.get("render_mode", "auto") or "auto")
|
||||
|
||||
pets = store.installed_pets()
|
||||
active = store.resolve_active_pet(configured_slug)
|
||||
|
||||
_print("petdex doctor")
|
||||
_print(f" pets dir: {store.pets_dir()}")
|
||||
_print(f" installed: {len(pets)} ({', '.join(p.slug for p in pets) or 'none'})")
|
||||
_print(f" display.pet.enabled: {enabled}")
|
||||
_print(f" display.pet.slug: {configured_slug or '(unset)'}")
|
||||
_print(f" active (resolved): {active.slug if active else '(none)'}")
|
||||
_print(f" display.pet.render_mode: {mode_cfg}")
|
||||
_print(f" detected graphics: {detect_terminal_graphics()}")
|
||||
_print(f" effective mode (TTY): {resolve_mode(mode_cfg)}")
|
||||
|
||||
ok = True
|
||||
if not pets:
|
||||
_print(" → no pets installed. Run: hermes pets install boba")
|
||||
ok = False
|
||||
elif active is None:
|
||||
_print(" → active pet unresolved. Run: hermes pets select <slug>")
|
||||
ok = False
|
||||
elif not enabled:
|
||||
_print(" → pet display is disabled. Run: hermes pets select " + active.slug)
|
||||
|
||||
try:
|
||||
import PIL # noqa: F401
|
||||
except ImportError:
|
||||
_print(" ✗ Pillow not importable — sprite decoding will be unavailable")
|
||||
ok = False
|
||||
|
||||
_print(" ✓ ready" if ok and enabled else " (run the suggestions above to finish setup)")
|
||||
return 0
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# config helpers
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _pet_config() -> dict:
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
cfg = load_config()
|
||||
display = cfg.get("display", {}) if isinstance(cfg.get("display"), dict) else {}
|
||||
pet = display.get("pet", {})
|
||||
return pet if isinstance(pet, dict) else {}
|
||||
|
||||
|
||||
def _has_active_pet() -> bool:
|
||||
return bool(_pet_config().get("enabled")) and bool(_pet_config().get("slug"))
|
||||
|
||||
|
||||
def _set_active(slug: str) -> None:
|
||||
from hermes_cli.config import load_config, save_config
|
||||
|
||||
cfg = load_config()
|
||||
display = cfg.setdefault("display", {})
|
||||
pet = display.setdefault("pet", {})
|
||||
pet["slug"] = slug
|
||||
pet["enabled"] = True
|
||||
save_config(cfg)
|
||||
|
||||
|
||||
def _set_enabled(enabled: bool) -> None:
|
||||
from hermes_cli.config import load_config, save_config
|
||||
|
||||
cfg = load_config()
|
||||
display = cfg.setdefault("display", {})
|
||||
pet = display.setdefault("pet", {})
|
||||
pet["enabled"] = enabled
|
||||
save_config(cfg)
|
||||
|
||||
|
||||
def _set_scale(scale: float) -> None:
|
||||
from hermes_cli.config import load_config, save_config
|
||||
|
||||
cfg = load_config()
|
||||
display = cfg.setdefault("display", {})
|
||||
pet = display.setdefault("pet", {})
|
||||
pet["scale"] = scale
|
||||
save_config(cfg)
|
||||
|
||||
|
||||
def set_pet_scale(value: float | str) -> tuple[float, str | None]:
|
||||
"""Set ``display.pet.scale`` (clamped to bounds). Returns ``(applied, error)``.
|
||||
|
||||
The single write path behind ``/pet scale`` and the desktop slider, so every
|
||||
surface that resolves scale from config picks it up identically. *error* is
|
||||
set (and nothing written) only when *value* isn't a number.
|
||||
"""
|
||||
from agent.pet.constants import clamp_scale
|
||||
|
||||
try:
|
||||
scale = clamp_scale(float(value))
|
||||
except (TypeError, ValueError):
|
||||
return 0.0, f"not a number: {value!r} — try a value like 0.5"
|
||||
|
||||
_set_scale(scale)
|
||||
return scale, None
|
||||
|
||||
|
||||
def toggle_pet_display() -> tuple[bool, str | None, str | None]:
|
||||
"""Toggle ``display.pet.enabled``.
|
||||
|
||||
Returns ``(enabled, display_name, error_message)``. *error_message* is set
|
||||
when turning on but nothing is installed to show.
|
||||
"""
|
||||
from agent.pet import store
|
||||
|
||||
cfg = _pet_config()
|
||||
slug = str(cfg.get("slug", "") or "")
|
||||
pet = store.resolve_active_pet(slug)
|
||||
|
||||
if bool(cfg.get("enabled")):
|
||||
_set_enabled(False)
|
||||
return False, pet.display_name if pet else None, None
|
||||
|
||||
if pet is None:
|
||||
installed = store.installed_pets()
|
||||
if not installed:
|
||||
return False, None, "no pets installed — /pet list to browse, or /pet <slug> to adopt"
|
||||
pet = installed[0]
|
||||
_set_active(pet.slug)
|
||||
else:
|
||||
_set_enabled(True)
|
||||
return True, pet.display_name, None
|
||||
|
||||
|
||||
def print_pet_gallery(*, limit: int = 20) -> None:
|
||||
"""Print a slice of the public petdex gallery (CLI/TUI text fallback)."""
|
||||
from agent.pet import store
|
||||
from agent.pet.manifest import ManifestError, fetch_manifest
|
||||
|
||||
try:
|
||||
entries = fetch_manifest()
|
||||
except ManifestError as exc:
|
||||
print(f"(._.) Couldn't reach the petdex gallery: {exc}")
|
||||
return
|
||||
|
||||
installed = {p.slug for p in store.installed_pets()}
|
||||
shown = entries[:limit] if limit > 0 else entries
|
||||
print(f"(^o^)/ petdex gallery — first {len(shown)} of {len(entries)}:")
|
||||
for entry in shown:
|
||||
mark = "●" if entry.slug in installed else "○"
|
||||
print(f" {mark} {entry.slug:<24} {entry.display_name}")
|
||||
print(" /pet <slug> to adopt · /pet to toggle")
|
||||
|
||||
|
||||
def _clear_active_if(slug: str) -> bool:
|
||||
"""Disable + unset the active pet iff it's ``slug`` (e.g. after removal).
|
||||
|
||||
Returns whether anything changed, so callers don't write config needlessly.
|
||||
"""
|
||||
from hermes_cli.config import load_config, save_config
|
||||
|
||||
cfg = load_config()
|
||||
pet = cfg.setdefault("display", {}).setdefault("pet", {})
|
||||
if not isinstance(pet, dict) or str(pet.get("slug", "") or "") != slug:
|
||||
return False
|
||||
pet["slug"] = ""
|
||||
pet["enabled"] = False
|
||||
save_config(cfg)
|
||||
return True
|
||||
|
||||
|
||||
def _interactive_pick(pets) -> str:
|
||||
"""Minimal numbered picker (avoids curses dep for a tiny list)."""
|
||||
_print("Installed pets:")
|
||||
for i, pet in enumerate(pets, 1):
|
||||
_print(f" {i}. {pet.slug:<24} {pet.display_name}")
|
||||
try:
|
||||
choice = input("Select a pet [1]: ").strip() or "1"
|
||||
idx = int(choice) - 1
|
||||
except (EOFError, KeyboardInterrupt, ValueError):
|
||||
_err("✗ cancelled")
|
||||
return ""
|
||||
if 0 <= idx < len(pets):
|
||||
return pets[idx].slug
|
||||
_err("✗ invalid selection")
|
||||
return ""
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# argparse wiring
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def register_cli(parent: argparse.ArgumentParser) -> None:
|
||||
"""Attach ``pets`` subcommands to *parent* (called by main.py)."""
|
||||
parent.set_defaults(func=lambda a: (parent.print_help(), 0)[1])
|
||||
subs = parent.add_subparsers(dest="pets_command")
|
||||
|
||||
p_list = subs.add_parser("list", help="Browse the petdex gallery")
|
||||
p_list.add_argument("query", nargs="?", default="", help="Filter by slug/name substring")
|
||||
p_list.add_argument("--installed", action="store_true", help="Only show installed pets")
|
||||
p_list.add_argument("--limit", type=int, default=40, help="Max rows (0 = all)")
|
||||
p_list.set_defaults(func=_cmd_list)
|
||||
|
||||
p_install = subs.add_parser("install", help="Install a pet from the gallery")
|
||||
p_install.add_argument("slug", help="Pet slug (e.g. boba)")
|
||||
p_install.add_argument("--force", action="store_true", help="Re-download even if present")
|
||||
p_install.add_argument("--select", action="store_true", help="Make it the active pet")
|
||||
p_install.set_defaults(func=_cmd_install)
|
||||
|
||||
p_select = subs.add_parser("select", help="Set the active pet (writes display.pet.*)")
|
||||
p_select.add_argument("slug", nargs="?", default="", help="Pet slug (omit for picker)")
|
||||
p_select.set_defaults(func=_cmd_select)
|
||||
|
||||
p_show = subs.add_parser("show", help="Animate the active pet in the terminal")
|
||||
p_show.add_argument("slug", nargs="?", default="", help="Pet slug (default: active)")
|
||||
p_show.add_argument("--state", default="", help="Single state: idle/run/review/failed/wave/jump")
|
||||
p_show.add_argument("--cycle", action="store_true", help="Cycle through all states")
|
||||
p_show.add_argument("--once", action="store_true", help="Play once instead of looping")
|
||||
p_show.add_argument("--mode", default=None, help="Override render mode (kitty/iterm/sixel/unicode/auto)")
|
||||
p_show.add_argument("--scale", type=float, default=0, help="Override scale (0 = config)")
|
||||
p_show.set_defaults(func=_cmd_show)
|
||||
|
||||
subs.add_parser("off", help="Disable the pet display").set_defaults(func=_cmd_off)
|
||||
|
||||
p_scale = subs.add_parser("scale", help="Resize the pet everywhere (display.pet.scale)")
|
||||
p_scale.add_argument("factor", help="Scale factor, e.g. 0.5 (clamped 0.1–3.0)")
|
||||
p_scale.set_defaults(func=_cmd_scale)
|
||||
|
||||
p_remove = subs.add_parser("remove", help="Delete an installed pet")
|
||||
p_remove.add_argument("slug", help="Pet slug")
|
||||
p_remove.set_defaults(func=_cmd_remove)
|
||||
|
||||
subs.add_parser("doctor", help="Check pet setup + terminal graphics support").set_defaults(
|
||||
func=_cmd_doctor
|
||||
)
|
||||
@ -171,6 +171,29 @@ class OpenAIImageGenProvider(ImageGenProvider):
|
||||
],
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _edit(client: Any, payload: Dict[str, Any], reference_images: List[str]) -> Any:
|
||||
"""Run gpt-image-2 image *edit* grounded on reference image(s).
|
||||
|
||||
``images.edit`` keeps the output anchored to the supplied character,
|
||||
which is what makes per-state pet rows stay the same creature. Opens the
|
||||
reference files as binary handles and forwards the same size/quality/
|
||||
background knobs as ``images.generate``.
|
||||
"""
|
||||
handles = [open(path, "rb") for path in reference_images]
|
||||
try:
|
||||
edit_payload = dict(payload)
|
||||
# The edit endpoint takes one image or a list; a single handle is
|
||||
# the broadly-supported shape.
|
||||
edit_payload["image"] = handles if len(handles) > 1 else handles[0]
|
||||
return client.images.edit(**edit_payload)
|
||||
finally:
|
||||
for handle in handles:
|
||||
try:
|
||||
handle.close()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
def generate(
|
||||
self,
|
||||
prompt: str,
|
||||
@ -223,9 +246,20 @@ class OpenAIImageGenProvider(ImageGenProvider):
|
||||
"quality": meta["quality"],
|
||||
}
|
||||
|
||||
# Optional sprite-oriented extras (used by pet generation; ignored by
|
||||
# the prompt-only ``image_generate`` tool). ``background=transparent``
|
||||
# asks gpt-image for a cutout; ``reference_images`` routes to the image
|
||||
# *edit* endpoint so the output stays grounded on a base character.
|
||||
if str(kwargs.get("background", "")).lower() == "transparent":
|
||||
payload["background"] = "transparent"
|
||||
reference_images = kwargs.get("reference_images") or []
|
||||
|
||||
try:
|
||||
client = openai.OpenAI()
|
||||
response = client.images.generate(**payload)
|
||||
if reference_images:
|
||||
response = self._edit(client, payload, reference_images)
|
||||
else:
|
||||
response = client.images.generate(**payload)
|
||||
except Exception as exc:
|
||||
logger.debug("OpenAI image generation failed", exc_info=True)
|
||||
return error_response(
|
||||
|
||||
89
skills/productivity/petdex/SKILL.md
Normal file
89
skills/productivity/petdex/SKILL.md
Normal file
@ -0,0 +1,89 @@
|
||||
---
|
||||
name: petdex
|
||||
description: Install and select animated petdex mascots for Hermes.
|
||||
version: 1.0.0
|
||||
author: Hermes Agent
|
||||
license: MIT
|
||||
platforms: [linux, macos, windows]
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [petdex, mascot, display, cli, tui, desktop]
|
||||
category: productivity
|
||||
homepage: https://petdex.dev
|
||||
---
|
||||
|
||||
# Petdex Skill
|
||||
|
||||
Browse, install, and select animated "pet" mascots from the public
|
||||
[petdex](https://github.com/crafter-station/petdex) gallery. An installed pet
|
||||
reacts to agent activity (idle, running a tool, reviewing, error, done) across
|
||||
the Hermes CLI, TUI, and desktop app. This skill drives the `hermes pets` CLI
|
||||
and the `display.pet` config — it does not generate sprites.
|
||||
|
||||
## When to Use
|
||||
|
||||
- The user wants a desktop/terminal mascot or asks about "pets" / petdex.
|
||||
- The user wants to change, preview, or disable the active pet.
|
||||
- Diagnosing why a pet isn't showing (terminal graphics support, config).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Network access to `petdex.dev` for the gallery/manifest (read-only, no auth).
|
||||
- Pillow (a core Hermes dependency) for sprite decoding — already installed.
|
||||
- For full-fidelity terminal rendering: a graphics-capable terminal (kitty,
|
||||
Ghostty, WezTerm, iTerm2, or sixel). Otherwise a truecolor Unicode
|
||||
half-block fallback is used automatically.
|
||||
|
||||
## How to Run
|
||||
|
||||
Use the `terminal` tool to run `hermes pets <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.1–3.0) |
|
||||
| Preview/animate in terminal | `hermes pets show [slug] [--cycle] [--state run]` |
|
||||
| Disable the pet | `hermes pets off` |
|
||||
| Remove a pet | `hermes pets remove <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.1–3.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.
|
||||
371
tests/agent/test_pet_engine.py
Normal file
371
tests/agent/test_pet_engine.py
Normal file
@ -0,0 +1,371 @@
|
||||
"""Tests for the petdex pet engine (agent/pet/*).
|
||||
|
||||
Behavior/invariant focused — no network, no live manifest. A tiny synthetic
|
||||
spritesheet is generated with Pillow so render paths exercise real decode
|
||||
without depending on a downloaded pet.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
|
||||
import pytest
|
||||
|
||||
from agent.pet import constants, render, state, store
|
||||
from agent.pet.constants import FRAME_H, FRAME_W, PetState
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# state mapping — priority invariants
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_derive_idle_default():
|
||||
assert state.derive_pet_state() is PetState.IDLE
|
||||
# awaiting input uses the dedicated waiting row when available.
|
||||
assert state.derive_pet_state(awaiting_input=True) is PetState.WAITING
|
||||
|
||||
|
||||
def test_derive_priority_order():
|
||||
# error beats everything
|
||||
assert state.derive_pet_state(error=True, celebrate=True, busy=True) is PetState.FAILED
|
||||
# celebrate beats completion/tool
|
||||
assert state.derive_pet_state(celebrate=True, just_completed=True, tool_running=True) is PetState.JUMP
|
||||
# completion beats waiting/tool
|
||||
assert state.derive_pet_state(just_completed=True, awaiting_input=True) is PetState.WAVE
|
||||
# waiting (blocked on the user) outranks the in-flight signals — a clarify
|
||||
# mid-turn pauses on you even though a tool is technically still open.
|
||||
assert state.derive_pet_state(awaiting_input=True, tool_running=True, busy=True) is PetState.WAITING
|
||||
# tool beats reasoning
|
||||
assert state.derive_pet_state(tool_running=True, reasoning=True) is PetState.RUN
|
||||
# reasoning beats bare-busy
|
||||
assert state.derive_pet_state(reasoning=True, busy=True) is PetState.REVIEW
|
||||
# bare busy runs
|
||||
assert state.derive_pet_state(busy=True) is PetState.RUN
|
||||
|
||||
|
||||
def test_todos_all_done():
|
||||
# empty / falsy → not done (no plan to celebrate)
|
||||
assert state.todos_all_done(None) is False
|
||||
assert state.todos_all_done([]) is False
|
||||
# any open item → not done
|
||||
assert state.todos_all_done([{"status": "completed"}, {"status": "pending"}]) is False
|
||||
assert state.todos_all_done([{"status": "in_progress"}]) is False
|
||||
# every item terminal → done (completed and/or cancelled)
|
||||
assert state.todos_all_done([{"status": "completed"}, {"status": "cancelled"}]) is True
|
||||
|
||||
# objects with a .status attr work too (mirrors dict + attr access)
|
||||
class _T:
|
||||
def __init__(self, status):
|
||||
self.status = status
|
||||
|
||||
assert state.todos_all_done([_T("completed")]) is True
|
||||
assert state.todos_all_done([_T("completed"), _T("pending")]) is False
|
||||
|
||||
|
||||
def test_state_row_index_maps_to_supported_atlas_taxonomies():
|
||||
# Current Petdex sheets are 8 columns x 9 rows.
|
||||
assert constants.state_row_index(PetState.IDLE, 9) == 0
|
||||
assert constants.state_row_index(PetState.WAVE, 9) == 3
|
||||
assert constants.state_row_index(PetState.JUMP, 9) == 4
|
||||
assert constants.state_row_index(PetState.FAILED, 9) == 5
|
||||
assert constants.state_row_index(PetState.WAITING, 9) == 6
|
||||
assert constants.state_row_index(PetState.RUN, 9) == 7
|
||||
assert constants.state_row_index(PetState.REVIEW, 9) == 8
|
||||
|
||||
# Legacy Hermes/petdex sheets were 8 rows with Hermes state names packed in
|
||||
# order. Keep those readable instead of forcing old installs through the
|
||||
# newer Codex taxonomy.
|
||||
assert constants.state_row_index(PetState.WAVE, 8) == 1
|
||||
assert constants.state_row_index(PetState.RUN, 8) == 2
|
||||
assert constants.state_row_index(PetState.FAILED, 8) == 3
|
||||
assert constants.state_row_index(PetState.REVIEW, 8) == 4
|
||||
assert constants.state_row_index(PetState.JUMP, 8) == 5
|
||||
assert constants.state_row_index(PetState.WAITING, 8) == 0
|
||||
|
||||
# Alias rows resolve as expected.
|
||||
assert constants.state_row_index("wave", 9) == constants.state_row_index("waving", 9) == 3
|
||||
assert constants.state_row_index("jump", 9) == constants.state_row_index("jumping", 9) == 4
|
||||
assert constants.state_row_index("run", 9) == constants.state_row_index("running", 9) == 7
|
||||
|
||||
# unknown row names clamp to idle (row 0), never raise
|
||||
assert constants.state_row_index("nonsense") == 0
|
||||
|
||||
|
||||
def test_cols_for_scale_is_monotonic_and_floored():
|
||||
# scale is the master size knob: smaller scale never yields more columns,
|
||||
# and half-blocks clamp to a legibility floor rather than devolving to mush.
|
||||
sizes = [constants.cols_for_scale(s) for s in (0.1, 0.3, 0.5, 0.7, 1.0, 1.5)]
|
||||
assert sizes == sorted(sizes)
|
||||
assert all(c >= constants.UNICODE_MIN_COLS for c in sizes)
|
||||
# tiny scales pin to the floor; large scales grow past it.
|
||||
assert constants.cols_for_scale(0.05) == constants.UNICODE_MIN_COLS
|
||||
assert constants.cols_for_scale(0.33) == constants.UNICODE_MIN_COLS
|
||||
assert constants.cols_for_scale(2.0) > constants.UNICODE_MIN_COLS
|
||||
|
||||
|
||||
def test_resolve_cols_override_else_scale():
|
||||
# 0 / falsy → derive from scale; a positive int hard-overrides scale.
|
||||
assert constants.resolve_cols(0.7, 0) == constants.cols_for_scale(0.7)
|
||||
assert constants.resolve_cols(0.7, None) == constants.cols_for_scale(0.7)
|
||||
assert constants.resolve_cols(2.0, 12) == 12
|
||||
assert constants.resolve_cols(0.1, -5) == constants.cols_for_scale(0.1)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# synthetic spritesheet fixture
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.fixture
|
||||
def boba_like(tmp_path, monkeypatch):
|
||||
"""Install a synthetic 8-col × 9-row pet into a temp HERMES_HOME."""
|
||||
from PIL import Image
|
||||
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
|
||||
cols, rows = 8, 9
|
||||
sheet = Image.new("RGBA", (FRAME_W * cols, FRAME_H * rows), (0, 0, 0, 0))
|
||||
# paint each row a distinct opaque color so frames are non-empty
|
||||
for r in range(rows):
|
||||
color = (20 + r * 25, 60, 120, 255)
|
||||
for c in range(cols):
|
||||
block = Image.new("RGBA", (FRAME_W, FRAME_H), color)
|
||||
sheet.paste(block, (c * FRAME_W, r * FRAME_H))
|
||||
|
||||
pet_dir = store.pets_dir() / "boba"
|
||||
pet_dir.mkdir(parents=True, exist_ok=True)
|
||||
sheet.save(pet_dir / "spritesheet.webp")
|
||||
(pet_dir / "pet.json").write_text(
|
||||
'{"id":"boba","displayName":"Boba","description":"d","spritesheetPath":"spritesheet.webp"}'
|
||||
)
|
||||
return pet_dir
|
||||
|
||||
|
||||
def test_store_install_resolution(boba_like):
|
||||
pets = store.installed_pets()
|
||||
assert [p.slug for p in pets] == ["boba"]
|
||||
assert store.installed_pets()[0].exists
|
||||
|
||||
# configured slug wins when installed
|
||||
assert store.resolve_active_pet("boba").slug == "boba"
|
||||
# bogus slug falls back to first installed
|
||||
assert store.resolve_active_pet("does-not-exist").slug == "boba"
|
||||
# display metadata flows from pet.json
|
||||
assert store.load_pet("boba").display_name == "Boba"
|
||||
|
||||
|
||||
def test_store_remove(boba_like):
|
||||
assert store.remove_pet("boba") is True
|
||||
assert store.installed_pets() == []
|
||||
assert store.remove_pet("boba") is False # idempotent
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# render — decode + every encoder produces output
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_renderer_decodes_frames(boba_like):
|
||||
sprite = store.load_pet("boba").spritesheet
|
||||
r = render.PetRenderer(str(sprite), mode="unicode", scale=0.5, unicode_cols=12)
|
||||
assert r.available
|
||||
# standard sheet yields FRAMES_PER_STATE frames per state
|
||||
assert r.frame_count("idle") == constants.FRAMES_PER_STATE
|
||||
assert r.frame_count(PetState.RUN) == constants.FRAMES_PER_STATE
|
||||
|
||||
|
||||
def test_trims_trailing_blank_frames(tmp_path):
|
||||
"""Ragged state rows (real frames + transparent padding) trim to real count.
|
||||
|
||||
petdex sheets are left-packed: a state with fewer than FRAMES_PER_STATE real
|
||||
frames pads the trailing columns transparent. Stepping into one flashes the
|
||||
pet blank, so the engine must stop the row at the first gap.
|
||||
"""
|
||||
from PIL import Image
|
||||
|
||||
cols, rows = 8, 9
|
||||
sheet = Image.new("RGBA", (FRAME_W * cols, FRAME_H * rows), (0, 0, 0, 0))
|
||||
# row index -> number of real (opaque) frames; the rest stay transparent.
|
||||
# Codex row taxonomy: idle, running-right, running-left, wave, jump, failed,
|
||||
# waiting, run, review.
|
||||
real = {0: 6, 3: 4, 4: 5, 5: 8, 7: 6, 8: 5}
|
||||
for r, k in real.items():
|
||||
for c in range(k):
|
||||
block = Image.new("RGBA", (FRAME_W, FRAME_H), (200, 80, 80, 255))
|
||||
sheet.paste(block, (c * FRAME_W, r * FRAME_H))
|
||||
sprite = tmp_path / "ragged.webp"
|
||||
sheet.save(sprite)
|
||||
|
||||
r = render.PetRenderer(str(sprite), mode="unicode", scale=0.5)
|
||||
# Full rows cap at FRAMES_PER_STATE; ragged rows trim to their real count.
|
||||
assert r.frame_count("idle") == constants.FRAMES_PER_STATE
|
||||
assert r.frame_count("run") == constants.FRAMES_PER_STATE
|
||||
assert r.frame_count("wave") == 4
|
||||
assert r.frame_count("jump") == 5
|
||||
assert r.frame_count("failed") == constants.FRAMES_PER_STATE
|
||||
assert r.frame_count("review") == 5
|
||||
|
||||
# Every stepped frame is non-empty — no blank flash for the trimmed states.
|
||||
for state in ("wave", "jump", "review"):
|
||||
for i in range(r.frame_count(state)):
|
||||
assert r.frame(state, i), f"{state}[{i}] rendered blank"
|
||||
|
||||
counts = render.state_frame_counts(str(sprite))
|
||||
assert counts == {
|
||||
"idle": 6,
|
||||
"wave": 4,
|
||||
"run": 6,
|
||||
"failed": 6,
|
||||
"review": 5,
|
||||
"jump": 5,
|
||||
"waiting": 0,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["unicode", "kitty", "iterm", "sixel"])
|
||||
def test_every_encoder_emits(boba_like, mode):
|
||||
sprite = store.load_pet("boba").spritesheet
|
||||
r = render.PetRenderer(str(sprite), mode=mode, scale=0.4)
|
||||
frame = r.frame("run", 1)
|
||||
assert isinstance(frame, str) and frame, f"{mode} produced no frame"
|
||||
if mode == "unicode":
|
||||
assert "\x1b[" in frame # has color escapes
|
||||
elif mode == "kitty":
|
||||
assert frame.startswith("\x1b_G")
|
||||
elif mode == "iterm":
|
||||
assert frame.startswith("\x1b]1337;File=")
|
||||
elif mode == "sixel":
|
||||
assert frame.startswith("\x1bP")
|
||||
|
||||
|
||||
def test_frame_index_wraps(boba_like):
|
||||
sprite = store.load_pet("boba").spritesheet
|
||||
r = render.PetRenderer(str(sprite), mode="unicode", scale=0.4)
|
||||
# index beyond count wraps rather than indexing out of range
|
||||
assert r.frame("idle", 999) == r.frame("idle", 999 % r.frame_count("idle"))
|
||||
|
||||
|
||||
def test_cells_grid_shape(boba_like):
|
||||
sprite = store.load_pet("boba").spritesheet
|
||||
r = render.PetRenderer(str(sprite), mode="unicode", scale=0.4, unicode_cols=14)
|
||||
grid = r.cells("run", 0, cols=14)
|
||||
assert grid, "no cells produced"
|
||||
# every row is the requested width; every cell is (top, bottom) RGBA pairs
|
||||
assert all(len(row) == 14 for row in grid)
|
||||
(top, bottom) = grid[0][0]
|
||||
assert len(top) == 4 and len(bottom) == 4
|
||||
# missing-sheet renderer yields no cells, never raises
|
||||
assert render.PetRenderer(str(sprite.parent / "missing.webp"), mode="unicode").cells("idle", 0) == []
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# render — kitty Unicode placeholders (TUI graphics path)
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_kitty_image_id_stable_bounded_nonzero():
|
||||
# Deterministic per slug so re-renders reuse the same terminal-side image,
|
||||
# and always a valid 24-bit-encodable, non-zero id.
|
||||
a = render.kitty_image_id("boba")
|
||||
assert a == render.kitty_image_id("boba")
|
||||
assert 1 <= a <= 0x7FFF
|
||||
|
||||
|
||||
def test_kitty_color_hex_decodes_to_id():
|
||||
# The placeholder's foreground color IS the image id (24-bit). The terminal
|
||||
# reconstructs id = (r<<16)|(g<<8)|b, so the hex must round-trip.
|
||||
for slug in ("boba", "clawd", "pixel-fox"):
|
||||
image_id = render.kitty_image_id(slug)
|
||||
h = render.kitty_color_hex(image_id)
|
||||
assert h.startswith("#") and len(h) == 7
|
||||
assert int(h[1:], 16) == image_id
|
||||
|
||||
|
||||
def test_kitty_placeholder_rows_grid_contract():
|
||||
cols, rows = 18, 10
|
||||
grid = render.kitty_placeholder_rows(cols, rows)
|
||||
assert len(grid) == rows
|
||||
placeholder = "\U0010eeee"
|
||||
for r, row in enumerate(grid):
|
||||
# Each line is exactly `cols` placeholder cells (combining diacritics
|
||||
# are zero-width, so this is the rendered width Ink must measure).
|
||||
assert row.count(placeholder) == cols
|
||||
# First cell carries this row's diacritic; the rest inherit row + col.
|
||||
assert row.startswith(placeholder + chr(render._ROWCOL_DIACRITICS[r]))
|
||||
|
||||
|
||||
def test_kitty_payload_structure(boba_like):
|
||||
sprite = store.load_pet("boba").spritesheet
|
||||
image_id = render.kitty_image_id("boba")
|
||||
scale = 0.4
|
||||
r = render.PetRenderer(str(sprite), mode="kitty", scale=scale, unicode_cols=18)
|
||||
payload = r.kitty_payload("run", image_id=image_id)
|
||||
assert payload is not None
|
||||
# placement box must follow scaled pixels, not unicode_cols (kitty upscales to c×r).
|
||||
frames = r._frames("run")
|
||||
expect_cols, expect_rows = r._cell_box(frames[0])
|
||||
assert payload["cols"] == expect_cols
|
||||
assert payload["rows"] == expect_rows
|
||||
assert expect_cols < 18 # 0.4 scale is much smaller than a pinned 18-col box
|
||||
# placeholder grid matches the requested geometry
|
||||
assert len(payload["placeholder"]) == payload["rows"]
|
||||
# one transmit escape per animation frame, each a kitty virtual placement
|
||||
assert len(payload["frames"]) == r.frame_count("run")
|
||||
for esc in payload["frames"]:
|
||||
assert esc.startswith("\x1b_G")
|
||||
assert esc.endswith("\x1b\\")
|
||||
assert f"i={image_id}" in esc
|
||||
assert "a=T" in esc and "U=1" in esc
|
||||
assert f"c={payload['cols']}" in esc and f"r={payload['rows']}" in esc
|
||||
|
||||
|
||||
def test_kitty_payload_none_when_no_frames(tmp_path):
|
||||
r = render.PetRenderer(str(tmp_path / "missing.webp"), mode="kitty")
|
||||
assert r.kitty_payload("idle", image_id=1) is None
|
||||
|
||||
|
||||
def test_off_mode_and_missing_sheet_degrade(tmp_path):
|
||||
# off mode never emits
|
||||
r_off = render.PetRenderer(str(tmp_path / "nope.webp"), mode="off")
|
||||
assert r_off.frame("idle", 0) == ""
|
||||
# missing sheet → not available, empty frames, no raise
|
||||
r_missing = render.PetRenderer(str(tmp_path / "nope.webp"), mode="unicode")
|
||||
assert not r_missing.available
|
||||
assert r_missing.frame("idle", 0) == ""
|
||||
|
||||
|
||||
def test_resolve_mode_non_tty_is_off():
|
||||
# a non-tty stream forces 'off' regardless of configured mode
|
||||
assert render.resolve_mode("kitty", stream=io.StringIO()) == "off"
|
||||
assert render.resolve_mode("auto", stream=io.StringIO()) == "off"
|
||||
|
||||
|
||||
def test_detect_terminal_graphics_env(monkeypatch):
|
||||
for key in ("KITTY_WINDOW_ID", "TERM_PROGRAM", "ITERM_SESSION_ID", "WEZTERM_PANE", "TERM"):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
monkeypatch.setenv("KITTY_WINDOW_ID", "1")
|
||||
assert render.detect_terminal_graphics() == "kitty"
|
||||
monkeypatch.delenv("KITTY_WINDOW_ID")
|
||||
|
||||
monkeypatch.setenv("TERM_PROGRAM", "iTerm.app")
|
||||
assert render.detect_terminal_graphics() == "iterm"
|
||||
monkeypatch.delenv("TERM_PROGRAM")
|
||||
|
||||
monkeypatch.setenv("TERM", "xterm-256color")
|
||||
assert render.detect_terminal_graphics() == "unicode"
|
||||
|
||||
|
||||
def test_vscode_terminal_ignores_leaked_graphics_env(monkeypatch):
|
||||
# The VS Code / Cursor integrated terminal can't show inline images by
|
||||
# default, yet inherits ITERM_SESSION_ID/KITTY_WINDOW_ID when launched from
|
||||
# those terminals. TERM_PROGRAM=vscode must win → unicode, never a protocol
|
||||
# whose escapes the embedded terminal would silently drop.
|
||||
for key in ("KITTY_WINDOW_ID", "TERM_PROGRAM", "ITERM_SESSION_ID", "WEZTERM_PANE", "TERM"):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
monkeypatch.setenv("TERM_PROGRAM", "vscode")
|
||||
|
||||
assert render.detect_terminal_graphics() == "unicode"
|
||||
for leaked in ("ITERM_SESSION_ID", "KITTY_WINDOW_ID", "WEZTERM_PANE"):
|
||||
monkeypatch.setenv(leaked, "1")
|
||||
assert render.detect_terminal_graphics() == "unicode"
|
||||
monkeypatch.delenv(leaked)
|
||||
309
tests/agent/test_pet_generate.py
Normal file
309
tests/agent/test_pet_generate.py
Normal file
@ -0,0 +1,309 @@
|
||||
"""Tests for pet generation: deterministic atlas ops, store register, orchestration.
|
||||
|
||||
No network/API calls — image generation is mocked with synthetic strips so the
|
||||
whole pipeline (segmentation → compose → validate → register → adopt) is
|
||||
exercised hermetically.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from agent.pet.generate import atlas
|
||||
|
||||
PIL = pytest.importorskip("PIL")
|
||||
from PIL import Image, ImageDraw # noqa: E402
|
||||
|
||||
|
||||
def _strip(n_blobs: int, *, transparent: bool = True, bg=(0, 255, 0, 255), size=(208, 208)) -> Image.Image:
|
||||
"""A horizontal strip with *n_blobs* clearly-separated colored ellipses."""
|
||||
w = size[0] * n_blobs
|
||||
h = size[1]
|
||||
base = (0, 0, 0, 0) if transparent else bg
|
||||
img = Image.new("RGBA", (w, h), base)
|
||||
draw = ImageDraw.Draw(img)
|
||||
for i in range(n_blobs):
|
||||
cx = i * size[0] + size[0] // 2
|
||||
cy = h // 2
|
||||
r = size[0] // 3
|
||||
color = (40 + i * 30 % 200, 80, 200 - i * 20 % 180, 255)
|
||||
draw.ellipse((cx - r, cy - r, cx + r, cy + r), fill=color)
|
||||
return img
|
||||
|
||||
|
||||
# ───────────────────────── frame extraction ─────────────────────────
|
||||
|
||||
|
||||
def test_extract_strip_frames_transparent_returns_centered_cells():
|
||||
frames = atlas.extract_strip_frames(_strip(6), 6)
|
||||
assert len(frames) == 6
|
||||
for frame in frames:
|
||||
assert frame.size == (atlas.CELL_WIDTH, atlas.CELL_HEIGHT)
|
||||
# Background corners must be transparent.
|
||||
assert frame.getpixel((0, 0))[3] == 0
|
||||
# Something is drawn.
|
||||
assert frame.getchannel("A").getextrema()[1] > 0
|
||||
|
||||
|
||||
def test_extract_strip_frames_keys_out_solid_background():
|
||||
frames = atlas.extract_strip_frames(_strip(4, transparent=False), 4)
|
||||
assert len(frames) == 4
|
||||
# The green backdrop must be gone (corner transparent).
|
||||
assert frames[0].getpixel((0, 0))[3] == 0
|
||||
|
||||
|
||||
def test_extract_strip_frames_slot_fallback_when_unsegmentable():
|
||||
# A single connected smear can't be split into 5 components → slot fallback.
|
||||
img = Image.new("RGBA", (200 * 5, 208), (0, 0, 0, 0))
|
||||
ImageDraw.Draw(img).rectangle((0, 80, 200 * 5 - 1, 120), fill=(200, 50, 50, 255))
|
||||
frames = atlas.extract_strip_frames(img, 5, method="auto")
|
||||
assert len(frames) == 5
|
||||
|
||||
|
||||
def test_extract_components_method_raises_when_too_few():
|
||||
img = Image.new("RGBA", (400, 208), (0, 0, 0, 0))
|
||||
ImageDraw.Draw(img).ellipse((10, 10, 100, 100), fill=(255, 0, 0, 255))
|
||||
with pytest.raises(ValueError):
|
||||
atlas.extract_strip_frames(img, 6, method="components")
|
||||
|
||||
|
||||
# ───────────────────────── atlas compose / validate ─────────────────────────
|
||||
|
||||
|
||||
def _frames_for_all_states() -> dict[str, list]:
|
||||
out: dict[str, list] = {}
|
||||
for state, _row, count in atlas.ROW_SPECS:
|
||||
out[state] = atlas.extract_strip_frames(_strip(count), count)
|
||||
return out
|
||||
|
||||
|
||||
def test_compose_atlas_geometry_and_validation():
|
||||
sheet = atlas.compose_atlas(_frames_for_all_states())
|
||||
assert sheet.size == (atlas.ATLAS_WIDTH, atlas.ATLAS_HEIGHT)
|
||||
result = atlas.validate_atlas(sheet)
|
||||
assert result["ok"], result["errors"]
|
||||
assert set(result["filled_states"]) == {s for s, _, _ in atlas.ROW_SPECS}
|
||||
|
||||
|
||||
def test_compose_atlas_leaves_unused_tail_transparent():
|
||||
# wave has 4 frames; columns 4 and 5 of its row must be transparent.
|
||||
sheet = atlas.compose_atlas(_frames_for_all_states())
|
||||
wave_row = next(r for s, r, _ in atlas.ROW_SPECS if s == "wave")
|
||||
top = wave_row * atlas.CELL_HEIGHT
|
||||
for col in (4, 5):
|
||||
left = col * atlas.CELL_WIDTH
|
||||
cell = sheet.crop((left, top, left + atlas.CELL_WIDTH, top + atlas.CELL_HEIGHT))
|
||||
assert cell.getchannel("A").getextrema()[1] == 0
|
||||
|
||||
|
||||
def test_validate_atlas_rejects_wrong_size():
|
||||
bad = Image.new("RGBA", (100, 100), (0, 0, 0, 0))
|
||||
result = atlas.validate_atlas(bad)
|
||||
assert not result["ok"]
|
||||
assert any("expected" in e for e in result["errors"])
|
||||
|
||||
|
||||
def test_validate_atlas_rejects_rgb_residue():
|
||||
sheet = atlas.compose_atlas(_frames_for_all_states())
|
||||
# Poke a fully-transparent pixel with non-zero RGB.
|
||||
sheet.putpixel((0, 0), (120, 0, 0, 0))
|
||||
result = atlas.validate_atlas(sheet)
|
||||
assert not result["ok"]
|
||||
assert any("residue" in e for e in result["errors"])
|
||||
|
||||
|
||||
def test_validate_atlas_warns_on_empty_state():
|
||||
frames = _frames_for_all_states()
|
||||
frames["jump"] = []
|
||||
sheet = atlas.compose_atlas(frames)
|
||||
result = atlas.validate_atlas(sheet)
|
||||
assert result["ok"] # one empty row is a warning, not an error
|
||||
assert any("jump" in w for w in result["warnings"])
|
||||
|
||||
|
||||
def test_single_frame_fits_cell():
|
||||
frame = atlas.single_frame(_strip(1))
|
||||
assert frame.size == (atlas.CELL_WIDTH, atlas.CELL_HEIGHT)
|
||||
assert frame.getchannel("A").getextrema()[1] > 0
|
||||
|
||||
|
||||
# ───────────────────────── store register / adopt ─────────────────────────
|
||||
|
||||
|
||||
def test_slugify_and_unique_slug():
|
||||
from agent.pet import store
|
||||
|
||||
assert store.slugify("My Cool Pet!") == "my-cool-pet"
|
||||
assert store.slugify(" ") == "pet"
|
||||
first = store.unique_slug("Robo")
|
||||
(store.pets_dir() / first).mkdir(parents=True)
|
||||
assert store.unique_slug("Robo") == "robo-2"
|
||||
|
||||
|
||||
def test_register_local_pet_appears_and_is_adoptable():
|
||||
from agent.pet import store
|
||||
|
||||
sheet = atlas.compose_atlas(_frames_for_all_states())
|
||||
pet = store.register_local_pet(sheet, slug="Sparky", display_name="Sparky", description="zappy")
|
||||
assert pet.slug == "sparky"
|
||||
assert pet.exists
|
||||
assert any(p.slug == "sparky" for p in store.installed_pets())
|
||||
|
||||
# install_pet returns the on-disk pet without ever hitting the manifest.
|
||||
adopted = store.install_pet("sparky")
|
||||
assert adopted.slug == "sparky"
|
||||
assert adopted.display_name == "Sparky"
|
||||
|
||||
|
||||
def test_register_local_pet_accepts_bytes():
|
||||
from agent.pet import store
|
||||
|
||||
sheet = atlas.compose_atlas(_frames_for_all_states())
|
||||
data = atlas.atlas_to_webp_bytes(sheet)
|
||||
pet = store.register_local_pet(data, slug="bytey")
|
||||
assert pet.exists
|
||||
|
||||
|
||||
# ───────────────────────── orchestration (mocked imagegen) ─────────────────────────
|
||||
|
||||
|
||||
def test_generate_base_drafts_returns_n(monkeypatch, tmp_path):
|
||||
from agent.pet.generate import imagegen, orchestrate
|
||||
|
||||
calls = {"n": 0}
|
||||
|
||||
def fake_generate(prompt, *, n=1, reference_images=None, provider=None, prefix="pet"):
|
||||
paths = []
|
||||
for i in range(n):
|
||||
calls["n"] += 1
|
||||
p = tmp_path / f"{prefix}_{calls['n']}.png"
|
||||
_strip(1).save(p)
|
||||
paths.append(p)
|
||||
return paths
|
||||
|
||||
monkeypatch.setattr(imagegen, "resolve_provider", lambda **_: object())
|
||||
monkeypatch.setattr(imagegen, "generate", fake_generate)
|
||||
|
||||
drafts = orchestrate.generate_base_drafts("a fox", n=4)
|
||||
assert len(drafts) == 4
|
||||
|
||||
|
||||
def test_generate_base_drafts_hardens_opaque_background(monkeypatch, tmp_path):
|
||||
"""A provider that ignores background=transparent still yields a cutout."""
|
||||
from agent.pet.generate import imagegen, orchestrate
|
||||
|
||||
def fake_generate(prompt, *, n=1, reference_images=None, provider=None, prefix="pet"):
|
||||
# Solid-green backdrop with a blob — i.e. the provider painted a backdrop.
|
||||
p = tmp_path / f"{prefix}_opaque.png"
|
||||
_strip(1, transparent=False, bg=(0, 255, 0, 255)).save(p)
|
||||
return [p]
|
||||
|
||||
monkeypatch.setattr(imagegen, "resolve_provider", lambda **_: object())
|
||||
monkeypatch.setattr(imagegen, "generate", fake_generate)
|
||||
|
||||
drafts = orchestrate.generate_base_drafts("a fox", n=1)
|
||||
assert len(drafts) == 1
|
||||
|
||||
with Image.open(drafts[0]) as out:
|
||||
rgba = out.convert("RGBA")
|
||||
# The keyed backdrop is now transparent (corner pixel fully see-through).
|
||||
assert rgba.getpixel((0, 0))[3] == 0
|
||||
# The pet blob in the center is still opaque.
|
||||
assert rgba.getpixel((rgba.width // 2, rgba.height // 2))[3] > 0
|
||||
|
||||
|
||||
def test_hatch_pet_end_to_end(monkeypatch, tmp_path):
|
||||
from agent.pet import store
|
||||
from agent.pet.generate import atlas as atlas_mod
|
||||
from agent.pet.generate import imagegen, orchestrate
|
||||
|
||||
base = tmp_path / "base.png"
|
||||
_strip(1).save(base)
|
||||
|
||||
def fake_generate(prompt, *, n=1, reference_images=None, provider=None, prefix="pet"):
|
||||
# Return a synthetic row strip; frame count is inferable from the spec.
|
||||
state = prefix.replace("pet_row_", "")
|
||||
count = atlas_mod.FRAME_COUNTS.get(state, 6)
|
||||
p = tmp_path / f"{prefix}.png"
|
||||
_strip(count).save(p)
|
||||
return [p]
|
||||
|
||||
monkeypatch.setattr(imagegen, "resolve_provider", lambda **_: object())
|
||||
monkeypatch.setattr(imagegen, "generate", fake_generate)
|
||||
|
||||
events: list[tuple[str, str]] = []
|
||||
result = orchestrate.hatch_pet(
|
||||
base_image=base,
|
||||
slug="mocky",
|
||||
display_name="Mocky",
|
||||
description="a test pet",
|
||||
concept="a fox",
|
||||
on_progress=lambda ev, detail: events.append((ev, detail)),
|
||||
)
|
||||
|
||||
assert result.slug == "mocky"
|
||||
assert result.validation["ok"]
|
||||
assert set(result.states) == {s for s, _, _ in atlas_mod.ROW_SPECS}
|
||||
assert ("compose", "") in events
|
||||
# The pet is on disk and adoptable.
|
||||
assert store.load_pet("mocky").exists
|
||||
|
||||
|
||||
def test_hatch_pet_idle_fallback_when_row_fails(monkeypatch, tmp_path):
|
||||
from agent.pet.generate import atlas as atlas_mod
|
||||
from agent.pet.generate import imagegen, orchestrate
|
||||
from agent.pet.generate.imagegen import GenerationError
|
||||
|
||||
base = tmp_path / "base.png"
|
||||
_strip(1).save(base)
|
||||
|
||||
def fake_generate(prompt, *, n=1, reference_images=None, provider=None, prefix="pet"):
|
||||
if prefix == "pet_row_idle":
|
||||
raise GenerationError("boom")
|
||||
state = prefix.replace("pet_row_", "")
|
||||
count = atlas_mod.FRAME_COUNTS.get(state, 6)
|
||||
p = tmp_path / f"{prefix}.png"
|
||||
_strip(count).save(p)
|
||||
return [p]
|
||||
|
||||
monkeypatch.setattr(imagegen, "resolve_provider", lambda **_: object())
|
||||
monkeypatch.setattr(imagegen, "generate", fake_generate)
|
||||
|
||||
result = orchestrate.hatch_pet(base_image=base, slug="fallbacky", concept="a fox")
|
||||
assert "idle" in result.states # filled by the base-image fallback
|
||||
|
||||
|
||||
def test_resolve_provider_errors_without_backend(monkeypatch):
|
||||
from agent.pet.generate import imagegen
|
||||
|
||||
monkeypatch.setattr(imagegen, "_discover", lambda: None)
|
||||
monkeypatch.setattr("agent.image_gen_registry.get_active_provider", lambda: None)
|
||||
monkeypatch.setattr("agent.image_gen_registry.get_provider", lambda name: None)
|
||||
|
||||
with pytest.raises(imagegen.GenerationError):
|
||||
imagegen.resolve_provider(require_references=True)
|
||||
|
||||
|
||||
def test_generate_retries_without_transparent_background(monkeypatch, tmp_path):
|
||||
"""A model that rejects background=transparent still produces images."""
|
||||
from agent.pet.generate import imagegen
|
||||
|
||||
saved = tmp_path / "img.png"
|
||||
_strip(1).save(saved)
|
||||
calls: list[dict] = []
|
||||
|
||||
class FakeProvider:
|
||||
def generate(self, prompt, **kwargs):
|
||||
calls.append(kwargs)
|
||||
if kwargs.get("background") == "transparent":
|
||||
return {"success": False, "error": "Transparent background is not supported for this model."}
|
||||
return {"success": True, "image": str(saved)}
|
||||
|
||||
sprite = imagegen.SpriteProvider(name="openai", provider=FakeProvider(), supports_references=False)
|
||||
|
||||
out = imagegen.generate("a fox", n=2, provider=sprite)
|
||||
assert len(out) == 2
|
||||
# First variant probes transparent (rejected) then retries opaque; the second
|
||||
# variant skips the transparent probe entirely.
|
||||
backgrounds = [c.get("background") for c in calls]
|
||||
assert backgrounds == ["transparent", None, None]
|
||||
136
tests/cli/test_cli_pet_pane.py
Normal file
136
tests/cli/test_cli_pet_pane.py
Normal file
@ -0,0 +1,136 @@
|
||||
"""The base-CLI petdex pane: reactive half-block sprite above the prompt.
|
||||
|
||||
Mirrors the TUI's PetPane. The methods are tested in isolation via __new__ so
|
||||
we don't pay the full HermesCLI.__init__ cost; a synthetic spritesheet exercises
|
||||
the real engine decode + half-block fragment building.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
|
||||
from agent.pet import store
|
||||
from agent.pet.constants import FRAME_H, FRAME_W
|
||||
from agent.pet.render import PetRenderer
|
||||
from cli import HermesCLI
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def boba_like(tmp_path, monkeypatch):
|
||||
"""Install a synthetic pet into a temp HERMES_HOME and return its slug."""
|
||||
from PIL import Image
|
||||
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
|
||||
cols, rows = 8, 9
|
||||
sheet = Image.new("RGBA", (FRAME_W * cols, FRAME_H * rows), (0, 0, 0, 0))
|
||||
for r in range(rows):
|
||||
color = (20 + r * 25, 60, 120, 255)
|
||||
for c in range(cols):
|
||||
block = Image.new("RGBA", (FRAME_W, FRAME_H), color)
|
||||
sheet.paste(block, (c * FRAME_W, r * FRAME_H))
|
||||
|
||||
pet_dir = store.pets_dir() / "boba"
|
||||
pet_dir.mkdir(parents=True, exist_ok=True)
|
||||
sheet.save(pet_dir / "spritesheet.webp")
|
||||
(pet_dir / "pet.json").write_text(
|
||||
'{"id":"boba","displayName":"Boba","description":"d","spritesheetPath":"spritesheet.webp"}'
|
||||
)
|
||||
return "boba"
|
||||
|
||||
|
||||
def _make_cli():
|
||||
cli_obj = HermesCLI.__new__(HermesCLI)
|
||||
cli_obj._app = None
|
||||
cli_obj._pet_lock = threading.Lock()
|
||||
cli_obj._pet_enabled = False
|
||||
cli_obj._pet_renderer = None
|
||||
cli_obj._pet_slug = ""
|
||||
cli_obj._pet_cols = 18
|
||||
cli_obj._pet_scale = 0.7
|
||||
cli_obj._pet_frames_cache = {}
|
||||
cli_obj._pet_frame_idx = 0
|
||||
cli_obj._agent_running = False
|
||||
# Transient-beat + reasoning state (set by HermesCLI.__init__ in production).
|
||||
cli_obj._pet_event = ""
|
||||
cli_obj._pet_event_until = 0.0
|
||||
cli_obj._pet_reasoning = False
|
||||
# Blocking-modal state — a live one maps the pet to `waiting`.
|
||||
cli_obj._approval_state = None
|
||||
cli_obj._clarify_state = None
|
||||
cli_obj._sudo_state = None
|
||||
cli_obj._secret_state = None
|
||||
cli_obj._slash_confirm_state = None
|
||||
return cli_obj
|
||||
|
||||
|
||||
def test_pet_state_tracks_agent_running():
|
||||
cli_obj = _make_cli()
|
||||
assert cli_obj._derive_pet_state() == "idle"
|
||||
cli_obj._agent_running = True
|
||||
assert cli_obj._derive_pet_state() == "run"
|
||||
|
||||
|
||||
def test_pet_state_waits_on_a_blocking_modal():
|
||||
# A live clarify/approval pauses the agent on the user → `waiting`, even
|
||||
# while the turn is technically still running.
|
||||
cli_obj = _make_cli()
|
||||
cli_obj._agent_running = True
|
||||
cli_obj._clarify_state = {"question": "?"}
|
||||
assert cli_obj._derive_pet_state() == "waiting"
|
||||
|
||||
|
||||
def test_pet_pane_collapsed_when_disabled():
|
||||
# No renderer resolved → the window reports zero height and no fragments,
|
||||
# so it's invisible for users without a pet.
|
||||
cli_obj = _make_cli()
|
||||
assert cli_obj._pet_widget_height() == 0
|
||||
assert cli_obj._pet_fragments() == []
|
||||
|
||||
|
||||
def test_pet_fragments_render_half_blocks(boba_like):
|
||||
cli_obj = _make_cli()
|
||||
cli_obj._pet_renderer = PetRenderer(
|
||||
str(store.load_pet("boba").spritesheet), mode="unicode", scale=0.4, unicode_cols=14
|
||||
)
|
||||
cli_obj._pet_cols = 14
|
||||
cli_obj._pet_enabled = True
|
||||
|
||||
height = cli_obj._pet_widget_height()
|
||||
assert height > 0
|
||||
|
||||
frags = cli_obj._pet_fragments()
|
||||
assert frags, "expected fragments for an enabled pet"
|
||||
# Each fragment is a (style, text) pair; glyphs are half-blocks or blanks.
|
||||
glyphs = {text for _, text in frags}
|
||||
assert glyphs <= {"▀", "▄", " ", "\n"}
|
||||
# Opaque cells carry a truecolor foreground style.
|
||||
assert any(text == "▀" and "fg:#" in style for style, text in frags)
|
||||
# Row count in the fragment stream matches the reported window height.
|
||||
assert sum(1 for _, text in frags if text == "\n") == height - 1
|
||||
|
||||
|
||||
def test_pet_resolve_config_enables_and_disables(boba_like):
|
||||
from hermes_cli.config import load_config, save_config
|
||||
|
||||
cli_obj = _make_cli()
|
||||
|
||||
cfg = load_config()
|
||||
cfg.setdefault("display", {}).setdefault("pet", {})
|
||||
cfg["display"]["pet"].update({"enabled": True, "slug": "boba"})
|
||||
save_config(cfg)
|
||||
|
||||
cli_obj._pet_resolve_config()
|
||||
assert cli_obj._pet_enabled is True
|
||||
assert cli_obj._pet_renderer is not None
|
||||
assert cli_obj._pet_slug == "boba"
|
||||
|
||||
cfg["display"]["pet"]["enabled"] = False
|
||||
save_config(cfg)
|
||||
cli_obj._pet_resolve_config()
|
||||
assert cli_obj._pet_enabled is False
|
||||
assert cli_obj._pet_renderer is None
|
||||
104
tests/hermes_cli/test_pet_toggle.py
Normal file
104
tests/hermes_cli/test_pet_toggle.py
Normal file
@ -0,0 +1,104 @@
|
||||
"""Tests for pet slash-command config helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from agent.pet import store
|
||||
from agent.pet.constants import FRAME_H, FRAME_W
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def boba_installed(tmp_path, monkeypatch):
|
||||
from PIL import Image
|
||||
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
|
||||
sheet = Image.new("RGBA", (FRAME_W * 8, FRAME_H * 9), (0, 0, 0, 0))
|
||||
pet_dir = store.pets_dir() / "boba"
|
||||
pet_dir.mkdir(parents=True, exist_ok=True)
|
||||
sheet.save(pet_dir / "spritesheet.webp")
|
||||
(pet_dir / "pet.json").write_text(
|
||||
'{"id":"boba","displayName":"Boba","description":"d","spritesheetPath":"spritesheet.webp"}'
|
||||
)
|
||||
return home
|
||||
|
||||
|
||||
def _write_config(home, *, enabled: bool, slug: str = "") -> None:
|
||||
import yaml
|
||||
|
||||
cfg = {"display": {"pet": {"enabled": enabled, "slug": slug, "scale": 0.33}}}
|
||||
(home / "config.yaml").write_text(yaml.dump(cfg), encoding="utf-8")
|
||||
|
||||
|
||||
def test_toggle_pet_display_turns_off_when_enabled(boba_installed):
|
||||
from hermes_cli.pets import _pet_config, toggle_pet_display
|
||||
|
||||
_write_config(boba_installed, enabled=True, slug="boba")
|
||||
|
||||
enabled, name, err = toggle_pet_display()
|
||||
|
||||
assert err is None
|
||||
assert enabled is False
|
||||
assert name == "Boba"
|
||||
assert _pet_config()["enabled"] is False
|
||||
|
||||
|
||||
def test_toggle_pet_display_turns_on_resolved_pet(boba_installed):
|
||||
from hermes_cli.pets import _pet_config, toggle_pet_display
|
||||
|
||||
_write_config(boba_installed, enabled=False, slug="boba")
|
||||
|
||||
enabled, name, err = toggle_pet_display()
|
||||
|
||||
assert err is None
|
||||
assert enabled is True
|
||||
assert name == "Boba"
|
||||
assert _pet_config()["enabled"] is True
|
||||
|
||||
|
||||
def test_toggle_pet_display_errors_with_no_installed_pets(tmp_path, monkeypatch):
|
||||
from hermes_cli.pets import toggle_pet_display
|
||||
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
_write_config(home, enabled=False, slug="")
|
||||
|
||||
enabled, name, err = toggle_pet_display()
|
||||
|
||||
assert enabled is False
|
||||
assert name is None
|
||||
assert err is not None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def empty_home(tmp_path, monkeypatch):
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
return home
|
||||
|
||||
|
||||
def test_set_pet_scale_writes_clamped_value(empty_home):
|
||||
from agent.pet.constants import MAX_SCALE, MIN_SCALE
|
||||
from hermes_cli.pets import _pet_config, set_pet_scale
|
||||
|
||||
applied, err = set_pet_scale("0.5")
|
||||
assert err is None
|
||||
assert applied == 0.5
|
||||
assert _pet_config()["scale"] == 0.5
|
||||
|
||||
# Out-of-range values clamp to the bounds rather than erroring.
|
||||
assert set_pet_scale(99) == (MAX_SCALE, None)
|
||||
assert set_pet_scale(0) == (MIN_SCALE, None)
|
||||
|
||||
|
||||
def test_set_pet_scale_rejects_non_numbers(empty_home):
|
||||
from hermes_cli.pets import set_pet_scale
|
||||
|
||||
applied, err = set_pet_scale("huge")
|
||||
assert applied == 0.0
|
||||
assert err is not None
|
||||
144
tests/tui_gateway/test_pet_generate_rpc.py
Normal file
144
tests/tui_gateway/test_pet_generate_rpc.py
Normal file
@ -0,0 +1,144 @@
|
||||
"""Gateway RPC tests for pet generation (pet.generate / pet.hatch).
|
||||
|
||||
Image generation is mocked, so these assert the RPC contract + staging behavior
|
||||
(draft tokens, data-URI previews, expiry, activation) without any API calls.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("PIL")
|
||||
from PIL import Image # noqa: E402
|
||||
|
||||
from tui_gateway import server # noqa: E402
|
||||
|
||||
|
||||
def _png(path):
|
||||
Image.new("RGBA", (64, 64), (200, 80, 80, 255)).save(path)
|
||||
|
||||
|
||||
def test_pet_generate_requires_prompt():
|
||||
resp = server._methods["pet.generate"]("r1", {"prompt": " "})
|
||||
assert "error" in resp
|
||||
|
||||
|
||||
def test_pet_generate_returns_token_and_previews(monkeypatch, tmp_path):
|
||||
import agent.pet.generate as gen
|
||||
|
||||
def fake_drafts(prompt, *, n=4, style="auto"):
|
||||
paths = []
|
||||
for i in range(n):
|
||||
p = tmp_path / f"d{i}.png"
|
||||
_png(p)
|
||||
paths.append(p)
|
||||
return paths
|
||||
|
||||
monkeypatch.setattr(gen, "generate_base_drafts", fake_drafts)
|
||||
|
||||
resp = server._methods["pet.generate"]("r2", {"prompt": "a robot fox", "count": 4})
|
||||
result = resp["result"]
|
||||
assert result["ok"]
|
||||
assert len(result["drafts"]) == 4
|
||||
assert all(d["dataUri"].startswith("data:image/png;base64,") for d in result["drafts"])
|
||||
|
||||
# Drafts are staged on disk under the returned token.
|
||||
staged = server._pet_gen_root() / result["token"] / "draft-0.png"
|
||||
assert staged.is_file()
|
||||
|
||||
|
||||
def test_pet_hatch_validates_params():
|
||||
assert "error" in server._methods["pet.hatch"]("r1", {"name": "x"}) # missing token
|
||||
assert "error" in server._methods["pet.hatch"]("r2", {"token": "abc"}) # missing name
|
||||
|
||||
|
||||
def test_pet_hatch_expired_draft():
|
||||
resp = server._methods["pet.hatch"]("r3", {"token": "nope", "index": 0, "name": "Ghost"})
|
||||
assert "error" in resp
|
||||
assert "expired" in resp["error"]["message"]
|
||||
|
||||
|
||||
def _fake_drafts_factory(tmp_path):
|
||||
def fake_drafts(prompt, *, n=4, style="auto"):
|
||||
paths = []
|
||||
for i in range(n):
|
||||
p = tmp_path / f"d{i}.png"
|
||||
_png(p)
|
||||
paths.append(p)
|
||||
return paths
|
||||
|
||||
return fake_drafts
|
||||
|
||||
|
||||
def _fake_hatch_factory(captured):
|
||||
"""A hatch that registers a real local pet (so the preview payload populates)."""
|
||||
import agent.pet.generate as gen
|
||||
from agent.pet import store
|
||||
|
||||
def fake_hatch(*, base_image, slug, display_name="", description="", concept="", style="auto", on_progress=None, provider=None):
|
||||
captured["base_image"] = str(base_image)
|
||||
captured["slug"] = slug
|
||||
pet = store.register_local_pet(
|
||||
Image.new("RGBA", (192, 208), (10, 20, 30, 255)),
|
||||
slug=slug,
|
||||
display_name=display_name,
|
||||
description=description,
|
||||
)
|
||||
return gen.HatchResult(
|
||||
slug=pet.slug,
|
||||
display_name=display_name or pet.display_name,
|
||||
spritesheet=pet.spritesheet,
|
||||
states=["idle", "wave"],
|
||||
validation={"ok": True, "warnings": ["state 'jump' has no frames"]},
|
||||
)
|
||||
|
||||
return fake_hatch
|
||||
|
||||
|
||||
def test_pet_generate_then_hatch_previews_without_activating(monkeypatch, tmp_path):
|
||||
import agent.pet.generate as gen
|
||||
from agent.pet import store
|
||||
|
||||
captured = {}
|
||||
monkeypatch.setattr(gen, "generate_base_drafts", _fake_drafts_factory(tmp_path))
|
||||
monkeypatch.setattr(gen, "hatch_pet", _fake_hatch_factory(captured))
|
||||
|
||||
token = server._methods["pet.generate"]("r1", {"prompt": "a fox"})["result"]["token"]
|
||||
|
||||
resp = server._methods["pet.hatch"](
|
||||
"r2",
|
||||
{"token": token, "index": 1, "name": "My Fox", "description": "vulpine"},
|
||||
)
|
||||
result = resp["result"]
|
||||
assert result["ok"]
|
||||
assert result["slug"] == "my-fox"
|
||||
assert result["displayName"] == "My Fox"
|
||||
assert result["warnings"] == ["state 'jump' has no frames"]
|
||||
# Hatched from the chosen draft index.
|
||||
assert captured["base_image"].endswith("draft-1.png")
|
||||
|
||||
# The pet is installed on disk and the preview payload carries the sheet,
|
||||
# but hatch must NOT activate it — adoption is a separate step.
|
||||
assert store.load_pet("my-fox") is not None
|
||||
assert result["pet"]["slug"] == "my-fox"
|
||||
assert result["pet"]["spritesheetBase64"]
|
||||
assert server._methods["pet.info"]("r3", {}).get("result", {}).get("enabled") in (False, None)
|
||||
|
||||
|
||||
def test_pet_hatch_then_adopt_activates(monkeypatch, tmp_path):
|
||||
import agent.pet.generate as gen
|
||||
|
||||
captured = {}
|
||||
monkeypatch.setattr(gen, "generate_base_drafts", _fake_drafts_factory(tmp_path))
|
||||
monkeypatch.setattr(gen, "hatch_pet", _fake_hatch_factory(captured))
|
||||
|
||||
activated = {}
|
||||
monkeypatch.setattr("hermes_cli.pets._set_active", lambda slug: activated.setdefault("slug", slug))
|
||||
|
||||
token = server._methods["pet.generate"]("r1", {"prompt": "a fox"})["result"]["token"]
|
||||
hatched = server._methods["pet.hatch"]("r2", {"token": token, "index": 0, "name": "My Fox"})["result"]
|
||||
|
||||
# Adoption is the existing pet.select path, against the now-installed slug.
|
||||
adopt = server._methods["pet.select"]("r3", {"slug": hatched["slug"]})["result"]
|
||||
assert adopt["ok"]
|
||||
assert activated["slug"] == "my-fox"
|
||||
@ -176,6 +176,18 @@ _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",
|
||||
# Generation is the heaviest pet path by far — multiple image-model
|
||||
# round-trips per call — so it must never block the reader thread.
|
||||
"pet.generate",
|
||||
"pet.hatch",
|
||||
"pet.select",
|
||||
"pet.thumb",
|
||||
"plugins.manage",
|
||||
"session.branch",
|
||||
"session.compress",
|
||||
@ -4985,6 +4997,570 @@ def _(rid, params: dict) -> dict:
|
||||
return _ok(rid, usage)
|
||||
|
||||
|
||||
def _pet_frame_counts(spritesheet) -> dict:
|
||||
"""Real (padding-trimmed) frame count per state, for the desktop canvas.
|
||||
|
||||
Fail-open: a decode hiccup returns ``{}`` and the canvas falls back to its
|
||||
static ``framesPerState`` rather than breaking the (cosmetic) pet.
|
||||
"""
|
||||
try:
|
||||
from agent.pet import render
|
||||
|
||||
return render.state_frame_counts(str(spritesheet))
|
||||
except Exception: # noqa: BLE001 - cosmetic, never break the surface
|
||||
return {}
|
||||
|
||||
|
||||
def _pet_config_scale() -> float:
|
||||
"""Configured ``display.pet.scale`` (or the engine default), never raises."""
|
||||
from agent.pet import constants
|
||||
|
||||
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 {}
|
||||
return float(pet_cfg.get("scale", constants.DEFAULT_SCALE) or constants.DEFAULT_SCALE)
|
||||
except Exception: # noqa: BLE001
|
||||
return constants.DEFAULT_SCALE
|
||||
|
||||
|
||||
def _pet_sprite_payload(pet, *, scale: float) -> dict:
|
||||
"""Build the renderer payload (spritesheet bytes + geometry) for *pet*.
|
||||
|
||||
Shared by ``pet.info`` (the active mascot) and ``pet.hatch`` (the unadopted
|
||||
preview) so both feed the desktop canvas / TUI from one shape.
|
||||
"""
|
||||
import base64
|
||||
|
||||
from agent.pet import constants
|
||||
|
||||
raw = pet.spritesheet.read_bytes()
|
||||
suffix = pet.spritesheet.suffix.lower()
|
||||
mime = "image/png" if suffix == ".png" else "image/webp"
|
||||
return {
|
||||
"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": scale,
|
||||
"stateRows": _pet_state_rows(pet.spritesheet),
|
||||
}
|
||||
|
||||
|
||||
def _pet_state_rows(spritesheet) -> list[str]:
|
||||
"""Row taxonomy for the concrete active pet sheet.
|
||||
|
||||
Hermes has to support both the legacy 8-row petdex atlas and the current
|
||||
Codex/petdex 9-row atlas. The desktop canvas gets this list and indexes it
|
||||
with the same `PetState` names the Python renderer uses.
|
||||
"""
|
||||
try:
|
||||
from PIL import Image
|
||||
|
||||
from agent.pet import constants
|
||||
|
||||
with Image.open(spritesheet) as image:
|
||||
row_count = max(1, image.height // constants.FRAME_H)
|
||||
return list(constants.state_rows_for_grid(row_count))
|
||||
except Exception: # noqa: BLE001 - cosmetic, never break the surface
|
||||
from agent.pet import constants
|
||||
|
||||
return list(constants.STATE_ROWS)
|
||||
|
||||
|
||||
@method("pet.info")
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""Return the active petdex pet for surfaces that render sprites.
|
||||
|
||||
Shared by the desktop (canvas) and the TUI (half-block). Carries the
|
||||
spritesheet bytes (base64) plus the engine's frame geometry + state-row
|
||||
taxonomy so the renderer is a thin, framework-native consumer. The
|
||||
activity→state decision is mirrored from ``agent.pet.state`` client-side.
|
||||
|
||||
Agent-independent (reads config + disk), so it works on any session and
|
||||
before the agent finishes building. Fail-open: returns ``enabled=False``
|
||||
on any error rather than erroring the surface.
|
||||
"""
|
||||
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})
|
||||
|
||||
scale = float(pet_cfg.get("scale", constants.DEFAULT_SCALE) or constants.DEFAULT_SCALE)
|
||||
return _ok(rid, {"enabled": True, **_pet_sprite_payload(pet, scale=scale)})
|
||||
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}")
|
||||
|
||||
|
||||
def _pet_gen_root():
|
||||
"""Profile-scoped staging dir for in-progress generation drafts."""
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
root = get_hermes_home() / "cache" / "pet-gen"
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
return root
|
||||
|
||||
|
||||
def _pet_gen_sweep(root, *, max_age_s: float = 3600.0) -> None:
|
||||
"""Drop stale draft staging dirs so cache never grows unbounded."""
|
||||
import shutil
|
||||
import time
|
||||
|
||||
try:
|
||||
now = time.time()
|
||||
for child in root.iterdir():
|
||||
if child.is_dir() and now - child.stat().st_mtime > max_age_s:
|
||||
shutil.rmtree(child, ignore_errors=True)
|
||||
except Exception as exc: # noqa: BLE001 - cleanup is best-effort
|
||||
logger.debug("pet-gen sweep failed: %s", exc)
|
||||
|
||||
|
||||
def _pet_png_data_uri(path, *, max_px: int = 160) -> str:
|
||||
"""Downscaled PNG data URI for a draft image (small preview payload)."""
|
||||
import base64
|
||||
import io
|
||||
|
||||
from PIL import Image
|
||||
|
||||
with Image.open(path) as opened:
|
||||
img = opened.convert("RGBA")
|
||||
img.thumbnail((max_px, max_px), Image.LANCZOS)
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="PNG")
|
||||
return "data:image/png;base64," + base64.standard_b64encode(buf.getvalue()).decode("ascii")
|
||||
|
||||
|
||||
@method("pet.generate")
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""Generate candidate base looks for a new pet (the draft/variant step).
|
||||
|
||||
Params: ``prompt`` (required), ``count`` (default 4), ``style`` (default
|
||||
``auto``). Returns ``{ok, token, drafts:[{index, dataUri}]}`` — the token
|
||||
keys the staged base images for a later ``pet.hatch``. Retry == call again
|
||||
(fresh token). Heavy (network): runs on the worker pool.
|
||||
"""
|
||||
prompt = str(params.get("prompt") or "").strip()
|
||||
if not prompt:
|
||||
return _err(rid, 4004, "missing prompt")
|
||||
try:
|
||||
count = max(1, min(4, int(params.get("count") or 4)))
|
||||
except (TypeError, ValueError):
|
||||
count = 4
|
||||
style = str(params.get("style") or "auto").strip() or "auto"
|
||||
|
||||
try:
|
||||
import shutil
|
||||
import uuid
|
||||
|
||||
from agent.pet.generate import generate_base_drafts
|
||||
from agent.pet.generate.imagegen import GenerationError
|
||||
|
||||
root = _pet_gen_root()
|
||||
_pet_gen_sweep(root)
|
||||
|
||||
try:
|
||||
drafts = generate_base_drafts(prompt, n=count, style=style)
|
||||
except GenerationError as exc:
|
||||
return _err(rid, 5031, str(exc))
|
||||
|
||||
token = uuid.uuid4().hex[:12]
|
||||
stage = root / token
|
||||
stage.mkdir(parents=True, exist_ok=True)
|
||||
out = []
|
||||
for i, src in enumerate(drafts):
|
||||
dest = stage / f"draft-{i}.png"
|
||||
try:
|
||||
shutil.copyfile(src, dest)
|
||||
out.append({"index": i, "dataUri": _pet_png_data_uri(dest)})
|
||||
except Exception as exc: # noqa: BLE001 - skip a bad draft, keep the rest
|
||||
logger.debug("pet.generate draft %d failed: %s", i, exc)
|
||||
|
||||
if not out:
|
||||
return _err(rid, 5031, "generation produced no usable drafts")
|
||||
return _ok(rid, {"ok": True, "token": token, "drafts": out})
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.debug("pet.generate failed: %s", exc)
|
||||
return _err(rid, 5031, f"pet.generate failed: {exc}")
|
||||
|
||||
|
||||
@method("pet.hatch")
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""Turn a chosen base draft into a full pet — installed but NOT yet active.
|
||||
|
||||
Generation is expensive and the result varies, so hatch produces a *preview*
|
||||
the surface plays (all frames) before the user commits: the pet is written to
|
||||
the store (so it can be rendered + later activated) but the active pet is left
|
||||
untouched. Adopt with ``pet.select`` or throw it away with ``pet.remove``.
|
||||
|
||||
Params: ``token`` + ``index`` (from ``pet.generate``), ``name`` (required),
|
||||
``description`` (optional), ``prompt`` (optional concept for row prompts),
|
||||
``style`` (optional). Returns ``{ok, slug, displayName, warnings, pet}`` where
|
||||
``pet`` is the renderer payload. Heavy (network + raster): worker pool.
|
||||
"""
|
||||
token = str(params.get("token") or "").strip()
|
||||
index = params.get("index", 0)
|
||||
name = str(params.get("name") or "").strip()
|
||||
if not token:
|
||||
return _err(rid, 4004, "missing token")
|
||||
if not name:
|
||||
return _err(rid, 4004, "missing name")
|
||||
try:
|
||||
index = int(index)
|
||||
except (TypeError, ValueError):
|
||||
index = 0
|
||||
|
||||
try:
|
||||
from agent.pet import store
|
||||
from agent.pet.generate import hatch_pet
|
||||
from agent.pet.generate.imagegen import GenerationError
|
||||
|
||||
base = _pet_gen_root() / token / f"draft-{index}.png"
|
||||
if not base.is_file():
|
||||
return _err(rid, 4004, "draft expired — generate again")
|
||||
|
||||
slug = store.unique_slug(name)
|
||||
try:
|
||||
result = hatch_pet(
|
||||
base_image=base,
|
||||
slug=slug,
|
||||
display_name=name,
|
||||
description=str(params.get("description") or ""),
|
||||
concept=str(params.get("prompt") or name),
|
||||
style=str(params.get("style") or "auto").strip() or "auto",
|
||||
)
|
||||
except GenerationError as exc:
|
||||
return _err(rid, 5031, str(exc))
|
||||
|
||||
pet = store.load_pet(result.slug)
|
||||
payload = _pet_sprite_payload(pet, scale=_pet_config_scale()) if pet else {}
|
||||
return _ok(
|
||||
rid,
|
||||
{
|
||||
"ok": True,
|
||||
"slug": result.slug,
|
||||
"displayName": result.display_name,
|
||||
"warnings": result.validation.get("warnings", []),
|
||||
"pet": payload,
|
||||
},
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.debug("pet.hatch failed: %s", exc)
|
||||
return _err(rid, 5031, f"pet.hatch failed: {exc}")
|
||||
|
||||
|
||||
@method("credits.view")
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""Structured Nous credit view for the TUI /credits command.
|
||||
|
||||
@ -208,6 +208,41 @@ describe('createSlashHandler', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('opens the pet picker for /pet list only', () => {
|
||||
const ctx = buildCtx()
|
||||
|
||||
expect(createSlashHandler(ctx)('/pet list')).toBe(true)
|
||||
expect(getOverlayState().petPicker).toBe(true)
|
||||
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
|
||||
|
||||
resetOverlayState()
|
||||
expect(createSlashHandler(ctx)('/pet')).toBe(true)
|
||||
expect(getOverlayState().petPicker).toBe(false)
|
||||
expect(ctx.gateway.gw.request).toHaveBeenCalledWith(
|
||||
'slash.exec',
|
||||
expect.objectContaining({ command: 'pet' })
|
||||
)
|
||||
|
||||
resetOverlayState()
|
||||
expect(createSlashHandler(ctx)('/pet toggle')).toBe(true)
|
||||
expect(getOverlayState().petPicker).toBe(false)
|
||||
expect(ctx.gateway.gw.request).toHaveBeenCalledWith(
|
||||
'slash.exec',
|
||||
expect.objectContaining({ command: 'pet toggle' })
|
||||
)
|
||||
})
|
||||
|
||||
it('routes /pet <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()
|
||||
|
||||
|
||||
@ -9,6 +9,7 @@ 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'
|
||||
@ -18,7 +19,9 @@ import type { Msg, SubagentProgress, SubagentStatus } from '../types.js'
|
||||
import { applyDelegationStatus, getDelegationState } from './delegationStore.js'
|
||||
import type { GatewayEventHandlerContext } from './interfaces.js'
|
||||
import { getOverlayState, patchOverlayState } from './overlayStore.js'
|
||||
import { flashPet } from './petFlashStore.js'
|
||||
import { turnController } from './turnController.js'
|
||||
import { getTurnState } from './turnStore.js'
|
||||
import { getUiState, patchUiState } from './uiStore.js'
|
||||
|
||||
const NO_PROVIDER_RE = /\bNo (?:LLM|inference) provider configured\b/i
|
||||
@ -884,6 +887,9 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
||||
const msgs: Msg[] = finalMessages.length ? finalMessages : [{ role: 'assistant', text: finalText }]
|
||||
msgs.forEach(appendMessage)
|
||||
|
||||
// Pet beat: celebrate a finished plan, otherwise a clean-finish wave.
|
||||
flashPet(isTodoDone(getTurnState().todos) ? 'jump' : 'wave')
|
||||
|
||||
if (bellOnComplete && stdout?.isTTY) {
|
||||
stdout.write('\x07')
|
||||
}
|
||||
@ -900,6 +906,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
||||
|
||||
case 'error':
|
||||
turnController.recordError()
|
||||
flashPet('failed')
|
||||
|
||||
{
|
||||
const message = String(ev.payload?.message || 'unknown error')
|
||||
|
||||
@ -93,6 +93,7 @@ export interface OverlayState {
|
||||
confirm: ConfirmReq | null
|
||||
modelPicker: boolean
|
||||
pager: null | PagerState
|
||||
petPicker: boolean
|
||||
pluginsHub: boolean
|
||||
secret: null | SecretReq
|
||||
sessions: boolean
|
||||
|
||||
@ -10,6 +10,7 @@ const buildOverlayState = (): OverlayState => ({
|
||||
confirm: null,
|
||||
modelPicker: false,
|
||||
pager: null,
|
||||
petPicker: false,
|
||||
pluginsHub: false,
|
||||
secret: null,
|
||||
sessions: false,
|
||||
@ -21,9 +22,9 @@ export const $overlayState = atom<OverlayState>(buildOverlayState())
|
||||
|
||||
export const $isBlocked = computed(
|
||||
$overlayState,
|
||||
({ agents, approval, clarify, confirm, modelPicker, pager, pluginsHub, secret, sessions, skillsHub, sudo }) =>
|
||||
({ agents, approval, clarify, confirm, modelPicker, pager, petPicker, pluginsHub, secret, sessions, skillsHub, sudo }) =>
|
||||
Boolean(
|
||||
agents || approval || clarify || confirm || modelPicker || pager || pluginsHub || secret || sessions || skillsHub || sudo
|
||||
agents || approval || clarify || confirm || modelPicker || pager || petPicker || pluginsHub || secret || sessions || skillsHub || sudo
|
||||
)
|
||||
)
|
||||
|
||||
@ -49,6 +50,7 @@ export const resetFlowOverlays = () =>
|
||||
agents: $overlayState.get().agents,
|
||||
agentsInitialHistoryIndex: $overlayState.get().agentsInitialHistoryIndex,
|
||||
modelPicker: $overlayState.get().modelPicker,
|
||||
petPicker: $overlayState.get().petPicker,
|
||||
pluginsHub: $overlayState.get().pluginsHub,
|
||||
sessions: $overlayState.get().sessions,
|
||||
skillsHub: $overlayState.get().skillsHub
|
||||
|
||||
16
ui-tui/src/app/petFlashStore.ts
Normal file
16
ui-tui/src/app/petFlashStore.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { atom } from 'nanostores'
|
||||
|
||||
import type { PetState } from './usePet.js'
|
||||
|
||||
interface PetFlash {
|
||||
state: PetState
|
||||
until: number
|
||||
}
|
||||
|
||||
// Transient reaction beats (wave/jump/failed) the pet shows for a moment at
|
||||
// turn end before falling back to its steady state. The gateway event handler
|
||||
// sets these; usePet reads them with priority over the derived state.
|
||||
export const $petFlash = atom<PetFlash | null>(null)
|
||||
|
||||
export const flashPet = (state: PetState, ms = 1600) =>
|
||||
$petFlash.set({ state, until: Date.now() + ms })
|
||||
@ -8,6 +8,7 @@ import type {
|
||||
SessionBranchResponse,
|
||||
SessionCompressResponse,
|
||||
SessionUsageResponse,
|
||||
SlashExecResponse,
|
||||
VoiceToggleResponse
|
||||
} from '../../../gatewayTypes.js'
|
||||
import { formatVoiceRecordKey, parseVoiceRecordKey } from '../../../lib/platform.js'
|
||||
@ -340,6 +341,31 @@ export const sessionCommands: SlashCommand[] = [
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
help: 'toggle / adopt / resize an animated pet',
|
||||
name: 'pet',
|
||||
usage: '/pet [toggle | list | scale <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',
|
||||
@ -553,6 +579,7 @@ export const sessionCommands: SlashCommand[] = [
|
||||
// even with zero API calls or on a resumed session. Render it whenever
|
||||
// present, before the token panel.
|
||||
const creditsLines = r?.credits_lines ?? []
|
||||
|
||||
if (creditsLines.length) {
|
||||
ctx.transcript.panel('Nous credits', [{ text: creditsLines.join('\n') }])
|
||||
}
|
||||
@ -561,6 +588,7 @@ export const sessionCommands: SlashCommand[] = [
|
||||
if (!creditsLines.length) {
|
||||
ctx.transcript.sys('no API calls yet')
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@ -147,6 +147,10 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
|
||||
return patchOverlayState({ modelPicker: false })
|
||||
}
|
||||
|
||||
if (overlay.petPicker) {
|
||||
return patchOverlayState({ petPicker: false })
|
||||
}
|
||||
|
||||
if (overlay.skillsHub) {
|
||||
return patchOverlayState({ skillsHub: false })
|
||||
}
|
||||
|
||||
313
ui-tui/src/app/usePet.ts
Normal file
313
ui-tui/src/app/usePet.ts
Normal file
@ -0,0 +1,313 @@
|
||||
import { useStdout } from '@hermes/ink'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import type { PetGrid } from '../components/petSprite.js'
|
||||
|
||||
import { useGateway } from './gatewayContext.js'
|
||||
import { getOverlayState, $overlayState } from './overlayStore.js'
|
||||
import { $petFlash } from './petFlashStore.js'
|
||||
import { $turnState } from './turnStore.js'
|
||||
import { $uiState } from './uiStore.js'
|
||||
|
||||
export type PetState = 'idle' | 'wave' | 'run' | 'failed' | 'review' | 'jump' | 'waiting'
|
||||
|
||||
interface PetActivity {
|
||||
busy: boolean
|
||||
toolRunning: boolean
|
||||
reasoning: boolean
|
||||
awaitingInput: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the animation state — mirrors `agent.pet.state.derive_pet_state`
|
||||
* (and the desktop's `derivePetState`) so all surfaces agree. `awaitingInput`
|
||||
* (a clarify/approval blocking on the user) outranks the in-flight signals
|
||||
* because the turn is paused on you, not working.
|
||||
*/
|
||||
export function derivePetState({ busy, toolRunning, reasoning, awaitingInput }: PetActivity): PetState {
|
||||
if (awaitingInput) {
|
||||
return 'waiting'
|
||||
}
|
||||
|
||||
if (toolRunning) {
|
||||
return 'run'
|
||||
}
|
||||
|
||||
if (reasoning) {
|
||||
return 'review'
|
||||
}
|
||||
|
||||
if (busy) {
|
||||
return 'run'
|
||||
}
|
||||
|
||||
return 'idle'
|
||||
}
|
||||
|
||||
// The overlays that mean "the agent is blocked on the user" (vs. user-toggled
|
||||
// pickers like model/sessions, which aren't the agent waiting).
|
||||
function isAwaitingInput(): boolean {
|
||||
const o = getOverlayState()
|
||||
|
||||
return Boolean(o.clarify || o.approval || o.sudo || o.secret || o.confirm)
|
||||
}
|
||||
|
||||
// A kitty Unicode-placeholder frame set: a static placeholder grid (painted by
|
||||
// Ink in the image-id color) plus per-frame transmit escapes written straight
|
||||
// to the terminal out-of-band.
|
||||
interface KittyView {
|
||||
color: string
|
||||
placeholder: string[]
|
||||
}
|
||||
|
||||
interface PetCellsResult {
|
||||
color?: string
|
||||
enabled?: boolean
|
||||
frameMs?: number
|
||||
// unicode mode: cell grids; kitty mode: transmit-escape strings.
|
||||
frames?: PetGrid[] | string[]
|
||||
graphics?: string
|
||||
imageId?: number
|
||||
placeholder?: string[]
|
||||
scale?: number
|
||||
slug?: string
|
||||
state?: string
|
||||
}
|
||||
|
||||
type CacheEntry =
|
||||
| { kind: 'cells'; frameMs: number; frames: PetGrid[] }
|
||||
| { kind: 'kitty'; frameMs: number; frames: string[]; placeholder: string[]; color: string }
|
||||
|
||||
const FRAME_MS = 160
|
||||
const POLL_MS = 2500
|
||||
|
||||
// Only the standalone TUI owns a real terminal it can splat image escapes into;
|
||||
// when piped (or running under the dashboard PTY the gateway resolves to
|
||||
// half-blocks anyway) we never ask for graphics.
|
||||
const IS_TTY = Boolean(process.stdout?.isTTY)
|
||||
|
||||
export interface PetRender {
|
||||
enabled: boolean
|
||||
grid: PetGrid | null
|
||||
kitty: KittyView | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives the TUI pet. Fetches each (slug, state)'s frames via the `pet.cells`
|
||||
* RPC (cached) and animates the frame index. Two render paths:
|
||||
*
|
||||
* - **kitty** (Ghostty/kitty): the engine returns a static placeholder grid +
|
||||
* per-frame transmit escapes. We paint the placeholder with Ink and write the
|
||||
* current frame's escape to the terminal out-of-band, so the image animates
|
||||
* underneath without Ink ever repainting.
|
||||
* - **cells** (everywhere else): truecolor half-block grids painted by Ink.
|
||||
*
|
||||
* A steady poll keeps it reactive to config changes made elsewhere (`/pet`, the
|
||||
* picker, `hermes pets select`) so adopting/switching/disabling takes effect
|
||||
* live. The frame cache is keyed by `slug:state` so a switch re-pulls cleanly.
|
||||
*/
|
||||
export function usePet(): PetRender {
|
||||
const { rpc } = useGateway()
|
||||
const { write } = useStdout()
|
||||
const [enabled, setEnabled] = useState(false)
|
||||
const [grid, setGrid] = useState<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({
|
||||
awaitingInput: isAwaitingInput(),
|
||||
busy: ui.busy,
|
||||
reasoning: turn.reasoningActive,
|
||||
toolRunning: turn.tools.length > 0
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
recompute()
|
||||
const unsubTurn = $turnState.listen(recompute)
|
||||
const unsubUi = $uiState.listen(recompute)
|
||||
const unsubFlash = $petFlash.listen(recompute)
|
||||
const unsubOverlay = $overlayState.listen(recompute)
|
||||
|
||||
return () => {
|
||||
clearTimeout(expiry)
|
||||
unsubTurn()
|
||||
unsubUi()
|
||||
unsubFlash()
|
||||
unsubOverlay()
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Free the terminal-side image when the pet goes away or the hook unmounts.
|
||||
const releaseKitty = useCallback(() => {
|
||||
if (imageIdRef.current) {
|
||||
try {
|
||||
write(`\x1b_Ga=d,d=i,i=${imageIdRef.current},q=2\x1b\\`)
|
||||
} catch {
|
||||
// best-effort cleanup
|
||||
}
|
||||
|
||||
imageIdRef.current = 0
|
||||
}
|
||||
}, [write])
|
||||
|
||||
// Fetch + cache one (slug, state). `pet.cells` resolves the active pet from
|
||||
// config, so its `slug`/`enabled` are the source of truth.
|
||||
const sync = useCallback(
|
||||
async (state: PetState) => {
|
||||
try {
|
||||
const res = (await rpc('pet.cells', { graphics: IS_TTY, state })) as PetCellsResult | null
|
||||
|
||||
if (!res) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!res.enabled) {
|
||||
releaseKitty()
|
||||
slugRef.current = ''
|
||||
cache.current.clear()
|
||||
setGrid(null)
|
||||
setKitty(null)
|
||||
setEnabled(false)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const slug = res.slug ?? ''
|
||||
const scale = res.scale ?? 0
|
||||
|
||||
// A switch OR a live `/pet scale` change invalidates the cached frames
|
||||
// (they're rendered at the old size), so the steady poll repaints at the
|
||||
// new scale without a restart.
|
||||
if (slug !== slugRef.current || (scale > 0 && scale !== scaleRef.current)) {
|
||||
releaseKitty()
|
||||
slugRef.current = slug
|
||||
scaleRef.current = scale
|
||||
cache.current.clear()
|
||||
frameRef.current = 0
|
||||
}
|
||||
|
||||
if (res.graphics === 'kitty' && res.frames?.length && res.placeholder?.length) {
|
||||
imageIdRef.current = res.imageId ?? 0
|
||||
cache.current.set(`${slug}:${state}`, {
|
||||
color: res.color ?? '#000001',
|
||||
frameMs: res.frameMs ?? FRAME_MS,
|
||||
frames: res.frames as string[],
|
||||
kind: 'kitty',
|
||||
placeholder: res.placeholder
|
||||
})
|
||||
} else if (res.frames?.length) {
|
||||
cache.current.set(`${slug}:${state}`, {
|
||||
frameMs: res.frameMs ?? FRAME_MS,
|
||||
frames: res.frames as PetGrid[],
|
||||
kind: 'cells'
|
||||
})
|
||||
}
|
||||
|
||||
setEnabled(true)
|
||||
} catch {
|
||||
// cosmetic — ignore RPC failures
|
||||
}
|
||||
},
|
||||
[rpc, releaseKitty]
|
||||
)
|
||||
|
||||
// Pull frames whenever the state changes (if not already cached for the
|
||||
// active pet), plus a steady poll that catches adopt/switch/disable.
|
||||
useEffect(() => {
|
||||
if (!cache.current.has(`${slugRef.current}:${petState}`)) {
|
||||
void sync(petState)
|
||||
}
|
||||
|
||||
const timer = setInterval(() => void sync(stateRef.current), POLL_MS)
|
||||
|
||||
return () => clearInterval(timer)
|
||||
}, [petState, sync])
|
||||
|
||||
useEffect(() => releaseKitty, [releaseKitty])
|
||||
|
||||
// Animation timer.
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
return
|
||||
}
|
||||
|
||||
const tick = () => {
|
||||
const entry = cache.current.get(`${slugRef.current}:${stateRef.current}`)
|
||||
|
||||
if (!entry?.frames.length) {
|
||||
return // keep the last frame painted while the new state loads
|
||||
}
|
||||
|
||||
const idx = frameRef.current % entry.frames.length
|
||||
frameRef.current = idx + 1
|
||||
|
||||
if (entry.kind === 'kitty') {
|
||||
// Transmit this frame's image under the shared id; the static
|
||||
// placeholder cells (set below) render it. No Ink repaint needed.
|
||||
try {
|
||||
write(entry.frames[idx] ?? '')
|
||||
} catch {
|
||||
// ignore transmit failures
|
||||
}
|
||||
|
||||
setGrid(null)
|
||||
setKitty(prev =>
|
||||
prev && prev.color === entry.color && prev.placeholder === entry.placeholder
|
||||
? prev
|
||||
: { color: entry.color, placeholder: entry.placeholder }
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setKitty(null)
|
||||
setGrid(entry.frames[idx] ?? null)
|
||||
}
|
||||
|
||||
tick()
|
||||
const interval = setInterval(tick, FRAME_MS)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [enabled, petState, write])
|
||||
|
||||
return { enabled, grid, kitty }
|
||||
}
|
||||
@ -6,6 +6,7 @@ import { useGateway } from '../app/gatewayContext.js'
|
||||
import type { AppLayoutProps } from '../app/interfaces.js'
|
||||
import { $isBlocked, $overlayState, patchOverlayState } from '../app/overlayStore.js'
|
||||
import { $uiState } from '../app/uiStore.js'
|
||||
import { usePet } from '../app/usePet.js'
|
||||
import { INLINE_MODE, SHOW_FPS, TERMUX_TUI_MODE } from '../config/env.js'
|
||||
import { PLACEHOLDER } from '../content/placeholders.js'
|
||||
import { prevRenderedMsg } from '../domain/blockLayout.js'
|
||||
@ -25,10 +26,29 @@ import { Banner, Panel, SessionPanel } from './branding.js'
|
||||
import { FpsOverlay } from './fpsOverlay.js'
|
||||
import { HelpHint } from './helpHint.js'
|
||||
import { MessageLine } from './messageLine.js'
|
||||
import { PetKitty, PetSprite } from './petSprite.js'
|
||||
import { QueuedMessages } from './queuedMessages.js'
|
||||
import { LiveTodoPanel, StreamingAssistant } from './streamingAssistant.js'
|
||||
import { TextInput, type TextInputMouseApi } from './textInput.js'
|
||||
|
||||
// Petdex mascot — sits just above the composer, right-aligned. Renders
|
||||
// nothing unless a pet is installed + enabled (`hermes pets select <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,
|
||||
@ -420,6 +440,8 @@ export const AppLayout = memo(function AppLayout({
|
||||
|
||||
{!overlay.agents && (
|
||||
<>
|
||||
<PetPane />
|
||||
|
||||
<PerfPane id="prompt">
|
||||
<PromptZone
|
||||
cols={composer.cols}
|
||||
|
||||
@ -11,6 +11,7 @@ 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'
|
||||
@ -124,6 +125,7 @@ export function FloatingOverlays({
|
||||
const hasAny =
|
||||
overlay.modelPicker ||
|
||||
overlay.pager ||
|
||||
overlay.petPicker ||
|
||||
overlay.sessions ||
|
||||
overlay.skillsHub ||
|
||||
overlay.pluginsHub ||
|
||||
@ -170,6 +172,12 @@ 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} />
|
||||
|
||||
183
ui-tui/src/components/petPicker.tsx
Normal file
183
ui-tui/src/components/petPicker.tsx
Normal file
@ -0,0 +1,183 @@
|
||||
import { Box, Text, useInput, useStdout } from '@hermes/ink'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import type { GatewayClient } from '../gatewayClient.js'
|
||||
import { rpcErrorMessage } from '../lib/rpc.js'
|
||||
import type { Theme } from '../theme.js'
|
||||
|
||||
import { OverlayHint, windowItems } from './overlayControls.js'
|
||||
|
||||
const VISIBLE = 10
|
||||
const MIN_WIDTH = 40
|
||||
const MAX_WIDTH = 90
|
||||
|
||||
interface GalleryPet {
|
||||
slug: string
|
||||
displayName: string
|
||||
installed: boolean
|
||||
curated?: boolean
|
||||
}
|
||||
|
||||
interface Gallery {
|
||||
enabled: boolean
|
||||
active: string
|
||||
pets: GalleryPet[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Interactive petdex picker overlay. Pulls the gallery via `pet.gallery`,
|
||||
* filters as you type, and adopts the highlighted pet with `pet.select`
|
||||
* (install-on-demand). The mascot lights up live once `usePet` next polls —
|
||||
* no restart. This is the interactive sibling of the text `/pet <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
|
||||
}
|
||||
93
ui-tui/src/components/petSprite.tsx
Normal file
93
ui-tui/src/components/petSprite.tsx
Normal file
@ -0,0 +1,93 @@
|
||||
import { Box, Text } from '@hermes/ink'
|
||||
import { memo } from 'react'
|
||||
|
||||
// A cell is [tr,tg,tb,ta, br,bg,bb,ba] — the top + bottom pixel of one
|
||||
// half-block, as produced by the `pet.cells` gateway RPC.
|
||||
export type PetCell = number[]
|
||||
export type PetGrid = PetCell[][]
|
||||
|
||||
const UPPER_HALF = '▀'
|
||||
const LOWER_HALF = '▄'
|
||||
|
||||
const hex = (r: number, g: number, b: number) =>
|
||||
`#${[r, g, b].map(v => Math.max(0, Math.min(255, v | 0)).toString(16).padStart(2, '0')).join('')}`
|
||||
|
||||
/**
|
||||
* Renders one petdex frame as truecolor half-blocks using native Ink color
|
||||
* props (no raw ANSI, so width measurement stays correct). The engine
|
||||
* (`agent/pet/render.py`) does the decode + downscale; this is a thin painter.
|
||||
*/
|
||||
export const PetSprite = memo(function PetSprite({ grid }: { grid: PetGrid }) {
|
||||
if (!grid.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
})
|
||||
5
ui-tui/src/types/hermes-ink.d.ts
vendored
5
ui-tui/src/types/hermes-ink.d.ts
vendored
@ -156,7 +156,10 @@ declare module '@hermes/ink' {
|
||||
readonly setSelectionBgColor: (color: string) => void
|
||||
}
|
||||
export function useHasSelection(): boolean
|
||||
export function useStdout(): { readonly stdout?: NodeJS.WriteStream }
|
||||
export function useStdout(): {
|
||||
readonly stdout?: NodeJS.WriteStream
|
||||
readonly write: (data: string) => boolean
|
||||
}
|
||||
export function useTerminalFocus(): boolean
|
||||
export function useTerminalTitle(title: string | null): void
|
||||
export function useDeclaredCursor(args: {
|
||||
|
||||
@ -125,6 +125,7 @@ If a skill is missing from this list but present in the repo, the catalog is reg
|
||||
| [`nano-pdf`](/docs/user-guide/skills/bundled/productivity/productivity-nano-pdf) | Edit PDF text/typos/titles via nano-pdf CLI (NL prompts). | `productivity/nano-pdf` |
|
||||
| [`notion`](/docs/user-guide/skills/bundled/productivity/productivity-notion) | Notion API + ntn CLI: pages, databases, markdown, Workers. | `productivity/notion` |
|
||||
| [`ocr-and-documents`](/docs/user-guide/skills/bundled/productivity/productivity-ocr-and-documents) | Extract text from PDFs/scans (pymupdf, marker-pdf). | `productivity/ocr-and-documents` |
|
||||
| [`petdex`](/docs/user-guide/skills/bundled/productivity/productivity-petdex) | Install and select animated petdex mascots for Hermes. | `productivity/petdex` |
|
||||
| [`powerpoint`](/docs/user-guide/skills/bundled/productivity/productivity-powerpoint) | Create, read, edit .pptx decks, slides, notes, templates. | `productivity/powerpoint` |
|
||||
| [`teams-meeting-pipeline`](/docs/user-guide/skills/bundled/productivity/productivity-teams-meeting-pipeline) | Operate the Teams meeting summary pipeline via Hermes CLI — summarize meetings, inspect pipeline status, replay jobs, manage Microsoft Graph subscriptions. | `productivity/teams-meeting-pipeline` |
|
||||
|
||||
|
||||
190
website/docs/user-guide/features/pets.md
Normal file
190
website/docs/user-guide/features/pets.md
Normal file
@ -0,0 +1,190 @@
|
||||
---
|
||||
sidebar_position: 11
|
||||
title: "Pets (Petdex Mascots)"
|
||||
description: "Adopt an animated mascot that reacts to agent activity across the CLI, TUI, and desktop app"
|
||||
---
|
||||
|
||||
# Pets
|
||||
|
||||
Hermes can show an animated **pet** — a small mascot sprite that reacts to what
|
||||
the agent is doing (idle, running a tool, thinking, finishing, failing) across
|
||||
the **CLI**, **TUI**, and **desktop app**. Pets come from the public
|
||||
[petdex](https://github.com/crafter-station/petdex) gallery.
|
||||
|
||||
Pets are purely cosmetic. They have **no effect on prompt caching, tokens, or
|
||||
the agent's behavior** — the sprite is a display concern only. The feature is
|
||||
**off by default** and stays dormant until you install and select a pet.
|
||||
|
||||
## How it works
|
||||
|
||||
- Pets are installed into your profile's `pets/` directory
|
||||
(`<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` |
|
||||
| Blocked on you (a clarify/approval prompt is open) | `waiting` (falls back to `idle` on legacy 8-row sheets) |
|
||||
| Nothing happening | `idle` |
|
||||
|
||||
## Rendering
|
||||
|
||||
In the terminal (CLI/TUI), Hermes renders the sprite at full fidelity when your
|
||||
terminal supports a graphics protocol (**kitty**, **Ghostty**, **WezTerm**,
|
||||
**iTerm2**, or **sixel**). Otherwise it falls back automatically to a truecolor
|
||||
Unicode **half-block** rendering. Inside a pipe or redirect (no TTY), terminal
|
||||
rendering is disabled by design.
|
||||
|
||||
The desktop app draws the pet as a floating sprite on a canvas and toggles it
|
||||
from **Settings → Appearance**.
|
||||
|
||||
## Quick start (CLI)
|
||||
|
||||
```bash
|
||||
# Browse the gallery (filter by substring)
|
||||
hermes pets list
|
||||
hermes pets list cat
|
||||
|
||||
# Install a pet and make it active in one step
|
||||
hermes pets install boba --select
|
||||
|
||||
# Preview / animate it in your terminal (Ctrl+C to stop)
|
||||
hermes pets show
|
||||
|
||||
# Check your setup
|
||||
hermes pets doctor
|
||||
```
|
||||
|
||||
## `hermes pets` commands
|
||||
|
||||
| Goal | Command |
|
||||
| --- | --- |
|
||||
| Browse the gallery | `hermes pets list [query] [--limit N]` |
|
||||
| List installed pets | `hermes pets list --installed` |
|
||||
| Install a pet | `hermes pets install <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.1–3.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.
|
||||
|
||||
### Pop-out overlay
|
||||
|
||||
**Shift-click** the floating pet to pop it out into its own transparent,
|
||||
always-on-top desktop window. Out there it stays visible while Hermes is
|
||||
minimized (Codex-style), so a glance tells you what the agent is doing.
|
||||
|
||||
Gestures once it's popped out:
|
||||
|
||||
| Gesture | Action |
|
||||
| --- | --- |
|
||||
| **Drag** | Move the pet anywhere on screen, even outside the app. Its spot and in/out state persist across restarts. |
|
||||
| **Single-click** | Open a mini composer to send a prompt to the most recent session — without surfacing the app. |
|
||||
| **Double-click** | Toggle the app window: minimize it if it's up front, restore it if it's hidden. |
|
||||
| **Shift-click** | Pop the pet back into the window. |
|
||||
| **Mail icon** | Appears only when a turn finished while you were away; click to raise the app on the most recent thread (and mark it read). |
|
||||
|
||||
Only the popped-out pet shows a **speech bubble** (`working…`, `thinking…`,
|
||||
`your turn`, …) — in-window the app itself is the surface, so the pet stays
|
||||
quiet there.
|
||||
|
||||
The overlay is a pure puppet of the in-app pet — it carries no separate gateway
|
||||
connection and never appears in the dock or app switcher.
|
||||
|
||||
## Configuration
|
||||
|
||||
All settings live under `display.pet` in `config.yaml`:
|
||||
|
||||
```yaml
|
||||
display:
|
||||
pet:
|
||||
enabled: false # master on/off (true once you select a pet)
|
||||
slug: "" # active pet; empty = first installed
|
||||
render_mode: auto # auto | kitty | iterm | sixel | unicode | off
|
||||
scale: 0.33 # master size knob (relative to native 192x208 frames)
|
||||
unicode_cols: 0 # hard override for terminal width (0 = derive from scale)
|
||||
```
|
||||
|
||||
- **`scale`** is the single master size knob. One number shrinks every surface:
|
||||
the desktop canvas scales its pixels by it, and the CLI/TUI derive their
|
||||
terminal column width from it. The half-block fallback clamps to a legibility
|
||||
floor — it can't shrink as far as true-pixel kitty/GUI rendering without
|
||||
turning to mush, so the same `scale` looks crisp under kitty but is floored in
|
||||
half-blocks.
|
||||
- **`render_mode: auto`** detects kitty/iTerm2/sixel and falls back to unicode
|
||||
half-blocks. Set it explicitly to force a protocol or `off` to disable
|
||||
terminal rendering while keeping the pet on the desktop.
|
||||
- **`unicode_cols`** pins the terminal column width independently of `scale`;
|
||||
leave it at `0` to derive width from `scale`.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
Run `hermes pets doctor` — it reports:
|
||||
|
||||
- the pets directory and which pets are installed,
|
||||
- `display.pet.enabled`, `display.pet.slug`, and the resolved active pet,
|
||||
- the configured `render_mode`, the detected terminal graphics protocol, and the
|
||||
effective mode for a TTY,
|
||||
- whether Pillow (used for sprite decoding) is importable.
|
||||
|
||||
It prints `✓ ready` once a pet is installed, selected, enabled, and Pillow is
|
||||
available.
|
||||
|
||||
Common gotchas:
|
||||
|
||||
- A pet only shows once one is **installed AND selected** (`enabled: true`).
|
||||
- Inside a pipe/redirect (no TTY), terminal rendering is disabled by design.
|
||||
- The petdex npm CLI installs to `~/.codex/pets`; Hermes uses its own
|
||||
profile-scoped `<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.
|
||||
@ -0,0 +1,105 @@
|
||||
---
|
||||
title: "Petdex — Install and select animated petdex mascots for Hermes"
|
||||
sidebar_label: "Petdex"
|
||||
description: "Install and select animated petdex mascots for Hermes"
|
||||
---
|
||||
|
||||
{/* This page is auto-generated from the skill's SKILL.md by website/scripts/generate-skill-docs.py. Edit the source SKILL.md, not this page. */}
|
||||
|
||||
# Petdex
|
||||
|
||||
Install and select animated petdex mascots for Hermes.
|
||||
|
||||
## Skill metadata
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Source | Bundled (installed by default) |
|
||||
| Path | `skills/productivity/petdex` |
|
||||
| Version | `1.0.0` |
|
||||
| Author | Hermes Agent |
|
||||
| License | MIT |
|
||||
| Platforms | linux, macos, windows |
|
||||
| Tags | `petdex`, `mascot`, `display`, `cli`, `tui`, `desktop` |
|
||||
|
||||
## Reference: full SKILL.md
|
||||
|
||||
:::info
|
||||
The following is the complete skill definition that Hermes loads when this skill is triggered. This is what the agent sees as instructions when the skill is active.
|
||||
:::
|
||||
|
||||
# Petdex Skill
|
||||
|
||||
Browse, install, and select animated "pet" mascots from the public
|
||||
[petdex](https://github.com/crafter-station/petdex) gallery. An installed pet
|
||||
reacts to agent activity (idle, running a tool, reviewing, error, done) across
|
||||
the Hermes CLI, TUI, and desktop app. This skill drives the `hermes pets` CLI
|
||||
and the `display.pet` config — it does not generate sprites.
|
||||
|
||||
## When to Use
|
||||
|
||||
- The user wants a desktop/terminal mascot or asks about "pets" / petdex.
|
||||
- The user wants to change, preview, or disable the active pet.
|
||||
- Diagnosing why a pet isn't showing (terminal graphics support, config).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Network access to `petdex.dev` for the gallery/manifest (read-only, no auth).
|
||||
- Pillow (a core Hermes dependency) for sprite decoding — already installed.
|
||||
- For full-fidelity terminal rendering: a graphics-capable terminal (kitty,
|
||||
Ghostty, WezTerm, iTerm2, or sixel). Otherwise a truecolor Unicode
|
||||
half-block fallback is used automatically.
|
||||
|
||||
## How to Run
|
||||
|
||||
Use the `terminal` tool to run `hermes pets <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.1–3.0) |
|
||||
| Preview/animate in terminal | `hermes pets show [slug] [--cycle] [--state run]` |
|
||||
| Disable the pet | `hermes pets off` |
|
||||
| Remove a pet | `hermes pets remove <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.1–3.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.
|
||||
@ -102,6 +102,7 @@ const sidebars: SidebarsConfig = {
|
||||
'user-guide/features/vision',
|
||||
'user-guide/features/image-generation',
|
||||
'user-guide/features/spotify',
|
||||
'user-guide/features/pets',
|
||||
'user-guide/features/tts',
|
||||
'user-guide/features/deliverable-mode',
|
||||
],
|
||||
@ -277,6 +278,7 @@ const sidebars: SidebarsConfig = {
|
||||
'user-guide/skills/bundled/productivity/productivity-nano-pdf',
|
||||
'user-guide/skills/bundled/productivity/productivity-notion',
|
||||
'user-guide/skills/bundled/productivity/productivity-ocr-and-documents',
|
||||
'user-guide/skills/bundled/productivity/productivity-petdex',
|
||||
'user-guide/skills/bundled/productivity/productivity-powerpoint',
|
||||
'user-guide/skills/bundled/productivity/productivity-teams-meeting-pipeline',
|
||||
],
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user