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 00000000..5b4040f8 --- /dev/null +++ b/ui/goose2/src-tauri/src/commands/extensions.rs @@ -0,0 +1,168 @@ +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 { + if !obj.contains_key("type") { + continue; + } + obj.insert("config_key".to_string(), Value::String(key.clone())); + obj.entry("name".to_string()) + .or_insert_with(|| Value::String(key)); + obj.entry("enabled".to_string()) + .or_insert(Value::Bool(false)); + 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(); + let yaml_key = serde_yaml::Value::String(config_key.clone()); + if raw.remove(&yaml_key).is_none() { + return Err(format!("Extension '{}' not found", 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 10db2b12..4611b7e6 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 799d0d32..5a474178 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 b77b154d..b29319b8 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 14ad5f52..ce199a5f 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 00000000..6d629ce4 --- /dev/null +++ b/ui/goose2/src/features/chat/ui/widgets/ExtensionsWidget.tsx @@ -0,0 +1,93 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { IconPuzzle, IconSearch } from "@tabler/icons-react"; +import { Input } from "@/shared/ui/input"; +import { Widget } from "./Widget"; +import { listExtensions } from "@/features/extensions/api/extensions"; +import { + getDisplayName, + type ExtensionEntry, +} from "@/features/extensions/types"; + +export function ExtensionsWidget() { + const { t } = useTranslation("chat"); + const [extensions, setExtensions] = useState([]); + const [searchTerm, setSearchTerm] = useState(""); + + const fetchEnabled = useCallback(() => { + listExtensions() + .then((all) => setExtensions(all.filter((e) => e.enabled))) + .catch(() => setExtensions([])); + }, []); + + useEffect(() => { + fetchEnabled(); + const handleVisibility = () => { + if (document.visibilityState === "visible") fetchEnabled(); + }; + document.addEventListener("visibilitychange", handleVisibility); + window.addEventListener("focus", fetchEnabled); + return () => { + document.removeEventListener("visibilitychange", handleVisibility); + window.removeEventListener("focus", fetchEnabled); + }; + }, [fetchEnabled]); + + const filtered = useMemo(() => { + if (!searchTerm) return extensions; + const q = searchTerm.toLowerCase(); + return extensions.filter((ext) => { + const name = getDisplayName(ext).toLowerCase(); + return ( + name.includes(q) || (ext.description ?? "").toLowerCase().includes(q) + ); + }); + }, [extensions, searchTerm]); + + return ( + } + flush + > + {extensions.length === 0 ? ( +

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

+ ) : ( +
+
+
+ + setSearchTerm(e.target.value)} + placeholder={t("contextPanel.widgets.searchExtensions")} + className="text-xs" + /> +
+
+
+ {filtered.length === 0 ? ( +

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

+ ) : ( +
+ {filtered.map((ext) => ( +
+ + + {getDisplayName(ext)} + +
+ ))} +
+ )} +
+
+ )} +
+ ); +} 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 6df92495..00000000 --- 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 00000000..1d28f213 --- /dev/null +++ b/ui/goose2/src/features/extensions/api/extensions.ts @@ -0,0 +1,36 @@ +import { invoke } from "@tauri-apps/api/core"; +import type { ExtensionConfig, ExtensionEntry } from "../types"; + +export function nameToKey(name: string): string { + return name + .replace(/\s/g, "") + .replace(/[^a-zA-Z0-9_-]/g, "_") + .toLowerCase(); +} + +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 00000000..6ef544c0 --- /dev/null +++ b/ui/goose2/src/features/extensions/types.ts @@ -0,0 +1,61 @@ +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; + bundled?: boolean; +} + +export type ExtensionConfig = + | StdioExtensionConfig + | BuiltinExtensionConfig + | StreamableHttpExtensionConfig + | SseExtensionConfig; + +export type ExtensionEntry = ExtensionConfig & { + config_key: string; + enabled: boolean; +}; + +export function getDisplayName(ext: ExtensionEntry): string { + if (ext.type === "builtin" && ext.display_name) { + return ext.display_name; + } + return ext.name; +} 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 00000000..813b955d --- /dev/null +++ b/ui/goose2/src/features/extensions/ui/ExtensionItem.tsx @@ -0,0 +1,92 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { IconSettings } from "@tabler/icons-react"; +import { Button } from "@/shared/ui/button"; +import { Switch } from "@/shared/ui/switch"; +import { getDisplayName, type ExtensionEntry } from "../types"; + +interface ExtensionItemProps { + extension: ExtensionEntry; + onToggle: (extension: ExtensionEntry) => Promise; + onConfigure?: (extension: ExtensionEntry) => void; +} + +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; +} + +const EDITABLE_TYPES = new Set(["stdio", "streamable_http"]); + +function isEditable(ext: ExtensionEntry): boolean { + return EDITABLE_TYPES.has(ext.type) && !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 00000000..1d13ce8f --- /dev/null +++ b/ui/goose2/src/features/extensions/ui/ExtensionModal.tsx @@ -0,0 +1,363 @@ +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, + ) => Promise; + onDelete?: (configKey: string) => Promise; + onClose: () => void; +} + +interface EnvVar { + id: number; + key: string; + value: string; +} + +let nextEnvId = 0; + +function parseEnvVars(envs?: Record): EnvVar[] { + if (!envs || Object.keys(envs).length === 0) + return [{ id: nextEnvId++, key: "", value: "" }]; + return Object.entries(envs).map(([key, value]) => ({ + id: nextEnvId++, + 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 [isSaving, setIsSaving] = useState(false); + + const [name, setName] = useState(extension?.name ?? ""); + const [type, setType] = useState( + extension?.type === "streamable_http" || extension?.type === "sse" + ? "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 + : extension?.type === "sse" + ? (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 [{ id: nextEnvId++, key: "", value: "" }]; + }); + + const canSubmit = + name.trim().length > 0 && + (type === "stdio" ? cmd.trim().length > 0 : uri.trim().length > 0); + + const handleSubmit = async () => { + if (!canSubmit || isSaving) return; + setIsSaving(true); + + try { + const trimmedName = name.trim(); + const envs = buildEnvVars(envVars); + const timeoutNum = Number.parseInt(timeout, 10) || 300; + + let config: ExtensionConfig; + + if (type === "stdio") { + config = { + ...(extension?.type === "stdio" ? extension : {}), + 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 = { + ...(extension?.type === "streamable_http" ? extension : {}), + type: "streamable_http", + name: trimmedName, + description, + uri: uri.trim(), + envs, + timeout: timeoutNum, + }; + } + + await onSubmit(trimmedName, config, extension?.enabled ?? true); + } finally { + setIsSaving(false); + } + }; + + 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, { id: nextEnvId++, key: "", value: "" }]); + }; + + const removeEnvVar = (id: number) => { + setEnvVars((prev) => { + if (prev.length <= 1) return [{ id: nextEnvId++, key: "", value: "" }]; + return prev.filter((v) => v.id !== id); + }); + }; + + 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")} + /> +
+
+ +