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:
commit
d8083221a8
5 changed files with 252 additions and 18 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
90
ui-tui/src/__tests__/petPane.test.tsx
Normal file
90
ui-tui/src/__tests__/petPane.test.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue