Merge pull request #55865 from NousResearch/bb/pet-pane-layout

fix(tui): float petdex pet on the status bar + responsive text reservation
This commit is contained in:
brooklyn! 2026-06-30 15:46:41 -05:00 committed by GitHub
commit d8083221a8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 252 additions and 18 deletions

View file

@ -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,

View file

@ -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

View file

@ -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(
<Box flexDirection="column" height={8} position="relative" width={columns}>
<PetPane />
</Box>,
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(
<Box flexDirection="column" height={8} position="relative" width={40}>
<PetPane />
</Box>
)
expect(lines.every(line => firstGlyphCol(line) < 0)).toBe(true)
})
})

View file

@ -13,3 +13,9 @@ interface PetFlash {
export const $petFlash = atom<PetFlash | null>(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)

View file

@ -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 <slug>`),
// 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 (
<NoSelect flexShrink={0} justifyContent="flex-end" paddingX={1} width="100%">
<NoSelect bottom={PET_BOTTOM} flexShrink={0} paddingLeft={PET_PAD_LEFT} paddingTop={1} position="absolute" right={PET_RIGHT}>
{kitty ? <PetKitty color={kitty.color} placeholder={kitty.placeholder} /> : null}
{!kitty && grid ? <PetSprite grid={grid} /> : null}
</NoSelect>
@ -82,6 +131,16 @@ const TranscriptPane = memo(function TranscriptPane({
transcript
}: Pick<AppLayoutProps, 'actions' | 'composer' | 'progress' | 'transcript'>) {
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({
<Panel sections={row.msg.panelData.sections} t={ui.theme} title={row.msg.panelData.title} />
) : (
<MessageLine
cols={composer.cols}
cols={bodyCols}
compact={ui.compact}
detailsMode={ui.detailsMode}
detailsModeCommandOverride={ui.detailsModeCommandOverride}
@ -171,7 +230,7 @@ const TranscriptPane = memo(function TranscriptPane({
{transcript.virtualHistory.bottomSpacer > 0 ? <Box height={transcript.virtualHistory.bottomSpacer} /> : null}
<StreamingAssistant
cols={composer.cols}
cols={bodyCols}
compact={ui.compact}
detailsMode={ui.detailsMode}
detailsModeCommandOverride={ui.detailsModeCommandOverride}
@ -179,6 +238,9 @@ const TranscriptPane = memo(function TranscriptPane({
progress={progress}
sections={ui.sections}
/>
{/* Narrow terminals: reserve rows so the newest lines sit above the pet. */}
{petBandRows > 0 ? <Box height={petBandRows} /> : null}
</Box>
</ScrollBox>
@ -447,7 +509,7 @@ export const AppLayout = memo(function AppLayout({
return (
<Shell {...shellProps}>
<Box flexDirection="column" flexGrow={1}>
<Box flexDirection="column" flexGrow={1} position="relative">
<Box flexDirection="row" flexGrow={1}>
{overlay.agents ? (
<PerfPane id="agents">
@ -466,8 +528,6 @@ export const AppLayout = memo(function AppLayout({
{!overlay.agents && !overlay.journey && (
<>
<PetPane />
<PerfPane id="prompt">
<PromptZone
cols={composer.cols}
@ -489,6 +549,8 @@ export const AppLayout = memo(function AppLayout({
)}
</>
)}
{!overlay.agents && <PetPane />}
</Box>
</Shell>
)