import { useCallback, useEffect, useState } from "react"; import { createPortal } from "react-dom"; import { Check, ExternalLink, Loader2, Terminal, X } from "lucide-react"; import { api } from "@/lib/api"; import type { ToolsetConfig, ToolsetInfo, ToolsetProvider, } from "@/lib/api"; import { useToast } from "@nous-research/ui/hooks/use-toast"; import { Button } from "@nous-research/ui/ui/components/button"; import { Input } from "@nous-research/ui/ui/components/input"; import { Label } from "@nous-research/ui/ui/components/label"; import { Badge } from "@nous-research/ui/ui/components/badge"; import { Switch } from "@nous-research/ui/ui/components/switch"; import { Spinner } from "@nous-research/ui/ui/components/spinner"; import { Toast } from "@nous-research/ui/ui/components/toast"; import { cn, themedBody } from "@/lib/utils"; interface Props { /** The toolset whose backends are being configured. */ toolset: ToolsetInfo; /** Optional profile to scope config reads/writes to (Skills page profile * selector). Omitted = the dashboard process's own profile. */ profile?: string; onClose: () => void; /** Called after a toggle/provider/key change so the parent grid refreshes. */ onChanged: () => void; } /** * Full configuration surface for a single toolset's backends — the dashboard * equivalent of selecting a toolset in the `hermes tools` curses UI: toggle * the toolset on/off, pick a provider, enter API keys, and run a provider's * post-setup install hook (npm/pip/binary) with a live log tail. */ export function ToolsetConfigDrawer({ toolset, profile, onClose, onChanged }: Props) { const { toast, showToast } = useToast(); const [config, setConfig] = useState(null); const [loading, setLoading] = useState(true); const [enabled, setEnabled] = useState(toolset.enabled); const [toggling, setToggling] = useState(false); const [selecting, setSelecting] = useState(null); const [activeProvider, setActiveProvider] = useState(null); // Per-env-var draft input values, keyed by env var name. const [drafts, setDrafts] = useState>({}); const [savingProvider, setSavingProvider] = useState(null); const [isSet, setIsSet] = useState>({}); // Post-setup install log tail state. const [postSetupRunning, setPostSetupRunning] = useState(false); const [postSetupLog, setPostSetupLog] = useState([]); const [postSetupKey, setPostSetupKey] = useState(null); // Bumped each time a post-setup is kicked off, to (re)trigger the poll // effect below. Mirrors the SkillsPage HubBrowser action-poll pattern so // the recursive timer lives inside the effect (lint-clean — no ref // mutation, no self-referencing memo). const [postSetupTrigger, setPostSetupTrigger] = useState(0); const loadConfig = useCallback(() => { // Promise-chain shape (not async/await with a leading synchronous // setLoading) so callers in a useEffect don't trip // react-hooks/set-state-in-effect — setState only fires inside the // async .then/.catch/.finally callbacks. return api .getToolsetConfig(toolset.name, profile) .then((cfg) => { setConfig(cfg); setActiveProvider(cfg.active_provider); const seed: Record = {}; for (const p of cfg.providers) { for (const e of p.env_vars) seed[e.key] = e.is_set; } setIsSet(seed); }) .catch(() => showToast("Failed to load toolset config", "error")) .finally(() => setLoading(false)); }, [toolset.name, profile, showToast]); useEffect(() => { void loadConfig(); }, [loadConfig]); // Poll the post-setup action's log until it exits. Driven by // postSetupTrigger; the recursive timer + cleanup live entirely inside the // effect (matches the SkillsPage HubBrowser pattern — lint-clean). useEffect(() => { if (postSetupTrigger === 0) return; let cancelled = false; let timer: ReturnType | null = null; const poll = async () => { try { const st = await api.getActionStatus("tools-post-setup", 300); if (cancelled) return; setPostSetupLog(st.lines); if (st.running) { timer = setTimeout(() => void poll(), 1200); } else { setPostSetupRunning(false); const ok = st.exit_code === 0; showToast( ok ? "Post-setup complete" : "Post-setup finished with errors", ok ? "success" : "error", ); // Refresh — a backend may now report itself configured/available. void loadConfig(); onChanged(); } } catch { if (!cancelled) { setPostSetupRunning(false); showToast("Lost track of the post-setup process", "error"); } } }; // Small delay so the spawned action has a log file to read. timer = setTimeout(() => void poll(), 800); return () => { cancelled = true; if (timer) clearTimeout(timer); }; }, [postSetupTrigger, showToast, loadConfig, onChanged]); const handleToggle = async (next: boolean) => { setToggling(true); try { await api.toggleToolset(toolset.name, next, profile); setEnabled(next); showToast( `${toolset.label || toolset.name} ${next ? "enabled" : "disabled"}`, "success", ); onChanged(); } catch { showToast("Failed to toggle toolset", "error"); } finally { setToggling(false); } }; const handleSelectProvider = async (provider: ToolsetProvider) => { setSelecting(provider.name); try { await api.selectToolsetProvider(toolset.name, provider.name, profile); setActiveProvider(provider.name); showToast(`Provider set to ${provider.name}`, "success"); onChanged(); } catch (e) { showToast( e instanceof Error ? e.message : "Failed to select provider", "error", ); } finally { setSelecting(null); } }; const handleSaveKeys = async (provider: ToolsetProvider) => { const env: Record = {}; for (const e of provider.env_vars) { const v = drafts[e.key]; if (v && v.trim()) env[e.key] = v.trim(); } if (Object.keys(env).length === 0) { showToast("Enter at least one value to save", "error"); return; } setSavingProvider(provider.name); try { const res = await api.saveToolsetEnv(toolset.name, env, profile); setIsSet((prev) => ({ ...prev, ...res.is_set })); // Clear saved drafts so the inputs reset to the "saved" placeholder. setDrafts((prev) => { const next = { ...prev }; for (const k of res.saved) delete next[k]; return next; }); showToast( res.saved.length ? `Saved ${res.saved.length} key${res.saved.length > 1 ? "s" : ""}` : "Nothing to save", "success", ); onChanged(); } catch (e) { showToast( e instanceof Error ? e.message : "Failed to save keys", "error", ); } finally { setSavingProvider(null); } }; const handleRunPostSetup = async (provider: ToolsetProvider) => { if (!provider.post_setup) return; setPostSetupRunning(true); setPostSetupLog([]); setPostSetupKey(provider.post_setup); try { await api.runToolsetPostSetup(toolset.name, provider.post_setup, profile); // Bump the trigger so the poll effect (re)starts tailing the log. setPostSetupTrigger((n) => n + 1); } catch (e) { setPostSetupRunning(false); showToast( e instanceof Error ? e.message : "Failed to start post-setup", "error", ); } }; const labelText = toolset.label?.trim() || toolset.name; return createPortal(
{ if (e.target === e.currentTarget) onClose(); }} >
{/* Header — toolset identity + enable toggle */}
{labelText} {enabled ? "Active" : "Inactive"}

