diff --git a/hermes_cli/skills_hub.py b/hermes_cli/skills_hub.py index 0ecb677fc..fa4981c1a 100644 --- a/hermes_cli/skills_hub.py +++ b/hermes_cli/skills_hub.py @@ -496,8 +496,11 @@ def do_inspect(identifier: str, console: Optional[Console] = None) -> None: c.print() -def browse_skills(page: int = 1, page_size: int = 20, source: str = "all") -> list[dict]: - """Paginated hub browse for programmatic callers (e.g. TUI gateway).""" +def browse_skills(page: int = 1, page_size: int = 20, source: str = "all") -> dict: + """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 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: continue if not all_results: - return [] + return {"items": [], "page": 1, "total_pages": 1, "total": 0} seen: dict = {} for r in all_results: 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)) start = (page - 1) * page_size 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]: diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 023da60b1..c204e1904 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -903,31 +903,74 @@ def _(rid, params: dict) -> dict: 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") def _(rid, params: dict) -> dict: - """Registry-backed slash metadata (same surface as SlashCommandCompleter).""" + """Registry-backed slash metadata for the TUI — categorized, no aliases.""" 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]) - sub = {k: v[:] for k, v in SUBCOMMANDS.items()} + all_pairs: list[list[str]] = [] canon: dict[str, str] = {} + categories: list[dict] = [] + cat_map: dict[str, list[list[str]]] = {} + cat_order: list[str] = [] + for cmd in COMMAND_REGISTRY: - if cmd.gateway_only: - continue c = f"/{cmd.name}" canon[c.lower()] = c for a in cmd.aliases: 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: 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")) - 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: 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: return _err(rid, 5020, str(e)) @@ -1416,8 +1459,8 @@ def _(rid, params: dict) -> dict: return _ok(rid, {"installed": True, "name": query}) if action == "browse": from hermes_cli.skills_hub import browse_skills - return _ok(rid, {"results": [{"name": r.get("name", ""), "description": r.get("description", "")} - for r in (browse_skills(page=int(query) if query.isdigit() else 1) or [])]}) + pg = int(params.get("page", 0) or 0) or (int(query) if query.isdigit() else 1) + return _ok(rid, browse_skills(page=pg, page_size=int(params.get("page_size", 20)))) if action == "inspect": from hermes_cli.skills_hub import inspect_skill return _ok(rid, {"info": inspect_skill(query) or {}}) diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index 255a62d0e..25c87205c 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -280,6 +280,7 @@ export function App({ gw }: { gw: GatewayClient }) { const [turnTrail, setTurnTrail] = useState([]) const [bgTasks, setBgTasks] = useState>(new Set()) const [catalog, setCatalog] = useState(null) + const [pager, setPager] = useState<{ lines: string[]; offset: number } | null>(null) // ── Refs ───────────────────────────────────────────────────────── @@ -312,7 +313,7 @@ export function App({ gw }: { gw: GatewayClient }) { const { completions, compIdx, setCompIdx, compReplace } = useCompletion(input, blocked(), gw) function blocked() { - return !!(clarify || approval || pasteReview || picker || secret || sudo) + return !!(clarify || approval || pasteReview || picker || secret || sudo || pager) } 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 page = useCallback((text: string) => { + const lines = text.split('\n') + setPager({ lines, offset: 0 }) + }, []) + const pushActivity = useCallback((text: string, tone: ActivityItem['tone'] = 'info', replaceLabel?: string) => { setActivity(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 pagerPageSize = Math.max(5, (stdout?.rows ?? 24) - 6) + useInput((ch, key) => { 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 (key.return) { setPasteReview(null) @@ -970,7 +994,9 @@ export function App({ gw }: { gw: GatewayClient }) { setCatalog({ canon: (r.canon ?? {}) as Record, + categories: (r.categories ?? []) as SlashCatalog['categories'], pairs: r.pairs as [string, string][], + skillCount: (r.skill_count ?? 0) as number, sub: (r.sub ?? {}) as Record }) }) @@ -1272,21 +1298,26 @@ export function App({ gw }: { gw: GatewayClient }) { switch (name) { case 'help': { - const rows = catalog?.pairs ?? [] - const cap = 52 + const cats = catalog?.categories ?? [] + const skills = catalog?.skillCount ?? 0 + const lines: string[] = [] - sys( - [ - ' Commands:', - ...rows.slice(0, cap).map(([c, d]) => ` ${c.padEnd(16)} ${d}`), - rows.length > cap ? ` … ${rows.length - cap} more` : '', - '', - ' Hotkeys:', - ...HOTKEYS.map(([k, d]) => ` ${k.padEnd(14)} ${d}`) - ] - .filter(Boolean) - .join('\n') - ) + for (const { name: catName, pairs } of cats) { + if (lines.length) lines.push('') + lines.push(` ${catName}:`) + for (const [c, d] of pairs) lines.push(` ${c.padEnd(18)} ${d}`) + } + + if (!lines.length) lines.push(' (no commands loaded)') + + if (skills > 0) { + lines.push('', ` ${skills} skill commands available — /skills to browse`) + } + + lines.push('', ' Hotkeys:') + for (const [k, d] of HOTKEYS) lines.push(` ${k.padEnd(14)} ${d}`) + + sys(lines.join('\n')) return true } @@ -1527,6 +1558,13 @@ export function App({ gw }: { gw: GatewayClient }) { 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': if (arg) { 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 + 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 | 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: gw.request('slash.exec', { command: cmd.slice(1), session_id: sid }) .then((r: any) => sys(r?.output || `/${name}: no output`)) @@ -1737,7 +1825,7 @@ export function App({ gw }: { gw: GatewayClient }) { 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 @@ -1974,6 +2062,20 @@ export function App({ gw }: { gw: GatewayClient }) { /> )} + {pager && ( + + {pager.lines.slice(pager.offset, pager.offset + pagerPageSize).map((line, i) => ( + {line} + ))} + + + {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) ──`} + + + )} + {!isBlocked && ( {inputBuf.map((line, i) => ( diff --git a/ui-tui/src/types.ts b/ui-tui/src/types.ts index 1cfa03540..0c87b2cc7 100644 --- a/ui-tui/src/types.ts +++ b/ui-tui/src/types.ts @@ -77,6 +77,13 @@ export interface PendingPaste { export interface SlashCatalog { canon: Record + categories: SlashCategory[] pairs: [string, string][] + skillCount: number sub: Record } + +export interface SlashCategory { + name: string + pairs: [string, string][] +}