import React, {useCallback, useEffect, useState} from "react"; import {Box, Text, useInput, useStdout} from "ink"; import {TextInput} from "@inkjs/ui"; import type {GooseClient} from "@aaif/goose-sdk"; import {CRANBERRY, GOLD, RULE_COLOR, TEAL, TEXT_DIM, TEXT_PRIMARY} from "./colors.js"; import {Spinner, SPINNER_FRAMES} from "./components/Spinner.js"; import {ErrorScreen} from "./components/ErrorScreen.js"; type ExtEntry = { enabled: boolean; type: string; name: string; description: string; [key: string]: unknown; }; function isExtEntry(v: unknown): v is ExtEntry { return !!v && typeof v === "object" && "enabled" in v && "type" in v && "name" in v && typeof (v as ExtEntry).enabled === "boolean" && typeof (v as ExtEntry).type === "string" && typeof (v as ExtEntry).name === "string"; } type AddType = "stdio" | "streamable_http"; type Phase = "loading" | "list" | "add_type" | "add_value" | "add_name" | "add_desc" | "saving" | "error"; function deriveNameFromValue(addType: AddType, value: string): string { if (addType === "stdio") { const cmd = value.trim().split(/\s+/)[0] ?? ""; return cmd.split("/").pop() ?? cmd; } try { return new URL(value.trim()).hostname; } catch { return value.trim(); } } function keyFromName(name: string): string { return name.replace(/[^A-Za-z0-9_-]/g, "_").toLowerCase(); } function buildConfig(addType: AddType, value: string, name: string, description: string): ExtEntry { if (addType === "stdio") { const parts = value.trim().split(/\s+/); return {type: "stdio", enabled: true, name, description, cmd: parts[0] ?? "", args: parts.slice(1)}; } return {type: "streamable_http", enabled: true, name, description, uri: value.trim()}; } export default function ExtensionsManager({ client, sessionId, height, onClose, }: { client: GooseClient; sessionId: string; height: number; onClose: () => void; }) { const {stdout} = useStdout(); const columns = stdout?.columns ?? 80; const [phase, setPhase] = useState("loading"); const [spinIdx, setSpinIdx] = useState(0); const [errorMsg, setErrorMsg] = useState(""); const [entries, setEntries] = useState([]); const [warnings, setWarnings] = useState([]); const [selectedIdx, setSelectedIdx] = useState(0); const [addType, setAddType] = useState("stdio"); const [addValue, setAddValue] = useState(""); const [addName, setAddName] = useState(""); const [addDesc, setAddDesc] = useState(""); const [inputKey, setInputKey] = useState(0); useEffect(() => { const t = setInterval(() => setSpinIdx(i => (i + 1) % SPINNER_FRAMES.length), 300); return () => clearInterval(t); }, []); const reload = useCallback(async () => { setPhase("loading"); try { const [configResp, sessionResp] = await Promise.all([ client.goose.GooseConfigExtensions({}), client.goose.GooseSessionExtensions({sessionId}), ]); const allExtensions = (configResp.extensions as unknown[]).filter(isExtEntry); const activeNames = new Set( (sessionResp.extensions as Array<{name?: string}>).map(e => e.name), ); setEntries(allExtensions.map(ext => ({...ext, enabled: activeNames.has(ext.name)}))); setWarnings(configResp.warnings ?? []); setPhase("list"); } catch (e: unknown) { setErrorMsg(e instanceof Error ? e.message : String(e)); setPhase("error"); } }, [client, sessionId]); useEffect(() => { reload(); }, [reload]); const withSaving = useCallback(async (fn: () => Promise) => { setPhase("saving"); try { await fn(); await reload(); } catch (e: unknown) { setErrorMsg(e instanceof Error ? e.message : String(e)); setPhase("error"); } }, [reload]); const toggleSelected = useCallback(() => { const sel = entries[selectedIdx]; if (!sel) return; withSaving(async () => { if (sel.enabled) { await client.goose.GooseExtensionsRemove({sessionId, name: sel.name}); } else { await client.goose.GooseExtensionsAdd({sessionId, config: sel as any}); } }); }, [entries, selectedIdx, client, sessionId, withSaving]); const saveNewExtension = useCallback((description: string) => { const config = buildConfig(addType, addValue, addName, description); const key = keyFromName(config.name); withSaving(async () => { let extMap: Record = {}; try { const raw = await client.goose.GooseConfigRead({key: "extensions"}); if (raw.value && typeof raw.value === "object") extMap = raw.value as Record; } catch { } extMap[key] = config; await client.goose.GooseConfigUpsert({key: "extensions", value: extMap as any}); await client.goose.GooseExtensionsAdd({sessionId, config: config as any}); }); }, [addType, addValue, addName, client, sessionId, withSaving]); useInput((ch, key) => { if (phase === "list") { if (key.escape) { onClose(); return; } if (key.upArrow) { setSelectedIdx(i => Math.max(i - 1, 0)); return; } if (key.downArrow) { setSelectedIdx(i => Math.min(i + 1, entries.length - 1)); return; } if (ch === " " || key.return) { toggleSelected(); return; } if (ch === "a") { setAddType("stdio"); setPhase("add_type"); return; } } if (phase === "add_type") { if (key.escape) { setPhase("list"); return; } if (key.upArrow || key.downArrow) { setAddType(t => t === "stdio" ? "streamable_http" : "stdio"); return; } if (key.return) { setAddValue(""); setInputKey(k => k + 1); setPhase("add_value"); return; } } if (key.escape) { if (phase === "add_value") { setPhase("add_type"); return; } if (phase === "add_name") { setInputKey(k => k + 1); setPhase("add_value"); return; } if (phase === "add_desc") { setInputKey(k => k + 1); setPhase("add_name"); return; } } }); if (phase === "loading" || phase === "saving") { return ( ◆ Manage extensions ◆ {phase === "loading" ? "Loading extensions…" : "Saving…"} ); } if (phase === "error") { return ( ◆ Manage extensions ◆ reload()} /> ); } const maxW = Math.min(columns - 4, 80); const inputW = Math.min(maxW - 10, 70); if (phase === "add_type") { const types: {value: AddType; label: string; hint: string}[] = [ {value: "stdio", label: "Command (stdio)", hint: "run a local command"}, {value: "streamable_http", label: "Endpoint (HTTP)", hint: "connect to a remote server"}, ]; return ( ◆ Add extension ◆ Choose a connection type {types.map(t => { const active = addType === t.value; return ( {active ? "▸ " : " "} {t.label} {t.hint} ); })} ↑↓ select · enter confirm · esc cancel ); } if (phase === "add_value") { const isStdio = addType === "stdio"; const placeholder = isStdio ? "npx -y @modelcontextprotocol/server-filesystem /tmp" : "http://localhost:8080/mcp"; return ( ◆ {isStdio ? "Enter command" : "Enter endpoint URL"} ◆ {isStdio ? "The command to launch the extension" : "URL of the remote MCP server"} {"❯ "} { if (!v.trim()) return; setAddValue(v); setAddName(deriveNameFromValue(addType, v)); setInputKey(k => k + 1); setPhase("add_name"); }} /> enter continue · esc back ); } if (phase === "add_name") { return ( ◆ Name this extension ◆ A short name to identify this extension {"❯ "} { if (!v.trim()) return; setAddName(v.trim()); setAddDesc(""); setInputKey(k => k + 1); setPhase("add_desc"); }} /> enter continue · esc back ); } if (phase === "add_desc") { return ( ◆ Description ◆ What does this extension do? (optional) {"❯ "} saveNewExtension(v.trim())} /> enter save (leave empty to skip) · esc back ); } const layoutW = maxW; const GUTTER = 2; const STATUS_W = 10; const nameW = Math.max(16, Math.floor(layoutW * 0.30)); const descW = Math.max(8, layoutW - 2 - STATUS_W - nameW - 2 * GUTTER); const rows = Math.max(height - 9, 4); const maxStart = Math.max(0, entries.length - rows); const start = Math.min(maxStart, Math.max(0, selectedIdx - Math.floor(rows / 2))); const end = Math.min(entries.length, start + rows); const windowed = entries.slice(start, end); return ( {/* Header */} ◆ Manage extensions ◆ Toggle, add, or remove extensions for this session {/* Extension List */} {entries.length === 0 ? ( No extensions configured — press a to add one ) : ( <> {start > 0 && ( ▲ {start} more above )} {windowed.map((ext, i) => { const globalIdx = start + i; const active = globalIdx === selectedIdx; return ( {active ? "▸ " : " "} {ext.name} {" ".repeat(GUTTER)} {ext.description || ""} {" ".repeat(GUTTER)} {ext.enabled ? "enabled" : "disabled"} ); })} {end < entries.length && ( ▼ {entries.length - end} more below )} )} {warnings.length > 0 && ( Warnings {warnings.map((w, i) => ( • {w} ))} )} {/* Footer */} space/enter toggle · a add · esc back ); }