feat(tui): float petdex pet bottom-right with responsive text reservation

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).
This commit is contained in:
Brooklyn Nicholson 2026-06-30 15:41:44 -05:00
parent c874999bc5
commit e96d2871bc
2 changed files with 164 additions and 12 deletions

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

@ -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 <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>
@ -81,6 +130,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
@ -148,7 +207,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}
@ -170,7 +229,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}
@ -178,6 +237,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>
@ -439,7 +501,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">
@ -454,8 +516,6 @@ export const AppLayout = memo(function AppLayout({
{!overlay.agents && (
<>
<PetPane />
<PerfPane id="prompt">
<PromptZone
cols={composer.cols}
@ -477,6 +537,8 @@ export const AppLayout = memo(function AppLayout({
)}
</>
)}
{!overlay.agents && <PetPane />}
</Box>
</Shell>
)