feat(pets): make display.pet.scale the single master size knob

One scalar now shrinks every surface together. The desktop canvas already
multiplied native pixels by scale; the CLI/TUI now derive their terminal
width from it via constants.resolve_cols() instead of a separate pinned
unicode_cols (which is now an optional override, default 0 = auto).

Two sizing bugs fixed along the way:
- kitty placement sized its c×r cell box from a native-aspect column count,
  so small pets got upscaled ~2× to fill it. It now derives the box from the
  scaled frame pixels (_cell_box, shared with the half-block frame() path),
  so kitty tracks scale like the GUI does.
- half-blocks can't follow scale all the way down — a cell samples the sprite
  at 1 horizontal + 2 vertical taps, so a tiny width turns the pet to mush.
  cols_for_scale() clamps to a legibility floor (UNICODE_MIN_COLS) and only
  grows above it, while kitty/GUI keep shrinking on true pixels.

Default scale lowered 0.7 -> 0.33 (glanceable corner sprite); the 0.7 literal
fallbacks in pets.py and the desktop sprite now reference the shared default.
This commit is contained in:
Brooklyn Nicholson 2026-06-15 01:49:27 -05:00
parent fdcfa44584
commit c176054c33
8 changed files with 101 additions and 34 deletions

View File

@ -24,9 +24,43 @@ FRAMES_PER_STATE = 6
# Full-loop duration for one state, milliseconds (petdex default).
LOOP_MS = 1100
# Default on-screen scale relative to native frame size (petdex desktop uses
# 0.7). Surfaces may override via ``display.pet.scale``.
DEFAULT_SCALE = 0.7
# 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
# 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):

View File

@ -489,27 +489,29 @@ class PetRenderer:
frame = frames[index % len(frames)]
return _downscale_cells(frame, target_cols=cols or self.unicode_cols)
def kitty_cell_rows(self, cols: int) -> int:
"""Cell height that mirrors the half-block footprint for *cols* wide.
def _cell_box(self, frame) -> tuple[int, int]:
"""Terminal cell box for a scaled frame (~8×16 px per cell).
Keeps the kitty image and the unicode fallback occupying the same area
so swapping renderers doesn't shift the layout.
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).
"""
aspect = self.frame_h / max(1, self.frame_w)
return max(1, round(cols * aspect * 0.5))
return max(1, frame.width // 8), max(1, frame.height // 16)
def kitty_payload(self, state: PetState | str, *, cols: int, image_id: int) -> dict | None:
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.
``None`` when no frame is available.
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
rows = self.kitty_cell_rows(cols)
cols, rows = self._cell_box(frames[0])
return {
"cols": cols,
"rows": rows,
@ -531,11 +533,7 @@ class PetRenderer:
if not frames:
return ""
frame = frames[index % len(frames)]
# Display box in cells for graphics protocols (≈ scaled px / cell size,
# assuming a ~8×16 cell; terminals re-fit anyway).
cell_cols = max(1, frame.width // 8)
cell_rows = max(1, frame.height // 16)
cell_cols, cell_rows = self._cell_box(frame)
try:
if self.mode == "kitty":

View File

@ -6,6 +6,9 @@ const DEFAULT_FRAME_W = 192
const DEFAULT_FRAME_H = 208
const DEFAULT_FRAMES = 6
const DEFAULT_LOOP_MS = 1100
// Mirrors agent.pet.constants.DEFAULT_SCALE — fallback only; the gateway sends
// the configured scale.
const DEFAULT_SCALE = 0.33
const DEFAULT_STATE_ROWS = ['idle', 'wave', 'run', 'failed', 'review', 'jump', 'extra1', 'extra2']
interface PetSpriteProps {
@ -33,7 +36,7 @@ function PetSpriteImpl({ info, zoom = 1 }: PetSpriteProps) {
const frameH = info.frameH ?? DEFAULT_FRAME_H
const frames = info.framesPerState ?? DEFAULT_FRAMES
const loopMs = info.loopMs ?? DEFAULT_LOOP_MS
const scale = (info.scale ?? 0.7) * zoom
const scale = (info.scale ?? DEFAULT_SCALE) * zoom
const rows = info.stateRows ?? DEFAULT_STATE_ROWS
const drawW = Math.round(frameW * scale)

2
cli.py
View File

@ -4150,8 +4150,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
enabled = bool(pet_cfg.get("enabled"))
slug = str(pet_cfg.get("slug", "") or "")
cols = int(pet_cfg.get("unicode_cols", 18) or 18)
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:

View File

@ -1531,10 +1531,16 @@ DEFAULT_CONFIG = {
# auto — detect kitty/iTerm2/sixel, else unicode half-blocks
# kitty | iterm | sixel | unicode | off
"render_mode": "auto",
# On-screen scale relative to native 192×208 frames (petdex uses 0.7).
"scale": 0.7,
# Width in terminal columns for the unicode half-block fallback.
"unicode_cols": 18,
# 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,
},
},

View File

@ -137,7 +137,7 @@ def _cmd_show(args) -> int:
import time
from agent.pet import store
from agent.pet.constants import LOOP_MS, STATE_ROWS, PetState
from agent.pet.constants import DEFAULT_SCALE, LOOP_MS, STATE_ROWS, PetState, resolve_cols
from agent.pet.render import build_renderer
cfg = _pet_config()
@ -148,8 +148,8 @@ def _cmd_show(args) -> int:
return 1
mode_cfg = getattr(args, "mode", None) or str(cfg.get("render_mode", "auto") or "auto")
scale = float(getattr(args, "scale", 0) or cfg.get("scale", 0.7) or 0.7)
cols = int(cfg.get("unicode_cols", 18) or 18)
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,

View File

@ -48,6 +48,26 @@ def test_state_row_index_maps_to_taxonomy():
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
# ─────────────────────────────────────────────────────────────────────────
@ -185,11 +205,16 @@ def test_kitty_placeholder_rows_grid_contract():
def test_kitty_payload_structure(boba_like):
sprite = store.load_pet("boba").spritesheet
image_id = render.kitty_image_id("boba")
r = render.PetRenderer(str(sprite), mode="kitty", scale=0.4, unicode_cols=18)
payload = r.kitty_payload("run", cols=18, image_id=image_id)
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
assert payload["cols"] == 18
assert payload["rows"] == r.kitty_cell_rows(18) >= 1
# 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
@ -204,7 +229,7 @@ def test_kitty_payload_structure(boba_like):
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", cols=18, image_id=1) is None
assert r.kitty_payload("idle", image_id=1) is None
def test_off_mode_and_missing_sheet_degrade(tmp_path):

View File

@ -4902,8 +4902,8 @@ def _(rid, params: dict) -> dict:
return _ok(rid, {"enabled": False})
state = str(params.get("state") or constants.PetState.IDLE.value)
cols = int(params.get("cols") or pet_cfg.get("unicode_cols", 18) or 18)
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-
@ -4917,9 +4917,10 @@ def _(rid, params: dict) -> dict:
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, unicode_cols=cols
).kitty_payload(state, cols=cols, image_id=image_id)
str(pet.spritesheet), mode="kitty", scale=scale
).kitty_payload(state, image_id=image_id)
if payload:
kcount = len(payload["frames"]) or 1
return _ok(