feat(pets): interactive /pet picker overlay in the TUI
/pet (and /pet list) opened the text-only slash worker, so the TUI just printed a catalog you couldn't act on. The TUI already runs interactive overlays (sessions, model, skills) — pets just never got one. Add a PetPicker overlay (mirrors SkillsHub): it pulls pet.gallery, filters as you type, ranks active→installed→curated, and adopts the highlight via pet.select — the mascot lights up live on usePet's next poll. /pet <slug>, /pet off, /pet status keep their text behaviour through the slash worker.
This commit is contained in:
parent
db00cbfd56
commit
4db86e349c
@ -208,6 +208,30 @@ describe('createSlashHandler', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('opens the pet picker locally for bare /pet and /pet list', () => {
|
||||
const ctx = buildCtx()
|
||||
|
||||
expect(createSlashHandler(ctx)('/pet')).toBe(true)
|
||||
expect(getOverlayState().petPicker).toBe(true)
|
||||
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
|
||||
|
||||
resetOverlayState()
|
||||
expect(createSlashHandler(ctx)('/pet list')).toBe(true)
|
||||
expect(getOverlayState().petPicker).toBe(true)
|
||||
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes /pet <slug> to the slash worker without opening the picker', () => {
|
||||
const ctx = buildCtx()
|
||||
|
||||
expect(createSlashHandler(ctx)('/pet boba')).toBe(true)
|
||||
expect(getOverlayState().petPicker).toBe(false)
|
||||
expect(ctx.gateway.gw.request).toHaveBeenCalledWith(
|
||||
'slash.exec',
|
||||
expect.objectContaining({ command: 'pet boba' })
|
||||
)
|
||||
})
|
||||
|
||||
it('routes /skills inspect <name> to skills.manage', () => {
|
||||
const ctx = buildCtx()
|
||||
|
||||
|
||||
@ -93,6 +93,7 @@ export interface OverlayState {
|
||||
confirm: ConfirmReq | null
|
||||
modelPicker: boolean
|
||||
pager: null | PagerState
|
||||
petPicker: boolean
|
||||
pluginsHub: boolean
|
||||
secret: null | SecretReq
|
||||
sessions: boolean
|
||||
|
||||
@ -10,6 +10,7 @@ const buildOverlayState = (): OverlayState => ({
|
||||
confirm: null,
|
||||
modelPicker: false,
|
||||
pager: null,
|
||||
petPicker: false,
|
||||
pluginsHub: false,
|
||||
secret: null,
|
||||
sessions: false,
|
||||
@ -21,9 +22,9 @@ export const $overlayState = atom<OverlayState>(buildOverlayState())
|
||||
|
||||
export const $isBlocked = computed(
|
||||
$overlayState,
|
||||
({ agents, approval, clarify, confirm, modelPicker, pager, pluginsHub, secret, sessions, skillsHub, sudo }) =>
|
||||
({ agents, approval, clarify, confirm, modelPicker, pager, petPicker, pluginsHub, secret, sessions, skillsHub, sudo }) =>
|
||||
Boolean(
|
||||
agents || approval || clarify || confirm || modelPicker || pager || pluginsHub || secret || sessions || skillsHub || sudo
|
||||
agents || approval || clarify || confirm || modelPicker || pager || petPicker || pluginsHub || secret || sessions || skillsHub || sudo
|
||||
)
|
||||
)
|
||||
|
||||
@ -49,6 +50,7 @@ export const resetFlowOverlays = () =>
|
||||
agents: $overlayState.get().agents,
|
||||
agentsInitialHistoryIndex: $overlayState.get().agentsInitialHistoryIndex,
|
||||
modelPicker: $overlayState.get().modelPicker,
|
||||
petPicker: $overlayState.get().petPicker,
|
||||
pluginsHub: $overlayState.get().pluginsHub,
|
||||
sessions: $overlayState.get().sessions,
|
||||
skillsHub: $overlayState.get().skillsHub
|
||||
|
||||
@ -8,6 +8,7 @@ import type {
|
||||
SessionBranchResponse,
|
||||
SessionCompressResponse,
|
||||
SessionUsageResponse,
|
||||
SlashExecResponse,
|
||||
VoiceToggleResponse
|
||||
} from '../../../gatewayTypes.js'
|
||||
import { formatVoiceRecordKey, parseVoiceRecordKey } from '../../../lib/platform.js'
|
||||
@ -340,6 +341,32 @@ export const sessionCommands: SlashCommand[] = [
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
help: 'pick / adopt an animated pet',
|
||||
name: 'pet',
|
||||
usage: '/pet [list | <slug> | off]',
|
||||
run: (arg, ctx, cmd) => {
|
||||
const sub = arg.trim().toLowerCase()
|
||||
|
||||
// No slug (or an explicit "list") → the interactive picker; the TUI can
|
||||
// do this even though the text `/pet` path only prints. status/off/<slug>
|
||||
// keep their text behaviour via the slash worker.
|
||||
if (!sub || sub === 'list') {
|
||||
return patchOverlayState({ petPicker: true })
|
||||
}
|
||||
|
||||
ctx.gateway.gw
|
||||
.request<SlashExecResponse>('slash.exec', { command: cmd.slice(1), session_id: ctx.sid })
|
||||
.then(
|
||||
ctx.guarded<SlashExecResponse>(r => {
|
||||
const body = r.output || '/pet: no output'
|
||||
ctx.transcript.sys(r.warning ? `warning: ${r.warning}\n${body}` : body)
|
||||
})
|
||||
)
|
||||
.catch(ctx.guardedErr)
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
help: 'switch theme skin (fires skin.changed)',
|
||||
name: 'skin',
|
||||
@ -553,6 +580,7 @@ export const sessionCommands: SlashCommand[] = [
|
||||
// even with zero API calls or on a resumed session. Render it whenever
|
||||
// present, before the token panel.
|
||||
const creditsLines = r?.credits_lines ?? []
|
||||
|
||||
if (creditsLines.length) {
|
||||
ctx.transcript.panel('Nous credits', [{ text: creditsLines.join('\n') }])
|
||||
}
|
||||
@ -561,6 +589,7 @@ export const sessionCommands: SlashCommand[] = [
|
||||
if (!creditsLines.length) {
|
||||
ctx.transcript.sys('no API calls yet')
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@ -147,6 +147,10 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
|
||||
return patchOverlayState({ modelPicker: false })
|
||||
}
|
||||
|
||||
if (overlay.petPicker) {
|
||||
return patchOverlayState({ petPicker: false })
|
||||
}
|
||||
|
||||
if (overlay.skillsHub) {
|
||||
return patchOverlayState({ skillsHub: false })
|
||||
}
|
||||
|
||||
@ -11,6 +11,7 @@ import { FloatBox } from './appChrome.js'
|
||||
import { MaskedPrompt } from './maskedPrompt.js'
|
||||
import { ModelPicker } from './modelPicker.js'
|
||||
import { OverlayHint } from './overlayControls.js'
|
||||
import { PetPicker } from './petPicker.js'
|
||||
import { PluginsHub } from './pluginsHub.js'
|
||||
import { ApprovalPrompt, ClarifyPrompt, ConfirmPrompt } from './prompts.js'
|
||||
import { SkillsHub } from './skillsHub.js'
|
||||
@ -124,6 +125,7 @@ export function FloatingOverlays({
|
||||
const hasAny =
|
||||
overlay.modelPicker ||
|
||||
overlay.pager ||
|
||||
overlay.petPicker ||
|
||||
overlay.sessions ||
|
||||
overlay.skillsHub ||
|
||||
overlay.pluginsHub ||
|
||||
@ -170,6 +172,12 @@ export function FloatingOverlays({
|
||||
</FloatBox>
|
||||
)}
|
||||
|
||||
{overlay.petPicker && (
|
||||
<FloatBox color={theme.color.border}>
|
||||
<PetPicker gw={gw} onClose={() => patchOverlayState({ petPicker: false })} t={theme} />
|
||||
</FloatBox>
|
||||
)}
|
||||
|
||||
{overlay.skillsHub && (
|
||||
<FloatBox color={theme.color.border}>
|
||||
<SkillsHub gw={gw} onClose={() => patchOverlayState({ skillsHub: false })} t={theme} />
|
||||
|
||||
183
ui-tui/src/components/petPicker.tsx
Normal file
183
ui-tui/src/components/petPicker.tsx
Normal file
@ -0,0 +1,183 @@
|
||||
import { Box, Text, useInput, useStdout } from '@hermes/ink'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import type { GatewayClient } from '../gatewayClient.js'
|
||||
import { rpcErrorMessage } from '../lib/rpc.js'
|
||||
import type { Theme } from '../theme.js'
|
||||
|
||||
import { OverlayHint, windowItems } from './overlayControls.js'
|
||||
|
||||
const VISIBLE = 10
|
||||
const MIN_WIDTH = 40
|
||||
const MAX_WIDTH = 90
|
||||
|
||||
interface GalleryPet {
|
||||
slug: string
|
||||
displayName: string
|
||||
installed: boolean
|
||||
curated?: boolean
|
||||
}
|
||||
|
||||
interface Gallery {
|
||||
enabled: boolean
|
||||
active: string
|
||||
pets: GalleryPet[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Interactive petdex picker overlay. Pulls the gallery via `pet.gallery`,
|
||||
* filters as you type, and adopts the highlighted pet with `pet.select`
|
||||
* (install-on-demand). The mascot lights up live once `usePet` next polls —
|
||||
* no restart. This is the interactive sibling of the text `/pet <slug>` path.
|
||||
*/
|
||||
export function PetPicker({ gw, onClose, t }: PetPickerProps) {
|
||||
const [gallery, setGallery] = useState<Gallery | null>(null)
|
||||
const [query, setQuery] = useState('')
|
||||
const [idx, setIdx] = useState(0)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [err, setErr] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const { stdout } = useStdout()
|
||||
const width = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (stdout?.columns ?? 80) - 6))
|
||||
|
||||
useEffect(() => {
|
||||
gw.request<Gallery>('pet.gallery')
|
||||
.then(r => {
|
||||
setGallery(r)
|
||||
setErr('')
|
||||
})
|
||||
.catch((e: unknown) => setErr(rpcErrorMessage(e)))
|
||||
.finally(() => setLoading(false))
|
||||
}, [gw])
|
||||
|
||||
const enabled = gallery?.enabled ?? false
|
||||
const active = gallery?.active ?? ''
|
||||
|
||||
// Rank by the signals petdex gives us — active, then installed, then curated
|
||||
// (its official set), then the rest — and hide the clawd placeholders.
|
||||
const view = useMemo(() => {
|
||||
const pets = (gallery?.pets ?? []).filter(p => !/^clawd(-|$)/i.test(p.slug))
|
||||
const needle = query.trim().toLowerCase()
|
||||
|
||||
const matched = needle
|
||||
? pets.filter(p => p.slug.toLowerCase().includes(needle) || p.displayName.toLowerCase().includes(needle))
|
||||
: pets
|
||||
|
||||
const rank = (p: GalleryPet) => (enabled && p.slug === active ? 4 : 0) + (p.installed ? 2 : 0) + (p.curated ? 1 : 0)
|
||||
|
||||
return [...matched].sort((a, b) => rank(b) - rank(a))
|
||||
}, [gallery, query, enabled, active])
|
||||
|
||||
const adopt = (slug: string) => {
|
||||
setBusy(true)
|
||||
setErr('')
|
||||
gw.request('pet.select', { slug })
|
||||
.then(() => onClose())
|
||||
.catch((e: unknown) => {
|
||||
setErr(rpcErrorMessage(e))
|
||||
setBusy(false)
|
||||
})
|
||||
}
|
||||
|
||||
useInput((input, key) => {
|
||||
if (busy) {
|
||||
return
|
||||
}
|
||||
|
||||
if (key.escape) {
|
||||
return onClose()
|
||||
}
|
||||
|
||||
if (key.upArrow) {
|
||||
return setIdx(i => Math.max(0, i - 1))
|
||||
}
|
||||
|
||||
if (key.downArrow) {
|
||||
return setIdx(i => Math.min(view.length - 1, i + 1))
|
||||
}
|
||||
|
||||
if (key.return) {
|
||||
const pet = view[idx]
|
||||
|
||||
return pet ? adopt(pet.slug) : undefined
|
||||
}
|
||||
|
||||
if (key.backspace || key.delete) {
|
||||
setQuery(q => q.slice(0, -1))
|
||||
|
||||
return setIdx(0)
|
||||
}
|
||||
|
||||
// Printable char → extend the filter (ignore control/chorded keys).
|
||||
if (input && input.length === 1 && input >= ' ' && !key.ctrl && !key.meta) {
|
||||
setQuery(q => q + input)
|
||||
setIdx(0)
|
||||
}
|
||||
})
|
||||
|
||||
if (loading) {
|
||||
return <Text color={t.color.muted}>loading pets…</Text>
|
||||
}
|
||||
|
||||
if (err && !gallery) {
|
||||
return (
|
||||
<Box flexDirection="column" width={width}>
|
||||
<Text color={t.color.label}>error: {err}</Text>
|
||||
<OverlayHint t={t}>Esc cancel</OverlayHint>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
const { items, offset } = windowItems(view, idx, VISIBLE)
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width={width}>
|
||||
<Text bold color={t.color.accent}>
|
||||
Pets
|
||||
</Text>
|
||||
|
||||
<Text color={t.color.muted} wrap="truncate-end">
|
||||
{query ? `filter: ${query}` : 'type to filter'} · {view.length} pet{view.length === 1 ? '' : 's'}
|
||||
</Text>
|
||||
|
||||
{offset > 0 && <Text color={t.color.muted}> ↑ {offset} more</Text>}
|
||||
|
||||
{view.length === 0 ? (
|
||||
<Text color={t.color.muted}>{query ? `no pets match "${query}"` : 'no pets available'}</Text>
|
||||
) : (
|
||||
items.map((pet, i) => {
|
||||
const at = offset + i === idx
|
||||
const isActive = enabled && pet.slug === active
|
||||
const mark = isActive ? '●' : pet.installed ? '✓' : ' '
|
||||
const tag = pet.installed ? '' : pet.curated ? ' · official' : ''
|
||||
|
||||
return (
|
||||
<Text bold={at} color={at ? t.color.accent : t.color.muted} inverse={at} key={pet.slug} wrap="truncate-end">
|
||||
{at ? '▸ ' : ' '}
|
||||
{mark} {pet.displayName}
|
||||
<Text color={at ? t.color.accent : t.color.muted}>
|
||||
{' '}
|
||||
({pet.slug}
|
||||
{tag})
|
||||
</Text>
|
||||
</Text>
|
||||
)
|
||||
})
|
||||
)}
|
||||
|
||||
{offset + VISIBLE < view.length && <Text color={t.color.muted}> ↓ {view.length - offset - VISIBLE} more</Text>}
|
||||
|
||||
{err ? <Text color={t.color.label}>error: {err}</Text> : null}
|
||||
{busy ? <Text color={t.color.accent}>adopting…</Text> : null}
|
||||
|
||||
<OverlayHint t={t}>↑/↓ select · Enter adopt · type to filter · Esc cancel</OverlayHint>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
interface PetPickerProps {
|
||||
gw: GatewayClient
|
||||
onClose: () => void
|
||||
t: Theme
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user