feat(pets): crisp Kitty images in the TUI + reactive pet pane in the base CLI
The TUI now renders pets through Kitty's Unicode-placeholder protocol on Kitty/Ghostty: the image transmits once per frame under a stable id and the static placeholder grid (U+10EEEE + diacritics, image id in the fg color) animates underneath without Ink ever repainting. Only Kitty is grid-safe in Ink, so iTerm/Sixel/tmux/dashboard keep the half-block fallback. The base CLI gains parity with the TUI's PetPane: a right-aligned half-block sprite above the prompt, reactive to agent activity and animated by an invalidate timer. Half-blocks only — raw image escapes can't survive prompt_toolkit's patch_stdout output layer. Also: right-align the pet in the TUI (justifyContent) and in `hermes pets show` (graphics path), and render lone-opaque half-blocks fg-only so transparent sprite edges stop painting black boxes.
This commit is contained in:
parent
2572617d5a
commit
fdcfa44584
@ -178,33 +178,131 @@ def _png_bytes(frame) -> bytes:
|
||||
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).
|
||||
|
||||
Splits the base64 PNG into ≤4096-byte chunks per the protocol, using
|
||||
``a=T`` (transmit & display at the cursor). ``c``/``r`` request a display
|
||||
``a=T`` transmits & displays at the cursor; ``c``/``r`` request a display
|
||||
box in terminal cells so successive frames overwrite the same area.
|
||||
"""
|
||||
data = base64.standard_b64encode(_png_bytes(frame)).decode("ascii")
|
||||
extra = "f=100,a=T,q=2"
|
||||
ctrl = "f=100,a=T,q=2"
|
||||
if cell_cols:
|
||||
extra += f",c={cell_cols}"
|
||||
ctrl += f",c={cell_cols}"
|
||||
if cell_rows:
|
||||
extra += f",r={cell_rows}"
|
||||
ctrl += f",r={cell_rows}"
|
||||
return _kitty_apc(ctrl, base64.standard_b64encode(_png_bytes(frame)).decode("ascii"))
|
||||
|
||||
chunk = 4096
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# 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] = []
|
||||
if len(data) <= chunk:
|
||||
out.append(f"\x1b_G{extra},m=0;{data}\x1b\\")
|
||||
else:
|
||||
first = data[:chunk]
|
||||
out.append(f"\x1b_G{extra},m=1;{first}\x1b\\")
|
||||
rest = data[chunk:]
|
||||
while rest:
|
||||
piece, rest = rest[:chunk], rest[chunk:]
|
||||
more = 1 if rest else 0
|
||||
out.append(f"\x1b_Gm={more};{piece}\x1b\\")
|
||||
return "".join(out)
|
||||
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:
|
||||
@ -391,6 +489,36 @@ 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.
|
||||
|
||||
Keeps the kitty image and the unicode fallback occupying the same area
|
||||
so swapping renderers doesn't shift the layout.
|
||||
"""
|
||||
aspect = self.frame_h / max(1, self.frame_w)
|
||||
return max(1, round(cols * aspect * 0.5))
|
||||
|
||||
def kitty_payload(self, state: PetState | str, *, cols: int, 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.
|
||||
"""
|
||||
frames = self._frames(state)
|
||||
if not frames:
|
||||
return None
|
||||
rows = self.kitty_cell_rows(cols)
|
||||
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 ``""``.
|
||||
|
||||
|
||||
197
cli.py
197
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
|
||||
@ -3567,6 +3567,20 @@ 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
|
||||
self._attached_images: list[Path] = []
|
||||
self._image_counter = 0
|
||||
self.preloaded_skills: list[str] = []
|
||||
@ -4107,6 +4121,173 @@ 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 "")
|
||||
cols = int(pet_cfg.get("unicode_cols", 18) or 18)
|
||||
scale = float(pet_cfg.get("scale", constants.DEFAULT_SCALE) or constants.DEFAULT_SCALE)
|
||||
|
||||
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 _derive_pet_state(self) -> str:
|
||||
"""Map current CLI activity to a pet animation state (mirrors the TUI)."""
|
||||
if getattr(self, "_agent_running", False):
|
||||
return "run"
|
||||
return "idle"
|
||||
|
||||
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.
|
||||
|
||||
@ -10917,6 +11098,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,
|
||||
@ -12258,6 +12440,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,
|
||||
@ -13270,6 +13462,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
|
||||
@ -13296,6 +13490,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
|
||||
|
||||
@ -176,13 +176,19 @@ def _cmd_show(args) -> int:
|
||||
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 half-block sprite against the terminal's right edge.
|
||||
# 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:
|
||||
term_cols = shutil.get_terminal_size((80, 24)).columns
|
||||
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
|
||||
@ -209,6 +215,8 @@ def _cmd_show(args) -> int:
|
||||
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()
|
||||
|
||||
@ -147,6 +147,66 @@ def test_cells_grid_shape(boba_like):
|
||||
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")
|
||||
r = render.PetRenderer(str(sprite), mode="kitty", scale=0.4, unicode_cols=18)
|
||||
payload = r.kitty_payload("run", cols=18, image_id=image_id)
|
||||
assert payload is not None
|
||||
assert payload["cols"] == 18
|
||||
assert payload["rows"] == r.kitty_cell_rows(18) >= 1
|
||||
# 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", cols=18, 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")
|
||||
|
||||
117
tests/cli/test_cli_pet_pane.py
Normal file
117
tests/cli/test_cli_pet_pane.py
Normal file
@ -0,0 +1,117 @@
|
||||
"""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
|
||||
return cli_obj
|
||||
|
||||
|
||||
def test_pet_state_tracks_agent_running():
|
||||
cli_obj = _make_cli()
|
||||
assert cli_obj._derive_pet_state() == "idle"
|
||||
cli_obj._agent_running = True
|
||||
assert cli_obj._derive_pet_state() == "run"
|
||||
|
||||
|
||||
def test_pet_pane_collapsed_when_disabled():
|
||||
# No renderer resolved → the window reports zero height and no fragments,
|
||||
# so it's invisible for users without a pet.
|
||||
cli_obj = _make_cli()
|
||||
assert cli_obj._pet_widget_height() == 0
|
||||
assert cli_obj._pet_fragments() == []
|
||||
|
||||
|
||||
def test_pet_fragments_render_half_blocks(boba_like):
|
||||
cli_obj = _make_cli()
|
||||
cli_obj._pet_renderer = PetRenderer(
|
||||
str(store.load_pet("boba").spritesheet), mode="unicode", scale=0.4, unicode_cols=14
|
||||
)
|
||||
cli_obj._pet_cols = 14
|
||||
cli_obj._pet_enabled = True
|
||||
|
||||
height = cli_obj._pet_widget_height()
|
||||
assert height > 0
|
||||
|
||||
frags = cli_obj._pet_fragments()
|
||||
assert frags, "expected fragments for an enabled pet"
|
||||
# Each fragment is a (style, text) pair; glyphs are half-blocks or blanks.
|
||||
glyphs = {text for _, text in frags}
|
||||
assert glyphs <= {"▀", "▄", " ", "\n"}
|
||||
# Opaque cells carry a truecolor foreground style.
|
||||
assert any(text == "▀" and "fg:#" in style for style, text in frags)
|
||||
# Row count in the fragment stream matches the reported window height.
|
||||
assert sum(1 for _, text in frags if text == "\n") == height - 1
|
||||
|
||||
|
||||
def test_pet_resolve_config_enables_and_disables(boba_like):
|
||||
from hermes_cli.config import load_config, save_config
|
||||
|
||||
cli_obj = _make_cli()
|
||||
|
||||
cfg = load_config()
|
||||
cfg.setdefault("display", {}).setdefault("pet", {})
|
||||
cfg["display"]["pet"].update({"enabled": True, "slug": "boba"})
|
||||
save_config(cfg)
|
||||
|
||||
cli_obj._pet_resolve_config()
|
||||
assert cli_obj._pet_enabled is True
|
||||
assert cli_obj._pet_renderer is not None
|
||||
assert cli_obj._pet_slug == "boba"
|
||||
|
||||
cfg["display"]["pet"]["enabled"] = False
|
||||
save_config(cfg)
|
||||
cli_obj._pet_resolve_config()
|
||||
assert cli_obj._pet_enabled is False
|
||||
assert cli_obj._pet_renderer is None
|
||||
@ -176,9 +176,11 @@ _LONG_HANDLERS = frozenset(
|
||||
{
|
||||
"browser.manage",
|
||||
"cli.exec",
|
||||
# Pet RPCs hit the network (manifest fetch / spritesheet download): inline
|
||||
# they serialize on the reader thread, so picker previews trickle in one at
|
||||
# a time. On the pool they fetch concurrently and stop blocking other RPCs.
|
||||
# Pet RPCs hit the network (manifest fetch / spritesheet download) or do
|
||||
# per-frame PNG decode/encode (pet.cells): inline they serialize on the
|
||||
# reader thread, so picker previews trickle in one at a time and the
|
||||
# animation poll stutters. On the pool they run concurrently.
|
||||
"pet.cells",
|
||||
"pet.gallery",
|
||||
"pet.select",
|
||||
"pet.thumb",
|
||||
@ -4880,7 +4882,7 @@ def _(rid, params: dict) -> dict:
|
||||
Fail-open: ``enabled=False`` on any problem.
|
||||
"""
|
||||
try:
|
||||
from agent.pet import constants, store
|
||||
from agent.pet import constants, render, store
|
||||
from agent.pet.render import PetRenderer
|
||||
|
||||
try:
|
||||
@ -4901,11 +4903,47 @@ def _(rid, params: dict) -> dict:
|
||||
|
||||
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)
|
||||
|
||||
# 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)
|
||||
payload = PetRenderer(
|
||||
str(pet.spritesheet), mode="kitty", scale=scale, unicode_cols=cols
|
||||
).kitty_payload(state, cols=cols, 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),
|
||||
},
|
||||
)
|
||||
|
||||
renderer = PetRenderer(
|
||||
str(pet.spritesheet),
|
||||
mode="unicode",
|
||||
scale=float(pet_cfg.get("scale", constants.DEFAULT_SCALE) or constants.DEFAULT_SCALE),
|
||||
scale=scale,
|
||||
unicode_cols=cols,
|
||||
)
|
||||
count = renderer.frame_count(state) or 1
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { useStdout } from '@hermes/ink'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import type { PetGrid } from '../components/petSprite.js'
|
||||
@ -34,35 +35,69 @@ export function derivePetState({ busy, toolRunning, reasoning }: PetActivity): P
|
||||
return 'idle'
|
||||
}
|
||||
|
||||
// A kitty Unicode-placeholder frame set: a static placeholder grid (painted by
|
||||
// Ink in the image-id color) plus per-frame transmit escapes written straight
|
||||
// to the terminal out-of-band.
|
||||
interface KittyView {
|
||||
color: string
|
||||
placeholder: string[]
|
||||
}
|
||||
|
||||
interface PetCellsResult {
|
||||
color?: string
|
||||
enabled?: boolean
|
||||
frameMs?: number
|
||||
frames?: PetGrid[]
|
||||
// unicode mode: cell grids; kitty mode: transmit-escape strings.
|
||||
frames?: PetGrid[] | string[]
|
||||
graphics?: string
|
||||
imageId?: number
|
||||
placeholder?: string[]
|
||||
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: derives the live state from the turn/ui stores, fetches
|
||||
* each (slug, state)'s half-block frames via the `pet.cells` RPC (cached), and
|
||||
* animates the frame index. Returns the grid to paint, or null when no pet is
|
||||
* enabled/installed.
|
||||
* Drives the TUI pet. Fetches each (slug, state)'s frames via the `pet.cells`
|
||||
* RPC (cached) and animates the frame index. Two render paths:
|
||||
*
|
||||
* A steady `pet.cells` poll keeps it reactive to config changes made elsewhere
|
||||
* — `/pet`, the picker, `hermes pets select` — so adopting, switching, or
|
||||
* disabling a pet takes effect live (no restart). The frame cache is keyed by
|
||||
* slug so a switch re-pulls the new sprite instead of showing the old one.
|
||||
* - **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(): { enabled: boolean; grid: PetGrid | null } {
|
||||
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, { frameMs: number; frames: PetGrid[] }>>(new Map())
|
||||
const cache = useRef<Map<string, CacheEntry>>(new Map())
|
||||
const slugRef = useRef('')
|
||||
const imageIdRef = useRef(0)
|
||||
const stateRef = useRef<PetState>('idle')
|
||||
const frameRef = useRef(0)
|
||||
|
||||
@ -97,22 +132,36 @@ export function usePet(): { enabled: boolean; grid: PetGrid | null } {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 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: a changed slug
|
||||
// invalidates the cache, a disabled pet clears everything.
|
||||
// config, so its `slug`/`enabled` are the source of truth.
|
||||
const sync = useCallback(
|
||||
async (state: PetState) => {
|
||||
try {
|
||||
const res = (await rpc('pet.cells', { state })) as PetCellsResult | null
|
||||
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
|
||||
@ -121,13 +170,27 @@ export function usePet(): { enabled: boolean; grid: PetGrid | null } {
|
||||
const slug = res.slug ?? ''
|
||||
|
||||
if (slug !== slugRef.current) {
|
||||
releaseKitty()
|
||||
slugRef.current = slug
|
||||
cache.current.clear()
|
||||
frameRef.current = 0
|
||||
}
|
||||
|
||||
if (res.frames?.length) {
|
||||
cache.current.set(`${slug}:${state}`, { frameMs: res.frameMs ?? FRAME_MS, frames: res.frames })
|
||||
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)
|
||||
@ -135,7 +198,7 @@ export function usePet(): { enabled: boolean; grid: PetGrid | null } {
|
||||
// cosmetic — ignore RPC failures
|
||||
}
|
||||
},
|
||||
[rpc]
|
||||
[rpc, releaseKitty]
|
||||
)
|
||||
|
||||
// Pull frames whenever the state changes (if not already cached for the
|
||||
@ -150,6 +213,8 @@ export function usePet(): { enabled: boolean; grid: PetGrid | null } {
|
||||
return () => clearInterval(timer)
|
||||
}, [petState, sync])
|
||||
|
||||
useEffect(() => releaseKitty, [releaseKitty])
|
||||
|
||||
// Animation timer.
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
@ -164,15 +229,36 @@ export function usePet(): { enabled: boolean; grid: PetGrid | null } {
|
||||
}
|
||||
|
||||
const idx = frameRef.current % entry.frames.length
|
||||
setGrid(entry.frames[idx] ?? null)
|
||||
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])
|
||||
}, [enabled, petState, write])
|
||||
|
||||
return { enabled, grid }
|
||||
return { enabled, grid, kitty }
|
||||
}
|
||||
|
||||
@ -26,7 +26,7 @@ import { Banner, Panel, SessionPanel } from './branding.js'
|
||||
import { FpsOverlay } from './fpsOverlay.js'
|
||||
import { HelpHint } from './helpHint.js'
|
||||
import { MessageLine } from './messageLine.js'
|
||||
import { PetSprite } from './petSprite.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'
|
||||
@ -35,15 +35,16 @@ import { TextInput, type TextInputMouseApi } from './textInput.js'
|
||||
// nothing unless a pet is installed + enabled (`hermes pets select <slug>`),
|
||||
// so it's a no-op for everyone else.
|
||||
const PetPane = memo(function PetPane() {
|
||||
const { enabled, grid } = usePet()
|
||||
const { enabled, grid, kitty } = usePet()
|
||||
|
||||
if (!enabled || !grid) {
|
||||
if (!enabled || (!grid && !kitty)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<NoSelect alignItems="flex-end" flexShrink={0} paddingX={1} width="100%">
|
||||
<PetSprite grid={grid} />
|
||||
<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>
|
||||
)
|
||||
})
|
||||
|
||||
@ -6,7 +6,8 @@ import { memo } from 'react'
|
||||
export type PetCell = number[]
|
||||
export type PetGrid = PetCell[][]
|
||||
|
||||
const HALF_BLOCK = '▀'
|
||||
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('')}`
|
||||
@ -27,12 +28,31 @@ export const PetSprite = memo(function PetSprite({ grid }: { grid: PetGrid }) {
|
||||
<Box key={y}>
|
||||
{row.map((cell, x) => {
|
||||
const [tr, tg, tb, ta, br, bg, bb, ba] = cell
|
||||
if ((ta ?? 0) < 32 && (ba ?? 0) < 32) {
|
||||
const top = (ta ?? 0) >= 32
|
||||
const bot = (ba ?? 0) >= 32
|
||||
|
||||
if (!top && !bot) {
|
||||
return <Text key={x}> </Text>
|
||||
}
|
||||
return (
|
||||
<Text backgroundColor={hex(br, bg, bb)} color={hex(tr, tg, tb)} key={x}>
|
||||
{HALF_BLOCK}
|
||||
|
||||
// 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>
|
||||
)
|
||||
})}
|
||||
@ -41,3 +61,33 @@ export const PetSprite = memo(function PetSprite({ grid }: { grid: PetGrid }) {
|
||||
</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: {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user