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
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/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)
diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx
index 4e215628e..65c4f8697 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'
@@ -32,18 +33,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}
@@ -82,6 +131,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
@@ -149,7 +208,7 @@ const TranscriptPane = memo(function TranscriptPane({
) : (
0 ? : null}
+
+ {/* Narrow terminals: reserve rows so the newest lines sit above the pet. */}
+ {petBandRows > 0 ? : null}
@@ -447,7 +509,7 @@ export const AppLayout = memo(function AppLayout({
return (
-
+
{overlay.agents ? (
@@ -466,8 +528,6 @@ export const AppLayout = memo(function AppLayout({
{!overlay.agents && !overlay.journey && (
<>
-
-
)}
+
+ {!overlay.agents && }
)