From ce74207d62d878c840ec7d492e9d0fbaa087e12d Mon Sep 17 00:00:00 2001 From: morgmart <98432065+morgmart@users.noreply.github.com> Date: Tue, 14 Apr 2026 15:44:57 -0700 Subject: [PATCH 1/8] feat(goose2): add Extensions settings page and context panel widget Replace the placeholder MCP Servers widget with a fully functional Extensions system. Users can view, search, add, edit, enable/disable, and delete extensions directly from Settings. The context panel shows active extensions. Backend uses config_key from YAML for reliable operations instead of re-deriving keys from display names. Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com> --- .../src-tauri/src/commands/extensions.rs | 161 +++++++++ ui/goose2/src-tauri/src/commands/mod.rs | 1 + ui/goose2/src-tauri/src/lib.rs | 4 + .../src-tauri/src/services/goose_config.rs | 20 ++ .../src/features/chat/ui/ContextPanel.tsx | 4 +- .../chat/ui/widgets/ExtensionsWidget.tsx | 43 +++ .../chat/ui/widgets/McpServersWidget.tsx | 18 - .../src/features/extensions/api/extensions.ts | 29 ++ ui/goose2/src/features/extensions/types.ts | 53 +++ .../features/extensions/ui/ExtensionItem.tsx | 94 ++++++ .../features/extensions/ui/ExtensionModal.tsx | 313 ++++++++++++++++++ .../extensions/ui/ExtensionsSettings.tsx | 213 ++++++++++++ .../features/settings/ui/SettingsModal.tsx | 5 +- .../src/shared/i18n/locales/en/chat.json | 4 +- .../src/shared/i18n/locales/en/settings.json | 42 +++ .../src/shared/i18n/locales/es/chat.json | 4 +- .../src/shared/i18n/locales/es/settings.json | 42 +++ 17 files changed, 1025 insertions(+), 25 deletions(-) create mode 100644 ui/goose2/src-tauri/src/commands/extensions.rs create mode 100644 ui/goose2/src/features/chat/ui/widgets/ExtensionsWidget.tsx delete mode 100644 ui/goose2/src/features/chat/ui/widgets/McpServersWidget.tsx create mode 100644 ui/goose2/src/features/extensions/api/extensions.ts create mode 100644 ui/goose2/src/features/extensions/types.ts create mode 100644 ui/goose2/src/features/extensions/ui/ExtensionItem.tsx create mode 100644 ui/goose2/src/features/extensions/ui/ExtensionModal.tsx create mode 100644 ui/goose2/src/features/extensions/ui/ExtensionsSettings.tsx diff --git a/ui/goose2/src-tauri/src/commands/extensions.rs b/ui/goose2/src-tauri/src/commands/extensions.rs new file mode 100644 index 000000000000..4a29ae8904c0 --- /dev/null +++ b/ui/goose2/src-tauri/src/commands/extensions.rs @@ -0,0 +1,161 @@ +use serde_json::Value; +use tauri::State; + +use crate::services::goose_config::GooseConfig; + +fn yaml_to_json(yaml: serde_yaml::Value) -> Value { + match yaml { + serde_yaml::Value::Null => Value::Null, + serde_yaml::Value::Bool(b) => Value::Bool(b), + serde_yaml::Value::Number(n) => { + if let Some(i) = n.as_i64() { + Value::Number(i.into()) + } else if let Some(u) = n.as_u64() { + Value::Number(u.into()) + } else if let Some(f) = n.as_f64() { + serde_json::Number::from_f64(f) + .map(Value::Number) + .unwrap_or(Value::Null) + } else { + Value::Null + } + } + serde_yaml::Value::String(s) => Value::String(s), + serde_yaml::Value::Sequence(seq) => { + Value::Array(seq.into_iter().map(yaml_to_json).collect()) + } + serde_yaml::Value::Mapping(map) => { + let obj = map + .into_iter() + .filter_map(|(k, v)| { + let key = match k { + serde_yaml::Value::String(s) => s, + other => serde_yaml::to_string(&other).ok()?.trim().to_string(), + }; + Some((key, yaml_to_json(v))) + }) + .collect(); + Value::Object(obj) + } + serde_yaml::Value::Tagged(tagged) => yaml_to_json(tagged.value), + } +} + +fn json_to_yaml(json: Value) -> serde_yaml::Value { + match json { + Value::Null => serde_yaml::Value::Null, + Value::Bool(b) => serde_yaml::Value::Bool(b), + Value::Number(n) => { + if let Some(i) = n.as_i64() { + serde_yaml::Value::Number(i.into()) + } else if let Some(u) = n.as_u64() { + serde_yaml::Value::Number(u.into()) + } else if let Some(f) = n.as_f64() { + serde_yaml::Value::Number(f.into()) + } else { + serde_yaml::Value::Null + } + } + Value::String(s) => serde_yaml::Value::String(s), + Value::Array(arr) => { + serde_yaml::Value::Sequence(arr.into_iter().map(json_to_yaml).collect()) + } + Value::Object(obj) => { + let mut map = serde_yaml::Mapping::new(); + for (k, v) in obj { + map.insert(serde_yaml::Value::String(k), json_to_yaml(v)); + } + serde_yaml::Value::Mapping(map) + } + } +} + +fn name_to_key(name: &str) -> String { + let mut result = String::with_capacity(name.len()); + for c in name.chars() { + match c { + c if c.is_ascii_alphanumeric() || c == '_' || c == '-' => result.push(c), + c if c.is_whitespace() => continue, + _ => result.push('_'), + } + } + result.to_lowercase() +} + +#[tauri::command] +pub fn list_extensions(config: State<'_, GooseConfig>) -> Result, String> { + let raw = config.get_extensions_raw(); + let mut entries = Vec::with_capacity(raw.len()); + + for (k, v) in raw { + let key = match k { + serde_yaml::Value::String(s) => s, + _ => continue, + }; + + let mut json = yaml_to_json(v); + + if let Value::Object(ref mut obj) = json { + obj.insert("config_key".to_string(), Value::String(key.clone())); + obj.entry("name".to_string()) + .or_insert_with(|| Value::String(key)); + } + + entries.push(json); + } + + Ok(entries) +} + +#[tauri::command] +pub fn add_extension( + name: String, + extension_config: Value, + enabled: bool, + config: State<'_, GooseConfig>, +) -> Result<(), String> { + let key = name_to_key(&name); + let mut raw = config.get_extensions_raw(); + + let mut entry = match extension_config { + Value::Object(obj) => obj, + _ => return Err("extension_config must be a JSON object".to_string()), + }; + + entry.insert("enabled".to_string(), Value::Bool(enabled)); + entry.insert("name".to_string(), Value::String(name)); + + let yaml_value = json_to_yaml(Value::Object(entry)); + raw.insert(serde_yaml::Value::String(key), yaml_value); + + config.set_extensions_raw(raw) +} + +#[tauri::command] +pub fn remove_extension(config_key: String, config: State<'_, GooseConfig>) -> Result<(), String> { + let mut raw = config.get_extensions_raw(); + raw.remove(&serde_yaml::Value::String(config_key)); + config.set_extensions_raw(raw) +} + +#[tauri::command] +pub fn toggle_extension( + config_key: String, + enabled: bool, + config: State<'_, GooseConfig>, +) -> Result<(), String> { + let mut raw = config.get_extensions_raw(); + + let yaml_key = serde_yaml::Value::String(config_key.clone()); + if let Some(entry) = raw.get_mut(&yaml_key) { + if let serde_yaml::Value::Mapping(ref mut map) = entry { + map.insert( + serde_yaml::Value::String("enabled".to_string()), + serde_yaml::Value::Bool(enabled), + ); + } + config.set_extensions_raw(raw) + } else { + Err(format!("Extension '{}' not found", config_key)) + } +} diff --git a/ui/goose2/src-tauri/src/commands/mod.rs b/ui/goose2/src-tauri/src/commands/mod.rs index 10db2b12efce..4611b7e68d55 100644 --- a/ui/goose2/src-tauri/src/commands/mod.rs +++ b/ui/goose2/src-tauri/src/commands/mod.rs @@ -3,6 +3,7 @@ pub mod agent_setup; pub mod agents; pub mod credentials; pub mod doctor; +pub mod extensions; pub mod git; pub mod git_changes; pub mod model_setup; diff --git a/ui/goose2/src-tauri/src/lib.rs b/ui/goose2/src-tauri/src/lib.rs index 82ae9f3ce211..22fffc226379 100644 --- a/ui/goose2/src-tauri/src/lib.rs +++ b/ui/goose2/src-tauri/src/lib.rs @@ -78,6 +78,10 @@ pub fn run() { commands::projects::restore_project, commands::doctor::run_doctor, commands::doctor::run_doctor_fix, + commands::extensions::list_extensions, + commands::extensions::add_extension, + commands::extensions::remove_extension, + commands::extensions::toggle_extension, commands::git::get_git_state, commands::git_changes::get_changed_files, commands::git::git_switch_branch, diff --git a/ui/goose2/src-tauri/src/services/goose_config.rs b/ui/goose2/src-tauri/src/services/goose_config.rs index b77b154d34ef..b29319b89a08 100644 --- a/ui/goose2/src-tauri/src/services/goose_config.rs +++ b/ui/goose2/src-tauri/src/services/goose_config.rs @@ -321,6 +321,26 @@ impl GooseConfig { .collect()) } + pub fn get_extensions_raw(&self) -> serde_yaml::Mapping { + let config = self.read_config_map(); + let key = serde_yaml::Value::String("extensions".to_string()); + config + .get(&key) + .and_then(|v| v.as_mapping()) + .cloned() + .unwrap_or_default() + } + + pub fn set_extensions_raw(&self, extensions: serde_yaml::Mapping) -> Result<(), String> { + let _guard = self.guard.lock().unwrap(); + let mut config = self.read_config_map(); + config.insert( + serde_yaml::Value::String("extensions".to_string()), + serde_yaml::Value::Mapping(extensions), + ); + self.write_config_map(&config) + } + pub fn delete_all_provider_fields(&self, provider_id: &str) -> Result<(), String> { let def = find_provider_def(provider_id) .ok_or_else(|| format!("Unknown provider '{provider_id}'"))?; diff --git a/ui/goose2/src/features/chat/ui/ContextPanel.tsx b/ui/goose2/src/features/chat/ui/ContextPanel.tsx index 14ad5f529743..ce199a5f44b3 100644 --- a/ui/goose2/src/features/chat/ui/ContextPanel.tsx +++ b/ui/goose2/src/features/chat/ui/ContextPanel.tsx @@ -20,7 +20,7 @@ import type { WorkingContext } from "../stores/chatSessionStore"; import { WorkspaceWidget } from "./widgets/WorkspaceWidget"; import { ChangesWidget } from "./widgets/ChangesWidget"; import { ArtifactsWidget } from "./widgets/ArtifactsWidget"; -import { McpServersWidget } from "./widgets/McpServersWidget"; +import { ExtensionsWidget } from "./widgets/ExtensionsWidget"; import { openPath } from "@tauri-apps/plugin-opener"; interface ContextPanelProps { @@ -218,7 +218,7 @@ export function ContextPanel({ onOpenFile={handleOpenChangedFile} /> - + diff --git a/ui/goose2/src/features/chat/ui/widgets/ExtensionsWidget.tsx b/ui/goose2/src/features/chat/ui/widgets/ExtensionsWidget.tsx new file mode 100644 index 000000000000..0ac84e428adc --- /dev/null +++ b/ui/goose2/src/features/chat/ui/widgets/ExtensionsWidget.tsx @@ -0,0 +1,43 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { IconPuzzle } from "@tabler/icons-react"; +import { Widget } from "./Widget"; +import { listExtensions } from "@/features/extensions/api/extensions"; +import type { ExtensionEntry } from "@/features/extensions/types"; + +export function ExtensionsWidget() { + const { t } = useTranslation("chat"); + const [extensions, setExtensions] = useState([]); + + useEffect(() => { + listExtensions() + .then((all) => setExtensions(all.filter((e) => e.enabled))) + .catch(() => setExtensions([])); + }, []); + + return ( + } + > + {extensions.length === 0 ? ( +

