import { Box, Text, useInput, useStdout } from '@hermes/ink' import { useEffect, useState } from 'react' import type { GatewayClient } from '../gatewayClient.js' import { rpcErrorMessage } from '../lib/rpc.js' import type { Theme } from '../theme.js' import { OverlayHint, useOverlayKeys, windowItems, windowOffset } from './overlayControls.js' const VISIBLE = 12 const MIN_WIDTH = 40 const MAX_WIDTH = 90 export function SkillsHub({ gw, onClose, t }: SkillsHubProps) { const [skillsByCat, setSkillsByCat] = useState>({}) const [selectedCat, setSelectedCat] = useState('') const [catIdx, setCatIdx] = useState(0) const [skillIdx, setSkillIdx] = useState(0) const [stage, setStage] = useState<'actions' | 'category' | 'skill'>('category') const [info, setInfo] = useState(null) const [installing, setInstalling] = 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<{ skills?: Record }>('skills.manage', { action: 'list' }) .then(r => { setSkillsByCat(r?.skills ?? {}) setErr('') setLoading(false) }) .catch((e: unknown) => { setErr(rpcErrorMessage(e)) setLoading(false) }) }, [gw]) const cats = Object.keys(skillsByCat).sort() const skills = selectedCat ? (skillsByCat[selectedCat] ?? []) : [] const skillName = skills[skillIdx] ?? '' const back = () => { if (stage === 'actions') { setStage('skill') setInfo(null) setErr('') return } if (stage === 'skill') { setStage('category') setSkillIdx(0) return } onClose() } useOverlayKeys({ disabled: installing, onBack: back, onClose }) const inspect = (name: string) => { setInfo(null) setErr('') gw.request<{ info?: SkillInfo }>('skills.manage', { action: 'inspect', query: name }) .then(r => setInfo(r?.info ?? { name })) .catch((e: unknown) => setErr(rpcErrorMessage(e))) } const install = (name: string) => { setInstalling(true) setErr('') gw.request<{ installed?: boolean; name?: string }>('skills.manage', { action: 'install', query: name }) .then(() => onClose()) .catch((e: unknown) => setErr(rpcErrorMessage(e))) .finally(() => setInstalling(false)) } useInput((ch, key) => { if (installing) { return } if (stage === 'actions') { if (key.return) { setStage('skill') setInfo(null) setErr('') return } if (ch.toLowerCase() === 'x' && skillName) { install(skillName) return } if (ch.toLowerCase() === 'i' && skillName) { inspect(skillName) } return } const count = stage === 'category' ? cats.length : skills.length const sel = stage === 'category' ? catIdx : skillIdx const setSel = stage === 'category' ? setCatIdx : setSkillIdx if (key.upArrow && sel > 0) { setSel(v => v - 1) return } if (key.downArrow && sel < count - 1) { setSel(v => v + 1) return } if (key.return) { if (stage === 'category') { const cat = cats[catIdx] if (!cat) { return } setSelectedCat(cat) setSkillIdx(0) setStage('skill') return } const name = skills[skillIdx] if (name) { setStage('actions') inspect(name) } return } const n = ch === '0' ? 10 : parseInt(ch, 10) if (!Number.isNaN(n) && n >= 1 && n <= Math.min(10, count)) { const next = windowOffset(count, sel, VISIBLE) + n - 1 if (stage === 'category') { const cat = cats[next] if (cat) { setSelectedCat(cat) setCatIdx(next) setSkillIdx(0) setStage('skill') } return } const name = skills[next] if (name) { setSkillIdx(next) setStage('actions') inspect(name) } } }) if (loading) { return loading skills… } if (err && stage === 'category') { return ( error: {err} Esc/q cancel ) } if (!cats.length) { return ( no skills available Esc/q cancel ) } if (stage === 'category') { const rows = cats.map(c => `${c} · ${skillsByCat[c]?.length ?? 0} skills`) const { items, offset } = windowItems(rows, catIdx, VISIBLE) return ( Skills Hub select a category {offset > 0 && ↑ {offset} more} {items.map((row, i) => { const idx = offset + i return ( {catIdx === idx ? '▸ ' : ' '} {i + 1}. {row} ) })} {offset + VISIBLE < rows.length && ↓ {rows.length - offset - VISIBLE} more} ↑/↓ select · Enter open · 1-9,0 quick · Esc/q cancel ) } if (stage === 'skill') { const { items, offset } = windowItems(skills, skillIdx, VISIBLE) return ( {selectedCat} {skills.length} skill(s) {!skills.length ? no skills in this category : null} {offset > 0 && ↑ {offset} more} {items.map((row, i) => { const idx = offset + i return ( {skillIdx === idx ? '▸ ' : ' '} {i + 1}. {row} ) })} {offset + VISIBLE < skills.length && ( ↓ {skills.length - offset - VISIBLE} more )} {skills.length ? '↑/↓ select · Enter open · 1-9,0 quick · Esc back · q close' : 'Esc back · q close'} ) } return ( {info?.name ?? skillName} {info?.category ?? selectedCat} {info?.description ? {info.description} : null} {info?.path ? path: {info.path} : null} {!info && !err ? loading… : null} {err ? error: {err} : null} {installing ? installing… : null} i reinspect · x reinstall · Enter/Esc back · q close ) } interface SkillInfo { category?: string description?: string name?: string path?: string } interface SkillsHubProps { gw: GatewayClient onClose: () => void t: Theme }