Compare commits

...

1 Commits

Author SHA1 Message Date
Austin Pickett
7d8c31186d feat(tui): interactive Plugins Hub overlay for enable/disable
The TUI had no way to toggle plugins — `/plugins` only printed a static
list, and the classic `hermes plugins` picker is curses-based and can't
run inside the Ink UI. Users had to drop to a separate shell and run
`hermes plugins enable/disable`.

Add a PluginsHub overlay modeled on the existing SkillsHub:

- New gateway RPC `plugins.manage` (list + toggle) backed by the same
  disk-discovery + dashboard_set_agent_plugin_enabled primitives the CLI
  and dashboard already use, so all three surfaces agree on state. The
  toggle path also wires the plugin's toolset into platform_toolsets.
- `/plugins` with no arg opens the hub; any subcommand still falls
  through to the text slash worker for CLI parity.
- pluginsHub overlay state threaded through overlayStore / interfaces /
  useInputHandlers (Esc closes) / appOverlays (renders the FloatBox);
  preserved across turn teardown like other user-toggled overlays.
- Hub UI: arrow/number select, Enter/Space toggles live, Tab switches
  user-only vs all (bundled) scope, shows ✓/✗/○ activation glyphs.

plugins.manage added to _LONG_HANDLERS (disk + config I/O).
2026-06-09 00:23:50 -04:00
7 changed files with 363 additions and 3 deletions

View File

@@ -175,6 +175,7 @@ _LONG_HANDLERS = frozenset(
{
"browser.manage",
"cli.exec",
"plugins.manage",
"session.branch",
"session.compress",
"session.resume",
@@ -9020,7 +9021,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")

View File

@@ -93,6 +93,7 @@ export interface OverlayState {
confirm: ConfirmReq | null
modelPicker: boolean
pager: null | PagerState
pluginsHub: boolean
secret: null | SecretReq
sessions: boolean
skillsHub: boolean

View File

@@ -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<OverlayState>(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
})

View File

@@ -652,6 +652,34 @@ export const opsCommands: SlashCommand[] = [
}
},
{
help: 'view & toggle plugins (no arg opens the hub; enable/disable <name> 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<SlashExecResponse>('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',

View File

@@ -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 })
}

View File

@@ -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({
</FloatBox>
)}
{overlay.pluginsHub && (
<FloatBox color={theme.color.border}>
<PluginsHub gw={gw} onClose={() => patchOverlayState({ pluginsHub: false })} t={theme} />
</FloatBox>
)}
{overlay.pager && (
<FloatBox color={theme.color.border}>
<Box flexDirection="column" paddingX={1} paddingY={1}>

View File

@@ -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<string, string> = {
disabled: '✗',
enabled: '✓'
}
export function PluginsHub({ gw, onClose, t }: PluginsHubProps) {
const [rows, setRows] = useState<PluginRow[]>([])
const [bundledCount, setBundledCount] = useState(0)
const [userCount, setUserCount] = useState(0)
const [idx, setIdx] = useState(0)
const [scope, setScope] = useState<Scope>('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<PluginsListResponse>('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<PluginsToggleResponse>('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 <Text color={t.color.muted}>loading plugins</Text>
}
if (err && !rows.length) {
return (
<Box flexDirection="column" width={width}>
<Text color={t.color.label}>error: {err}</Text>
<OverlayHint t={t}>Esc/q close</OverlayHint>
</Box>
)
}
if (!rows.length) {
return (
<Box flexDirection="column" width={width}>
<Text bold color={t.color.accent}>
Plugins Hub
</Text>
<Text color={t.color.muted}>no plugins installed</Text>
<Text color={t.color.muted}>install: hermes plugins install owner/repo</Text>
<OverlayHint t={t}>Esc/q close</OverlayHint>
</Box>
)
}
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 (
<Box flexDirection="column" width={width}>
<Text bold color={t.color.accent}>
Plugins Hub
</Text>
<Text color={t.color.muted}>{scopeLabel}</Text>
{offset > 0 && <Text color={t.color.muted}> {offset} more</Text>}
{items.map((row, i) => {
const lineIdx = offset + i
const active = clampedIdx === lineIdx
return (
<Text
bold={active}
color={active ? t.color.accent : t.color.muted}
inverse={active}
key={effectiveRows[lineIdx]?.name ?? row}
wrap="truncate-end"
>
{active ? '▸ ' : ' '}
{i + 1}. {row}
</Text>
)
})}
{offset + VISIBLE < labels.length && (
<Text color={t.color.muted}> {labels.length - offset - VISIBLE} more</Text>
)}
{err ? <Text color={t.color.label}>error: {err}</Text> : null}
{busy ? <Text color={t.color.accent}>updating</Text> : null}
<OverlayHint t={t}>/ select · Enter/Space toggle · Tab user/all · 1-9,0 quick · Esc/q close</OverlayHint>
</Box>
)
}
interface PluginsHubProps {
gw: GatewayClient
onClose: () => void
t: Theme
}