fix: menus

This commit is contained in:
Austin Pickett 2026-04-10 00:01:37 -04:00
parent 4fe78d5b88
commit e1df13cf20
4 changed files with 193 additions and 32 deletions

View file

@ -496,8 +496,11 @@ def do_inspect(identifier: str, console: Optional[Console] = None) -> None:
c.print() c.print()
def browse_skills(page: int = 1, page_size: int = 20, source: str = "all") -> list[dict]: def browse_skills(page: int = 1, page_size: int = 20, source: str = "all") -> dict:
"""Paginated hub browse for programmatic callers (e.g. TUI gateway).""" """Paginated hub browse for programmatic callers (e.g. TUI gateway).
Returns ``{"items": [...], "page": int, "total_pages": int, "total": int}``.
"""
from tools.skills_hub import GitHubAuth, create_source_router from tools.skills_hub import GitHubAuth, create_source_router
page_size = max(1, min(page_size, 100)) page_size = max(1, min(page_size, 100))
@ -517,7 +520,7 @@ def browse_skills(page: int = 1, page_size: int = 20, source: str = "all") -> li
except Exception: except Exception:
continue continue
if not all_results: if not all_results:
return [] return {"items": [], "page": 1, "total_pages": 1, "total": 0}
seen: dict = {} seen: dict = {}
for r in all_results: for r in all_results:
rank = _TRUST_RANK.get(r.trust_level, 0) rank = _TRUST_RANK.get(r.trust_level, 0)
@ -530,7 +533,13 @@ def browse_skills(page: int = 1, page_size: int = 20, source: str = "all") -> li
page = max(1, min(page, total_pages)) page = max(1, min(page, total_pages))
start = (page - 1) * page_size start = (page - 1) * page_size
page_items = deduped[start : min(start + page_size, total)] page_items = deduped[start : min(start + page_size, total)]
return [{"name": r.name, "description": r.description} for r in page_items] return {
"items": [{"name": r.name, "description": r.description, "source": r.source,
"trust": r.trust_level} for r in page_items],
"page": page,
"total_pages": total_pages,
"total": total,
}
def inspect_skill(identifier: str) -> Optional[dict]: def inspect_skill(identifier: str) -> Optional[dict]:

View file

