Skip to content
Merged
163 changes: 163 additions & 0 deletions ui/goose2/src-tauri/src/commands/extensions.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
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 {
obj.insert("config_key".to_string(), Value::String(key.clone()));
obj.entry("name".to_string())
.or_insert_with(|| Value::String(key));
entries.push(json);
Comment thread
morgmart marked this conversation as resolved.
}
}

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 {
Comment thread
morgmart marked this conversation as resolved.
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 change: 1 addition & 0 deletions ui/goose2/src-tauri/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions ui/goose2/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
20 changes: 20 additions & 0 deletions ui/goose2/src-tauri/src/services/goose_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}'"))?;
Expand Down
4 changes: 2 additions & 2 deletions ui/goose2/src/features/chat/ui/ContextPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -218,7 +218,7 @@ export function ContextPanel({
onOpenFile={handleOpenChangedFile}
/>
<ArtifactsWidget />
<McpServersWidget />
<ExtensionsWidget />
</div>
</TabsContent>

Expand Down
93 changes: 93 additions & 0 deletions ui/goose2/src/features/chat/ui/widgets/ExtensionsWidget.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
}
18 changes: 0 additions & 18 deletions ui/goose2/src/features/chat/ui/widgets/McpServersWidget.tsx

This file was deleted.

36 changes: 36 additions & 0 deletions ui/goose2/src/features/extensions/api/extensions.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
Loading
Loading