+ {t("contextPanel.empty.noExtensions")} +

+ ) : ( +
+ {extensions.map((ext) => ( +
+ + + {ext.type === "builtin" && ext.display_name + ? ext.display_name + : ext.name} + +
+ ))} +
+ )} +
+ ); +} diff --git a/ui/goose2/src/features/chat/ui/widgets/McpServersWidget.tsx b/ui/goose2/src/features/chat/ui/widgets/McpServersWidget.tsx deleted file mode 100644 index 6df92495ddd8..000000000000 --- a/ui/goose2/src/features/chat/ui/widgets/McpServersWidget.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { IconServer } from "@tabler/icons-react"; -import { Widget } from "./Widget"; - -export function McpServersWidget() { - const { t } = useTranslation("chat"); - - return ( - } - > -

- {t("contextPanel.empty.noServersConfigured")} -

-
- ); -} diff --git a/ui/goose2/src/features/extensions/api/extensions.ts b/ui/goose2/src/features/extensions/api/extensions.ts new file mode 100644 index 000000000000..ae0599c34832 --- /dev/null +++ b/ui/goose2/src/features/extensions/api/extensions.ts @@ -0,0 +1,29 @@ +import { invoke } from "@tauri-apps/api/core"; +import type { ExtensionConfig, ExtensionEntry } from "../types"; + +export async function listExtensions(): Promise { + return invoke("list_extensions"); +} + +export async function addExtension( + name: string, + extensionConfig: ExtensionConfig, + enabled: boolean, +): Promise { + return invoke("add_extension", { + name, + extensionConfig, + enabled, + }); +} + +export async function removeExtension(configKey: string): Promise { + return invoke("remove_extension", { configKey }); +} + +export async function toggleExtension( + configKey: string, + enabled: boolean, +): Promise { + return invoke("toggle_extension", { configKey, enabled }); +} diff --git a/ui/goose2/src/features/extensions/types.ts b/ui/goose2/src/features/extensions/types.ts new file mode 100644 index 000000000000..01b4aa4e35f2 --- /dev/null +++ b/ui/goose2/src/features/extensions/types.ts @@ -0,0 +1,53 @@ +export interface StdioExtensionConfig { + type: "stdio"; + name: string; + description: string; + cmd: string; + args: string[]; + envs?: Record; + env_keys?: string[]; + timeout?: number; + bundled?: boolean; + available_tools?: string[]; +} + +export interface BuiltinExtensionConfig { + type: "builtin"; + name: string; + description: string; + display_name?: string; + timeout?: number; + bundled?: boolean; + available_tools?: string[]; +} + +export interface StreamableHttpExtensionConfig { + type: "streamable_http"; + name: string; + description: string; + uri: string; + envs?: Record; + env_keys?: string[]; + headers?: Record; + timeout?: number; + bundled?: boolean; + available_tools?: string[]; +} + +export interface SseExtensionConfig { + type: "sse"; + name: string; + description: string; + uri?: string; +} + +export type ExtensionConfig = + | StdioExtensionConfig + | BuiltinExtensionConfig + | StreamableHttpExtensionConfig + | SseExtensionConfig; + +export interface ExtensionEntry extends ExtensionConfig { + config_key: string; + enabled: boolean; +} diff --git a/ui/goose2/src/features/extensions/ui/ExtensionItem.tsx b/ui/goose2/src/features/extensions/ui/ExtensionItem.tsx new file mode 100644 index 000000000000..073e3028284b --- /dev/null +++ b/ui/goose2/src/features/extensions/ui/ExtensionItem.tsx @@ -0,0 +1,94 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { IconSettings } from "@tabler/icons-react"; +import { Switch } from "@/shared/ui/switch"; +import type { ExtensionEntry } from "../types"; + +interface ExtensionItemProps { + extension: ExtensionEntry; + onToggle: (extension: ExtensionEntry) => Promise; + onConfigure?: (extension: ExtensionEntry) => void; +} + +function getDisplayName(ext: ExtensionEntry): string { + if (ext.type === "builtin" && ext.display_name) { + return ext.display_name; + } + return ext.name; +} + +function getSubtitle(ext: ExtensionEntry): string { + if (ext.description) return ext.description; + if (ext.type === "stdio") return ext.cmd; + if (ext.type === "streamable_http") return ext.uri; + return ext.type; +} + +function isEditable(ext: ExtensionEntry): boolean { + return ext.type !== "builtin" && !ext.bundled; +} + +export function ExtensionItem({ + extension, + onToggle, + onConfigure, +}: ExtensionItemProps) { + const { t } = useTranslation("settings"); + const [isToggling, setIsToggling] = useState(false); + const [visualEnabled, setVisualEnabled] = useState(extension.enabled); + + const handleToggle = async () => { + if (isToggling) return; + setIsToggling(true); + setVisualEnabled(!extension.enabled); + try { + await onToggle(extension); + } catch { + setVisualEnabled(extension.enabled); + } finally { + setIsToggling(false); + } + }; + + const editable = isEditable(extension); + const checked = isToggling ? visualEnabled : extension.enabled; + const displayName = getDisplayName(extension); + + return ( +
+
+
+ {displayName} + + {t(`extensions.types.${extension.type}`, { defaultValue: extension.type })} + +
+

+ {getSubtitle(extension)} +

+
+
+ {editable && onConfigure && ( + + )} + +
+
+ ); +} diff --git a/ui/goose2/src/features/extensions/ui/ExtensionModal.tsx b/ui/goose2/src/features/extensions/ui/ExtensionModal.tsx new file mode 100644 index 000000000000..64942ef9b381 --- /dev/null +++ b/ui/goose2/src/features/extensions/ui/ExtensionModal.tsx @@ -0,0 +1,313 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { IconPlus, IconTrash } from "@tabler/icons-react"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/shared/ui/dialog"; +import { Button } from "@/shared/ui/button"; +import { Input } from "@/shared/ui/input"; +import { Label } from "@/shared/ui/label"; +import { Textarea } from "@/shared/ui/textarea"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shared/ui/select"; +import type { ExtensionConfig, ExtensionEntry } from "../types"; + +type ExtensionType = "stdio" | "streamable_http"; + +interface ExtensionModalProps { + extension?: ExtensionEntry; + onSubmit: (name: string, config: ExtensionConfig, enabled: boolean) => void; + onDelete?: (configKey: string) => void; + onClose: () => void; +} + +interface EnvVar { + key: string; + value: string; +} + +function parseEnvVars(envs?: Record): EnvVar[] { + if (!envs || Object.keys(envs).length === 0) return [{ key: "", value: "" }]; + return Object.entries(envs).map(([key, value]) => ({ key, value })); +} + +function buildEnvVars(vars: EnvVar[]): Record { + const result: Record = {}; + for (const v of vars) { + if (v.key.trim()) { + result[v.key.trim()] = v.value; + } + } + return result; +} + +export function ExtensionModal({ + extension, + onSubmit, + onDelete, + onClose, +}: ExtensionModalProps) { + const { t } = useTranslation("settings"); + const isEdit = !!extension; + + const [name, setName] = useState(extension?.name ?? ""); + const [type, setType] = useState( + extension?.type === "streamable_http" ? "streamable_http" : "stdio", + ); + const [description, setDescription] = useState(extension?.description ?? ""); + const [cmd, setCmd] = useState( + extension?.type === "stdio" ? extension.cmd : "", + ); + const [args, setArgs] = useState( + extension?.type === "stdio" ? extension.args.join("\n") : "", + ); + const [uri, setUri] = useState( + extension?.type === "streamable_http" ? extension.uri : "", + ); + const [timeout, setTimeout] = useState( + String(extension?.type === "stdio" || extension?.type === "streamable_http" + ? (extension.timeout ?? 300) + : 300), + ); + const [envVars, setEnvVars] = useState(() => { + if (extension?.type === "stdio") return parseEnvVars(extension.envs); + if (extension?.type === "streamable_http") return parseEnvVars(extension.envs); + return [{ key: "", value: "" }]; + }); + + const handleSubmit = () => { + const trimmedName = name.trim(); + if (!trimmedName) return; + + const envs = buildEnvVars(envVars); + const timeoutNum = Number.parseInt(timeout, 10) || 300; + + let config: ExtensionConfig; + + if (type === "stdio") { + if (!cmd.trim()) return; + config = { + type: "stdio", + name: trimmedName, + description, + cmd: cmd.trim(), + args: args + .split("\n") + .map((a) => a.trim()) + .filter(Boolean), + envs, + timeout: timeoutNum, + }; + } else { + if (!uri.trim()) return; + config = { + type: "streamable_http", + name: trimmedName, + description, + uri: uri.trim(), + envs, + timeout: timeoutNum, + }; + } + + onSubmit(trimmedName, config, extension?.enabled ?? true); + }; + + const updateEnvVar = (index: number, field: "key" | "value", val: string) => { + setEnvVars((prev) => { + const next = [...prev]; + next[index] = { ...next[index], [field]: val }; + return next; + }); + }; + + const addEnvVar = () => { + setEnvVars((prev) => [...prev, { key: "", value: "" }]); + }; + + const removeEnvVar = (index: number) => { + setEnvVars((prev) => { + if (prev.length <= 1) return [{ key: "", value: "" }]; + return prev.filter((_, i) => i !== index); + }); + }; + + return ( + !open && onClose()}> + + + + {isEdit + ? t("extensions.editExtension") + : t("extensions.addExtension")} + + + +
+
+ + setName(e.target.value)} + placeholder={t("extensions.fields.namePlaceholder")} + /> +
+ +
+ + +
+ +
+ + setDescription(e.target.value)} + placeholder={t("extensions.fields.descriptionPlaceholder")} + /> +
+ + {type === "stdio" && ( + <> +
+ + setCmd(e.target.value)} + placeholder={t("extensions.fields.commandPlaceholder")} + /> +
+
+ +