From ea236ab6648b4fd0bf37bf18377a3efec6a64657 Mon Sep 17 00:00:00 2001 From: Alex Hancock Date: Wed, 15 Apr 2026 13:39:01 -0400 Subject: [PATCH] feat(tui): add extension management screen (#8536) --- crates/goose-acp/acp-meta.json | 5 + crates/goose-acp/acp-schema.json | 44 +++ crates/goose-acp/src/server.rs | 28 ++ crates/goose-sdk/src/custom_requests.rs | 12 + ui/sdk/src/generated/client.gen.ts | 12 + ui/sdk/src/generated/index.ts | 7 +- ui/sdk/src/generated/types.gen.ts | 12 +- ui/sdk/src/generated/zod.gen.ts | 10 + ui/text/src/components/Header.tsx | 2 +- ui/text/src/configure.tsx | 271 ++++++++++------ ui/text/src/extensions.tsx | 409 ++++++++++++++++++++++++ ui/text/src/onboarding.tsx | 18 +- ui/text/src/tui.tsx | 64 ++-- 13 files changed, 753 insertions(+), 141 deletions(-) create mode 100644 ui/text/src/extensions.tsx diff --git a/crates/goose-acp/acp-meta.json b/crates/goose-acp/acp-meta.json index 4f307c5a81..7743ab6b33 100644 --- a/crates/goose-acp/acp-meta.json +++ b/crates/goose-acp/acp-meta.json @@ -35,6 +35,11 @@ "requestType": "GetExtensionsRequest", "responseType": "GetExtensionsResponse" }, + { + "method": "_goose/session/extensions", + "requestType": "GetSessionExtensionsRequest", + "responseType": "GetSessionExtensionsResponse" + }, { "method": "_goose/session/provider/update", "requestType": "UpdateProviderRequest", diff --git a/crates/goose-acp/acp-schema.json b/crates/goose-acp/acp-schema.json index d704e305a7..590d8a871f 100644 --- a/crates/goose-acp/acp-schema.json +++ b/crates/goose-acp/acp-schema.json @@ -168,6 +168,33 @@ "x-side": "agent", "x-method": "_goose/config/extensions" }, + "GetSessionExtensionsRequest": { + "type": "object", + "properties": { + "sessionId": { + "type": "string" + } + }, + "required": [ + "sessionId" + ], + "x-side": "agent", + "x-method": "_goose/session/extensions" + }, + "GetSessionExtensionsResponse": { + "type": "object", + "properties": { + "extensions": { + "type": "array", + "items": {} + } + }, + "required": [ + "extensions" + ], + "x-side": "agent", + "x-method": "_goose/session/extensions" + }, "UpdateProviderRequest": { "type": "object", "properties": { @@ -710,6 +737,15 @@ "description": "Params for _goose/config/extensions", "title": "GetExtensionsRequest" }, + { + "allOf": [ + { + "$ref": "#/$defs/GetSessionExtensionsRequest" + } + ], + "description": "Params for _goose/session/extensions", + "title": "GetSessionExtensionsRequest" + }, { "allOf": [ { @@ -898,6 +934,14 @@ ], "title": "GetExtensionsResponse" }, + { + "allOf": [ + { + "$ref": "#/$defs/GetSessionExtensionsResponse" + } + ], + "title": "GetSessionExtensionsResponse" + }, { "allOf": [ { diff --git a/crates/goose-acp/src/server.rs b/crates/goose-acp/src/server.rs index 681b9e84c9..bf902a9848 100644 --- a/crates/goose-acp/src/server.rs +++ b/crates/goose-acp/src/server.rs @@ -2291,6 +2291,34 @@ impl GooseAcpAgent { }) } + #[custom_method(GetSessionExtensionsRequest)] + async fn on_get_session_extensions( + &self, + req: GetSessionExtensionsRequest, + ) -> Result { + let internal_id = self.internal_session_id(&req.session_id).await?; + let session = self + .session_manager + .get_session(&internal_id, false) + .await + .map_err(|e| sacp::Error::internal_error().data(e.to_string()))?; + + let extensions = EnabledExtensionsState::extensions_or_default( + Some(&session.extension_data), + goose::config::Config::global(), + ); + + let extensions_json = extensions + .into_iter() + .map(|e| serde_json::to_value(&e)) + .collect::, _>>() + .map_err(|e| sacp::Error::internal_error().data(e.to_string()))?; + + Ok(GetSessionExtensionsResponse { + extensions: extensions_json, + }) + } + #[custom_method(UpdateProviderRequest)] async fn on_update_provider( &self, diff --git a/crates/goose-sdk/src/custom_requests.rs b/crates/goose-sdk/src/custom_requests.rs index c262bf534b..e1df7414bf 100644 --- a/crates/goose-sdk/src/custom_requests.rs +++ b/crates/goose-sdk/src/custom_requests.rs @@ -104,6 +104,18 @@ pub struct GetExtensionsResponse { pub warnings: Vec, } +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request(method = "_goose/session/extensions", response = GetSessionExtensionsResponse)] +#[serde(rename_all = "camelCase")] +pub struct GetSessionExtensionsRequest { + pub session_id: String, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] +pub struct GetSessionExtensionsResponse { + pub extensions: Vec, +} + /// Atomically update the provider for a live session. #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] #[request(method = "_goose/session/provider/update", response = UpdateProviderResponse)] diff --git a/ui/sdk/src/generated/client.gen.ts b/ui/sdk/src/generated/client.gen.ts index 08d5d6462b..693246052e 100644 --- a/ui/sdk/src/generated/client.gen.ts +++ b/ui/sdk/src/generated/client.gen.ts @@ -21,6 +21,8 @@ import type { GetProviderDetailsResponse, GetProviderModelsRequest, GetProviderModelsResponse, + GetSessionExtensionsRequest, + GetSessionExtensionsResponse, GetToolsRequest, GetToolsResponse, ImportSessionRequest, @@ -47,6 +49,7 @@ import { zGetExtensionsResponse, zGetProviderDetailsResponse, zGetProviderModelsResponse, + zGetSessionExtensionsResponse, zGetToolsResponse, zImportSessionResponse, zListProvidersResponse, @@ -93,6 +96,15 @@ export class GooseExtClient { return zGetExtensionsResponse.parse(raw) as GetExtensionsResponse; } + async GooseSessionExtensions( + params: GetSessionExtensionsRequest, + ): Promise { + const raw = await this.conn.extMethod("_goose/session/extensions", params); + return zGetSessionExtensionsResponse.parse( + raw, + ) as GetSessionExtensionsResponse; + } + async GooseSessionProviderUpdate( params: UpdateProviderRequest, ): Promise { diff --git a/ui/sdk/src/generated/index.ts b/ui/sdk/src/generated/index.ts index 509c5b0a79..f9190a0946 100644 --- a/ui/sdk/src/generated/index.ts +++ b/ui/sdk/src/generated/index.ts @@ -1,6 +1,6 @@ // This file is auto-generated by @hey-api/openapi-ts -export type { AddExtensionRequest, ArchiveSessionRequest, CheckSecretRequest, CheckSecretResponse, DeleteSessionRequest, EmptyResponse, ExportSessionRequest, ExportSessionResponse, ExtRequest, ExtResponse, GetExtensionsRequest, GetExtensionsResponse, GetProviderDetailsRequest, GetProviderDetailsResponse, GetProviderModelsRequest, GetProviderModelsResponse, GetToolsRequest, GetToolsResponse, ImportSessionRequest, ImportSessionResponse, ListProvidersRequest, ListProvidersResponse, ModelEntry, ProviderConfigKey, ProviderDetailEntry, ProviderListEntry, ReadConfigRequest, ReadConfigResponse, ReadResourceRequest, ReadResourceResponse, RemoveConfigRequest, RemoveExtensionRequest, RemoveSecretRequest, UnarchiveSessionRequest, UpdateProviderRequest, UpdateProviderResponse, UpdateWorkingDirRequest, UpsertConfigRequest, UpsertSecretRequest } from './types.gen.js'; +export type { AddExtensionRequest, ArchiveSessionRequest, CheckSecretRequest, CheckSecretResponse, DeleteSessionRequest, EmptyResponse, ExportSessionRequest, ExportSessionResponse, ExtRequest, ExtResponse, GetExtensionsRequest, GetExtensionsResponse, GetProviderDetailsRequest, GetProviderDetailsResponse, GetProviderModelsRequest, GetProviderModelsResponse, GetSessionExtensionsRequest, GetSessionExtensionsResponse, GetToolsRequest, GetToolsResponse, ImportSessionRequest, ImportSessionResponse, ListProvidersRequest, ListProvidersResponse, ModelEntry, ProviderConfigKey, ProviderDetailEntry, ProviderListEntry, ReadConfigRequest, ReadConfigResponse, ReadResourceRequest, ReadResourceResponse, RemoveConfigRequest, RemoveExtensionRequest, RemoveSecretRequest, UnarchiveSessionRequest, UpdateProviderRequest, UpdateProviderResponse, UpdateWorkingDirRequest, UpsertConfigRequest, UpsertSecretRequest } from './types.gen.js'; export const GOOSE_EXT_METHODS = [ { @@ -38,6 +38,11 @@ export const GOOSE_EXT_METHODS = [ requestType: "GetExtensionsRequest", responseType: "GetExtensionsResponse", }, + { + method: "_goose/session/extensions", + requestType: "GetSessionExtensionsRequest", + responseType: "GetSessionExtensionsResponse", + }, { method: "_goose/session/provider/update", requestType: "UpdateProviderRequest", diff --git a/ui/sdk/src/generated/types.gen.ts b/ui/sdk/src/generated/types.gen.ts index be5900817c..b89b8ced1b 100644 --- a/ui/sdk/src/generated/types.gen.ts +++ b/ui/sdk/src/generated/types.gen.ts @@ -96,6 +96,14 @@ export type GetExtensionsResponse = { warnings: Array; }; +export type GetSessionExtensionsRequest = { + sessionId: string; +}; + +export type GetSessionExtensionsResponse = { + extensions: Array; +}; + /** * Atomically update the provider for a live session. */ @@ -299,14 +307,14 @@ export type UnarchiveSessionRequest = { export type ExtRequest = { id: string; method: string; - params?: AddExtensionRequest | RemoveExtensionRequest | GetToolsRequest | ReadResourceRequest | UpdateWorkingDirRequest | DeleteSessionRequest | GetExtensionsRequest | UpdateProviderRequest | ListProvidersRequest | GetProviderDetailsRequest | GetProviderModelsRequest | ReadConfigRequest | UpsertConfigRequest | RemoveConfigRequest | CheckSecretRequest | UpsertSecretRequest | RemoveSecretRequest | ExportSessionRequest | ImportSessionRequest | ArchiveSessionRequest | UnarchiveSessionRequest | { + params?: AddExtensionRequest | RemoveExtensionRequest | GetToolsRequest | ReadResourceRequest | UpdateWorkingDirRequest | DeleteSessionRequest | GetExtensionsRequest | GetSessionExtensionsRequest | UpdateProviderRequest | ListProvidersRequest | GetProviderDetailsRequest | GetProviderModelsRequest | ReadConfigRequest | UpsertConfigRequest | RemoveConfigRequest | CheckSecretRequest | UpsertSecretRequest | RemoveSecretRequest | ExportSessionRequest | ImportSessionRequest | ArchiveSessionRequest | UnarchiveSessionRequest | { [key: string]: unknown; } | null; }; export type ExtResponse = { id: string; - result?: EmptyResponse | GetToolsResponse | ReadResourceResponse | GetExtensionsResponse | UpdateProviderResponse | ListProvidersResponse | GetProviderDetailsResponse | GetProviderModelsResponse | ReadConfigResponse | CheckSecretResponse | ExportSessionResponse | ImportSessionResponse | unknown; + result?: EmptyResponse | GetToolsResponse | ReadResourceResponse | GetExtensionsResponse | GetSessionExtensionsResponse | UpdateProviderResponse | ListProvidersResponse | GetProviderDetailsResponse | GetProviderModelsResponse | ReadConfigResponse | CheckSecretResponse | ExportSessionResponse | ImportSessionResponse | unknown; } | { error: { code: number; diff --git a/ui/sdk/src/generated/zod.gen.ts b/ui/sdk/src/generated/zod.gen.ts index 3e18b5b35b..2bf62f021c 100644 --- a/ui/sdk/src/generated/zod.gen.ts +++ b/ui/sdk/src/generated/zod.gen.ts @@ -81,6 +81,14 @@ export const zGetExtensionsResponse = z.object({ warnings: z.array(z.string()) }); +export const zGetSessionExtensionsRequest = z.object({ + sessionId: z.string() +}); + +export const zGetSessionExtensionsResponse = z.object({ + extensions: z.array(z.unknown()) +}); + /** * Atomically update the provider for a live session. */ @@ -302,6 +310,7 @@ export const zExtRequest = z.object({ zUpdateWorkingDirRequest, zDeleteSessionRequest, zGetExtensionsRequest, + zGetSessionExtensionsRequest, zUpdateProviderRequest, zListProvidersRequest, zGetProviderDetailsRequest, @@ -333,6 +342,7 @@ export const zExtResponse = z.union([ zGetToolsResponse, zReadResourceResponse, zGetExtensionsResponse, + zGetSessionExtensionsResponse, zUpdateProviderResponse, zListProvidersResponse, zGetProviderDetailsResponse, diff --git a/ui/text/src/components/Header.tsx b/ui/text/src/components/Header.tsx index 50f758a358..f74f6a6aed 100644 --- a/ui/text/src/components/Header.tsx +++ b/ui/text/src/components/Header.tsx @@ -48,7 +48,7 @@ export const Header = React.memo(function Header({ {turnInfo.current}/{turnInfo.total}{" "} )} - ^G configure · ^C exit + ^E exts · ^M models · ^P providers diff --git a/ui/text/src/configure.tsx b/ui/text/src/configure.tsx index 7ad99600cc..5e2d0b363e 100644 --- a/ui/text/src/configure.tsx +++ b/ui/text/src/configure.tsx @@ -2,6 +2,7 @@ import React, { useState, useEffect, useCallback } from "react"; import { Box, Text, useInput, useStdout } from "ink"; import type { GooseClient, ProviderDetailEntry } from "@aaif/goose-sdk"; import { + CRANBERRY, TEAL, GOLD, TEXT_PRIMARY, @@ -23,6 +24,8 @@ type Phase = | "saving" | "error"; +export type ConfigureIntent = "provider" | "model"; + interface ConfigureProps { client: GooseClient; sessionId: string; @@ -30,6 +33,7 @@ interface ConfigureProps { height: number; onComplete: () => void; onCancel: () => void; + initialIntent?: ConfigureIntent; } interface ModelSelectorProps { @@ -180,10 +184,16 @@ const ModelSelector = React.memo(function ModelSelector({ if (loading) { return ( - - - - loading models… + + + + ◆ Select model ◆ + + + Loading models for {provider.displayName}… + + + ); @@ -191,15 +201,21 @@ const ModelSelector = React.memo(function ModelSelector({ if (error) { return ( - - + + + + ◆ Select model ◆ + + ⚠ Failed to load models - + + + {error} - - m manual entry · esc back - + + + m manual entry · esc back ); @@ -213,32 +229,32 @@ const ModelSelector = React.memo(function ModelSelector({ : displayText; return ( - - - - Enter model name manually - + + + + ◆ Enter model name ◆ + + + Type a model identifier for {provider.displayName} + - - - {"❯ "} - - {truncatedText} - - - - - - - enter confirm · esc cancel + + + {"❯ "} + + {truncatedText} + + + enter confirm · esc cancel + ); } @@ -247,71 +263,87 @@ const ModelSelector = React.memo(function ModelSelector({ const searchBoxWidth = Math.min(60, maxWidth - 4); return ( - - - - Select model for {provider.displayName} - + + {/* Header */} + + + ◆ Select model ◆ + + + Choose a model for {provider.displayName} + - - - {"❯ "} - - - {searchQuery || "search models…"} - - + {/* Search Bar */} + + + {"❯ "} + + + {searchQuery || "search models…"} + + - - {filtered.length === 0 ? ( + {/* Model List */} + + {filtered.length === 0 ? ( + No matching models - ) : ( - <> - {scrollOffset > 0 && ( + + ) : ( + <> + {scrollOffset > 0 && ( + ▲ {scrollOffset} more above - )} - {visible.map((model, vi) => { - const idx = vi + scrollOffset; - const active = idx === selectedIdx; - const isDefault = model === provider.defaultModel; - const modelWidth = maxWidth - 8; - const truncatedModel = model.length > modelWidth - ? model.slice(0, modelWidth - 1) + "…" - : model; + + )} + + + {visible.map((model, vi) => { + const idx = vi + scrollOffset; + const active = idx === selectedIdx; + const isDefault = model === provider.defaultModel; + const modelWidth = maxWidth - 8; + const truncatedModel = model.length > modelWidth + ? model.slice(0, modelWidth - 1) + "…" + : model; - return ( - - - {active ? "▸ " : " "} - - - {truncatedModel} - - {isDefault && (default)} - - ); - })} - {scrollOffset + listHeight < filtered.length && ( + return ( + + + {active ? "▸ " : " "} + + + {truncatedModel} + + {isDefault && (default)} + + ); + })} + + + {scrollOffset + listHeight < filtered.length && ( + ▼ {filtered.length - scrollOffset - listHeight} more below - )} - - )} - + + )} + + )} + - - - ↑↓ navigate · enter select · m manual · esc back - - + {/* Footer */} + + + ↑↓ navigate · enter select · m manual · esc back + ); @@ -324,6 +356,7 @@ export default function ConfigureScreen({ height, onComplete, onCancel, + initialIntent, }: ConfigureProps) { const [phase, setPhase] = useState("loading"); const [providers, setProviders] = useState([]); @@ -346,16 +379,32 @@ export default function ConfigureScreen({ (async () => { try { const resp = await client.goose.GooseProvidersDetails({}); - if (!cancelled) { - const sorted = [...resp.providers].sort((a, b) => { - const aP = a.providerType === "Preferred" ? 0 : 1; - const bP = b.providerType === "Preferred" ? 0 : 1; - if (aP !== bP) return aP - bP; - return a.displayName.localeCompare(b.displayName); - }); - setProviders(sorted); - setPhase("select_provider"); + if (cancelled) return; + const sorted = [...resp.providers].sort((a, b) => { + const aP = a.providerType === "Preferred" ? 0 : 1; + const bP = b.providerType === "Preferred" ? 0 : 1; + if (aP !== bP) return aP - bP; + return a.displayName.localeCompare(b.displayName); + }); + setProviders(sorted); + + if (initialIntent === "model") { + try { + const cfg = await client.goose.GooseConfigRead({ key: "GOOSE_PROVIDER" }); + if (cancelled) return; + const current = sorted.find((p) => p.name === cfg.value); + if (current) { + setSelectedProvider(current); + setPendingConfigValues({}); + setPhase("select_model"); + return; + } + } catch { + // fall through to provider selector + } } + + if (!cancelled) setPhase("select_provider"); } catch (e: unknown) { if (!cancelled) { setErrorMsg(e instanceof Error ? e.message : String(e)); @@ -367,7 +416,7 @@ export default function ConfigureScreen({ return () => { cancelled = true; }; - }, [client, fetchKey]); + }, [client, fetchKey, initialIntent]); const applyProviderModel = useCallback( async (provider: ProviderDetailEntry, model: string, configValues: Record) => { @@ -440,22 +489,32 @@ export default function ConfigureScreen({ if (phase === "loading" || phase === "loading_models" || phase === "saving") { const label = - phase === "loading" ? "loading providers…" : - phase === "loading_models" ? "loading models…" : - "applying changes…"; + phase === "loading" ? "Loading providers…" : + phase === "loading_models" ? "Loading models…" : + "Applying changes…"; return ( - - - + + + + ◆ Configure provider ◆ + + {label} + + + ); } if (phase === "error") { return ( - + + + + ◆ Configure provider ◆ + ); @@ -483,7 +542,11 @@ export default function ConfigureScreen({ height={height} onSelect={handleModelSelected} onBack={() => { - setPhase("select_provider"); + if (initialIntent === "model") { + onCancel(); + } else { + setPhase("select_provider"); + } }} /> ); diff --git a/ui/text/src/extensions.tsx b/ui/text/src/extensions.tsx new file mode 100644 index 0000000000..bd4173952d --- /dev/null +++ b/ui/text/src/extensions.tsx @@ -0,0 +1,409 @@ +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-acp"; +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 + + + ); +} diff --git a/ui/text/src/onboarding.tsx b/ui/text/src/onboarding.tsx index 5789e64b6c..28db370be3 100644 --- a/ui/text/src/onboarding.tsx +++ b/ui/text/src/onboarding.tsx @@ -381,12 +381,16 @@ export const ProviderConfigurator = React.memo(function ProviderConfigurator({ p {topPad > 0 && } {/* Header */} - - Configure {provider.displayName} - + + + ◆ Configure {provider.displayName} ◆ + + {provider.description && ( - - {provider.description} + + + {provider.description} + )} @@ -436,10 +440,10 @@ export const ProviderConfigurator = React.memo(function ProviderConfigurator({ p - enter to confirm · esc to go back + enter confirm · esc back {currentKey.secret && ( <> - {" · tab to "} + {" · tab "} {masked ? "reveal" : "hide"} )} diff --git a/ui/text/src/tui.tsx b/ui/text/src/tui.tsx index c86770f985..4054d08319 100644 --- a/ui/text/src/tui.tsx +++ b/ui/text/src/tui.tsx @@ -20,7 +20,8 @@ import type { import { ndJsonStream } from "@agentclientprotocol/sdk"; import { GooseClient } from "@aaif/goose-sdk"; import Onboarding from "./onboarding.js"; -import ConfigureScreen from "./configure.js"; +import ConfigureScreen, { ConfigureIntent } from "./configure.js"; +import ExtensionsManager from "./extensions.js"; import type { PendingPermission, ResponseItem, Turn } from "./types.js"; import { emptyLine, @@ -483,7 +484,8 @@ function App({ const [scrollOffset, setScrollOffset] = useState(0); const [pastedFull, setPastedFull] = useState(null); const [needsOnboarding, setNeedsOnboarding] = useState(false); - const [configuring, setConfiguring] = useState(false); + type Overlay = { screen: "configure"; intent: ConfigureIntent } | { screen: "extensions" }; + const [overlay, setOverlay] = useState(null); const clientRef = useRef(null); const sessionIdRef = useRef(null); @@ -805,9 +807,11 @@ function App({ exit(); } - if (ch === "g" && key.ctrl && !loading && !pendingPermission && sessionIdRef.current) { - setConfiguring(true); - return; + if (!loading && !pendingPermission && sessionIdRef.current) { + if (key.ctrl && (ch === "p" || ch === "P")) { setOverlay({ screen: "configure", intent: "provider" }); return; } + if (key.ctrl && (ch === "m" || ch === "M")) { setOverlay({ screen: "configure", intent: "model" }); return; } + if (key.ctrl && (ch === "e" || ch === "E")) { setOverlay({ screen: "extensions" }); return; } + if (ch === "g" && key.ctrl) { setOverlay({ screen: "configure", intent: "provider" }); return; } } if (pendingPermission) { @@ -875,7 +879,7 @@ function App({ }); return; } - }, { isActive: !needsOnboarding && !configuring }); + }, { isActive: !needsOnboarding && !overlay }); const PAD_X = 2; const PAD_Y = 1; @@ -935,26 +939,34 @@ function App({ ); } - if (configuring && clientRef.current && sessionIdRef.current) { - return ( - - { - setConfiguring(false); - setStatus("ready"); - }} - onCancel={() => setConfiguring(false)} - /> - - ); + if (overlay && clientRef.current && sessionIdRef.current) { + if (overlay.screen === "configure") { + const intent = overlay.intent; + return ( + + { setOverlay(null); setStatus("ready"); }} + onCancel={() => setOverlay(null)} + initialIntent={intent} + /> + + ); + } else if (overlay.screen === "extensions") { + return ( + + setOverlay(null)} + /> + + ); + } } return (