@ -903,31 +903,74 @@ def _(rid, params: dict) -> dict:
return _err(rid, 5015, str(e)) return _err(rid, 5015, str(e))
_TUI_HIDDEN: frozenset[str] = frozenset({
"sethome", "set-home", "update", "commands", "status", "approve", "deny",
})
_TUI_EXTRA: list[tuple[str, str, str]] = [
("/compact", "Toggle compact display mode", "TUI"),
("/logs", "Show recent gateway log lines", "TUI"),
]
@method("commands.catalog") @method("commands.catalog")
def _(rid, params: dict) -> dict: def _(rid, params: dict) -> dict:
"""Registry-backed slash metadata (same surface as SlashCommandCompleter).""" """Registry-backed slash metadata for the TUI — categorized, no aliases."""
try: try:
from hermes_cli.commands import COMMAND_REGISTRY, COMMANDS, SUBCOMMANDS from hermes_cli.commands import COMMAND_REGISTRY, SUBCOMMANDS, _build_description
pairs = sorted(COMMANDS.items(), key=lambda kv: kv[0]) all_pairs: list[list[str]] = []
sub = {k: v[:] for k, v in SUBCOMMANDS.items()}
canon: dict[str, str] = {} canon: dict[str, str] = {}
categories: list[dict] = []
cat_map: dict[str, list[list[str]]] = {}
cat_order: list[str] = []
for cmd in COMMAND_REGISTRY: for cmd in COMMAND_REGISTRY:
if cmd.gateway_only:
continue
c = f"/{cmd.name}" c = f"/{cmd.name}"
canon[c.lower()] = c canon[c.lower()] = c
for a in cmd.aliases: for a in cmd.aliases:
canon[f"/{a}".lower()] = c canon[f"/{a}".lower()] = c
skills = []
if cmd.name in _TUI_HIDDEN:
continue
desc = _build_description(cmd)
all_pairs.append([c, desc])
cat = cmd.category
if cat not in cat_map:
cat_map[cat] = []
cat_order.append(cat)
cat_map[cat].append([c, desc])
for name, desc, cat in _TUI_EXTRA:
all_pairs.append([name, desc])
if cat not in cat_map:
cat_map[cat] = []
cat_order.append(cat)
cat_map[cat].append([name, desc])
skill_count = 0
try: try:
from agent.skill_commands import scan_skill_commands from agent.skill_commands import scan_skill_commands
for k, info in scan_skill_commands().items(): for k, info in sorted(scan_skill_commands().items()):
d = str(info.get("description", "Skill")) d = str(info.get("description", "Skill"))
skills.append([k, f"{d[:120]}{'' if len(d) > 120 else ''}"]) all_pairs.append([k, d[:120] + ("" if len(d) > 120 else "")])
skill_count += 1
except Exception: except Exception:
pass pass
return _ok(rid, {"pairs": pairs + skills, "sub": sub, "canon": canon})
for cat in cat_order:
categories.append({"name": cat, "pairs": cat_map[cat]})
sub = {k: v[:] for k, v in SUBCOMMANDS.items()}
return _ok(rid, {
"pairs": all_pairs,
"sub": sub,
"canon": canon,
"categories": categories,
"skill_count": skill_count,
})
except Exception as e: except Exception as e:
return _err(rid, 5020, str(e)) return _err(rid, 5020, str(e))
@ -1416,8 +1459,8 @@ def _(rid, params: dict) -> dict:
return _ok(rid, {"installed": True, "name": query}) return _ok(rid, {"installed": True, "name": query})
if action == "browse": if action == "browse":
from hermes_cli.skills_hub import browse_skills from hermes_cli.skills_hub import browse_skills
return _ok(rid, {"results": [{"name": r.get("name", ""), "description": r.get("description", "")} pg = int(params.get("page", 0) or 0) or (int(query) if query.isdigit() else 1)
for r in (browse_skills(page=int(query) if query.isdigit() else 1) or [])]}) return _ok(rid, browse_skills(page=pg, page_size=int(params.get("page_size", 20))))
if action == "inspect": if action == "inspect":
from hermes_cli.skills_hub import inspect_skill from hermes_cli.skills_hub import inspect_skill
return _ok(rid, {"info": inspect_skill(query) or {}}) return _ok(rid, {"info": inspect_skill(query) or {}})

View file

