feat(pets): gateway pet.scale RPC + per-state frames; TUI live rescale

pet.info now ships framesByState; pet.cells returns the active scale. New
pet.scale RPC persists display.pet.scale for the desktop slider. The TUI busts
its frame cache when the scale changes on its existing poll — no new polling.
This commit is contained in:
Brooklyn Nicholson 2026-06-16 18:35:25 -05:00
commit eceb91cffd
2 changed files with 45 additions and 1 deletions

View file

@ -4993,6 +4993,20 @@ def _(rid, params: dict) -> dict:
return _ok(rid, usage)
def _pet_frame_counts(spritesheet) -> dict:
"""Real (padding-trimmed) frame count per state, for the desktop canvas.
Fail-open: a decode hiccup returns ``{}`` and the canvas falls back to its
static ``framesPerState`` rather than breaking the (cosmetic) pet.
"""
try:
from agent.pet import render
return render.state_frame_counts(str(spritesheet))
except Exception: # noqa: BLE001 - cosmetic, never break the surface
return {}
@method("pet.info")
def _(rid, params: dict) -> dict:
"""Return the active petdex pet for surfaces that render sprites.
@ -5041,6 +5055,7 @@ def _(rid, params: dict) -> dict:
"frameW": constants.FRAME_W,
"frameH": constants.FRAME_H,
"framesPerState": constants.FRAMES_PER_STATE,
"framesByState": _pet_frame_counts(pet.spritesheet),
"loopMs": constants.LOOP_MS,
"scale": float(pet_cfg.get("scale", constants.DEFAULT_SCALE) or constants.DEFAULT_SCALE),
"stateRows": list(constants.STATE_ROWS),
@ -5119,6 +5134,7 @@ def _(rid, params: dict) -> dict:
"placeholder": payload["placeholder"],
"frames": payload["frames"],
"frameMs": constants.LOOP_MS / max(1, kcount),
"scale": scale,
},
)
@ -5146,6 +5162,7 @@ def _(rid, params: dict) -> dict:
"cols": cols,
"frameMs": constants.LOOP_MS / max(1, count),
"frames": frames,
"scale": scale,
},
)
except Exception as exc: # noqa: BLE001
@ -5320,6 +5337,26 @@ def _(rid, params: dict) -> dict:
return _err(rid, 5031, f"pet.disable failed: {exc}")
@method("pet.scale")
def _(rid, params: dict) -> dict:
"""Persist ``display.pet.scale`` from the desktop slider. Params: ``scale``.
Clamped to the engine bounds. The renderer updates its own ``$petInfo`` for
instant feedback; this just makes the change durable + visible to the other
terminal surfaces on their next read.
"""
try:
from hermes_cli.pets import set_pet_scale
scale, err = set_pet_scale(params.get("scale"))
if err:
return _err(rid, 4004, err)
return _ok(rid, {"ok": True, "scale": scale})
except Exception as exc: # noqa: BLE001
logger.debug("pet.scale failed: %s", exc)
return _err(rid, 5031, f"pet.scale failed: {exc}")
@method("credits.view")
def _(rid, params: dict) -> dict:
"""Structured Nous credit view for the TUI /credits command.

View file

@ -53,6 +53,7 @@ interface PetCellsResult {
graphics?: string
imageId?: number
placeholder?: string[]
scale?: number
slug?: string
state?: string
}
@ -98,6 +99,7 @@ export function usePet(): PetRender {
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)
@ -185,10 +187,15 @@ export function usePet(): PetRender {
}
const slug = res.slug ?? ''
const scale = res.scale ?? 0
if (slug !== slugRef.current) {
// 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
}