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 { OverlayControls, useOverlayKeys } from './overlayControls.js' const VISIBLE = 12 const MIN_WIDTH = 40 const MAX_WIDTH = 90 const pageOffset = (count: number, sel: number) => Math.max(0, Math.min(sel - Math.floor(VISIBLE / 2), count - VISIBLE)) const visibleItems = (items: string[], sel: number) => { const off = pageOffset(items.length, sel) return { items: items.slice(off, off + VISIBLE), off } } 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 off = pageOffset(count, sel) const next = off + 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, off } = visibleItems(rows, catIdx) return ( Skills Hub select a category {off > 0 && ↑ {off} more} {items.map((row, i) => { const idx = off + i return ( {catIdx === idx ? '▸ ' : ' '} {i + 1}. {row} ) })} {off + VISIBLE < rows.length && ↓ {rows.length - off - VISIBLE} more} ↑/↓ select · Enter open · 1-9,0 quick · Esc/q cancel ) } if (stage === 'skill') { const { items, off } = visibleItems(skills, skillIdx) return ( {selectedCat} {skills.length} skill(s) {!skills.length ? no skills in this category : null} {off > 0 && ↑ {off} more} {items.map((row, i) => { const idx = off + i return ( {skillIdx === idx ? '▸ ' : ' '} {i + 1}. {row} ) })} {off + VISIBLE < skills.length && ↓ {skills.length - off - 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 }