@ -280,6 +280,7 @@ export function App({ gw }: { gw: GatewayClient }) {
const [turnTrail, setTurnTrail] = useState<string[]>([]) const [turnTrail, setTurnTrail] = useState<string[]>([])
const [bgTasks, setBgTasks] = useState<Set<string>>(new Set()) const [bgTasks, setBgTasks] = useState<Set<string>>(new Set())
const [catalog, setCatalog] = useState<SlashCatalog | null>(null) const [catalog, setCatalog] = useState<SlashCatalog | null>(null)
const [pager, setPager] = useState<{ lines: string[]; offset: number } | null>(null)
// ── Refs ───────────────────────────────────────────────────────── // ── Refs ─────────────────────────────────────────────────────────
@ -312,7 +313,7 @@ export function App({ gw }: { gw: GatewayClient }) {
const { completions, compIdx, setCompIdx, compReplace } = useCompletion(input, blocked(), gw) const { completions, compIdx, setCompIdx, compReplace } = useCompletion(input, blocked(), gw)
function blocked() { function blocked() {
return !!(clarify || approval || pasteReview || picker || secret || sudo) return !!(clarify || approval || pasteReview || picker || secret || sudo || pager)
} }
const empty = !messages.length const empty = !messages.length
@ -349,6 +350,11 @@ export function App({ gw }: { gw: GatewayClient }) {
const sys = useCallback((text: string) => appendMessage({ role: 'system' as const, text }), [appendMessage]) const sys = useCallback((text: string) => appendMessage({ role: 'system' as const, text }), [appendMessage])
const page = useCallback((text: string) => {
const lines = text.split('\n')
setPager({ lines, offset: 0 })
}, [])
const pushActivity = useCallback((text: string, tone: ActivityItem['tone'] = 'info', replaceLabel?: string) => { const pushActivity = useCallback((text: string, tone: ActivityItem['tone'] = 'info', replaceLabel?: string) => {
setActivity(prev => { setActivity(prev => {
const base = replaceLabel ? prev.filter(a => !sameToolTrailGroup(replaceLabel, a.text)) : prev const base = replaceLabel ? prev.filter(a => !sameToolTrailGroup(replaceLabel, a.text)) : prev
@ -803,8 +809,26 @@ export function App({ gw }: { gw: GatewayClient }) {
const ctrl = (key: { ctrl: boolean }, ch: string, target: string) => key.ctrl && ch.toLowerCase() === target const ctrl = (key: { ctrl: boolean }, ch: string, target: string) => key.ctrl && ch.toLowerCase() === target
const pagerPageSize = Math.max(5, (stdout?.rows ?? 24) - 6)
useInput((ch, key) => { useInput((ch, key) => {
if (isBlocked) { if (isBlocked) {
if (pager) {
if (key.return || ch === ' ') {
const next = pager.offset + pagerPageSize
if (next >= pager.lines.length) {
setPager(null)
} else {
setPager({ ...pager, offset: next })
}
} else if (key.escape || ctrl(key, ch, 'c') || ch === 'q') {
setPager(null)
}
return
}
if (pasteReview) { if (pasteReview) {
if (key.return) { if (key.return) {
setPasteReview(null) setPasteReview(null)
@ -970,7 +994,9 @@ export function App({ gw }: { gw: GatewayClient }) {
setCatalog({ setCatalog({
canon: (r.canon ?? {}) as Record<string, string>, canon: (r.canon ?? {}) as Record<string, string>,
categories: (r.categories ?? []) as SlashCatalog['categories'],
pairs: r.pairs as [string, string][], pairs: r.pairs as [string, string][],
skillCount: (r.skill_count ?? 0) as number,
sub: (r.sub ?? {}) as Record<string, string[]> sub: (r.sub ?? {}) as Record<string, string[]>
}) })
}) })
@ -1272,21 +1298,26 @@ export function App({ gw }: { gw: GatewayClient }) {
switch (name) { switch (name) {
case 'help': { case 'help': {
const rows = catalog?.pairs ?? [] const cats = catalog?.categories ?? []
const cap = 52 const skills = catalog?.skillCount ?? 0
const lines: string[] = []
sys( for (const { name: catName, pairs } of cats) {
[ if (lines.length) lines.push('')
' Commands:', lines.push(` ${catName}:`)
...rows.slice(0, cap).map(([c, d]) => ` ${c.padEnd(16)} ${d}`), for (const [c, d] of pairs) lines.push(` ${c.padEnd(18)} ${d}`)
rows.length > cap ? `${rows.length - cap} more` : '', }
'',
' Hotkeys:', if (!lines.length) lines.push(' (no commands loaded)')
...HOTKEYS.map(([k, d]) => ` ${k.padEnd(14)} ${d}`)
] if (skills > 0) {
.filter(Boolean) lines.push('', ` ${skills} skill commands available — /skills to browse`)
.join('\n') }
)
lines.push('', ' Hotkeys:')
for (const [k, d] of HOTKEYS) lines.push(` ${k.padEnd(14)} ${d}`)
sys(lines.join('\n'))
return true return true
} }
@ -1527,6 +1558,13 @@ export function App({ gw }: { gw: GatewayClient }) {
return true return true
case 'provider':
gw.request('slash.exec', { command: 'provider', session_id: sid })
.then((r: any) => page(r?.output || '(no output)'))
.catch(() => sys('provider command failed'))
return true
case 'skin': case 'skin':
if (arg) { if (arg) {
rpc('config.set', { key: 'skin', value: arg }).then((r: any) => sys(`skin → ${r.value}`)) rpc('config.set', { key: 'skin', value: arg }).then((r: any) => sys(`skin → ${r.value}`))
@ -1714,6 +1752,56 @@ export function App({ gw }: { gw: GatewayClient }) {
return true return true
case 'skills': {
const [sub, ...sArgs] = (arg || '').split(/\s+/).filter(Boolean)
if (!sub || sub === 'list') {
rpc('skills.manage', { action: 'list' }).then((r: any) => {
const sk = r.skills as Record<string, string[]> | undefined
if (!sk || !Object.keys(sk).length) return sys('no skills installed')
const lines: string[] = []
for (const [cat, names] of Object.entries(sk)) {
lines.push(` ${cat}: ${(names as string[]).join(', ')}`)
}
sys(lines.join('\n'))
})
return true
}
if (sub === 'browse') {
const page = parseInt(sArgs[0] ?? '1', 10) || 1
rpc('skills.manage', { action: 'browse', page }).then((r: any) => {
if (!r.items?.length) return sys('no skills found in the hub')
const lines = [
` Skills Hub (page ${r.page}/${r.total_pages}, ${r.total} total)`,
'',
...r.items.map((s: any) =>
` ${(s.name ?? '').padEnd(28)} ${(s.description ?? '').slice(0, 60)}${s.description?.length > 60 ? '…' : ''}`
),
]
if (r.page < r.total_pages) lines.push('', ` /skills browse ${r.page + 1} → next page`)
if (r.page > 1) lines.push(` /skills browse ${r.page - 1} → prev page`)
sys(lines.join('\n'))
})
return true
}
gw.request('slash.exec', { command: cmd.slice(1), session_id: sid })
.then((r: any) => sys(r?.output || '/skills: no output'))
.catch(() => sys(`skills: ${sub} failed`))
return true
}
default: default:
gw.request('slash.exec', { command: cmd.slice(1), session_id: sid }) gw.request('slash.exec', { command: cmd.slice(1), session_id: sid })
.then((r: any) => sys(r?.output || `/${name}: no output`)) .then((r: any) => sys(r?.output || `/${name}: no output`))
@ -1737,7 +1825,7 @@ export function App({ gw }: { gw: GatewayClient }) {
return true return true
} }
}, },
[catalog, compact, gw, lastUserMsg, messages, newSession, pastes, pushActivity, rpc, send, sid, statusBar, sys] [catalog, compact, gw, lastUserMsg, messages, newSession, page, pastes, pushActivity, rpc, send, sid, statusBar, sys]
) )
slashRef.current = slash slashRef.current = slash
@ -1974,6 +2062,20 @@ export function App({ gw }: { gw: GatewayClient }) {
/> />
)} )}
{pager && (
<Box flexDirection="column">
{pager.lines.slice(pager.offset, pager.offset + pagerPageSize).map((line, i) => (
<Text key={i}>{line}</Text>
))}
<Text color={theme.color.dim}>
{pager.offset + pagerPageSize < pager.lines.length
? `── Enter/Space for more · q to close (${Math.min(pager.offset + pagerPageSize, pager.lines.length)}/${pager.lines.length}) ──`
: `── end · q to close (${pager.lines.length} lines) ──`}
</Text>
</Box>
)}
{!isBlocked && ( {!isBlocked && (
<Box flexDirection="column"> <Box flexDirection="column">
{inputBuf.map((line, i) => ( {inputBuf.map((line, i) => (

View file

@ -77,6 +77,13 @@ export interface PendingPaste {
export interface SlashCatalog { export interface SlashCatalog {
canon: Record<string, string> canon: Record<string, string>
categories: SlashCategory[]
pairs: [string, string][] pairs: [string, string][]
skillCount: number
sub: Record<string, string[]> sub: Record<string, string[]>
} }
export interface SlashCategory {
name: string
pairs: [string, string][]
}