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:
parent
c874999bc5
commit
e96d2871bc
2 changed files with 164 additions and 12 deletions
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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue