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 && }
)