diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 23d8430e2..00e4fa04b 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -175,6 +175,7 @@ _LONG_HANDLERS = frozenset( { "browser.manage", "cli.exec", + "plugins.manage", "session.branch", "session.compress", "session.resume", @@ -9242,7 +9243,83 @@ def _(rid, params: dict) -> dict: return _err(rid, 5025, str(e)) -# ── Methods: shell ─────────────────────────────────────────────────── +@method("plugins.manage") +def _(rid, params: dict) -> dict: + """List installed plugins with activation state, or toggle one on/off. + + Backs the TUI Plugins Hub. Uses the same disk-discovery + enable/disable + primitives as ``hermes plugins`` / the dashboard, so the three surfaces + agree on what's installed and what's enabled. + + Actions: + - ``list`` → {"plugins": [{name, version, description, source, + status}], "user_count": N, "bundled_count": M} + - ``toggle`` → flip ``name`` based on ``enable`` (bool). Returns the + refreshed row plus {"ok", "unchanged"}. + """ + action = params.get("action", "list") + try: + from hermes_cli.plugins_cmd import ( + _discover_all_plugins, + _get_disabled_set, + _get_enabled_set, + _plugin_status, + ) + + def _rows(): + enabled = _get_enabled_set() + disabled = _get_disabled_set() + out = [] + for name, version, desc, source, _dir, key in sorted( + _discover_all_plugins() + ): + out.append( + { + "name": name, + "version": str(version or ""), + "description": desc or "", + "source": source, + "status": _plugin_status(name, enabled, disabled, key=key), + } + ) + return out + + if action == "list": + rows = _rows() + user_count = sum(1 for r in rows if r["source"] != "bundled") + return _ok( + rid, + { + "plugins": rows, + "user_count": user_count, + "bundled_count": len(rows) - user_count, + }, + ) + + if action == "toggle": + from hermes_cli.plugins_cmd import dashboard_set_agent_plugin_enabled + + name = (params.get("name") or "").strip() + if not name: + return _err(rid, 4019, "plugins.toggle requires a 'name'") + enable = bool(params.get("enable")) + result = dashboard_set_agent_plugin_enabled(name, enabled=enable) + if not result.get("ok"): + return _err(rid, 5026, result.get("error") or "toggle failed") + row = next((r for r in _rows() if r["name"] == name), None) + return _ok( + rid, + { + "ok": True, + "unchanged": bool(result.get("unchanged")), + "name": name, + "plugin": row, + }, + ) + + return _err(rid, 4017, f"unknown plugins action: {action}") + except Exception as e: + return _err(rid, 5026, str(e)) @method("shell.exec") diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index 499232924..5382bac9b 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -93,6 +93,7 @@ export interface OverlayState { confirm: ConfirmReq | null modelPicker: boolean pager: null | PagerState + pluginsHub: boolean secret: null | SecretReq sessions: boolean skillsHub: boolean diff --git a/ui-tui/src/app/overlayStore.ts b/ui-tui/src/app/overlayStore.ts index 52692c6b3..82c1629ab 100644 --- a/ui-tui/src/app/overlayStore.ts +++ b/ui-tui/src/app/overlayStore.ts @@ -10,6 +10,7 @@ const buildOverlayState = (): OverlayState => ({ confirm: null, modelPicker: false, pager: null, + pluginsHub: false, secret: null, sessions: false, skillsHub: false, @@ -20,8 +21,10 @@ export const $overlayState = atom(buildOverlayState()) export const $isBlocked = computed( $overlayState, - ({ agents, approval, clarify, confirm, modelPicker, pager, secret, sessions, skillsHub, sudo }) => - Boolean(agents || approval || clarify || confirm || modelPicker || pager || secret || sessions || skillsHub || sudo) + ({ agents, approval, clarify, confirm, modelPicker, pager, pluginsHub, secret, sessions, skillsHub, sudo }) => + Boolean( + agents || approval || clarify || confirm || modelPicker || pager || pluginsHub || secret || sessions || skillsHub || sudo + ) ) export const getOverlayState = () => $overlayState.get() @@ -46,6 +49,7 @@ export const resetFlowOverlays = () => agents: $overlayState.get().agents, agentsInitialHistoryIndex: $overlayState.get().agentsInitialHistoryIndex, modelPicker: $overlayState.get().modelPicker, + pluginsHub: $overlayState.get().pluginsHub, sessions: $overlayState.get().sessions, skillsHub: $overlayState.get().skillsHub }) diff --git a/ui-tui/src/app/slash/commands/ops.ts b/ui-tui/src/app/slash/commands/ops.ts index 791a96c1d..ad41a0497 100644 --- a/ui-tui/src/app/slash/commands/ops.ts +++ b/ui-tui/src/app/slash/commands/ops.ts @@ -652,6 +652,34 @@ export const opsCommands: SlashCommand[] = [ } }, + { + help: 'view & toggle plugins (no arg opens the hub; enable/disable for direct toggle)', + name: 'plugins', + run: (arg, ctx, cmd) => { + // No argument → open the interactive Plugins Hub overlay. Any + // subcommand (enable/disable/list/install/…) falls through to the + // text slash worker so it stays at parity with `hermes plugins`. + if (!arg.trim()) { + return patchOverlayState({ pluginsHub: true }) + } + + ctx.gateway.gw + .request('slash.exec', { command: cmd.slice(1), session_id: ctx.sid }) + .then(r => { + if (ctx.stale()) { + return + } + + const body = r?.output || '/plugins: no output' + const text = r?.warning ? `warning: ${r.warning}\n${body}` : body + const long = text.length > 180 || text.split('\n').filter(Boolean).length > 2 + + long ? ctx.transcript.page(text, 'Plugins') : ctx.transcript.sys(text) + }) + .catch(ctx.guardedErr) + } + }, + { help: 'enable or disable tools (client-side history reset on change)', name: 'tools', diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts index f18ebc580..4e8dac7e3 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -151,6 +151,10 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { return patchOverlayState({ skillsHub: false }) } + if (overlay.pluginsHub) { + return patchOverlayState({ pluginsHub: false }) + } + if (overlay.sessions) { return patchOverlayState({ sessions: false }) } diff --git a/ui-tui/src/components/appOverlays.tsx b/ui-tui/src/components/appOverlays.tsx index fd7f8aaa8..9ad3a5ea7 100644 --- a/ui-tui/src/components/appOverlays.tsx +++ b/ui-tui/src/components/appOverlays.tsx @@ -11,6 +11,7 @@ import { FloatBox } from './appChrome.js' import { MaskedPrompt } from './maskedPrompt.js' import { ModelPicker } from './modelPicker.js' import { OverlayHint } from './overlayControls.js' +import { PluginsHub } from './pluginsHub.js' import { ApprovalPrompt, ClarifyPrompt, ConfirmPrompt } from './prompts.js' import { SkillsHub } from './skillsHub.js' @@ -125,6 +126,7 @@ export function FloatingOverlays({ overlay.pager || overlay.sessions || overlay.skillsHub || + overlay.pluginsHub || completions.length if (!hasAny) { @@ -174,6 +176,12 @@ export function FloatingOverlays({ )} + {overlay.pluginsHub && ( + + patchOverlayState({ pluginsHub: false })} t={theme} /> + + )} + {overlay.pager && ( diff --git a/ui-tui/src/components/pluginsHub.tsx b/ui-tui/src/components/pluginsHub.tsx new file mode 100644 index 000000000..1c235a125 --- /dev/null +++ b/ui-tui/src/components/pluginsHub.tsx @@ -0,0 +1,238 @@ +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 { OverlayHint, useOverlayKeys, windowItems, windowOffset } from './overlayControls.js' + +const VISIBLE = 12 +const MIN_WIDTH = 44 +const MAX_WIDTH = 96 + +interface PluginRow { + description?: string + name: string + source?: string + status?: string + version?: string +} + +interface PluginsListResponse { + bundled_count?: number + plugins?: PluginRow[] + user_count?: number +} + +interface PluginsToggleResponse { + name?: string + ok?: boolean + plugin?: PluginRow + unchanged?: boolean +} + +type Scope = 'all' | 'user' + +const GLYPH: Record = { + disabled: '✗', + enabled: '✓' +} + +export function PluginsHub({ gw, onClose, t }: PluginsHubProps) { + const [rows, setRows] = useState([]) + const [bundledCount, setBundledCount] = useState(0) + const [userCount, setUserCount] = useState(0) + const [idx, setIdx] = useState(0) + const [scope, setScope] = useState('user') + const [busy, setBusy] = 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)) + + const load = () => { + gw.request('plugins.manage', { action: 'list' }) + .then(r => { + setRows(r?.plugins ?? []) + setUserCount(Number(r?.user_count ?? 0)) + setBundledCount(Number(r?.bundled_count ?? 0)) + setErr('') + setLoading(false) + }) + .catch((e: unknown) => { + setErr(rpcErrorMessage(e)) + setLoading(false) + }) + } + + useEffect(load, [gw]) + + // Default to user plugins; fall back to all when there are none so the + // overlay is never empty when bundled plugins exist. + const visibleRows = scope === 'user' ? rows.filter(r => r.source !== 'bundled') : rows + const effectiveRows = scope === 'user' && !visibleRows.length && rows.length ? rows : visibleRows + const effectiveScope: Scope = effectiveRows === visibleRows ? scope : 'all' + const clampedIdx = Math.min(idx, Math.max(0, effectiveRows.length - 1)) + + useOverlayKeys({ disabled: busy, onClose }) + + const toggle = (row: PluginRow) => { + if (busy || !row) { + return + } + + const enable = row.status !== 'enabled' + setBusy(true) + setErr('') + + gw.request('plugins.manage', { action: 'toggle', enable, name: row.name }) + .then(r => { + if (r?.plugin) { + setRows(prev => prev.map(p => (p.name === r.plugin!.name ? r.plugin! : p))) + } else { + load() + } + }) + .catch((e: unknown) => setErr(rpcErrorMessage(e))) + .finally(() => setBusy(false)) + } + + useInput((ch, key) => { + if (busy) { + return + } + + const count = effectiveRows.length + + if (key.upArrow && clampedIdx > 0) { + setIdx(clampedIdx - 1) + + return + } + + if (key.downArrow && clampedIdx < count - 1) { + setIdx(clampedIdx + 1) + + return + } + + // Tab toggles user-only vs all (bundled) scope. + if (key.tab) { + setScope(s => (s === 'user' ? 'all' : 'user')) + setIdx(0) + + return + } + + if (key.return || ch === ' ') { + const row = effectiveRows[clampedIdx] + + if (row) { + toggle(row) + } + + return + } + + const n = ch === '0' ? 10 : parseInt(ch, 10) + + if (!Number.isNaN(n) && n >= 1 && n <= Math.min(10, count)) { + const next = windowOffset(count, clampedIdx, VISIBLE) + n - 1 + const row = effectiveRows[next] + + if (row) { + setIdx(next) + toggle(row) + } + } + }) + + if (loading) { + return loading plugins… + } + + if (err && !rows.length) { + return ( + + error: {err} + Esc/q close + + ) + } + + if (!rows.length) { + return ( + + + Plugins Hub + + no plugins installed + install: hermes plugins install owner/repo + Esc/q close + + ) + } + + const labels = effectiveRows.map(r => { + const status = r.status ?? 'not enabled' + const glyph = GLYPH[status] ?? '○' + const ver = r.version ? ` v${r.version}` : '' + const src = effectiveScope === 'all' && r.source === 'bundled' ? ' [bundled]' : '' + const state = status === 'enabled' ? '' : ` (${status})` + + return `${glyph} ${r.name}${ver}${src}${state}` + }) + + const { items, offset } = windowItems(labels, clampedIdx, VISIBLE) + + const scopeLabel = + effectiveScope === 'user' + ? `${userCount} user plugin(s)${bundledCount ? ` · +${bundledCount} bundled (Tab)` : ''}` + : `all ${rows.length} plugins` + + return ( + + + Plugins Hub + + + {scopeLabel} + {offset > 0 && ↑ {offset} more} + + {items.map((row, i) => { + const lineIdx = offset + i + const active = clampedIdx === lineIdx + + return ( + + {active ? '▸ ' : ' '} + {i + 1}. {row} + + ) + })} + + {offset + VISIBLE < labels.length && ( + ↓ {labels.length - offset - VISIBLE} more + )} + + {err ? error: {err} : null} + {busy ? updating… : null} + + ↑/↓ select · Enter/Space toggle · Tab user/all · 1-9,0 quick · Esc/q close + + ) +} + +interface PluginsHubProps { + gw: GatewayClient + onClose: () => void + t: Theme +}