{toolset.description}

void handleToggle(v)} disabled={toggling} aria-label="Enable toolset" /> {enabled ? "Enabled for the agent" : "Disabled"}
{/* Body — provider matrix */}
{loading ? (
) : !config?.has_category ? (

This toolset has no configurable backends — toggle it on or off above. It works with no provider selection or API keys.

) : config.providers.length === 0 ? (

No providers are available for this toolset in this install.

) : ( config.providers.map((provider) => { const isActive = provider.name === activeProvider; return (
{provider.name} {provider.badge && ( {provider.badge} )} {provider.requires_nous_auth && ( Nous Portal )}
{isActive ? ( Selected ) : ( )}
{provider.tag && (

{provider.tag}

)} {/* API key inputs */} {provider.env_vars.length > 0 && (
{provider.env_vars.map((ev) => (
{isSet[ev.key] && ( Saved )}
setDrafts((prev) => ({ ...prev, [ev.key]: e.target.value, })) } /> {ev.url && ( Get a key )}
))}
)} {/* Post-setup install hook */} {provider.post_setup && (

This backend needs a one-time install {" "} ({provider.post_setup}) . Runs on this host — may take a few minutes.

)}
); }) )} {/* Post-setup live log */} {(postSetupRunning || postSetupLog.length > 0) && (
post-setup: {postSetupKey} {postSetupRunning && ( )}
                {postSetupLog.length ? postSetupLog.join("\n") : "Starting…"}
              
)}
, document.body, ); }