From ef284e021ac73fcdac9a8392a10bb42f2018b74f Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 18 Apr 2026 09:27:48 -0500 Subject: [PATCH] feat(tui): add two-step SkillsHub overlay component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New SkillsHub mirrors ModelPicker's category → item → actions flow with paginated 12-line lists, 1-9/0 quick-pick, Esc-back navigation, and lazy skills.manage inspect/install calls. Mount it from appOverlays when overlay.skillsHub is true. --- ui-tui/src/components/appOverlays.tsx | 9 +- ui-tui/src/components/skillsHub.tsx | 290 ++++++++++++++++++++++++++ 2 files changed, 298 insertions(+), 1 deletion(-) create mode 100644 ui-tui/src/components/skillsHub.tsx diff --git a/ui-tui/src/components/appOverlays.tsx b/ui-tui/src/components/appOverlays.tsx index 23187cf3f9..27db09024f 100644 --- a/ui-tui/src/components/appOverlays.tsx +++ b/ui-tui/src/components/appOverlays.tsx @@ -11,6 +11,7 @@ import { MaskedPrompt } from './maskedPrompt.js' import { ModelPicker } from './modelPicker.js' import { ApprovalPrompt, ClarifyPrompt } from './prompts.js' import { SessionPicker } from './sessionPicker.js' +import { SkillsHub } from './skillsHub.js' export function PromptZone({ cols, @@ -82,7 +83,7 @@ export function FloatingOverlays({ const overlay = useStore($overlayState) const ui = useStore($uiState) - const hasAny = overlay.modelPicker || overlay.pager || overlay.picker || completions.length + const hasAny = overlay.modelPicker || overlay.pager || overlay.picker || overlay.skillsHub || completions.length if (!hasAny) { return null @@ -115,6 +116,12 @@ export function FloatingOverlays({ )} + {overlay.skillsHub && ( + + patchOverlayState({ skillsHub: false })} t={ui.theme} /> + + )} + {overlay.pager && ( diff --git a/ui-tui/src/components/skillsHub.tsx b/ui-tui/src/components/skillsHub.tsx new file mode 100644 index 0000000000..03ed3d92f3 --- /dev/null +++ b/ui-tui/src/components/skillsHub.tsx @@ -0,0 +1,290 @@ +import { Box, Text, useInput } 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' + +const VISIBLE = 12 + +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) + + 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 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 (key.escape) { + if (stage === 'actions') { + setStage('skill') + setInfo(null) + setErr('') + + return + } + + if (stage === 'skill') { + setStage('category') + setSkillIdx(0) + + return + } + + onClose() + + return + } + + if (stage === 'actions') { + if (key.return || ch.toLowerCase() === 'x') { + if (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 to cancel + + ) + } + + if (!cats.length) { + return ( + + no skills available + Esc to 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 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' : 'Esc back'} + + + ) + } + + 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} + + Enter install · i inspect · x install · Esc back + + ) +} + +interface SkillInfo { + category?: string + description?: string + name?: string + path?: string +} + +interface SkillsHubProps { + gw: GatewayClient + onClose: () => void + t: Theme +}