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 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.configExtensionsList_unstable({}), client.goose.sessionExtensionsList_unstable({ 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.sessionExtensionsRemove_unstable({ sessionId, name: sel.name, }); } else { await client.goose.sessionExtensionsAdd_unstable({ sessionId, config: sel as any, }); } }); }, [entries, selectedIdx, client, sessionId, withSaving]); const saveNewExtension = useCallback( (description: string) => { const config = buildConfig(addType, addValue, addName, description); withSaving(async () => { await client.goose.configExtensionsAdd_unstable({ name: config.name, extensionConfig: config as any, enabled: true, }); await client.goose.sessionExtensionsAdd_unstable({ 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.3)); 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 ); }