fix(pets): live pet switching in the TUI, steady-redraw + right-align in CLI
Three rendering/state bugs surfaced while testing: - TUI never reacted to a pet adopted/switched elsewhere (picker, /pet, hermes pets select). usePet stopped polling once enabled and keyed its frame cache by state only, so a new slug couldn't take over. Poll pet.cells steadily, treat its slug/enabled as source of truth, and key the cache by slug so a switch re-pulls the new sprite live. - `hermes pets show` climbed up the screen each frame: the cursor-up count was rows+2 but only rows+1 lines were drawn. Move up exactly what we wrote so frames overwrite in place. - Right-align the sprite against the terminal edge (CLI) and the pane (TUI width=100%), per request.
This commit is contained in:
parent
4db86e349c
commit
2572617d5a
@ -176,6 +176,14 @@ def _cmd_show(args) -> int:
|
||||
is_unicode = renderer.mode == "unicode"
|
||||
frame_delay = max(0.05, (LOOP_MS / 1000.0) / max(1, renderer.frame_count(states[0]) or 1))
|
||||
|
||||
# Right-align the half-block sprite against the terminal's right edge.
|
||||
import shutil
|
||||
|
||||
indent = ""
|
||||
if is_unicode:
|
||||
term_cols = shutil.get_terminal_size((80, 24)).columns
|
||||
indent = " " * max(0, term_cols - cols - 1)
|
||||
|
||||
out = sys.stdout
|
||||
out.write("\x1b[?25l") # hide cursor
|
||||
out.flush()
|
||||
@ -189,11 +197,15 @@ def _cmd_show(args) -> int:
|
||||
for i in range(count):
|
||||
encoded = renderer.frame(state, i)
|
||||
if is_unicode:
|
||||
if indent:
|
||||
encoded = "\n".join(indent + ln for ln in encoded.split("\n"))
|
||||
if prev_lines:
|
||||
out.write(f"\x1b[{prev_lines}F") # cursor up to redraw
|
||||
out.write(f"\x1b[{prev_lines}F") # cursor up to redraw in place
|
||||
out.write(encoded)
|
||||
out.write("\x1b[0m\n")
|
||||
prev_lines = encoded.count("\n") + 2
|
||||
# Lines drawn = sprite rows + the trailing newline; move
|
||||
# back up exactly that many so the next frame overwrites.
|
||||
prev_lines = encoded.count("\n") + 1
|
||||
else:
|
||||
out.write("\x1b[2J\x1b[3J\x1b[H") # clear for image protocols
|
||||
out.write(f"{pet.display_name} [{state}]\n")
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import type { PetGrid } from '../components/petSprite.js'
|
||||
|
||||
@ -38,27 +38,37 @@ interface PetCellsResult {
|
||||
enabled?: boolean
|
||||
frameMs?: number
|
||||
frames?: PetGrid[]
|
||||
slug?: string
|
||||
state?: string
|
||||
}
|
||||
|
||||
const FRAME_MS = 160
|
||||
const POLL_MS = 2500
|
||||
|
||||
/**
|
||||
* Drives the TUI pet: derives the live state from the turn/ui stores, lazily
|
||||
* fetches each state's half-block frames via the `pet.cells` RPC (cached),
|
||||
* and animates the frame index. Returns the grid to paint, or null when no
|
||||
* pet is enabled/installed.
|
||||
* Drives the TUI pet: derives the live state from the turn/ui stores, fetches
|
||||
* each (slug, state)'s half-block frames via the `pet.cells` RPC (cached), and
|
||||
* animates the frame index. Returns the grid to paint, or null when no pet is
|
||||
* enabled/installed.
|
||||
*
|
||||
* A steady `pet.cells` poll keeps it reactive to config changes made elsewhere
|
||||
* — `/pet`, the picker, `hermes pets select` — so adopting, switching, or
|
||||
* disabling a pet takes effect live (no restart). The frame cache is keyed by
|
||||
* slug so a switch re-pulls the new sprite instead of showing the old one.
|
||||
*/
|
||||
export function usePet(): { enabled: boolean; grid: PetGrid | null } {
|
||||
const { rpc } = useGateway()
|
||||
const [enabled, setEnabled] = useState(false)
|
||||
const [grid, setGrid] = useState<PetGrid | null>(null)
|
||||
|
||||
const cache = useRef<Map<PetState, { frameMs: number; frames: PetGrid[] }>>(new Map())
|
||||
const cache = useRef<Map<string, { frameMs: number; frames: PetGrid[] }>>(new Map())
|
||||
const slugRef = useRef('')
|
||||
const stateRef = useRef<PetState>('idle')
|
||||
const frameRef = useRef(0)
|
||||
const probed = useRef(false)
|
||||
|
||||
const [petState, setPetState] = useState<PetState>('idle')
|
||||
|
||||
// Recompute the desired state on every turn/ui change.
|
||||
const [petState, setPetState] = useState<PetState>('idle')
|
||||
useEffect(() => {
|
||||
const recompute = () => {
|
||||
const turn = $turnState.get()
|
||||
@ -70,8 +80,11 @@ export function usePet(): { enabled: boolean; grid: PetGrid | null } {
|
||||
reasoning: turn.reasoningActive
|
||||
})
|
||||
|
||||
stateRef.current = next
|
||||
setPetState(next)
|
||||
if (next !== stateRef.current) {
|
||||
stateRef.current = next
|
||||
frameRef.current = 0
|
||||
setPetState(next)
|
||||
}
|
||||
}
|
||||
|
||||
recompute()
|
||||
@ -84,75 +97,58 @@ export function usePet(): { enabled: boolean; grid: PetGrid | null } {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Fetch frames for the current state (lazily, cached).
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
if (cache.current.has(petState)) {
|
||||
frameRef.current = 0
|
||||
|
||||
return
|
||||
}
|
||||
void (async () => {
|
||||
// Fetch + cache one (slug, state). `pet.cells` resolves the active pet from
|
||||
// config, so its `slug`/`enabled` are the source of truth: a changed slug
|
||||
// invalidates the cache, a disabled pet clears everything.
|
||||
const sync = useCallback(
|
||||
async (state: PetState) => {
|
||||
try {
|
||||
const res = (await rpc('pet.cells', { state: petState })) as PetCellsResult | null
|
||||
const res = (await rpc('pet.cells', { state })) as PetCellsResult | null
|
||||
|
||||
if (cancelled || !res) {
|
||||
if (!res) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!probed.current) {
|
||||
probed.current = true
|
||||
setEnabled(Boolean(res.enabled))
|
||||
if (!res.enabled) {
|
||||
slugRef.current = ''
|
||||
cache.current.clear()
|
||||
setGrid(null)
|
||||
setEnabled(false)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (res.enabled && res.frames?.length) {
|
||||
cache.current.set(petState, { frameMs: res.frameMs ?? 180, frames: res.frames })
|
||||
const slug = res.slug ?? ''
|
||||
|
||||
if (slug !== slugRef.current) {
|
||||
slugRef.current = slug
|
||||
cache.current.clear()
|
||||
frameRef.current = 0
|
||||
}
|
||||
} catch {
|
||||
// cosmetic — ignore RPC failures
|
||||
}
|
||||
})()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [petState, rpc])
|
||||
|
||||
// While no pet is active, poll `pet.cells` so an in-app `/pet <slug>` (which
|
||||
// writes display.pet.* from the slash worker) lights the pet up live — no
|
||||
// restart. Stops once a pet is enabled.
|
||||
useEffect(() => {
|
||||
if (enabled) {
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
|
||||
const probe = async () => {
|
||||
try {
|
||||
const res = (await rpc('pet.cells', { state: stateRef.current })) as PetCellsResult | null
|
||||
|
||||
if (cancelled || !res?.enabled || !res.frames?.length) {
|
||||
return
|
||||
if (res.frames?.length) {
|
||||
cache.current.set(`${slug}:${state}`, { frameMs: res.frameMs ?? FRAME_MS, frames: res.frames })
|
||||
}
|
||||
|
||||
cache.current.set(stateRef.current, { frameMs: res.frameMs ?? 180, frames: res.frames })
|
||||
frameRef.current = 0
|
||||
setEnabled(true)
|
||||
} catch {
|
||||
// cosmetic — ignore RPC failures
|
||||
}
|
||||
},
|
||||
[rpc]
|
||||
)
|
||||
|
||||
// Pull frames whenever the state changes (if not already cached for the
|
||||
// active pet), plus a steady poll that catches adopt/switch/disable.
|
||||
useEffect(() => {
|
||||
if (!cache.current.has(`${slugRef.current}:${petState}`)) {
|
||||
void sync(petState)
|
||||
}
|
||||
|
||||
const timer = setInterval(() => void probe(), 3000)
|
||||
const timer = setInterval(() => void sync(stateRef.current), POLL_MS)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearInterval(timer)
|
||||
}
|
||||
}, [enabled, rpc])
|
||||
return () => clearInterval(timer)
|
||||
}, [petState, sync])
|
||||
|
||||
// Animation timer.
|
||||
useEffect(() => {
|
||||
@ -161,12 +157,10 @@ export function usePet(): { enabled: boolean; grid: PetGrid | null } {
|
||||
}
|
||||
|
||||
const tick = () => {
|
||||
const entry = cache.current.get(stateRef.current)
|
||||
const entry = cache.current.get(`${slugRef.current}:${stateRef.current}`)
|
||||
|
||||
if (!entry || !entry.frames.length) {
|
||||
setGrid(null)
|
||||
|
||||
return
|
||||
if (!entry?.frames.length) {
|
||||
return // keep the last frame painted while the new state loads
|
||||
}
|
||||
|
||||
const idx = frameRef.current % entry.frames.length
|
||||
@ -175,7 +169,7 @@ export function usePet(): { enabled: boolean; grid: PetGrid | null } {
|
||||
}
|
||||
|
||||
tick()
|
||||
const interval = setInterval(tick, 160)
|
||||
const interval = setInterval(tick, FRAME_MS)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [enabled, petState])
|
||||
|
||||
@ -26,8 +26,8 @@ import { Banner, Panel, SessionPanel } from './branding.js'
|
||||
import { FpsOverlay } from './fpsOverlay.js'
|
||||
import { HelpHint } from './helpHint.js'
|
||||
import { MessageLine } from './messageLine.js'
|
||||
import { QueuedMessages } from './queuedMessages.js'
|
||||
import { PetSprite } from './petSprite.js'
|
||||
import { QueuedMessages } from './queuedMessages.js'
|
||||
import { LiveTodoPanel, StreamingAssistant } from './streamingAssistant.js'
|
||||
import { TextInput, type TextInputMouseApi } from './textInput.js'
|
||||
|
||||
@ -42,7 +42,7 @@ const PetPane = memo(function PetPane() {
|
||||
}
|
||||
|
||||
return (
|
||||
<NoSelect alignItems="flex-end" flexShrink={0} paddingX={1}>
|
||||
<NoSelect alignItems="flex-end" flexShrink={0} paddingX={1} width="100%">
|
||||
<PetSprite grid={grid} />
|
||||
</NoSelect>
|
||||
)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user