add extensions settings page and context panel widget (#8540)

Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
This commit is contained in:
morgmart
2026-04-15 09:55:46 -07:00
committed by GitHub
parent a7396a7bd0
commit c9507ba672
18 changed files with 1208 additions and 32 deletions
@@ -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<Vec<Value>, 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))
}
}
+1
View File
@@ -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;
+4
View File
@@ -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,
@@ -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}'"))?;
@@ -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}
/>
<ArtifactsWidget />
<McpServersWidget />
<ExtensionsWidget />
</div>
</TabsContent>
@@ -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<ExtensionEntry[]>([]);
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 (
<Widget
title={t("contextPanel.widgets.extensions")}
icon={<IconPuzzle className="size-3.5" />}
flush
>
{extensions.length === 0 ? (
<p className="px-3 py-2.5 text-xs text-foreground-subtle">
{t("contextPanel.empty.noExtensions")}
</p>
) : (
<div>
<div className="border-b border-border px-3 py-1.5">
<div className="flex items-center gap-1.5 text-foreground-subtle">
<IconSearch className="size-3" />
<Input
variant="ghost"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder={t("contextPanel.widgets.searchExtensions")}
className="text-xs"
/>
</div>
</div>
<div className="max-h-40 overflow-y-auto px-3 py-2">
{filtered.length === 0 ? (
<p className="py-1 text-xs text-foreground-subtle">
{t("contextPanel.empty.noMatchingExtensions")}
</p>
) : (
<div className="space-y-2">
{filtered.map((ext) => (
<div key={ext.config_key} className="flex items-center gap-2">
<span className="size-1.5 shrink-0 rounded-full bg-green-500" />
<span className="truncate text-xs">
{getDisplayName(ext)}
</span>
</div>
))}
</div>
)}
</div>
</div>
)}
</Widget>
);
}
@@ -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 (
<Widget
title={t("contextPanel.widgets.mcpServers")}
icon={<IconServer className="size-3.5" />}
>
<p className="text-foreground-subtle">
{t("contextPanel.empty.noServersConfigured")}
</p>
</Widget>
);
}
@@ -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<ExtensionEntry[]> {
return invoke("list_extensions");
}
export async function addExtension(
name: string,
extensionConfig: ExtensionConfig,
enabled: boolean,
): Promise<void> {
return invoke("add_extension", {
name,
extensionConfig,
enabled,
});
}
export async function removeExtension(configKey: string): Promise<void> {
return invoke("remove_extension", { configKey });
}
export async function toggleExtension(
configKey: string,
enabled: boolean,
): Promise<void> {
return invoke("toggle_extension", { configKey, enabled });
}
@@ -0,0 +1,61 @@
export interface StdioExtensionConfig {
type: "stdio";
name: string;
description: string;
cmd: string;
args: string[];
envs?: Record<string, string>;
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<string, string>;
env_keys?: string[];
headers?: Record<string, string>;
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;
}
@@ -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<void>;
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 (
<div className="flex items-center justify-between gap-3 py-4">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium">{displayName}</span>
<span className="shrink-0 rounded bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
{t(`extensions.types.${extension.type}`, {
defaultValue: extension.type,
})}
</span>
</div>
<p className="mt-0.5 truncate text-xs text-muted-foreground">
{getSubtitle(extension)}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
{editable && onConfigure && (
<Button
variant="ghost"
size="icon-xs"
onClick={() => onConfigure(extension)}
aria-label={t("extensions.configure", {
name: displayName,
})}
>
<IconSettings className="size-4" />
</Button>
)}
<Switch
checked={checked}
onCheckedChange={handleToggle}
disabled={isToggling}
aria-label={t("extensions.toggle", {
name: displayName,
})}
/>
</div>
</div>
);
}
@@ -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<void>;
onDelete?: (configKey: string) => Promise<void>;
onClose: () => void;
}
interface EnvVar {
id: number;
key: string;
value: string;
}
let nextEnvId = 0;
function parseEnvVars(envs?: Record<string, string>): 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<string, string> {
const result: Record<string, string> = {};
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<ExtensionType>(
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<EnvVar[]>(() => {
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 (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
{isEdit
? t("extensions.editExtension")
: t("extensions.addExtension")}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="ext-name">{t("extensions.fields.name")}</Label>
<Input
id="ext-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t("extensions.fields.namePlaceholder")}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="ext-type">{t("extensions.fields.type")}</Label>
<Select
value={type}
onValueChange={(v) => setType(v as ExtensionType)}
>
<SelectTrigger id="ext-type" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="stdio">
{t("extensions.types.stdio")}
</SelectItem>
<SelectItem value="streamable_http">
{t("extensions.types.streamable_http")}
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor="ext-desc">
{t("extensions.fields.description")}
</Label>
<Input
id="ext-desc"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={t("extensions.fields.descriptionPlaceholder")}
/>
</div>
{type === "stdio" && (
<>
<div className="space-y-1.5">
<Label htmlFor="ext-cmd">
{t("extensions.fields.command")}
</Label>
<Input
id="ext-cmd"
value={cmd}
onChange={(e) => setCmd(e.target.value)}
placeholder={t("extensions.fields.commandPlaceholder")}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="ext-args">
{t("extensions.fields.arguments")}
</Label>
<Textarea
id="ext-args"
value={args}
onChange={(e) => setArgs(e.target.value)}
placeholder={t("extensions.fields.argumentsPlaceholder")}
rows={3}
/>
</div>
</>
)}
{type === "streamable_http" && (
<div className="space-y-1.5">
<Label htmlFor="ext-uri">{t("extensions.fields.url")}</Label>
<Input
id="ext-uri"
value={uri}
onChange={(e) => setUri(e.target.value)}
placeholder={t("extensions.fields.urlPlaceholder")}
/>
</div>
)}
<div className="space-y-1.5">
<Label htmlFor="ext-timeout">
{t("extensions.fields.timeout")}
</Label>
<Input
id="ext-timeout"
type="number"
value={timeout}
onChange={(e) => setTimeout(e.target.value)}
min={1}
/>
</div>
<div className="space-y-1.5">
<Label>{t("extensions.fields.envVars")}</Label>
<div className="space-y-2">
{envVars.map((env, i) => (
<div key={env.id} className="flex items-center gap-2">
<Input
value={env.key}
onChange={(e) => updateEnvVar(i, "key", e.target.value)}
placeholder={t("extensions.fields.envKeyPlaceholder")}
className="flex-1"
/>
<Input
value={env.value}
onChange={(e) => updateEnvVar(i, "value", e.target.value)}
placeholder={t("extensions.fields.envValuePlaceholder")}
className="flex-1"
/>
<Button
variant="ghost"
size="icon-xs"
onClick={() => removeEnvVar(env.id)}
className="shrink-0 hover:text-destructive"
aria-label={t("extensions.fields.removeEnvVar")}
>
<IconTrash className="size-3.5" />
</Button>
</div>
))}
<Button
type="button"
variant="ghost"
size="sm"
onClick={addEnvVar}
>
<IconPlus className="size-3.5" />
{t("extensions.fields.addEnvVar")}
</Button>
</div>
</div>
</div>
<DialogFooter>
{isEdit && onDelete && (
<Button
type="button"
variant="ghost"
onClick={async () => {
setIsSaving(true);
try {
await onDelete(extension.config_key);
} finally {
setIsSaving(false);
}
}}
disabled={isSaving}
className="mr-auto text-destructive hover:text-destructive hover:bg-destructive/10"
>
<IconTrash className="size-4" />
{t("extensions.deleteExtension")}
</Button>
)}
<Button
type="button"
variant="outline"
onClick={onClose}
disabled={isSaving}
>
{t("extensions.cancel")}
</Button>
<Button
type="button"
onClick={handleSubmit}
disabled={!canSubmit || isSaving}
>
{t("extensions.save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,242 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { IconPlus, IconSearch } from "@tabler/icons-react";
import { Button } from "@/shared/ui/button";
import { Input } from "@/shared/ui/input";
import {
listExtensions,
addExtension,
removeExtension,
toggleExtension,
nameToKey,
} from "../api/extensions";
import {
getDisplayName,
type ExtensionConfig,
type ExtensionEntry,
} from "../types";
import { ExtensionItem } from "./ExtensionItem";
import { ExtensionModal } from "./ExtensionModal";
export function ExtensionsSettings() {
const { t } = useTranslation("settings");
const [extensions, setExtensions] = useState<ExtensionEntry[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [modalMode, setModalMode] = useState<"add" | "edit" | null>(null);
const [editingExtension, setEditingExtension] =
useState<ExtensionEntry | null>(null);
const [searchTerm, setSearchTerm] = useState("");
const fetchExtensions = useCallback(async () => {
try {
const result = await listExtensions();
setExtensions(result);
} catch {
setExtensions([]);
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
void fetchExtensions();
}, [fetchExtensions]);
const matchesSearch = useCallback(
(ext: ExtensionEntry) => {
if (!searchTerm) return true;
const q = searchTerm.toLowerCase();
return (
getDisplayName(ext).toLowerCase().includes(q) ||
ext.name.toLowerCase().includes(q) ||
(ext.description ?? "").toLowerCase().includes(q) ||
ext.type.toLowerCase().includes(q)
);
},
[searchTerm],
);
const sorted = useMemo(() => {
return [...extensions].sort((a, b) => {
if (a.type === "builtin" && b.type !== "builtin") return -1;
if (a.type !== "builtin" && b.type === "builtin") return 1;
const aBundled = a.bundled === true;
const bBundled = b.bundled === true;
if (aBundled && !bBundled) return -1;
if (!aBundled && bBundled) return 1;
return a.name.localeCompare(b.name);
});
}, [extensions]);
const enabled = useMemo(
() => sorted.filter((e) => e.enabled && matchesSearch(e)),
[sorted, matchesSearch],
);
const available = useMemo(
() => sorted.filter((e) => !e.enabled && matchesSearch(e)),
[sorted, matchesSearch],
);
const handleToggle = async (ext: ExtensionEntry) => {
try {
await toggleExtension(ext.config_key, !ext.enabled);
await fetchExtensions();
} catch {
toast.error(t("extensions.errors.toggleFailed"));
}
};
const handleConfigure = (ext: ExtensionEntry) => {
setEditingExtension(ext);
setModalMode("edit");
};
const handleSubmit = async (
name: string,
config: ExtensionConfig,
extensionEnabled: boolean,
) => {
try {
const newKey = nameToKey(name);
const isEdit = !!editingExtension;
const isAdd = !editingExtension;
const keyChanged = isEdit && editingExtension.config_key !== newKey;
if (
(isAdd || keyChanged) &&
extensions.some((e) => e.config_key === newKey)
) {
toast.error(t("extensions.errors.nameConflict", { name }));
return;
}
await addExtension(name, config, extensionEnabled);
if (keyChanged) {
await removeExtension(editingExtension.config_key);
}
setModalMode(null);
setEditingExtension(null);
await fetchExtensions();
} catch {
toast.error(t("extensions.errors.saveFailed"));
}
};
const handleDelete = async (configKey: string) => {
try {
await removeExtension(configKey);
setModalMode(null);
setEditingExtension(null);
await fetchExtensions();
} catch {
toast.error(t("extensions.errors.deleteFailed"));
}
};
const handleModalClose = () => {
setModalMode(null);
setEditingExtension(null);
};
return (
<div className="space-y-6">
<div>
<h3 className="font-display text-lg font-semibold tracking-tight">
{t("extensions.title")}
</h3>
<p className="mt-1 text-sm text-muted-foreground">
{t("extensions.description")}
</p>
</div>
<div className="relative">
<IconSearch className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder={t("extensions.search")}
className="pl-9"
/>
</div>
{isLoading ? (
<div className="divide-y divide-border">
{[1, 2, 3].map((i) => (
<div key={i} className="h-14 animate-pulse bg-muted/30" />
))}
</div>
) : extensions.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("extensions.empty")}</p>
) : enabled.length === 0 && available.length === 0 && searchTerm ? (
<p className="text-sm text-muted-foreground">
{t("extensions.noResults")}
</p>
) : (
<div className="space-y-4">
{enabled.length > 0 && (
<div>
<h4 className="text-sm font-medium">
{t("extensions.enabledCount", { count: enabled.length })}
</h4>
<div className="divide-y divide-border">
{enabled.map((ext) => (
<ExtensionItem
key={ext.config_key}
extension={ext}
onToggle={handleToggle}
onConfigure={handleConfigure}
/>
))}
</div>
</div>
)}
{available.length > 0 && (
<div>
<h4 className="text-sm font-medium text-muted-foreground">
{t("extensions.availableCount", { count: available.length })}
</h4>
<div className="divide-y divide-border">
{available.map((ext) => (
<ExtensionItem
key={ext.config_key}
extension={ext}
onToggle={handleToggle}
onConfigure={handleConfigure}
/>
))}
</div>
</div>
)}
</div>
)}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setEditingExtension(null);
setModalMode("add");
}}
>
<IconPlus className="size-4" />
{t("extensions.addExtension")}
</Button>
{modalMode === "add" && (
<ExtensionModal onSubmit={handleSubmit} onClose={handleModalClose} />
)}
{modalMode === "edit" && editingExtension && (
<ExtensionModal
extension={editingExtension}
onSubmit={handleSubmit}
onDelete={handleDelete}
onClose={handleModalClose}
/>
)}
</div>
);
}
@@ -29,10 +29,11 @@ import {
Stethoscope,
X,
} from "lucide-react";
import { IconPlug } from "@tabler/icons-react";
import { IconPlug, IconPuzzle } from "@tabler/icons-react";
import { AppearanceSettings } from "./AppearanceSettings";
import { DoctorSettings } from "./DoctorSettings";
import { ProvidersSettings } from "./ProvidersSettings";
import { ExtensionsSettings } from "@/features/extensions/ui/ExtensionsSettings";
import {
listArchivedProjects,
restoreProject,
@@ -48,6 +49,7 @@ import type { Session } from "@/shared/types/chat";
const NAV_ITEMS = [
{ id: "appearance", labelKey: "nav.appearance", icon: Palette },
{ id: "providers", labelKey: "nav.providers", icon: IconPlug },
{ id: "extensions", labelKey: "nav.extensions", icon: IconPuzzle },
{ id: "general", labelKey: "nav.general", icon: Settings2 },
{ id: "projects", labelKey: "nav.projects", icon: FolderKanban },
{ id: "chats", labelKey: "nav.chats", icon: MessageSquare },
@@ -238,6 +240,7 @@ export function SettingsModal({
>
{activeSection === "appearance" && <AppearanceSettings />}
{activeSection === "providers" && <ProvidersSettings />}
{activeSection === "extensions" && <ExtensionsSettings />}
{activeSection === "doctor" && <DoctorSettings />}
{activeSection === "general" && (
<div className="space-y-6">
@@ -46,7 +46,8 @@
"folderNotSet": "Folder not set",
"noChanges": "No uncommitted changes",
"noProjectAssigned": "No project assigned.",
"noServersConfigured": "No servers configured"
"noExtensions": "No extensions enabled",
"noMatchingExtensions": "No matching extensions"
},
"errors": {
"gitRead": "Unable to read git status."
@@ -89,7 +90,8 @@
"artifacts": "Artifacts",
"changes": "Changes",
"changesOnBranch": "on",
"mcpServers": "MCP Servers",
"extensions": "Extensions",
"searchExtensions": "Search extensions...",
"workspace": "Workspace"
}
},
@@ -55,6 +55,54 @@
"description": "Are you sure you want to permanently delete \"{{name}}\"? This cannot be undone.",
"title": "Delete project permanently?"
},
"extensions": {
"title": "Extensions",
"description": "Manage MCP extensions that add tools and capabilities to Goose.",
"search": "Search extensions...",
"enabled": "Enabled",
"enabledCount": "Enabled ({{count}})",
"available": "Available",
"availableCount": "Available ({{count}})",
"empty": "No extensions configured.",
"noResults": "No extensions match your search.",
"addExtension": "Add Extension",
"editExtension": "Edit Extension",
"deleteExtension": "Delete Extension",
"toggle": "Toggle {{name}}",
"configure": "Configure {{name}}",
"save": "Save",
"cancel": "Cancel",
"fields": {
"name": "Name",
"namePlaceholder": "my-extension",
"type": "Type",
"command": "Command",
"commandPlaceholder": "npx",
"arguments": "Arguments",
"argumentsPlaceholder": "One argument per line",
"url": "URL",
"urlPlaceholder": "http://localhost:3000/mcp",
"description": "Description",
"descriptionPlaceholder": "What does this extension do?",
"timeout": "Timeout (seconds)",
"envVars": "Environment Variables",
"envKeyPlaceholder": "KEY",
"envValuePlaceholder": "value",
"addEnvVar": "Add variable",
"removeEnvVar": "Remove variable"
},
"types": {
"stdio": "Stdio",
"streamable_http": "HTTP",
"builtin": "Built-in"
},
"errors": {
"toggleFailed": "Failed to toggle extension. Please try again.",
"saveFailed": "Failed to save extension. Please try again.",
"deleteFailed": "Failed to delete extension. Please try again.",
"nameConflict": "An extension named \"{{name}}\" already exists."
}
},
"doctor": {
"agents": "Agents",
"copied": "Copied",
@@ -85,6 +133,7 @@
"doctor": "Doctor",
"general": "General",
"projects": "Projects",
"extensions": "Extensions",
"providers": "Providers"
},
"projects": {
@@ -46,7 +46,8 @@
"folderNotSet": "Carpeta no configurada",
"noChanges": "No hay cambios sin confirmar",
"noProjectAssigned": "No hay proyecto asignado.",
"noServersConfigured": "No hay servidores configurados"
"noExtensions": "No hay extensiones habilitadas",
"noMatchingExtensions": "No hay extensiones que coincidan"
},
"errors": {
"gitRead": "No se pudo leer el estado de git."
@@ -89,7 +90,8 @@
"artifacts": "Artefactos",
"changes": "Cambios",
"changesOnBranch": "en",
"mcpServers": "Servidores MCP",
"extensions": "Extensiones",
"searchExtensions": "Buscar extensiones...",
"workspace": "Espacio de trabajo"
}
},
@@ -55,6 +55,54 @@
"description": "¿Seguro que quieres eliminar permanentemente \"{{name}}\"? Esta acción no se puede deshacer.",
"title": "¿Eliminar el proyecto de forma permanente?"
},
"extensions": {
"title": "Extensiones",
"description": "Administra las extensiones MCP que agregan herramientas y capacidades a Goose.",
"search": "Buscar extensiones...",
"enabled": "Habilitadas",
"enabledCount": "Habilitadas ({{count}})",
"available": "Disponibles",
"availableCount": "Disponibles ({{count}})",
"empty": "No hay extensiones configuradas.",
"noResults": "Ninguna extensión coincide con tu búsqueda.",
"addExtension": "Agregar extensión",
"editExtension": "Editar extensión",
"deleteExtension": "Eliminar extensión",
"toggle": "Activar/desactivar {{name}}",
"configure": "Configurar {{name}}",
"save": "Guardar",
"cancel": "Cancelar",
"fields": {
"name": "Nombre",
"namePlaceholder": "mi-extension",
"type": "Tipo",
"command": "Comando",
"commandPlaceholder": "npx",
"arguments": "Argumentos",
"argumentsPlaceholder": "Un argumento por línea",
"url": "URL",
"urlPlaceholder": "http://localhost:3000/mcp",
"description": "Descripción",
"descriptionPlaceholder": "¿Qué hace esta extensión?",
"timeout": "Tiempo de espera (segundos)",
"envVars": "Variables de entorno",
"envKeyPlaceholder": "CLAVE",
"envValuePlaceholder": "valor",
"addEnvVar": "Agregar variable",
"removeEnvVar": "Eliminar variable"
},
"types": {
"stdio": "Stdio",
"streamable_http": "HTTP",
"builtin": "Integrada"
},
"errors": {
"toggleFailed": "Error al cambiar la extensión. Inténtalo de nuevo.",
"saveFailed": "Error al guardar la extensión. Inténtalo de nuevo.",
"deleteFailed": "Error al eliminar la extensión. Inténtalo de nuevo.",
"nameConflict": "Ya existe una extensión llamada \"{{name}}\"."
}
},
"doctor": {
"agents": "Agentes",
"copied": "Copiado",
@@ -85,6 +133,7 @@
"doctor": "Diagnóstico",
"general": "General",
"projects": "Proyectos",
"extensions": "Extensiones",
"providers": "Proveedores"
},
"projects": {
+16 -7
View File
@@ -2,17 +2,26 @@ import type * as React from "react";
import { cn } from "@/shared/lib/cn";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
const variantStyles = {
default: [
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground border-input hover:border-border-input-hover focus-visible:border-ring focus-visible:ring-0 focus-visible:ring-offset-0 flex h-9 w-full min-w-0 rounded-input border bg-transparent px-3 py-1 text-base transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
],
ghost: [
"w-full border-none bg-transparent text-sm text-foreground shadow-none outline-none placeholder:text-muted-foreground focus:outline-none focus:ring-0 disabled:cursor-not-allowed disabled:opacity-50",
],
} as const;
interface InputProps extends React.ComponentProps<"input"> {
variant?: keyof typeof variantStyles;
}
function Input({ className, type, variant = "default", ...props }: InputProps) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground border-input hover:border-border-input-hover focus-visible:border-ring focus-visible:ring-0 focus-visible:ring-offset-0 flex h-9 w-full min-w-0 rounded-input border bg-transparent px-3 py-1 text-base transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className,
)}
className={cn(...variantStyles[variant], className)}
{...props}
/>
);