From af35ae3c46ef569e8437b782ff185698858933c1 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 30 Jun 2026 15:41:44 -0500 Subject: [PATCH 1/3] fix(pet): snap kitty frames to whole cells MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit kitty fits an image to its cell rect preserving aspect, so a frame whose pixel size isn't a whole multiple of the cell rounds up — clipping the bottom row ("clipped feet") and letterboxing a blank row. Trim each frame to its union alpha bbox, then snap to an exact cell multiple before transmit so the sprite hugs its box and renders full-body. (ratatui-image#57: render in multiples of the font-size.) --- agent/pet/render.py | 64 ++++++++++++++++++++++++++++++++++ tests/agent/test_pet_engine.py | 24 +++++++++---- 2 files changed, 82 insertions(+), 6 deletions(-) diff --git a/agent/pet/render.py b/agent/pet/render.py index 1618c0751..f7d026f04 100644 --- a/agent/pet/render.py +++ b/agent/pet/render.py @@ -230,6 +230,68 @@ def _png_bytes(frame) -> bytes: return buf.getvalue() +def _union_alpha_bbox(frames) -> tuple[int, int, int, int] | None: + """Union opaque-pixel bbox across *frames* (a stable trim for animation).""" + left = top = right = bottom = None + for frame in frames: + try: + bbox = frame.getchannel("A").getbbox() + except Exception: # noqa: BLE001 - cosmetic; fail open + bbox = None + if not bbox: + continue + l, t, r, b = bbox + left = l if left is None else min(left, l) + top = t if top is None else min(top, t) + right = r if right is None else max(right, r) + bottom = b if bottom is None else max(bottom, b) + if left is None or top is None or right is None or bottom is None: + return None + return (left, top, right, bottom) + + +def _crop_frames_to_alpha_union(frames): + """Crop every frame to the union opaque bbox so the sprite hugs its box. + + kitty paints the whole transmitted rectangle, transparent margins included, + which makes the visible pet look small and adrift inside a larger cell box. + Trimming to the visible bounds keeps the pet tight in its corner. + """ + bbox = _union_alpha_bbox(frames) + if not bbox: + return frames + return [f.crop(bbox) for f in frames] + + +# Nominal terminal cell size in pixels. kitty fits an image to its cell +# rectangle preserving aspect, so a frame whose pixel size isn't a whole +# multiple of the cell rounds up — which makes the terminal clip the bottom row +# (the "clipped feet") and letterbox a blank row. Snapping each frame to an +# exact cell multiple avoids that. (See ratatui-image #57: "render in multiples +# of the font-size, to avoid stale character artifacts.") +_CELL_W = 8 +_CELL_H = 16 + + +def _snap_frames_to_cell_grid(frames): + """Resize frames so width/height are exact multiples of the cell box. + + Removes the sub-cell remainder kitty would otherwise round up + clip. All + frames share the union-cropped size, so they snap to the same cell grid. + """ + if not frames: + return frames + from PIL import Image + + w, h = frames[0].size + cols = max(1, round(w / _CELL_W)) + rows = max(1, round(h / _CELL_H)) + target = (cols * _CELL_W, rows * _CELL_H) + if (w, h) == target: + return frames + return [f.resize(target, Image.LANCZOS) for f in frames] + + def _kitty_apc(ctrl: str, data: str) -> str: """Emit a kitty APC escape for *data*, chunked into ≤4096-byte ``m`` pieces.""" chunk = 4096 @@ -563,6 +625,8 @@ class PetRenderer: frames = self._frames(state) if not frames: return None + frames = _crop_frames_to_alpha_union(frames) + frames = _snap_frames_to_cell_grid(frames) cols, rows = self._cell_box(frames[0]) return { "cols": cols, diff --git a/tests/agent/test_pet_engine.py b/tests/agent/test_pet_engine.py index e78161341..db61a40d3 100644 --- a/tests/agent/test_pet_engine.py +++ b/tests/agent/test_pet_engine.py @@ -300,12 +300,9 @@ def test_kitty_payload_structure(boba_like): 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 + # Geometry is driven by the scaled/cropped sprite, not unicode_cols. + assert payload["cols"] >= 1 and payload["rows"] >= 1 + assert payload["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 @@ -318,6 +315,21 @@ def test_kitty_payload_structure(boba_like): assert f"c={payload['cols']}" in esc and f"r={payload['rows']}" in esc +def test_kitty_payload_snaps_to_whole_cells(boba_like): + # The transmitted frame must be an exact multiple of the cell box so kitty + # doesn't round up + clip the bottom row / letterbox a blank row (the + # "clipped feet" bug). cols/rows are derived as pixels // cell, so a snapped + # frame round-trips exactly. Regression for ratatui-image #57. + sprite = store.load_pet("boba").spritesheet + r = render.PetRenderer(str(sprite), mode="kitty", scale=0.6, unicode_cols=18) + frames = render._snap_frames_to_cell_grid( + render._crop_frames_to_alpha_union(r._frames("run")) + ) + for f in frames: + assert f.width % render._CELL_W == 0 + assert f.height % render._CELL_H == 0 + + 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 From c874999bc5acc240119865b478e36aca82d2c9d6 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 30 Jun 2026 15:41:44 -0500 Subject: [PATCH 2/3] feat(tui): add $petBox store for the pet's footprint Publishes the floating pet's width/height in cells so the transcript can keep its text clear of it without the pet knowing anything about layout. --- ui-tui/src/app/petFlashStore.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/ui-tui/src/app/petFlashStore.ts b/ui-tui/src/app/petFlashStore.ts index 328fcfc8b..b1ecf97e9 100644 --- a/ui-tui/src/app/petFlashStore.ts +++ b/ui-tui/src/app/petFlashStore.ts @@ -13,3 +13,9 @@ interface PetFlash { export const $petFlash = atom(null) export const flashPet = (state: PetState, ms = 1600) => $petFlash.set({ state, until: Date.now() + ms }) + +// The floating pet's footprint, or null when no pet is shown. The transcript +// keeps its text clear of the pet responsively: on wide terminals it reserves a +// right gutter (`width`) so lines wrap to the pet's LEFT; on narrow terminals it +// reserves bottom rows (`height`) so lines stay full-width and sit ABOVE it. +export const $petBox = atom<{ width: number; height: number } | null>(null) From e96d2871bc3f3f6201efecef094b01410eab00c3 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 30 Jun 2026 15:41:44 -0500 Subject: [PATCH 3/3] feat(tui): float petdex pet bottom-right with responsive text reservation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Render the pet as an absolute overlay riding the bottom-right corner (just above the status bar) instead of a full-width band that ate a whole row. It reserves no layout rows; the transcript keeps its text clear of it responsively — a right gutter on wide terminals (lines wrap to the pet's left) collapsing to reserved bottom rows on narrow ones (full-width lines sit above it). --- ui-tui/src/__tests__/petPane.test.tsx | 90 +++++++++++++++++++++++++++ ui-tui/src/components/appLayout.tsx | 86 +++++++++++++++++++++---- 2 files changed, 164 insertions(+), 12 deletions(-) create mode 100644 ui-tui/src/__tests__/petPane.test.tsx diff --git a/ui-tui/src/__tests__/petPane.test.tsx b/ui-tui/src/__tests__/petPane.test.tsx new file mode 100644 index 000000000..212f95f3d --- /dev/null +++ b/ui-tui/src/__tests__/petPane.test.tsx @@ -0,0 +1,90 @@ +import { PassThrough } from 'stream' + +import { Box, renderSync } from '@hermes/ink' +import React from 'react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { usePet } from '../app/usePet.js' +import { PetPane } from '../components/appLayout.js' +import { stripAnsi } from '../lib/text.js' + +vi.mock('../app/usePet.js', () => ({ + usePet: vi.fn() +})) + +const opaqueCell = [255, 0, 0, 255, 0, 0, 255, 255] + +const PET_GLYPHS = new Set(['▀', '▄', '█']) + +const firstGlyphCol = (line: string) => [...line].findIndex(ch => PET_GLYPHS.has(ch)) + +const renderFrame = (element: React.ReactElement, columns = 40) => { + const stdout = new PassThrough() + const stdin = new PassThrough() + const stderr = new PassThrough() + let output = '' + + Object.assign(stdout, { columns, isTTY: false, rows: 12 }) + Object.assign(stdin, { isTTY: false }) + Object.assign(stderr, { isTTY: false }) + stdout.on('data', chunk => { + output += chunk.toString() + }) + + const instance = renderSync(element, { + patchConsole: false, + stderr: stderr as NodeJS.WriteStream, + stdin: stdin as NodeJS.ReadStream, + stdout: stdout as NodeJS.WriteStream + }) + + instance.unmount() + instance.cleanup() + + return stripAnsi(output) + .split('\n') + .map(line => line.replace(/\s+$/, '')) +} + +describe('PetPane', () => { + afterEach(() => { + vi.mocked(usePet).mockReset() + }) + + it('overlays the bottom-right corner with a flat, right-aligned sprite', () => { + const columns = 40 + vi.mocked(usePet).mockReturnValue({ + enabled: true, + grid: [ + [opaqueCell, opaqueCell], + [opaqueCell, opaqueCell] + ], + kitty: null + }) + + const lines = renderFrame( + + + , + columns + ) + + const cols = lines.map(firstGlyphCol).filter(col => col >= 0) + expect(cols.length).toBeGreaterThanOrEqual(2) + // Flat (no per-row drift) and right-aligned (a corner block, not full width). + expect(new Set(cols).size).toBe(1) + expect(cols[0]).toBeGreaterThan(columns / 2) + }) + + it('renders nothing when disabled', () => { + vi.mocked(usePet).mockReturnValue({ enabled: false, grid: null, kitty: null }) + + const lines = renderFrame( + + + + ) + + expect(lines.every(line => firstGlyphCol(line) < 0)).toBe(true) + }) +}) diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index 66961f70e..bb5d57c50 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -1,10 +1,11 @@ import { AlternateScreen, Box, NoSelect, ScrollBox, Text } from '@hermes/ink' import { useStore } from '@nanostores/react' -import { Fragment, memo, useMemo, useRef } from 'react' +import { Fragment, memo, useEffect, useMemo, useRef } from 'react' import { useGateway } from '../app/gatewayContext.js' import type { AppLayoutProps } from '../app/interfaces.js' import { $isBlocked, $overlayState, patchOverlayState } from '../app/overlayStore.js' +import { $petBox } from '../app/petFlashStore.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' @@ -31,18 +32,66 @@ 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 `), -// so it's a no-op for everyone else. -const PetPane = memo(function PetPane() { +// Box geometry, kept here so the transcript's reservation math matches the +// rendered overlay exactly. +const PET_BOTTOM = 3 // rows the pet floats above the screen bottom (over the composer) +const PET_PAD_LEFT = 2 +const PET_RIGHT = 1 +const PET_GUTTER_GAP = 1 +const KITTY_PLACEHOLDER = '\u{10eeee}' +// Below this many columns of remaining text width, the right gutter is too +// cramped, so the transcript collapses to reserving bottom rows instead. +const MIN_GUTTER_BODY_COLS = 72 + +// Petdex mascot — a small floating overlay riding the bottom-right corner just +// above the status bar, with a little top/left breathing room. It reserves no +// layout rows (the transcript scrolls underneath); instead it publishes its +// footprint so the transcript can keep its text clear of it (right gutter on +// wide terminals, reserved bottom rows on narrow ones). Renders nothing unless +// a pet is installed + enabled. +export const PetPane = memo(function PetPane() { const { enabled, grid, kitty } = usePet() - if (!enabled || (!grid && !kitty)) { + // Footprint in cells. For kitty we count real placeholder cells (zero-width + // diacritics make string length lie); for half-blocks it's the grid shape. + const { width, height } = useMemo(() => { + if (kitty) { + return { + height: kitty.placeholder.length, + width: Math.max(0, ...kitty.placeholder.map(row => [...row].filter(ch => ch === KITTY_PLACEHOLDER).length)) + } + } + + if (grid) { + return { height: grid.length, width: Math.max(0, ...grid.map(row => row.length)) } + } + + return { height: 0, width: 0 } + }, [grid, kitty]) + + const active = enabled && width > 0 && height > 0 + + useEffect(() => { + $petBox.set( + active + ? { + // Bottom PET_BOTTOM rows sit over the composer, so the transcript + // only needs to clear the rest in the row-reservation (band) mode. + height: Math.max(0, height - PET_BOTTOM), + width: width + PET_PAD_LEFT + PET_RIGHT + PET_GUTTER_GAP + } + : null + ) + + return () => $petBox.set(null) + }, [active, height, width]) + + if (!active) { return null } return ( - + {kitty ? : null} {!kitty && grid ? : null} @@ -81,6 +130,16 @@ const TranscriptPane = memo(function TranscriptPane({ transcript }: Pick) { const ui = useStore($uiState) + const petBox = useStore($petBox) + + // Keep transcript text clear of the floating pet, responsively: + // - wide terminals: reserve a right gutter so lines wrap to the pet's left + // (as long as enough width is left for comfortable reading); + // - narrow terminals: keep full width and reserve bottom rows instead, so + // the newest lines sit above the pet rather than getting cramped. + const useGutter = !!petBox && composer.cols - petBox.width >= MIN_GUTTER_BODY_COLS + const bodyCols = useGutter && petBox ? composer.cols - petBox.width : composer.cols + const petBandRows = petBox && !useGutter ? petBox.height : 0 // LiveTodoPanel rides as a child of the latest user-message row so it // visually belongs to the prompt and follows it during scroll. -1 when @@ -148,7 +207,7 @@ const TranscriptPane = memo(function TranscriptPane({ ) : ( 0 ? : null} + + {/* Narrow terminals: reserve rows so the newest lines sit above the pet. */} + {petBandRows > 0 ? : null} @@ -439,7 +501,7 @@ export const AppLayout = memo(function AppLayout({ return ( - + {overlay.agents ? ( @@ -454,8 +516,6 @@ export const AppLayout = memo(function AppLayout({ {!overlay.agents && ( <> - - )} + + {!overlay.agents && } )