diff --git a/crates/goose/src/acp/server.rs b/crates/goose/src/acp/server.rs index 198bdb4b..81dade38 100644 --- a/crates/goose/src/acp/server.rs +++ b/crates/goose/src/acp/server.rs @@ -12,7 +12,7 @@ use crate::config::extensions::get_enabled_extensions_with_config; use crate::config::paths::Paths; use crate::config::permission::PermissionManager; use crate::config::{Config, GooseMode}; -use crate::conversation::message::{ActionRequiredData, Message, MessageContent}; +use crate::conversation::message::{ActionRequiredData, Message, MessageContent, ToolRequest}; use crate::mcp_utils::ToolResult; use crate::permission::permission_confirmation::PrincipalType; use crate::permission::{Permission, PermissionConfirmation}; @@ -560,6 +560,77 @@ fn summarize_tool_call(tool_name: &str, arguments: Option<&serde_json::Value>) - } } +fn tool_call_identity_meta(tool_request: &ToolRequest) -> Option { + let tool_call = tool_request.tool_call.as_ref().ok()?; + let tool_name = tool_call.name.to_string(); + let extension_name = tool_request + .tool_meta + .as_ref() + .and_then(|meta| meta.get("goose_extension")) + .and_then(serde_json::Value::as_str) + .map(ToString::to_string) + .or_else(|| { + tool_name + .split_once("__") + .map(|(extension_name, _)| extension_name.to_string()) + }); + + let mut tool_call_meta = serde_json::Map::new(); + tool_call_meta.insert("toolName".to_string(), serde_json::Value::String(tool_name)); + if let Some(extension_name) = extension_name { + tool_call_meta.insert( + "extensionName".to_string(), + serde_json::Value::String(extension_name), + ); + } + + let mut goose_meta = serde_json::Map::new(); + goose_meta.insert( + "toolCall".to_string(), + serde_json::Value::Object(tool_call_meta), + ); + + let mut meta = serde_json::Map::new(); + meta.insert("goose".to_string(), serde_json::Value::Object(goose_meta)); + Some(meta) +} + +struct PendingToolCall { + tool_call: ToolCall, + identity_meta: Option, + fallback_title: String, +} + +fn pending_tool_call_from_request(tool_request: &ToolRequest) -> PendingToolCall { + let tool_name = match &tool_request.tool_call { + Ok(tool_call) => tool_call.name.to_string(), + Err(_) => "error".to_string(), + }; + let args_value = tool_request + .tool_call + .as_ref() + .ok() + .and_then(|tc| tc.arguments.as_ref()) + .map(|a| serde_json::Value::Object(a.clone())); + let fallback_title = summarize_tool_call(&tool_name, args_value.as_ref()); + let identity_meta = tool_call_identity_meta(tool_request); + + let mut tool_call = ToolCall::new( + ToolCallId::new(tool_request.id.clone()), + fallback_title.clone(), + ) + .status(ToolCallStatus::Pending); + if let Some(args) = args_value { + tool_call = tool_call.raw_input(args); + } + + PendingToolCall { + tool_call, + identity_meta, + fallback_title, + } +} + fn builtin_to_extension_config(name: &str) -> ExtensionConfig { if let Some(def) = PLATFORM_EXTENSIONS.get(name) { ExtensionConfig::Platform { @@ -1405,27 +1476,10 @@ impl GooseAcpAgent { .tool_requests .insert(tool_request.id.clone(), tool_request.clone()); - let tool_name = match &tool_request.tool_call { - Ok(tool_call) => tool_call.name.to_string(), - Err(_) => "error".to_string(), - }; - - let args_value = tool_request + let pending_tool_call = pending_tool_call_from_request(tool_request); + let initial_tool_call = pending_tool_call .tool_call - .as_ref() - .ok() - .and_then(|tc| tc.arguments.as_ref()) - .map(|a| serde_json::Value::Object(a.clone())); - let fallback_title = summarize_tool_call(&tool_name, args_value.as_ref()); - - let mut initial_tool_call = ToolCall::new( - ToolCallId::new(tool_request.id.clone()), - fallback_title.clone(), - ) - .status(ToolCallStatus::Pending); - if let Some(args) = args_value.clone() { - initial_tool_call = initial_tool_call.raw_input(args); - } + .meta(pending_tool_call.identity_meta.clone()); cx.send_notification(SessionNotification::new( session_id.clone(), SessionUpdate::ToolCall(initial_tool_call), @@ -1440,6 +1494,8 @@ impl GooseAcpAgent { let request_id = tool_request.id.clone(); let cx = cx.clone(); let name = tool_call.name.to_string(); + let identity_meta = pending_tool_call.identity_meta.clone(); + let fallback_title = pending_tool_call.fallback_title.clone(); let args_json = tool_call .arguments .as_ref() @@ -1454,71 +1510,56 @@ impl GooseAcpAgent { .unwrap_or_default(); tokio::spawn(async move { - let provider: Arc = match agent.provider().await { - Ok(p) => p, + let title = match agent.provider().await { + Ok(provider) => { + if provider.manages_own_context() { + return; + } + + let system = + "Summarize this tool call in a short lowercase phrase (3-8 words). \ + No punctuation. No quotes. Examples: reading project configuration, \ + checking network connectivity, listing files in src directory"; + let user_text = format!("Tool: {name}\nArguments: {args_json}"); + let message = Message::user().with_text(&user_text); + match provider + .complete_fast(&sid.0, system, &[message], &[]) + .await + { + Ok((response, _)) => { + let summary: String = response + .content + .iter() + .filter_map(|c: &MessageContent| c.as_text()) + .collect::() + .trim() + .to_string(); + if summary.is_empty() { + fallback_title.clone() + } else { + summary + } + } + Err(e) => { + warn!("tool call summary: fast_complete failed: {e}"); + fallback_title.clone() + } + } + } Err(e) => { warn!("tool call summary: failed to get provider: {e}"); - let fields = ToolCallUpdateFields::new().title(fallback_title); - let _ = cx.send_notification(SessionNotification::new( - sid, - SessionUpdate::ToolCallUpdate(ToolCallUpdate::new( - ToolCallId::new(request_id), - fields, - )), - )); - return; + fallback_title.clone() } }; - // in these case, the title summarization request would - // be added to the conversation which we don't want - if provider.manages_own_context() { - return; - } - - let system = "Summarize this tool call in a short lowercase phrase (3-8 words). \ - No punctuation. No quotes. Examples: reading project configuration, \ - checking network connectivity, listing files in src directory"; - let user_text = format!("Tool: {name}\nArguments: {args_json}"); - let message = Message::user().with_text(&user_text); - match provider - .complete_fast(&sid.0, system, &[message], &[]) - .await - { - Ok((response, _)) => { - let summary: String = response - .content - .iter() - .filter_map(|c: &MessageContent| c.as_text()) - .collect::() - .trim() - .to_string(); - let title = if summary.is_empty() { - fallback_title - } else { - summary - }; - let fields = ToolCallUpdateFields::new().title(title); - let _ = cx.send_notification(SessionNotification::new( - sid, - SessionUpdate::ToolCallUpdate(ToolCallUpdate::new( - ToolCallId::new(request_id), - fields, - )), - )); - } - Err(e) => { - warn!("tool call summary: fast_complete failed: {e}"); - let fields = ToolCallUpdateFields::new().title(fallback_title); - let _ = cx.send_notification(SessionNotification::new( - sid, - SessionUpdate::ToolCallUpdate(ToolCallUpdate::new( - ToolCallId::new(request_id), - fields, - )), - )); - } - } + let fields = ToolCallUpdateFields::new().title(title); + let _ = cx.send_notification(SessionNotification::new( + sid, + SessionUpdate::ToolCallUpdate( + ToolCallUpdate::new(ToolCallId::new(request_id), fields) + .meta(identity_meta), + ), + )); }); } @@ -2172,21 +2213,14 @@ impl GooseAcpAgent { // don't require a full GooseAcpSession. replay_tool_requests.insert(tool_request.id.clone(), tool_request.clone()); - let tool_name = match &tool_request.tool_call { - Ok(tool_call) => tool_call.name.to_string(), - Err(_) => "error".to_string(), - }; + let pending_tool_call = pending_tool_call_from_request(tool_request); + let tool_call = pending_tool_call.tool_call.meta( + merge_replay_message_meta(pending_tool_call.identity_meta, message), + ); cx.send_notification(SessionNotification::new( args.session_id.clone(), - SessionUpdate::ToolCall( - ToolCall::new( - ToolCallId::new(tool_request.id.clone()), - format_tool_name(&tool_name), - ) - .status(ToolCallStatus::Pending) - .meta(replay_message_meta(message)), - ), + SessionUpdate::ToolCall(tool_call), ))?; } MessageContent::ToolResponse(tool_response) => { @@ -3020,6 +3054,28 @@ print(\"hello, world\") ); } + #[test] + fn test_tool_call_identity_meta_uses_goose_extension_metadata() { + let request = ToolRequest { + id: "req_1".to_string(), + tool_call: Ok(CallToolRequestParams::new("context7__query-docs")), + metadata: None, + tool_meta: Some(serde_json::json!({"goose_extension": "context7"})), + }; + + let meta = tool_call_identity_meta(&request).expect("expected metadata"); + + assert_eq!( + meta.get("goose"), + Some(&serde_json::json!({ + "toolCall": { + "toolName": "context7__query-docs", + "extensionName": "context7", + }, + })), + ); + } + #[test] fn test_summarize_tool_call_long_value_truncated() { let long_path = "a".repeat(80); diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index 57d8da54..b59b94fa 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -783,13 +783,6 @@ impl Agent { let results = futures::future::join_all(extension_futures).await; - // Persist once after all extensions are loaded - if results.iter().any(|r| r.success) { - if let Err(e) = self.persist_extension_state(&session_id).await { - warn!("Failed to persist extension state after bulk load: {}", e); - } - } - results } diff --git a/crates/goose/src/agents/platform_extensions/mod.rs b/crates/goose/src/agents/platform_extensions/mod.rs index c68d19ee..f4f5dbc9 100644 --- a/crates/goose/src/agents/platform_extensions/mod.rs +++ b/crates/goose/src/agents/platform_extensions/mod.rs @@ -36,7 +36,7 @@ pub static PLATFORM_EXTENSIONS: Lazy display_name: "Analyze", description: "Analyze code structure with tree-sitter: directory overviews, file details, symbol call graphs", - default_enabled: true, + default_enabled: false, unprefixed_tools: true, hidden: false, client_factory: |ctx| Box::new(analyze::AnalyzeClient::new(ctx).unwrap()), @@ -64,7 +64,7 @@ pub static PLATFORM_EXTENSIONS: Lazy display_name: "Apps", description: "Create and manage custom Goose apps through chat. Apps are HTML/CSS/JavaScript and run in sandboxed windows.", - default_enabled: true, + default_enabled: false, unprefixed_tools: false, hidden: false, client_factory: |ctx| Box::new(apps::AppsManagerClient::new(ctx).unwrap()), @@ -105,7 +105,7 @@ pub static PLATFORM_EXTENSIONS: Lazy name: summon::EXTENSION_NAME, display_name: "Summon", description: "Load knowledge and delegate tasks to subagents", - default_enabled: true, + default_enabled: false, unprefixed_tools: true, hidden: false, client_factory: |ctx| Box::new(summon::SummonClient::new(ctx).unwrap()), @@ -182,7 +182,7 @@ pub static PLATFORM_EXTENSIONS: Lazy display_name: "Top Of Mind", description: "Inject custom context into every turn via GOOSE_MOIM_MESSAGE_TEXT and GOOSE_MOIM_MESSAGE_FILE environment variables", - default_enabled: true, + default_enabled: false, unprefixed_tools: false, hidden: false, client_factory: |ctx| Box::new(tom::TomClient::new(ctx).unwrap()), diff --git a/crates/goose/src/config/base.rs b/crates/goose/src/config/base.rs index 5266c5dc..b7fefc8c 100644 --- a/crates/goose/src/config/base.rs +++ b/crates/goose/src/config/base.rs @@ -1986,10 +1986,12 @@ extensions: ) .unwrap(); - // User config (higher priority / write target) disables developer and adds a new extension + // User config (higher priority / write target) has already migrated, then disables + // developer and adds a new extension. std::fs::write( local_file.path(), r#" +extensions_on_demand_migration: true extensions: developer: enabled: false @@ -2014,7 +2016,7 @@ extensions: let values = config.load()?; let extensions = values.get("extensions").unwrap().as_mapping().unwrap(); - // developer should be disabled (user config overrides system) + // developer should be disabled (user config overrides system after migration) let dev = extensions.get("developer").unwrap().as_mapping().unwrap(); assert!(!dev.get("enabled").unwrap().as_bool().unwrap()); // Fields from the system config should be preserved via merge diff --git a/crates/goose/src/config/extensions.rs b/crates/goose/src/config/extensions.rs index 460a6e93..3b47f285 100644 --- a/crates/goose/src/config/extensions.rs +++ b/crates/goose/src/config/extensions.rs @@ -13,8 +13,13 @@ pub const DEFAULT_EXTENSION_DESCRIPTION: &str = ""; pub const DEFAULT_DISPLAY_NAME: &str = "Developer"; const EXTENSIONS_CONFIG_KEY: &str = "extensions"; +fn default_extension_enabled() -> bool { + true +} + #[derive(Debug, Deserialize, Serialize, Clone, ToSchema)] pub struct ExtensionEntry { + #[serde(default = "default_extension_enabled")] pub enabled: bool, #[serde(flatten)] pub config: ExtensionConfig, @@ -210,4 +215,22 @@ mod tests { assert!(!is_extension_available(&unknown_platform)); assert!(is_extension_available(&builtin)); } + + #[test] + fn test_extension_entry_defaults_missing_enabled_to_true() { + let value = serde_yaml::from_str( + r#" +type: stdio +name: github +description: GitHub tools +cmd: npx +args: [] +"#, + ) + .unwrap(); + + let entry: ExtensionEntry = serde_yaml::from_value(value).unwrap(); + + assert!(entry.enabled); + } } diff --git a/crates/goose/src/config/migrations.rs b/crates/goose/src/config/migrations.rs index d6f20e91..1db4cb94 100644 --- a/crates/goose/src/config/migrations.rs +++ b/crates/goose/src/config/migrations.rs @@ -114,10 +114,18 @@ mod tests { assert!(changed); let extensions_key = serde_yaml::Value::String(EXTENSIONS_CONFIG_KEY.to_string()); assert!(config.contains_key(&extensions_key)); + + let extensions = config.get(&extensions_key).unwrap().as_mapping().unwrap(); + for (key, value) in extensions { + let key = key.as_str().unwrap(); + let def = PLATFORM_EXTENSIONS.get(key).unwrap(); + let entry: ExtensionEntry = serde_yaml::from_value(value.clone()).unwrap(); + assert_eq!(entry.enabled, def.default_enabled); + } } #[test] - fn test_migrate_platform_extensions_preserves_enabled_state() { + fn test_migrate_platform_extensions_refreshes_metadata_without_changing_enabled() { let mut config = Mapping::new(); let mut extensions = Mapping::new(); let todo_entry = ExtensionEntry { @@ -149,6 +157,105 @@ mod tests { let todo_entry: ExtensionEntry = serde_yaml::from_value(todo_value.clone()).unwrap(); assert!(!todo_entry.enabled); + match todo_entry.config { + ExtensionConfig::Platform { + description, + display_name, + .. + } => { + assert_ne!(description, "old description"); + assert_ne!(display_name.as_deref(), Some("Old Name")); + } + other => panic!("expected platform extension, got {other:?}"), + } + } + + #[test] + fn test_migrate_platform_extensions_preserves_existing_enabled_values() { + let mut config = Mapping::new(); + let mut extensions = Mapping::new(); + + let analyze_entry = ExtensionEntry { + config: ExtensionConfig::Platform { + name: "analyze".to_string(), + description: "Analyze code structure".to_string(), + display_name: Some("Analyze".to_string()), + bundled: Some(true), + available_tools: Vec::new(), + }, + enabled: true, + }; + let developer_entry = ExtensionEntry { + config: ExtensionConfig::Platform { + name: "developer".to_string(), + description: "Write and edit files, and execute shell commands".to_string(), + display_name: Some("Developer".to_string()), + bundled: Some(true), + available_tools: Vec::new(), + }, + enabled: false, + }; + let custom_developer_entry = ExtensionEntry { + config: ExtensionConfig::Stdio { + name: "developer".to_string(), + description: "Custom user developer tools".to_string(), + cmd: "custom-developer".to_string(), + args: Vec::new(), + envs: Default::default(), + env_keys: Vec::new(), + timeout: Some(300), + bundled: None, + available_tools: Vec::new(), + }, + enabled: true, + }; + + extensions.insert( + serde_yaml::Value::String("analyze".to_string()), + serde_yaml::to_value(&analyze_entry).unwrap(), + ); + extensions.insert( + serde_yaml::Value::String("developer".to_string()), + serde_yaml::to_value(&developer_entry).unwrap(), + ); + extensions.insert( + serde_yaml::Value::String("custom-developer".to_string()), + serde_yaml::to_value(&custom_developer_entry).unwrap(), + ); + config.insert( + serde_yaml::Value::String(EXTENSIONS_CONFIG_KEY.to_string()), + serde_yaml::Value::Mapping(extensions), + ); + + assert!(run_migrations(&mut config)); + + let extensions_key = serde_yaml::Value::String(EXTENSIONS_CONFIG_KEY.to_string()); + let extensions = config.get(&extensions_key).unwrap().as_mapping().unwrap(); + let analyze: ExtensionEntry = serde_yaml::from_value( + extensions + .get(serde_yaml::Value::String("analyze".to_string())) + .unwrap() + .clone(), + ) + .unwrap(); + let developer: ExtensionEntry = serde_yaml::from_value( + extensions + .get(serde_yaml::Value::String("developer".to_string())) + .unwrap() + .clone(), + ) + .unwrap(); + let custom_developer: ExtensionEntry = serde_yaml::from_value( + extensions + .get(serde_yaml::Value::String("custom-developer".to_string())) + .unwrap() + .clone(), + ) + .unwrap(); + + assert!(analyze.enabled); + assert!(!developer.enabled); + assert!(custom_developer.enabled); } #[test] diff --git a/crates/goose/tests/acp_common_tests/mod.rs b/crates/goose/tests/acp_common_tests/mod.rs index 34272c69..7f7a873d 100644 --- a/crates/goose/tests/acp_common_tests/mod.rs +++ b/crates/goose/tests/acp_common_tests/mod.rs @@ -144,7 +144,7 @@ pub async fn run_config_mcp() { let mcp = McpFixture::new(expected_session_id.clone()).await; let config_yaml = format!( - "GOOSE_MODEL: {TEST_MODEL}\nGOOSE_PROVIDER: openai\nextensions:\n mcp-fixture:\n enabled: true\n type: streamable_http\n name: mcp-fixture\n description: MCP fixture\n uri: \"{}\"\n", + "GOOSE_MODEL: {TEST_MODEL}\nGOOSE_PROVIDER: openai\nextensions_on_demand_migration: true\nextensions:\n mcp-fixture:\n enabled: true\n type: streamable_http\n name: mcp-fixture\n description: MCP fixture\n uri: \"{}\"\n", mcp.url ); fs::write(temp_dir.path().join(CONFIG_YAML_NAME), config_yaml).unwrap(); @@ -194,7 +194,7 @@ pub async fn run_config_mcp() { pub async fn run_fs_read_text_file_true() { let temp_dir = tempfile::tempdir().unwrap(); let config_yaml = format!( - "GOOSE_MODEL: {TEST_MODEL}\nGOOSE_PROVIDER: openai\nextensions:\n developer:\n enabled: true\n type: platform\n name: developer\n description: Developer\n display_name: Developer\n bundled: true\n available_tools: []\n" + "GOOSE_MODEL: {TEST_MODEL}\nGOOSE_PROVIDER: openai\nextensions_on_demand_migration: true\nextensions:\n developer:\n enabled: true\n type: platform\n name: developer\n description: Developer\n display_name: Developer\n bundled: true\n available_tools: []\n" ); fs::write(temp_dir.path().join(CONFIG_YAML_NAME), config_yaml).unwrap(); @@ -363,7 +363,7 @@ pub async fn run_load_mode() { let mcp = McpFixture::new(expected_session_id.clone()).await; let config_yaml = format!( - "GOOSE_MODEL: {TEST_MODEL}\nGOOSE_PROVIDER: openai\nextensions:\n mcp-fixture:\n enabled: true\n type: streamable_http\n name: mcp-fixture\n description: MCP fixture\n uri: \"{}\"\n", + "GOOSE_MODEL: {TEST_MODEL}\nGOOSE_PROVIDER: openai\nextensions_on_demand_migration: true\nextensions:\n mcp-fixture:\n enabled: true\n type: streamable_http\n name: mcp-fixture\n description: MCP fixture\n uri: \"{}\"\n", mcp.url ); fs::write(temp_dir.path().join(CONFIG_YAML_NAME), config_yaml).unwrap(); @@ -601,7 +601,7 @@ async fn run_mode_set_impl(via: SetModeVia) { let mcp = McpFixture::new(expected_session_id.clone()).await; let config_yaml = format!( - "GOOSE_MODEL: {TEST_MODEL}\nGOOSE_PROVIDER: openai\nextensions:\n mcp-fixture:\n enabled: true\n type: streamable_http\n name: mcp-fixture\n description: MCP fixture\n uri: \"{}\"\n", + "GOOSE_MODEL: {TEST_MODEL}\nGOOSE_PROVIDER: openai\nextensions_on_demand_migration: true\nextensions:\n mcp-fixture:\n enabled: true\n type: streamable_http\n name: mcp-fixture\n description: MCP fixture\n uri: \"{}\"\n", mcp.url ); fs::write(temp_dir.path().join(CONFIG_YAML_NAME), config_yaml).unwrap(); @@ -1198,7 +1198,7 @@ pub async fn run_prompt_skill() { .await; let config = TestConnectionConfig { - builtins: vec!["summon".to_string()], + builtins: vec!["summon".to_string(), "skills".to_string()], cwd: Some(cwd), ..Default::default() }; diff --git a/crates/goose/tests/acp_custom_requests_test.rs b/crates/goose/tests/acp_custom_requests_test.rs index a2a59516..5b2eece8 100644 --- a/crates/goose/tests/acp_custom_requests_test.rs +++ b/crates/goose/tests/acp_custom_requests_test.rs @@ -253,6 +253,7 @@ fn test_developer_fs_requests_use_acp_session_id() { // gpt-5-nano routes to the Responses API; use a Chat Completions // model so the canned SSE fixtures are parsed correctly. current_model: "gpt-4.1".to_string(), + builtins: vec!["developer".to_string()], read_text_file: Some(Arc::new(move |req| { *seen_session_id_clone.lock().unwrap() = Some(req.session_id.0.to_string()); Ok(sacp::schema::ReadTextFileResponse::new( diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 43f355fd..6a0b6316 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -5211,9 +5211,6 @@ }, { "type": "object", - "required": [ - "enabled" - ], "properties": { "enabled": { "type": "boolean" diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index 1b71c990..2e07e055 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -465,7 +465,7 @@ export type ExtensionData = { }; export type ExtensionEntry = ExtensionConfig & { - enabled: boolean; + enabled?: boolean; }; export type ExtensionLoadResult = { diff --git a/ui/desktop/src/components/ConfigContext.tsx b/ui/desktop/src/components/ConfigContext.tsx index 434cad8c..134e3faa 100644 --- a/ui/desktop/src/components/ConfigContext.tsx +++ b/ui/desktop/src/components/ConfigContext.tsx @@ -18,6 +18,7 @@ import type { ProviderDetails, ExtensionQuery, ExtensionConfig, + ExtensionEntry, } from '../api'; export type { ExtensionConfig } from '../api/types.gen'; @@ -27,6 +28,12 @@ export type FixedExtensionEntry = ExtensionConfig & { enabled: boolean; }; +const normalizeExtensions = (extensions: ExtensionEntry[]): FixedExtensionEntry[] => + extensions.map((extension) => ({ + ...extension, + enabled: extension.enabled ?? true, + })); + interface ConfigContextType { config: ConfigResponse['config']; providersList: ProviderDetails[]; @@ -126,9 +133,10 @@ export const ConfigProvider: React.FC = ({ children }) => { } const extensionResponse: ExtensionResponse = result.data!; - setExtensionsList(extensionResponse.extensions); + const extensions = normalizeExtensions(extensionResponse.extensions); + setExtensionsList(extensions); setExtensionWarnings(extensionResponse.warnings || []); - return extensionResponse.extensions; + return extensions; }, [extensionsList]); const addExtension = useCallback( @@ -213,7 +221,7 @@ export const ConfigProvider: React.FC = ({ children }) => { // Load extensions try { const extensionsResponse = await apiGetExtensions(); - let extensions = extensionsResponse.data?.extensions || []; + let extensions = normalizeExtensions(extensionsResponse.data?.extensions || []); // Always sync bundled extensions from bundled-extensions.json // This ensures: @@ -236,7 +244,7 @@ export const ConfigProvider: React.FC = ({ children }) => { await syncBundledExtensions(extensions, addExtensionForSync); // Reload extensions after sync const refreshedResponse = await apiGetExtensions(); - extensions = refreshedResponse.data?.extensions || []; + extensions = normalizeExtensions(refreshedResponse.data?.extensions || []); setExtensionsList(extensions); setExtensionWarnings(extensionsResponse.data?.warnings || []); diff --git a/ui/desktop/src/components/extensions/ExtensionsView.tsx b/ui/desktop/src/components/extensions/ExtensionsView.tsx index d21a4d5b..3f34dfbb 100644 --- a/ui/desktop/src/components/extensions/ExtensionsView.tsx +++ b/ui/desktop/src/components/extensions/ExtensionsView.tsx @@ -27,12 +27,12 @@ const i18n = defineMessages({ description: { id: 'extensionsView.description', defaultMessage: - 'These extensions use the Model Context Protocol (MCP). They can expand Goose\'s capabilities using three main components: Prompts, Resources, and Tools. {searchShortcut} to search.', + "These extensions use the Model Context Protocol (MCP). They can expand Goose's capabilities using three main components: Prompts, Resources, and Tools. {searchShortcut} to search.", }, defaultNote: { id: 'extensionsView.defaultNote', defaultMessage: - 'Extensions enabled here are used as the default for new chats. You can also toggle active extensions during chat.', + 'Extensions stay available here, and Goose can load them on demand during a chat.', }, addCustomExtension: { id: 'extensionsView.addCustomExtension', @@ -155,9 +155,7 @@ export default function ExtensionsView({ )} - handleToggle(extension)} - disabled={isToggling} - variant="mono" - aria-label={intl.formatMessage(i18n.toggleExtension, { name: getFriendlyTitle(extension) })} - /> diff --git a/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionList.tsx b/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionList.tsx index 7b3bbd77..800e5f5b 100644 --- a/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionList.tsx +++ b/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionList.tsx @@ -8,11 +8,11 @@ import { defineMessages, useIntl } from '../../../../i18n'; const i18n = defineMessages({ defaultExtensions: { id: 'extensionList.defaultExtensions', - defaultMessage: 'Default Extensions ({count})', + defaultMessage: 'Active by Default ({count})', }, availableExtensions: { id: 'extensionList.availableExtensions', - defaultMessage: 'Available Extensions ({count})', + defaultMessage: 'Available On Demand ({count})', }, noExtensions: { id: 'extensionList.noExtensions', @@ -26,7 +26,6 @@ const i18n = defineMessages({ interface ExtensionListProps { extensions: FixedExtensionEntry[]; - onToggle: (extension: FixedExtensionEntry) => Promise | void; onConfigure?: (extension: FixedExtensionEntry) => void; isStatic?: boolean; disableConfiguration?: boolean; @@ -35,10 +34,9 @@ interface ExtensionListProps { export default function ExtensionList({ extensions, - onToggle, onConfigure, isStatic, - disableConfiguration: _disableConfiguration, + disableConfiguration, searchTerm = '', }: ExtensionListProps) { const matchesSearch = (extension: FixedExtensionEntry): boolean => { @@ -68,6 +66,9 @@ export default function ExtensionList({ const sortedDisabledExtensions = [...disabledExtensions].sort((a, b) => getFriendlyTitle(a).localeCompare(getFriendlyTitle(b)) ); + const configureHandler = disableConfiguration ? undefined : onConfigure; + const hasVisibleExtensions = + sortedEnabledExtensions.length > 0 || sortedDisabledExtensions.length > 0; return (
@@ -82,8 +83,7 @@ export default function ExtensionList({ ))} @@ -95,15 +95,16 @@ export default function ExtensionList({

- {intl.formatMessage(i18n.availableExtensions, { count: sortedDisabledExtensions.length })} + {intl.formatMessage(i18n.availableExtensions, { + count: sortedDisabledExtensions.length, + })}

{sortedDisabledExtensions.map((extension) => ( ))} @@ -111,8 +112,10 @@ export default function ExtensionList({
)} - {extensions.length === 0 && ( -
{intl.formatMessage(i18n.noExtensions)}
+ {!hasVisibleExtensions && ( +
+ {intl.formatMessage(i18n.noExtensions)} +
)}
); diff --git a/ui/desktop/src/components/settings/extensions/utils.test.ts b/ui/desktop/src/components/settings/extensions/utils.test.ts index ab886aa1..8b271948 100644 --- a/ui/desktop/src/components/settings/extensions/utils.test.ts +++ b/ui/desktop/src/components/settings/extensions/utils.test.ts @@ -39,7 +39,7 @@ describe('Extension Utils', () => { type: 'stdio', cmd: '', endpoint: '', - enabled: true, + enabled: false, timeout: 300, envVars: [], headers: [], diff --git a/ui/desktop/src/components/settings/extensions/utils.ts b/ui/desktop/src/components/settings/extensions/utils.ts index 00e29c1f..77b3c79c 100644 --- a/ui/desktop/src/components/settings/extensions/utils.ts +++ b/ui/desktop/src/components/settings/extensions/utils.ts @@ -47,7 +47,7 @@ export function getDefaultFormData(): ExtensionFormData { type: 'stdio', cmd: '', endpoint: '', - enabled: true, + enabled: false, timeout: 300, envVars: [], headers: [], diff --git a/ui/desktop/src/i18n/messages/en.json b/ui/desktop/src/i18n/messages/en.json index c7f53f9a..73704372 100644 --- a/ui/desktop/src/i18n/messages/en.json +++ b/ui/desktop/src/i18n/messages/en.json @@ -1058,17 +1058,14 @@ "extensionItem.configureExtension": { "defaultMessage": "Configure {name} Extension" }, - "extensionItem.toggleExtension": { - "defaultMessage": "Toggle {name} extension On or Off" - }, "extensionList.availableExtensions": { - "defaultMessage": "Available Extensions ({count})" + "defaultMessage": "Available On Demand ({count})" }, "extensionList.builtInExtension": { "defaultMessage": "Built-in extension" }, "extensionList.defaultExtensions": { - "defaultMessage": "Default Extensions ({count})" + "defaultMessage": "Active by Default ({count})" }, "extensionList.noExtensions": { "defaultMessage": "No extensions available" @@ -1128,7 +1125,7 @@ "defaultMessage": "Browse extensions" }, "extensionsView.defaultNote": { - "defaultMessage": "Extensions enabled here are used as the default for new chats. You can also toggle active extensions during chat." + "defaultMessage": "Extensions stay available here, and Goose can load them on demand during a chat." }, "extensionsView.description": { "defaultMessage": "These extensions use the Model Context Protocol (MCP). They can expand Goose's capabilities using three main components: Prompts, Resources, and Tools. {searchShortcut} to search." diff --git a/ui/goose2/src/app/AppShell.tsx b/ui/goose2/src/app/AppShell.tsx index cc8cddb4..88d4a991 100644 --- a/ui/goose2/src/app/AppShell.tsx +++ b/ui/goose2/src/app/AppShell.tsx @@ -38,6 +38,7 @@ export type AppView = | "home" | "chat" | "skills" + | "extensions" | "agents" | "projects" | "session-history"; @@ -51,7 +52,6 @@ const SETTINGS_SECTIONS = new Set([ "appearance", "providers", "compaction", - "extensions", "voice", "general", "projects", diff --git a/ui/goose2/src/app/ui/AppShellContent.tsx b/ui/goose2/src/app/ui/AppShellContent.tsx index 1e09baa0..ec952554 100644 --- a/ui/goose2/src/app/ui/AppShellContent.tsx +++ b/ui/goose2/src/app/ui/AppShellContent.tsx @@ -1,6 +1,7 @@ import { HomeScreen } from "@/features/home/ui/HomeScreen"; import { ChatView } from "@/features/chat/ui/ChatView"; import { SkillsView } from "@/features/skills/ui/SkillsView"; +import { ExtensionsView } from "@/features/extensions/ui/ExtensionsView"; import { AgentsView } from "@/features/agents/ui/AgentsView"; import { ProjectsView } from "@/features/projects/ui/ProjectsView"; import { SessionHistoryView } from "@/features/sessions/ui/SessionHistoryView"; @@ -48,6 +49,8 @@ export function AppShellContent({ switch (activeView) { case "skills": return ; + case "extensions": + return ; case "agents": return ; case "projects": diff --git a/ui/goose2/src/features/chat/lib/__tests__/artifactPathPolicy.test.ts b/ui/goose2/src/features/chat/lib/__tests__/artifactPathPolicy.test.ts index 51956588..f1ed34b1 100644 --- a/ui/goose2/src/features/chat/lib/__tests__/artifactPathPolicy.test.ts +++ b/ui/goose2/src/features/chat/lib/__tests__/artifactPathPolicy.test.ts @@ -317,6 +317,44 @@ describe("artifactPathPolicy", () => { expect(ranking?.primaryCandidate?.allowed).toBe(true); }); + it("uses semantic tool names instead of display titles for write detection", () => { + const result = buildArtifactsIndexForMessages( + [ + { + id: "assistant-1", + role: "assistant", + created: Date.now(), + metadata: { userVisible: true, agentVisible: true }, + content: [ + { + type: "toolRequest", + id: "tool-1", + name: "Writing project summary", + toolName: "write_file", + arguments: { path: "/Users/test/project-a/summary.md" }, + status: "completed", + }, + { + type: "toolResponse", + id: "tool-1", + name: "Writing project summary", + result: "Created /Users/test/project-a/summary.md", + isError: false, + }, + ], + }, + ], + roots, + ); + + const ranking = result.byMessageId.get("assistant-1"); + expect(ranking?.primaryToolCallId).toBe("tool-1"); + expect(ranking?.primaryCandidate?.toolName).toBe("write_file"); + expect(ranking?.primaryCandidate?.resolvedPath).toBe( + "/Users/test/project-a/summary.md", + ); + }); + it("prefers an allowed candidate as primary when top-ranked candidate is blocked", () => { const ranking = rankMessageToolArtifacts( [ diff --git a/ui/goose2/src/features/chat/lib/artifactPathPolicy.ts b/ui/goose2/src/features/chat/lib/artifactPathPolicy.ts index 5cd22db2..8c9d60ae 100644 --- a/ui/goose2/src/features/chat/lib/artifactPathPolicy.ts +++ b/ui/goose2/src/features/chat/lib/artifactPathPolicy.ts @@ -190,11 +190,12 @@ function extractToolCallsFromMessage( for (const block of message.content) { if (block.type === "toolRequest") { + const toolName = block.toolName ?? block.name; if (!byId.has(block.id)) { orderedIds.push(block.id); byId.set(block.id, { toolCallId: block.id, - toolName: block.name, + toolName, args: toSafeRecord(block.arguments), toolCallIndex, }); @@ -202,7 +203,7 @@ function extractToolCallsFromMessage( } else { const existing = byId.get(block.id); if (existing) { - existing.toolName = block.name || existing.toolName; + existing.toolName = toolName || existing.toolName; existing.args = toSafeRecord(block.arguments); } } diff --git a/ui/goose2/src/features/chat/ui/ChatInputSelectionChips.tsx b/ui/goose2/src/features/chat/ui/ChatInputSelectionChips.tsx index 4602c62c..e0bdfb23 100644 --- a/ui/goose2/src/features/chat/ui/ChatInputSelectionChips.tsx +++ b/ui/goose2/src/features/chat/ui/ChatInputSelectionChips.tsx @@ -1,5 +1,5 @@ -import { IconStack2 } from "@tabler/icons-react"; import { useTranslation } from "react-i18next"; +import { SkillIcon } from "@/features/skills/ui/SkillIcon"; import type { Persona } from "@/shared/types/agents"; import type { ChatSkillDraft } from "../types"; import { ComposerChip } from "./ComposerChip"; @@ -40,7 +40,7 @@ export function ChatInputSelectionChips({ key={skill.id} tone="skill" label={skill.name} - leading={} + leading={} onRemove={() => onRemoveSkill(skill.id)} removeLabel={t("skill.clearSelected", { skill: skill.name, diff --git a/ui/goose2/src/features/chat/ui/ContextPanel.tsx b/ui/goose2/src/features/chat/ui/ContextPanel.tsx index 960a8ef9..bff41aa8 100644 --- a/ui/goose2/src/features/chat/ui/ContextPanel.tsx +++ b/ui/goose2/src/features/chat/ui/ContextPanel.tsx @@ -19,7 +19,6 @@ import type { ActiveWorkspace } from "../stores/chatSessionStore"; import { WorkspaceWidget } from "./widgets/WorkspaceWidget"; import { ChangesWidget } from "./widgets/ChangesWidget"; import { ArtifactsWidget } from "./widgets/ArtifactsWidget"; -import { ExtensionsWidget } from "./widgets/ExtensionsWidget"; import { openPath } from "@tauri-apps/plugin-opener"; interface ContextPanelProps { @@ -204,7 +203,6 @@ export function ContextPanel({ onOpenFile={handleOpenChangedFile} /> -
diff --git a/ui/goose2/src/features/chat/ui/MentionAutocomplete.tsx b/ui/goose2/src/features/chat/ui/MentionAutocomplete.tsx index 6e5b5c22..23ff5db0 100644 --- a/ui/goose2/src/features/chat/ui/MentionAutocomplete.tsx +++ b/ui/goose2/src/features/chat/ui/MentionAutocomplete.tsx @@ -1,7 +1,8 @@ import { useState, useEffect, useCallback, useMemo, useRef } from "react"; import { useTranslation } from "react-i18next"; -import { Sparkles, User, Zap } from "lucide-react"; +import { Sparkles, User } from "lucide-react"; import { IconFile, IconFolder } from "@tabler/icons-react"; +import { SkillIcon } from "@/features/skills/ui/SkillIcon"; import { cn } from "@/shared/lib/cn"; import { useAvatarSrc } from "@/shared/hooks/useAvatarSrc"; import { PopoverContent } from "@/shared/ui/popover"; @@ -165,7 +166,7 @@ export function MentionAutocomplete({ onMouseEnter={() => setInternalIndex(globalIndex)} >
- +
{skill.name} diff --git a/ui/goose2/src/features/chat/ui/MessageMetadataChip.tsx b/ui/goose2/src/features/chat/ui/MessageMetadataChip.tsx index ad32dfad..d5cfcff4 100644 --- a/ui/goose2/src/features/chat/ui/MessageMetadataChip.tsx +++ b/ui/goose2/src/features/chat/ui/MessageMetadataChip.tsx @@ -1,4 +1,4 @@ -import { IconStack2 } from "@tabler/icons-react"; +import { SkillIcon } from "@/features/skills/ui/SkillIcon"; import { cn } from "@/shared/lib/cn"; import type { MessageChip } from "@/shared/types/messages"; @@ -12,7 +12,7 @@ const messageChipClasses: Record = { }; export function MessageMetadataChip({ chip }: { chip: MessageChip }) { - const Icon = chip.type === "skill" ? IconStack2 : null; + const Icon = chip.type === "skill" ? SkillIcon : null; return ( ([]); - 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/extensions/api/extensions.ts b/ui/goose2/src/features/extensions/api/extensions.ts index dad02470..4073c7f8 100644 --- a/ui/goose2/src/features/extensions/api/extensions.ts +++ b/ui/goose2/src/features/extensions/api/extensions.ts @@ -1,13 +1,6 @@ import { getClient } from "@/shared/api/acpConnection"; 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 { const client = await getClient(); const response = await client.goose.GooseConfigExtensions({}); @@ -17,7 +10,7 @@ export async function listExtensions(): Promise { export async function addExtension( name: string, extensionConfig: ExtensionConfig, - enabled: boolean, + enabled = false, ): Promise { const client = await getClient(); await client.goose.GooseConfigExtensionsAdd({ @@ -31,11 +24,3 @@ export async function removeExtension(configKey: string): Promise { const client = await getClient(); await client.goose.GooseConfigExtensionsRemove({ configKey }); } - -export async function toggleExtension( - configKey: string, - enabled: boolean, -): Promise { - const client = await getClient(); - await client.goose.GooseConfigExtensionsToggle({ configKey, enabled }); -} diff --git a/ui/goose2/src/features/extensions/hooks/__tests__/useExtensionModalForm.test.ts b/ui/goose2/src/features/extensions/hooks/__tests__/useExtensionModalForm.test.ts new file mode 100644 index 00000000..3468f37e --- /dev/null +++ b/ui/goose2/src/features/extensions/hooks/__tests__/useExtensionModalForm.test.ts @@ -0,0 +1,163 @@ +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import type { ExtensionEntry } from "../../types"; +import { useExtensionModalForm } from "../useExtensionModalForm"; + +describe("useExtensionModalForm", () => { + it("builds trimmed stdio configs with args, env vars, and timeout", () => { + const { result } = renderHook(() => useExtensionModalForm()); + + act(() => { + result.current.setName(" GitHub MCP "); + result.current.setDescription("Issue tools"); + result.current.setCmd(" npx "); + result.current.setArgs(" -y \n @modelcontextprotocol/server-github \n\n"); + result.current.setTimeout("45"); + result.current.updateEnvVar(0, "key", " GITHUB_TOKEN "); + result.current.updateEnvVar(0, "value", "secret"); + }); + act(() => { + result.current.addEnvVar(); + }); + act(() => { + result.current.updateEnvVar(1, "key", " "); + result.current.updateEnvVar(1, "value", "ignored"); + }); + + expect(result.current.buildSubmitPayload()).toEqual({ + name: "GitHub MCP", + config: { + type: "stdio", + name: "GitHub MCP", + description: "Issue tools", + cmd: "npx", + args: ["-y", "@modelcontextprotocol/server-github"], + envs: { GITHUB_TOKEN: "secret" }, + timeout: 45, + }, + }); + }); + + it("builds streamable HTTP configs and falls back to the default timeout", () => { + const { result } = renderHook(() => useExtensionModalForm()); + + act(() => { + result.current.setType("streamable_http"); + result.current.setName(" Context7 "); + result.current.setDescription("Docs"); + result.current.setUri(" https://mcp.context7.com/mcp "); + result.current.setTimeout(""); + }); + + expect(result.current.buildSubmitPayload()).toEqual({ + name: "Context7", + config: { + type: "streamable_http", + name: "Context7", + description: "Docs", + uri: "https://mcp.context7.com/mcp", + timeout: 300, + }, + }); + }); + + it("preserves editable config fields without submitting entry fields", () => { + const extension: ExtensionEntry = { + type: "streamable_http", + name: "context7", + description: "Docs", + uri: "https://old.example/mcp", + env_keys: ["API_KEY"], + headers: { Authorization: "Bearer token" }, + socket: "/tmp/mcp.sock", + config_key: "context7", + enabled: true, + timeout: 60, + }; + const { result } = renderHook(() => useExtensionModalForm(extension)); + + act(() => { + result.current.setUri("https://new.example/mcp"); + }); + + expect(result.current.buildSubmitPayload()?.config).toMatchObject({ + type: "streamable_http", + name: "context7", + uri: "https://new.example/mcp", + env_keys: ["API_KEY"], + headers: { Authorization: "Bearer token" }, + socket: "/tmp/mcp.sock", + timeout: 60, + }); + expect(result.current.buildSubmitPayload()?.config).not.toHaveProperty( + "config_key", + ); + expect(result.current.buildSubmitPayload()?.config).not.toHaveProperty( + "enabled", + ); + }); + + it("keeps secret env keys visible and preserves them when unchanged", () => { + const extension: ExtensionEntry = { + type: "stdio", + name: "github", + description: "Issue tools", + cmd: "npx", + args: [], + envs: { LEGACY_TOKEN: "plain" }, + env_keys: ["GITHUB_TOKEN"], + config_key: "github", + enabled: false, + }; + const { result } = renderHook(() => useExtensionModalForm(extension)); + + expect(result.current.envVars).toMatchObject([ + { key: "LEGACY_TOKEN", value: "plain" }, + { key: "GITHUB_TOKEN", value: "" }, + ]); + expect(result.current.buildSubmitPayload()?.config).toMatchObject({ + envs: { LEGACY_TOKEN: "plain" }, + env_keys: ["GITHUB_TOKEN"], + }); + }); + + it("rejects non-HTTP streamable HTTP URIs", () => { + const { result } = renderHook(() => useExtensionModalForm()); + + act(() => { + result.current.setType("streamable_http"); + result.current.setName("Local file"); + result.current.setUri("file:///tmp/mcp"); + }); + + expect(result.current.canSubmit).toBe(false); + expect(result.current.buildSubmitPayload()).toBeNull(); + }); + + it("does not coerce unsupported SSE extensions into HTTP configs", () => { + const extension: ExtensionEntry = { + type: "sse", + name: "legacy-sse", + description: "Legacy SSE endpoint", + uri: "https://old.example/sse", + config_key: "legacy-sse", + enabled: true, + }; + const { result } = renderHook(() => useExtensionModalForm(extension)); + + expect(result.current.type).toBe("unsupported"); + expect(result.current.canSubmit).toBe(false); + expect(result.current.buildSubmitPayload()).toBeNull(); + }); + + it("returns null when required fields are missing", () => { + const { result } = renderHook(() => useExtensionModalForm()); + + act(() => { + result.current.setName("No command"); + }); + + expect(result.current.canSubmit).toBe(false); + expect(result.current.buildSubmitPayload()).toBeNull(); + }); +}); diff --git a/ui/goose2/src/features/extensions/hooks/__tests__/useExtensionsSettings.test.ts b/ui/goose2/src/features/extensions/hooks/__tests__/useExtensionsSettings.test.ts new file mode 100644 index 00000000..e8f39ce9 --- /dev/null +++ b/ui/goose2/src/features/extensions/hooks/__tests__/useExtensionsSettings.test.ts @@ -0,0 +1,121 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ExtensionEntry } from "../../types"; +import { useExtensionsSettings } from "../useExtensionsSettings"; + +const mocks = vi.hoisted(() => ({ + addExtension: vi.fn(), + listExtensions: vi.fn(), + removeExtension: vi.fn(), + toastError: vi.fn(), +})); + +vi.mock("../../api/extensions", () => ({ + addExtension: mocks.addExtension, + listExtensions: mocks.listExtensions, + removeExtension: mocks.removeExtension, +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +vi.mock("sonner", () => ({ + toast: { error: mocks.toastError }, +})); + +const enabledExtension: ExtensionEntry = { + type: "stdio", + name: "github", + description: "Issue tracker", + cmd: "npx", + args: [], + config_key: "github", + enabled: true, +}; + +describe("useExtensionsSettings", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.listExtensions.mockResolvedValue([enabledExtension]); + mocks.addExtension.mockResolvedValue(undefined); + mocks.removeExtension.mockResolvedValue(undefined); + }); + + it("preserves an edited extension's enabled flag", async () => { + const { result } = renderHook(() => useExtensionsSettings()); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + act(() => { + result.current.handleConfigure(enabledExtension); + }); + await act(async () => { + await result.current.handleSubmit("github", enabledExtension); + }); + + expect(mocks.addExtension).toHaveBeenCalledWith( + "github", + enabledExtension, + true, + ); + }); + + it("saves new extensions as disabled catalog entries", async () => { + const { result } = renderHook(() => useExtensionsSettings()); + const newExtension: ExtensionEntry = { + ...enabledExtension, + name: "linear", + config_key: "linear", + }; + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + await act(async () => { + await result.current.handleSubmit("linear", newExtension); + }); + + expect(mocks.addExtension).toHaveBeenCalledWith( + "linear", + newExtension, + false, + ); + }); + + it("does not delete the new extension when renamed old-key removal fails", async () => { + mocks.removeExtension.mockRejectedValueOnce(new Error("remove failed")); + const { result } = renderHook(() => useExtensionsSettings()); + const renamedExtension: ExtensionEntry = { + ...enabledExtension, + name: "linear", + config_key: "linear", + }; + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + act(() => { + result.current.handleConfigure(enabledExtension); + }); + await act(async () => { + await result.current.handleSubmit("linear", renamedExtension); + }); + + expect(mocks.addExtension).toHaveBeenCalledWith( + "linear", + renamedExtension, + true, + ); + expect(mocks.removeExtension).toHaveBeenCalledTimes(1); + expect(mocks.removeExtension).toHaveBeenCalledWith("github"); + expect(mocks.removeExtension).not.toHaveBeenCalledWith("linear"); + expect(mocks.toastError).toHaveBeenCalledWith( + "extensions.errors.saveFailed", + ); + }); +}); diff --git a/ui/goose2/src/features/extensions/hooks/useExtensionModalForm.ts b/ui/goose2/src/features/extensions/hooks/useExtensionModalForm.ts new file mode 100644 index 00000000..a8bca125 --- /dev/null +++ b/ui/goose2/src/features/extensions/hooks/useExtensionModalForm.ts @@ -0,0 +1,139 @@ +import { useState } from "react"; +import { + buildExtensionSubmitPayload, + canSubmitExtensionConfig, + parseExtensionEnvRows, + type ExtensionEnvRow, + type ExtensionModalType, +} from "../lib/extensionFormConfig"; +import type { ExtensionConfig, ExtensionEntry } from "../types"; + +export type { ExtensionModalType }; + +export interface EnvVar extends ExtensionEnvRow { + id: number; +} + +let nextEnvId = 0; + +function newEmptyEnvVar(): EnvVar { + return { id: nextEnvId++, key: "", value: "" }; +} + +function withEnvIds(rows: ExtensionEnvRow[]): EnvVar[] { + return rows.length > 0 + ? rows.map((row) => ({ id: nextEnvId++, ...row })) + : [newEmptyEnvVar()]; +} + +function initialType(extension?: ExtensionEntry): ExtensionModalType { + if (!extension) return "stdio"; + if (extension.type === "stdio" || extension.type === "streamable_http") { + return extension.type; + } + return "unsupported"; +} + +function initialEnvVars(extension?: ExtensionEntry): EnvVar[] { + if (extension?.type === "stdio") + return withEnvIds( + parseExtensionEnvRows(extension.envs, extension.env_keys), + ); + if (extension?.type === "streamable_http") + return withEnvIds( + parseExtensionEnvRows(extension.envs, extension.env_keys), + ); + return [newEmptyEnvVar()]; +} + +export function useExtensionModalForm(extension?: ExtensionEntry) { + const [name, setName] = useState(extension?.name ?? ""); + const [type, setType] = useState(() => + initialType(extension), + ); + 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(() => + initialEnvVars(extension), + ); + + const canSubmit = canSubmitExtensionConfig({ type, name, cmd, uri }); + + const updateEnvVar = ( + index: number, + field: "key" | "value", + value: string, + ) => { + setEnvVars((prev) => { + const next = [...prev]; + next[index] = { ...next[index], [field]: value }; + return next; + }); + }; + + const addEnvVar = () => { + setEnvVars((prev) => [...prev, newEmptyEnvVar()]); + }; + + const removeEnvVar = (id: number) => { + setEnvVars((prev) => { + if (prev.length <= 1) return [newEmptyEnvVar()]; + return prev.filter((v) => v.id !== id); + }); + }; + + const buildSubmitPayload = (): { + name: string; + config: ExtensionConfig; + } | null => { + return buildExtensionSubmitPayload({ + type, + name, + description, + cmd, + args, + uri, + timeout, + envVars, + extension, + }); + }; + + return { + name, + setName, + type, + setType, + description, + setDescription, + cmd, + setCmd, + args, + setArgs, + uri, + setUri, + timeout, + setTimeout, + envVars, + canSubmit, + updateEnvVar, + addEnvVar, + removeEnvVar, + buildSubmitPayload, + }; +} diff --git a/ui/goose2/src/features/extensions/hooks/useExtensionsSettings.ts b/ui/goose2/src/features/extensions/hooks/useExtensionsSettings.ts new file mode 100644 index 00000000..0f1fa173 --- /dev/null +++ b/ui/goose2/src/features/extensions/hooks/useExtensionsSettings.ts @@ -0,0 +1,110 @@ +import { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; +import { + addExtension, + listExtensions, + removeExtension, +} from "../api/extensions"; +import { nameToKey } from "../lib/extensionKeys"; +import type { ExtensionConfig, ExtensionEntry } from "../types"; + +type ExtensionModalMode = "add" | "edit" | null; + +export function useExtensionsSettings() { + const { t } = useTranslation("settings"); + const [extensions, setExtensions] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [modalMode, setModalMode] = useState(null); + const [editingExtension, setEditingExtension] = + useState(null); + + const fetchExtensions = useCallback(async () => { + setIsLoading(true); + try { + const result = await listExtensions(); + setExtensions(result); + } catch { + setExtensions([]); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + void fetchExtensions(); + }, [fetchExtensions]); + + const handleAdd = useCallback(() => { + setEditingExtension(null); + setModalMode("add"); + }, []); + + const handleConfigure = useCallback((extension: ExtensionEntry) => { + setEditingExtension(extension); + setModalMode("edit"); + }, []); + + const handleSubmit = useCallback( + async (name: string, config: ExtensionConfig) => { + try { + const newKey = nameToKey(name); + const isEdit = !!editingExtension; + const isAdd = !editingExtension; + const keyChanged = isEdit && editingExtension.config_key !== newKey; + + if ( + (isAdd || keyChanged) && + extensions.some((extension) => extension.config_key === newKey) + ) { + toast.error(t("extensions.errors.nameConflict", { name })); + return; + } + + await addExtension(name, config, editingExtension?.enabled ?? false); + if (keyChanged) { + await removeExtension(editingExtension.config_key); + } + setModalMode(null); + setEditingExtension(null); + await fetchExtensions(); + } catch { + await fetchExtensions(); + toast.error(t("extensions.errors.saveFailed")); + } + }, + [editingExtension, extensions, fetchExtensions, t], + ); + + const handleDelete = useCallback( + async (configKey: string) => { + try { + await removeExtension(configKey); + setModalMode(null); + setEditingExtension(null); + await fetchExtensions(); + } catch (error) { + toast.error(t("extensions.errors.deleteFailed")); + throw error; + } + }, + [fetchExtensions, t], + ); + + const handleModalClose = useCallback(() => { + setModalMode(null); + setEditingExtension(null); + }, []); + + return { + extensions, + isLoading, + modalMode, + editingExtension, + handleAdd, + handleConfigure, + handleSubmit, + handleDelete, + handleModalClose, + }; +} diff --git a/ui/goose2/src/features/extensions/lib/__tests__/extensionCategories.test.ts b/ui/goose2/src/features/extensions/lib/__tests__/extensionCategories.test.ts new file mode 100644 index 00000000..1a1a0e6e --- /dev/null +++ b/ui/goose2/src/features/extensions/lib/__tests__/extensionCategories.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; +import type { ExtensionEntry } from "../../types"; +import { + classifyExtension, + filterExtensions, + getExtensionCategoryCounts, + splitExtensionsByCategory, +} from "../extensionCategories"; + +function extension( + name: string, + type: ExtensionEntry["type"], + description = "", +): ExtensionEntry { + return { + type, + name, + description, + config_key: name, + enabled: true, + ...(type === "stdio" ? { cmd: "npx", args: [] } : {}), + ...(type === "streamable_http" ? { uri: "http://localhost:3000/mcp" } : {}), + } as ExtensionEntry; +} + +const labelForCategory = (category: string) => + category === "gooseCapabilities" ? "Goose capabilities" : "Apps & services"; + +describe("extension categories", () => { + it("classifies built-in and platform extensions as Goose capabilities", () => { + expect(classifyExtension(extension("developer", "builtin"))).toBe( + "gooseCapabilities", + ); + expect(classifyExtension(extension("computer", "platform"))).toBe( + "gooseCapabilities", + ); + expect(classifyExtension(extension("github", "stdio"))).toBe( + "appsServices", + ); + }); + + it("filters by search text across name, description, and category label", () => { + const extensions = [ + extension("github", "stdio", "Issue tracker"), + extension("developer", "builtin", "Code tools"), + ]; + + expect( + filterExtensions({ + extensions, + searchTerm: "issue", + activeFilter: "all", + getCategoryLabel: labelForCategory, + }).map((item) => item.name), + ).toEqual(["github"]); + + expect( + filterExtensions({ + extensions, + searchTerm: "goose", + activeFilter: "all", + getCategoryLabel: labelForCategory, + }).map((item) => item.name), + ).toEqual(["developer"]); + }); + + it("counts and splits extensions by category", () => { + const extensions = [ + extension("developer", "builtin"), + extension("computer", "platform"), + extension("github", "stdio"), + ]; + + expect(getExtensionCategoryCounts(extensions)).toEqual({ + appsServices: 1, + gooseCapabilities: 2, + }); + expect(splitExtensionsByCategory(extensions)).toMatchObject({ + primaryExtensions: [{ name: "github" }], + gooseCapabilities: [{ name: "developer" }, { name: "computer" }], + }); + }); +}); diff --git a/ui/goose2/src/features/extensions/lib/__tests__/extensionFormConfig.test.ts b/ui/goose2/src/features/extensions/lib/__tests__/extensionFormConfig.test.ts new file mode 100644 index 00000000..a5b20744 --- /dev/null +++ b/ui/goose2/src/features/extensions/lib/__tests__/extensionFormConfig.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vitest"; +import type { ExtensionEntry } from "../../types"; +import { + buildExtensionEnvConfig, + buildExtensionSubmitPayload, + canSubmitExtensionConfig, + parseExtensionEnvRows, +} from "../extensionFormConfig"; + +describe("extensionFormConfig", () => { + it("combines legacy envs and secret env keys without duplicates", () => { + expect( + parseExtensionEnvRows( + { LEGACY_TOKEN: "plain", SHARED_TOKEN: "plain-shared" }, + ["GITHUB_TOKEN", "SHARED_TOKEN"], + ), + ).toEqual([ + { key: "LEGACY_TOKEN", value: "plain" }, + { key: "SHARED_TOKEN", value: "plain-shared" }, + { key: "GITHUB_TOKEN", value: "" }, + ]); + }); + + it("builds envs for populated values and env_keys for blank values", () => { + expect( + buildExtensionEnvConfig([ + { key: " GITHUB_TOKEN ", value: "secret" }, + { key: " API_KEY ", value: "" }, + { key: " ", value: "ignored" }, + ]), + ).toEqual({ + envs: { GITHUB_TOKEN: "secret" }, + env_keys: ["API_KEY"], + }); + }); + + it("validates streamable HTTP URLs by scheme", () => { + expect( + canSubmitExtensionConfig({ + type: "streamable_http", + name: "Context7", + cmd: "", + uri: "https://mcp.context7.com/mcp", + }), + ).toBe(true); + expect( + canSubmitExtensionConfig({ + type: "streamable_http", + name: "Local file", + cmd: "", + uri: "file:///tmp/mcp", + }), + ).toBe(false); + }); + + it("builds clean submit payloads while preserving streamable-only fields", () => { + const extension: ExtensionEntry = { + type: "streamable_http", + name: "context7", + description: "Docs", + uri: "https://old.example/mcp", + env_keys: ["API_KEY"], + headers: { Authorization: "Bearer token" }, + socket: "/tmp/mcp.sock", + config_key: "context7", + enabled: true, + timeout: 60, + }; + + const payload = buildExtensionSubmitPayload({ + type: "streamable_http", + name: " context7 ", + description: "Docs", + cmd: "", + args: "", + uri: " https://new.example/mcp ", + timeout: "90", + envVars: [{ key: "API_KEY", value: "" }], + extension, + }); + + expect(payload).toEqual({ + name: "context7", + config: { + type: "streamable_http", + name: "context7", + description: "Docs", + uri: "https://new.example/mcp", + env_keys: ["API_KEY"], + headers: { Authorization: "Bearer token" }, + socket: "/tmp/mcp.sock", + timeout: 90, + }, + }); + expect(payload?.config).not.toHaveProperty("config_key"); + expect(payload?.config).not.toHaveProperty("enabled"); + }); +}); diff --git a/ui/goose2/src/features/extensions/lib/extensionCategories.ts b/ui/goose2/src/features/extensions/lib/extensionCategories.ts new file mode 100644 index 00000000..ffa9d8ec --- /dev/null +++ b/ui/goose2/src/features/extensions/lib/extensionCategories.ts @@ -0,0 +1,85 @@ +import type { ExtensionEntry } from "../types"; +import { getDisplayName } from "../types"; + +export type ExtensionCategory = "appsServices" | "gooseCapabilities"; + +export type ExtensionFilter = "all" | ExtensionCategory; + +export const EXTENSION_CATEGORIES: readonly ExtensionCategory[] = [ + "appsServices", + "gooseCapabilities", +]; + +const GOOSE_CAPABILITY_TYPES = new Set(["builtin", "platform"]); +export function classifyExtension( + extension: ExtensionEntry, +): ExtensionCategory { + if (GOOSE_CAPABILITY_TYPES.has(extension.type)) { + return "gooseCapabilities"; + } + return "appsServices"; +} + +export function compareExtensionsByName( + a: ExtensionEntry, + b: ExtensionEntry, +): number { + return getDisplayName(a).localeCompare(getDisplayName(b)); +} + +export function getExtensionCategoryCounts( + extensions: ExtensionEntry[], +): Record { + const counts: Record = { + appsServices: 0, + gooseCapabilities: 0, + }; + for (const extension of extensions) { + counts[classifyExtension(extension)] += 1; + } + return counts; +} + +export function filterExtensions(options: { + extensions: ExtensionEntry[]; + searchTerm: string; + activeFilter: ExtensionFilter; + getCategoryLabel: (category: ExtensionCategory) => string; +}): ExtensionEntry[] { + const { extensions, searchTerm, activeFilter, getCategoryLabel } = options; + const query = searchTerm.toLowerCase(); + + return extensions + .filter((extension) => { + const category = classifyExtension(extension); + const matchesSearch = + !query || + getDisplayName(extension).toLowerCase().includes(query) || + extension.name.toLowerCase().includes(query) || + (extension.description ?? "").toLowerCase().includes(query) || + getCategoryLabel(category).toLowerCase().includes(query); + + return ( + matchesSearch && (activeFilter === "all" || category === activeFilter) + ); + }) + .sort(compareExtensionsByName); +} + +export function splitExtensionsByCategory(extensions: ExtensionEntry[]): { + primaryExtensions: ExtensionEntry[]; + gooseCapabilities: ExtensionEntry[]; +} { + const primaryExtensions: ExtensionEntry[] = []; + const gooseCapabilities: ExtensionEntry[] = []; + + for (const extension of extensions) { + if (classifyExtension(extension) === "gooseCapabilities") { + gooseCapabilities.push(extension); + } else { + primaryExtensions.push(extension); + } + } + + return { primaryExtensions, gooseCapabilities }; +} diff --git a/ui/goose2/src/features/extensions/lib/extensionFormConfig.ts b/ui/goose2/src/features/extensions/lib/extensionFormConfig.ts new file mode 100644 index 00000000..3fd96510 --- /dev/null +++ b/ui/goose2/src/features/extensions/lib/extensionFormConfig.ts @@ -0,0 +1,172 @@ +import type { + ExtensionConfig, + ExtensionEntry, + StdioExtensionConfig, + StreamableHttpExtensionConfig, +} from "../types"; + +export type ExtensionModalType = "stdio" | "streamable_http" | "unsupported"; + +export interface ExtensionEnvRow { + key: string; + value: string; +} + +interface ExtensionSubmitConfigInput { + type: ExtensionModalType; + name: string; + description: string; + cmd: string; + args: string; + uri: string; + timeout: string; + envVars: ExtensionEnvRow[]; + extension?: ExtensionEntry; +} + +type PreservedCommonFields = { + available_tools?: string[]; + bundled?: boolean; +}; + +export function parseExtensionEnvRows( + envs?: Record, + envKeys?: string[], +): ExtensionEnvRow[] { + const rows: ExtensionEnvRow[] = []; + const seenKeys = new Set(); + + for (const [key, value] of Object.entries(envs ?? {})) { + rows.push({ key, value }); + seenKeys.add(key); + } + + for (const key of envKeys ?? []) { + if (seenKeys.has(key)) continue; + rows.push({ key, value: "" }); + } + + return rows; +} + +export function buildExtensionEnvConfig( + vars: ExtensionEnvRow[], +): Pick< + StdioExtensionConfig | StreamableHttpExtensionConfig, + "envs" | "env_keys" +> { + const envs: Record = {}; + const envKeys: string[] = []; + + for (const v of vars) { + const key = v.key.trim(); + if (!key) continue; + + if (v.value.trim().length > 0) envs[key] = v.value; + else envKeys.push(key); + } + + return { + ...(Object.keys(envs).length > 0 ? { envs } : {}), + ...(envKeys.length > 0 ? { env_keys: envKeys } : {}), + }; +} + +export function isValidStreamableHttpUri(value: string): boolean { + try { + const url = new URL(value.trim()); + return url.protocol === "http:" || url.protocol === "https:"; + } catch { + return false; + } +} + +export function canSubmitExtensionConfig(input: { + type: ExtensionModalType; + name: string; + cmd: string; + uri: string; +}): boolean { + return ( + input.type !== "unsupported" && + input.name.trim().length > 0 && + (input.type === "stdio" + ? input.cmd.trim().length > 0 + : isValidStreamableHttpUri(input.uri)) + ); +} + +function preservedCommonFields( + extension?: ExtensionEntry, +): PreservedCommonFields { + return { + ...(extension && + "available_tools" in extension && + extension.available_tools?.length + ? { available_tools: extension.available_tools } + : {}), + ...(extension && "bundled" in extension && extension.bundled !== undefined + ? { bundled: extension.bundled } + : {}), + }; +} + +export function buildExtensionSubmitPayload({ + type, + name, + description, + cmd, + args, + uri, + timeout, + envVars, + extension, +}: ExtensionSubmitConfigInput): { + name: string; + config: ExtensionConfig; +} | null { + if (!canSubmitExtensionConfig({ type, name, cmd, uri })) return null; + + const trimmedName = name.trim(); + const envConfig = buildExtensionEnvConfig(envVars); + const timeoutNum = Number.parseInt(timeout, 10) || 300; + const commonFields = preservedCommonFields(extension); + + if (type === "stdio") { + return { + name: trimmedName, + config: { + type: "stdio", + name: trimmedName, + description, + cmd: cmd.trim(), + args: args + .split("\n") + .map((arg) => arg.trim()) + .filter(Boolean), + ...envConfig, + timeout: timeoutNum, + ...commonFields, + }, + }; + } + + return { + name: trimmedName, + config: { + type: "streamable_http", + name: trimmedName, + description, + uri: uri.trim(), + ...envConfig, + ...(extension?.type === "streamable_http" && extension.headers + ? { headers: extension.headers } + : {}), + ...(extension?.type === "streamable_http" && extension.socket + ? { socket: extension.socket } + : {}), + timeout: timeoutNum, + ...commonFields, + }, + }; +} diff --git a/ui/goose2/src/features/extensions/lib/extensionKeys.ts b/ui/goose2/src/features/extensions/lib/extensionKeys.ts new file mode 100644 index 00000000..d74a49f7 --- /dev/null +++ b/ui/goose2/src/features/extensions/lib/extensionKeys.ts @@ -0,0 +1,8 @@ +export function nameToKey(name: string): string { + return name + .replace(/\s/g, "") + .replace(/[^a-zA-Z0-9_-]/g, "_") + .toLowerCase(); +} + +export const normalizeExtensionKey = nameToKey; diff --git a/ui/goose2/src/features/extensions/types.ts b/ui/goose2/src/features/extensions/types.ts index 6ef544c0..57b46367 100644 --- a/ui/goose2/src/features/extensions/types.ts +++ b/ui/goose2/src/features/extensions/types.ts @@ -21,6 +21,15 @@ export interface BuiltinExtensionConfig { available_tools?: string[]; } +export interface PlatformExtensionConfig { + type: "platform"; + name: string; + description: string; + display_name?: string; + bundled?: boolean; + available_tools?: string[]; +} + export interface StreamableHttpExtensionConfig { type: "streamable_http"; name: string; @@ -30,6 +39,7 @@ export interface StreamableHttpExtensionConfig { env_keys?: string[]; headers?: Record; timeout?: number; + socket?: string; bundled?: boolean; available_tools?: string[]; } @@ -42,19 +52,47 @@ export interface SseExtensionConfig { bundled?: boolean; } +export interface FrontendExtensionConfig { + type: "frontend"; + name: string; + description: string; + tools: unknown[]; + frontend_tools?: unknown[]; + instructions?: string; + bundled?: boolean; + available_tools?: string[]; +} + +export interface InlinePythonExtensionConfig { + type: "inline_python"; + name: string; + description: string; + code: string; + timeout?: number; + dependencies?: string[]; + available_tools?: string[]; +} + export type ExtensionConfig = | StdioExtensionConfig | BuiltinExtensionConfig + | PlatformExtensionConfig | StreamableHttpExtensionConfig - | SseExtensionConfig; + | SseExtensionConfig + | FrontendExtensionConfig + | InlinePythonExtensionConfig; export type ExtensionEntry = ExtensionConfig & { config_key: string; enabled: boolean; }; -export function getDisplayName(ext: ExtensionEntry): string { - if (ext.type === "builtin" && ext.display_name) { +export function getDisplayName(ext: { + type: ExtensionConfig["type"]; + name: string; + display_name?: string | null; +}): string { + if ((ext.type === "builtin" || ext.type === "platform") && 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 index 813b955d..5887f899 100644 --- a/ui/goose2/src/features/extensions/ui/ExtensionItem.tsx +++ b/ui/goose2/src/features/extensions/ui/ExtensionItem.tsx @@ -1,14 +1,13 @@ -import { useState } from "react"; import { useTranslation } from "react-i18next"; import { IconSettings } from "@tabler/icons-react"; +import { cn } from "@/shared/lib/cn"; 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; + className?: string; } function getSubtitle(ext: ExtensionEntry): string { @@ -18,48 +17,35 @@ function getSubtitle(ext: ExtensionEntry): string { return ext.type; } -const EDITABLE_TYPES = new Set(["stdio", "streamable_http"]); +function isUserManagedExtension(ext: ExtensionEntry): boolean { + return ( + (ext.type === "stdio" || ext.type === "streamable_http") && !ext.bundled + ); +} function isEditable(ext: ExtensionEntry): boolean { - return EDITABLE_TYPES.has(ext.type) && !ext.bundled; + return isUserManagedExtension(ext); } export function ExtensionItem({ extension, - onToggle, onConfigure, + className, }: 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)} @@ -78,14 +64,6 @@ export function ExtensionItem({ )} -

); diff --git a/ui/goose2/src/features/extensions/ui/ExtensionModal.tsx b/ui/goose2/src/features/extensions/ui/ExtensionModal.tsx index 1d13ce8f..a4439c95 100644 --- a/ui/goose2/src/features/extensions/ui/ExtensionModal.tsx +++ b/ui/goose2/src/features/extensions/ui/ExtensionModal.tsx @@ -9,6 +9,7 @@ import { DialogTitle, } from "@/shared/ui/dialog"; import { Button } from "@/shared/ui/button"; +import { ConfirmDialog } from "@/shared/ui/confirm-dialog"; import { Input } from "@/shared/ui/input"; import { Label } from "@/shared/ui/label"; import { Textarea } from "@/shared/ui/textarea"; @@ -19,49 +20,19 @@ import { SelectTrigger, SelectValue, } from "@/shared/ui/select"; +import { + useExtensionModalForm, + type ExtensionModalType, +} from "../hooks/useExtensionModalForm"; import type { ExtensionConfig, ExtensionEntry } from "../types"; -type ExtensionType = "stdio" | "streamable_http"; - interface ExtensionModalProps { extension?: ExtensionEntry; - onSubmit: ( - name: string, - config: ExtensionConfig, - enabled: boolean, - ) => Promise; + onSubmit: (name: string, config: ExtensionConfig) => 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, @@ -71,293 +42,238 @@ export function ExtensionModal({ 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 [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const form = useExtensionModalForm(extension); const handleSubmit = async () => { - if (!canSubmit || isSaving) return; + if (!form.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); + const payload = form.buildSubmitPayload(); + if (!payload) return; + await onSubmit(payload.name, payload.config); } 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 handleConfirmDelete = async () => { + if (!extension || !onDelete || isDeleting) return; - 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); - }); + setIsDeleting(true); + try { + await onDelete(extension.config_key); + setIsDeleteDialogOpen(false); + } finally { + setIsDeleting(false); + } }; return ( - !open && onClose()}> - - - - {isEdit - ? t("extensions.editExtension") - : t("extensions.addExtension")} - - + <> + !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")} - /> -
-
- -