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
+}