From e96d2871bc3f3f6201efecef094b01410eab00c3 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 30 Jun 2026 15:41:44 -0500 Subject: [PATCH] feat(tui): float petdex pet bottom-right with responsive text reservation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- ui-tui/src/__tests__/petPane.test.tsx | 90 +++++++++++++++++++++++++++ ui-tui/src/components/appLayout.tsx | 86 +++++++++++++++++++++---- 2 files changed, 164 insertions(+), 12 deletions(-) create mode 100644 ui-tui/src/__tests__/petPane.test.tsx 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 && } )