From 8de4b360e8c736271ff52dbe4ac56f9f5e8195c5 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Fri, 24 Oct 2025 07:52:51 -0400 Subject: [PATCH] ACP Extensions (#40663) Adds the ability to install ACP agents via extensions Release Notes: - N/A *or* Added/Fixed/Improved ... --- crates/agent_ui/src/agent_panel.rs | 212 ++++-- .../20221109000000_test_schema.sql | 1 + ...t_servers_provides_field_to_extensions.sql | 2 + crates/collab/src/db/queries/extensions.rs | 7 + .../collab/src/db/tables/extension_version.rs | 5 + crates/collab/src/db/tests/extension_tests.rs | 66 ++ crates/extension/src/extension_manifest.rs | 73 ++ crates/extension_cli/src/main.rs | 15 + .../extension_compilation_benchmark.rs | 1 + .../extension_host/src/capability_granter.rs | 1 + .../src/extension_store_test.rs | 3 + crates/extensions_ui/src/extensions_ui.rs | 2 + crates/project/src/agent_server_store.rs | 668 ++++++++++++++++-- crates/project/src/project.rs | 2 +- crates/rpc/src/extension.rs | 1 + crates/ui/src/components/context_menu.rs | 40 +- crates/zed_actions/src/lib.rs | 1 + 17 files changed, 982 insertions(+), 118 deletions(-) create mode 100644 crates/collab/migrations/20250618090000_add_agent_servers_provides_field_to_extensions.sql diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index deb202832469eaa16b3eab3bced0236dc5467c53..e28ae2ae1563a19624ea4a0248a4dd25922455c1 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -6,8 +6,11 @@ use std::sync::Arc; use acp_thread::AcpThread; use agent::{ContextServerRegistry, DbThreadMetadata, HistoryEntry, HistoryStore}; use db::kvp::{Dismissable, KEY_VALUE_STORE}; -use project::agent_server_store::{ - AgentServerCommand, AllAgentServersSettings, CLAUDE_CODE_NAME, CODEX_NAME, GEMINI_NAME, +use project::{ + ExternalAgentServerName, + agent_server_store::{ + AgentServerCommand, AllAgentServersSettings, CLAUDE_CODE_NAME, CODEX_NAME, GEMINI_NAME, + }, }; use serde::{Deserialize, Serialize}; use settings::{ @@ -41,6 +44,8 @@ use assistant_text_thread::{TextThread, TextThreadEvent, TextThreadSummary}; use client::{UserStore, zed_urls}; use cloud_llm_client::{Plan, PlanV1, PlanV2, UsageLimit}; use editor::{Anchor, AnchorRangeExt as _, Editor, EditorEvent, MultiBuffer}; +use extension::ExtensionEvents; +use extension_host::ExtensionStore; use fs::Fs; use gpui::{ Action, AnyElement, App, AsyncWindowContext, Corner, DismissEvent, Entity, EventEmitter, @@ -422,6 +427,7 @@ pub struct AgentPanel { agent_panel_menu_handle: PopoverMenuHandle, agent_navigation_menu_handle: PopoverMenuHandle, agent_navigation_menu: Option>, + _extension_subscription: Option, width: Option, height: Option, zoomed: bool, @@ -632,7 +638,24 @@ impl AgentPanel { ) }); - Self { + // Subscribe to extension events to sync agent servers when extensions change + let extension_subscription = if let Some(extension_events) = ExtensionEvents::try_global(cx) + { + Some( + cx.subscribe(&extension_events, |this, _source, event, cx| match event { + extension::Event::ExtensionInstalled(_) + | extension::Event::ExtensionUninstalled(_) + | extension::Event::ExtensionsInstalledChanged => { + this.sync_agent_servers_from_extensions(cx); + } + _ => {} + }), + ) + } else { + None + }; + + let mut panel = Self { active_view, workspace, user_store, @@ -650,6 +673,7 @@ impl AgentPanel { agent_panel_menu_handle: PopoverMenuHandle::default(), agent_navigation_menu_handle: PopoverMenuHandle::default(), agent_navigation_menu: None, + _extension_subscription: extension_subscription, width: None, height: None, zoomed: false, @@ -659,7 +683,11 @@ impl AgentPanel { history_store, selected_agent: AgentType::default(), loading: false, - } + }; + + // Initial sync of agent servers from extensions + panel.sync_agent_servers_from_extensions(cx); + panel } pub fn toggle_focus( @@ -1309,6 +1337,31 @@ impl AgentPanel { self.selected_agent.clone() } + fn sync_agent_servers_from_extensions(&mut self, cx: &mut Context) { + if let Some(extension_store) = ExtensionStore::try_global(cx) { + let (manifests, extensions_dir) = { + let store = extension_store.read(cx); + let installed = store.installed_extensions(); + let manifests: Vec<_> = installed + .iter() + .map(|(id, entry)| (id.clone(), entry.manifest.clone())) + .collect(); + let extensions_dir = paths::extensions_dir().join("installed"); + (manifests, extensions_dir) + }; + + self.project.update(cx, |project, cx| { + project.agent_server_store().update(cx, |store, cx| { + let manifest_refs: Vec<_> = manifests + .iter() + .map(|(id, manifest)| (id.as_ref(), manifest.as_ref())) + .collect(); + store.sync_extension_agents(manifest_refs, extensions_dir, cx); + }); + }); + } + } + pub fn new_agent_thread( &mut self, agent: AgentType, @@ -1744,6 +1797,16 @@ impl AgentPanel { let agent_server_store = self.project.read(cx).agent_server_store().clone(); let focus_handle = self.focus_handle(cx); + // Get custom icon path for selected agent before building menu (to avoid borrow issues) + let selected_agent_custom_icon = + if let AgentType::Custom { name, .. } = &self.selected_agent { + agent_server_store + .read(cx) + .agent_icon(&ExternalAgentServerName(name.clone())) + } else { + None + }; + let active_thread = match &self.active_view { ActiveView::ExternalAgentThread { thread_view } => { thread_view.read(cx).as_native_thread(cx) @@ -1757,12 +1820,7 @@ impl AgentPanel { { let focus_handle = focus_handle.clone(); move |_window, cx| { - Tooltip::for_action_in( - "New…", - &ToggleNewThreadMenu, - &focus_handle, - cx, - ) + Tooltip::for_action_in("New…", &ToggleNewThreadMenu, &focus_handle, cx) } }, ) @@ -1781,8 +1839,7 @@ impl AgentPanel { let active_thread = active_thread.clone(); Some(ContextMenu::build(window, cx, |menu, _window, cx| { - menu - .context(focus_handle.clone()) + menu.context(focus_handle.clone()) .header("Zed Agent") .when_some(active_thread, |this, active_thread| { let thread = active_thread.read(cx); @@ -1939,77 +1996,110 @@ impl AgentPanel { }), ) .map(|mut menu| { - let agent_names = agent_server_store - .read(cx) + let agent_server_store_read = agent_server_store.read(cx); + let agent_names = agent_server_store_read .external_agents() .filter(|name| { - name.0 != GEMINI_NAME && name.0 != CLAUDE_CODE_NAME && name.0 != CODEX_NAME + name.0 != GEMINI_NAME + && name.0 != CLAUDE_CODE_NAME + && name.0 != CODEX_NAME }) .cloned() .collect::>(); - let custom_settings = cx.global::().get::(None).custom.clone(); + let custom_settings = cx + .global::() + .get::(None) + .custom + .clone(); for agent_name in agent_names { - menu = menu.item( - ContextMenuEntry::new(format!("New {} Thread", agent_name)) - .icon(IconName::Terminal) - .icon_color(Color::Muted) - .disabled(is_via_collab) - .handler({ - let workspace = workspace.clone(); - let agent_name = agent_name.clone(); - let custom_settings = custom_settings.clone(); - move |window, cx| { - if let Some(workspace) = workspace.upgrade() { - workspace.update(cx, |workspace, cx| { - if let Some(panel) = - workspace.panel::(cx) - { - panel.update(cx, |panel, cx| { - panel.new_agent_thread( - AgentType::Custom { - name: agent_name.clone().into(), - command: custom_settings - .get(&agent_name.0) - .map(|settings| { - settings.command.clone() - }) - .unwrap_or(placeholder_command()), - }, - window, - cx, - ); - }); - } - }); - } + let icon_path = agent_server_store_read.agent_icon(&agent_name); + let mut entry = + ContextMenuEntry::new(format!("New {} Thread", agent_name)); + if let Some(icon_path) = icon_path { + entry = entry.custom_icon_path(icon_path); + } else { + entry = entry.icon(IconName::Terminal); + } + entry = entry + .icon_color(Color::Muted) + .disabled(is_via_collab) + .handler({ + let workspace = workspace.clone(); + let agent_name = agent_name.clone(); + let custom_settings = custom_settings.clone(); + move |window, cx| { + if let Some(workspace) = workspace.upgrade() { + workspace.update(cx, |workspace, cx| { + if let Some(panel) = + workspace.panel::(cx) + { + panel.update(cx, |panel, cx| { + panel.new_agent_thread( + AgentType::Custom { + name: agent_name + .clone() + .into(), + command: custom_settings + .get(&agent_name.0) + .map(|settings| { + settings + .command + .clone() + }) + .unwrap_or( + placeholder_command( + ), + ), + }, + window, + cx, + ); + }); + } + }); } - }), - ); + } + }); + menu = menu.item(entry); } menu }) - .separator().link( - "Add Other Agents", - OpenBrowser { - url: zed_urls::external_agents_docs(cx), - } - .boxed_clone(), - ) + .separator() + .link( + "Add Other Agents", + OpenBrowser { + url: zed_urls::external_agents_docs(cx), + } + .boxed_clone(), + ) })) } }); let selected_agent_label = self.selected_agent.label(); + + let has_custom_icon = selected_agent_custom_icon.is_some(); let selected_agent = div() .id("selected_agent_icon") - .when_some(self.selected_agent.icon(), |this, icon| { + .when_some(selected_agent_custom_icon, |this, icon_path| { + let label = selected_agent_label.clone(); this.px(DynamicSpacing::Base02.rems(cx)) - .child(Icon::new(icon).color(Color::Muted)) + .child(Icon::from_path(icon_path).color(Color::Muted)) .tooltip(move |_window, cx| { - Tooltip::with_meta(selected_agent_label.clone(), None, "Selected Agent", cx) + Tooltip::with_meta(label.clone(), None, "Selected Agent", cx) }) }) + .when(!has_custom_icon, |this| { + this.when_some(self.selected_agent.icon(), |this, icon| { + let label = selected_agent_label.clone(); + this.px(DynamicSpacing::Base02.rems(cx)) + .child(Icon::new(icon).color(Color::Muted)) + .tooltip(move |_window, cx| { + Tooltip::with_meta(label.clone(), None, "Selected Agent", cx) + }) + }) + }) .into_any_element(); h_flex() diff --git a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql index 72d0fa933fdd9115b05a7804ce7a9cd1802596a2..f2cbf419f0a64004a2210af216faba2baffca8b4 100644 --- a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql +++ b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql @@ -467,6 +467,7 @@ CREATE TABLE extension_versions ( provides_grammars BOOLEAN NOT NULL DEFAULT FALSE, provides_language_servers BOOLEAN NOT NULL DEFAULT FALSE, provides_context_servers BOOLEAN NOT NULL DEFAULT FALSE, + provides_agent_servers BOOLEAN NOT NULL DEFAULT FALSE, provides_slash_commands BOOLEAN NOT NULL DEFAULT FALSE, provides_indexed_docs_providers BOOLEAN NOT NULL DEFAULT FALSE, provides_snippets BOOLEAN NOT NULL DEFAULT FALSE, diff --git a/crates/collab/migrations/20250618090000_add_agent_servers_provides_field_to_extensions.sql b/crates/collab/migrations/20250618090000_add_agent_servers_provides_field_to_extensions.sql new file mode 100644 index 0000000000000000000000000000000000000000..3c399924b96891d490792fb36b61a034f8dce97f --- /dev/null +++ b/crates/collab/migrations/20250618090000_add_agent_servers_provides_field_to_extensions.sql @@ -0,0 +1,2 @@ +alter table extension_versions +add column provides_agent_servers bool not null default false diff --git a/crates/collab/src/db/queries/extensions.rs b/crates/collab/src/db/queries/extensions.rs index 914e78c6e3c327bf7f37a465771add478b3c68f5..b4dc4dd89d15fa1b80b561408f2bdc9a233094c0 100644 --- a/crates/collab/src/db/queries/extensions.rs +++ b/crates/collab/src/db/queries/extensions.rs @@ -310,6 +310,9 @@ impl Database { .provides .contains(&ExtensionProvides::ContextServers), ), + provides_agent_servers: ActiveValue::Set( + version.provides.contains(&ExtensionProvides::AgentServers), + ), provides_slash_commands: ActiveValue::Set( version.provides.contains(&ExtensionProvides::SlashCommands), ), @@ -422,6 +425,10 @@ fn apply_provides_filter( condition = condition.add(extension_version::Column::ProvidesContextServers.eq(true)); } + if provides_filter.contains(&ExtensionProvides::AgentServers) { + condition = condition.add(extension_version::Column::ProvidesAgentServers.eq(true)); + } + if provides_filter.contains(&ExtensionProvides::SlashCommands) { condition = condition.add(extension_version::Column::ProvidesSlashCommands.eq(true)); } diff --git a/crates/collab/src/db/tables/extension_version.rs b/crates/collab/src/db/tables/extension_version.rs index 80726248713c66f0cd8cbdec0fa374f3e60d9868..5e71914ddb0dd60c75fa3a6b1b5ee86fe1b662b6 100644 --- a/crates/collab/src/db/tables/extension_version.rs +++ b/crates/collab/src/db/tables/extension_version.rs @@ -24,6 +24,7 @@ pub struct Model { pub provides_grammars: bool, pub provides_language_servers: bool, pub provides_context_servers: bool, + pub provides_agent_servers: bool, pub provides_slash_commands: bool, pub provides_indexed_docs_providers: bool, pub provides_snippets: bool, @@ -57,6 +58,10 @@ impl Model { provides.insert(ExtensionProvides::ContextServers); } + if self.provides_agent_servers { + provides.insert(ExtensionProvides::AgentServers); + } + if self.provides_slash_commands { provides.insert(ExtensionProvides::SlashCommands); } diff --git a/crates/collab/src/db/tests/extension_tests.rs b/crates/collab/src/db/tests/extension_tests.rs index 9396b405fd52c19255159453afccaff5447b4544..cb58f6af2a6559b8ca3bb4c19c694a263e73d878 100644 --- a/crates/collab/src/db/tests/extension_tests.rs +++ b/crates/collab/src/db/tests/extension_tests.rs @@ -16,6 +16,72 @@ test_both_dbs!( test_extensions_sqlite ); +test_both_dbs!( + test_agent_servers_filter, + test_agent_servers_filter_postgres, + test_agent_servers_filter_sqlite +); + +async fn test_agent_servers_filter(db: &Arc) { + // No extensions initially + let versions = db.get_known_extension_versions().await.unwrap(); + assert!(versions.is_empty()); + + // Shared timestamp + let t0 = time::OffsetDateTime::from_unix_timestamp_nanos(0).unwrap(); + let t0 = time::PrimitiveDateTime::new(t0.date(), t0.time()); + + // Insert two extensions, only one provides AgentServers + db.insert_extension_versions( + &[ + ( + "ext_agent_servers", + vec![NewExtensionVersion { + name: "Agent Servers Provider".into(), + version: semver::Version::parse("1.0.0").unwrap(), + description: "has agent servers".into(), + authors: vec!["author".into()], + repository: "org/agent-servers".into(), + schema_version: 1, + wasm_api_version: None, + provides: BTreeSet::from_iter([ExtensionProvides::AgentServers]), + published_at: t0, + }], + ), + ( + "ext_plain", + vec![NewExtensionVersion { + name: "Plain Extension".into(), + version: semver::Version::parse("0.1.0").unwrap(), + description: "no agent servers".into(), + authors: vec!["author2".into()], + repository: "org/plain".into(), + schema_version: 1, + wasm_api_version: None, + provides: BTreeSet::default(), + published_at: t0, + }], + ), + ] + .into_iter() + .collect(), + ) + .await + .unwrap(); + + // Filter by AgentServers provides + let provides_filter = BTreeSet::from_iter([ExtensionProvides::AgentServers]); + + let filtered = db + .get_extensions(None, Some(&provides_filter), 1, 10) + .await + .unwrap(); + + // Expect only the extension that declared AgentServers + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].id.as_ref(), "ext_agent_servers"); +} + async fn test_extensions(db: &Arc) { let versions = db.get_known_extension_versions().await.unwrap(); assert!(versions.is_empty()); diff --git a/crates/extension/src/extension_manifest.rs b/crates/extension/src/extension_manifest.rs index 70c5a55c853f13ac35ad47d1a1d5c56fb93361e4..1e39ceca58fa8b0da450d98db2d6cc8fb0921f12 100644 --- a/crates/extension/src/extension_manifest.rs +++ b/crates/extension/src/extension_manifest.rs @@ -82,6 +82,8 @@ pub struct ExtensionManifest { #[serde(default)] pub context_servers: BTreeMap, ContextServerManifestEntry>, #[serde(default)] + pub agent_servers: BTreeMap, AgentServerManifestEntry>, + #[serde(default)] pub slash_commands: BTreeMap, SlashCommandManifestEntry>, #[serde(default)] pub snippets: Option, @@ -138,6 +140,48 @@ pub struct LibManifestEntry { pub version: Option, } +#[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)] +pub struct AgentServerManifestEntry { + /// Display name for the agent (shown in menus). + pub name: String, + /// Environment variables to set when launching the agent server. + #[serde(default)] + pub env: HashMap, + /// Optional icon path (relative to extension root, e.g., "ai.svg"). + /// Should be a small SVG icon for display in menus. + #[serde(default)] + pub icon: Option, + /// Per-target configuration for archive-based installation. + /// The key format is "{os}-{arch}" where: + /// - os: "darwin" (macOS), "linux", "windows" + /// - arch: "aarch64" (arm64), "x86_64" + /// + /// Example: + /// ```toml + /// [agent_servers.myagent.targets.darwin-aarch64] + /// archive = "https://example.com/myagent-darwin-arm64.zip" + /// cmd = "./myagent" + /// args = ["--serve"] + /// sha256 = "abc123..." # optional + /// ``` + pub targets: HashMap, +} + +#[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)] +pub struct TargetConfig { + /// URL to download the archive from (e.g., "https://github.com/owner/repo/releases/download/v1.0.0/myagent-darwin-arm64.zip") + pub archive: String, + /// Command to run (e.g., "./myagent" or "./myagent.exe") + pub cmd: String, + /// Command-line arguments to pass to the agent server. + #[serde(default)] + pub args: Vec, + /// Optional SHA-256 hash of the archive for verification. + /// If not provided and the URL is a GitHub release, we'll attempt to fetch it from GitHub. + #[serde(default)] + pub sha256: Option, +} + #[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)] pub enum ExtensionLibraryKind { Rust, @@ -266,6 +310,7 @@ fn manifest_from_old_manifest( .collect(), language_servers: Default::default(), context_servers: BTreeMap::default(), + agent_servers: BTreeMap::default(), slash_commands: BTreeMap::default(), snippets: None, capabilities: Vec::new(), @@ -298,6 +343,7 @@ mod tests { grammars: BTreeMap::default(), language_servers: BTreeMap::default(), context_servers: BTreeMap::default(), + agent_servers: BTreeMap::default(), slash_commands: BTreeMap::default(), snippets: None, capabilities: vec![], @@ -404,4 +450,31 @@ mod tests { ); assert!(manifest.allow_exec("docker", &["ps"]).is_err()); // wrong first arg } + #[test] + fn parse_manifest_with_agent_server_archive_launcher() { + let toml_src = r#" +id = "example.agent-server-ext" +name = "Agent Server Example" +version = "1.0.0" +schema_version = 0 + +[agent_servers.foo] +name = "Foo Agent" + +[agent_servers.foo.targets.linux-x86_64] +archive = "https://example.com/agent-linux-x64.tar.gz" +cmd = "./agent" +args = ["--serve"] +"#; + + let manifest: ExtensionManifest = toml::from_str(toml_src).expect("manifest should parse"); + assert_eq!(manifest.id.as_ref(), "example.agent-server-ext"); + assert!(manifest.agent_servers.contains_key("foo")); + let entry = manifest.agent_servers.get("foo").unwrap(); + assert!(entry.targets.contains_key("linux-x86_64")); + let target = entry.targets.get("linux-x86_64").unwrap(); + assert_eq!(target.archive, "https://example.com/agent-linux-x64.tar.gz"); + assert_eq!(target.cmd, "./agent"); + assert_eq!(target.args, vec!["--serve"]); + } } diff --git a/crates/extension_cli/src/main.rs b/crates/extension_cli/src/main.rs index 367dba98a32f5e8b0ade64095fbac5cad641b5ad..1dd65fe446232effc932a497601212cd039b6eed 100644 --- a/crates/extension_cli/src/main.rs +++ b/crates/extension_cli/src/main.rs @@ -235,6 +235,21 @@ async fn copy_extension_resources( .with_context(|| "failed to copy icons")?; } + for (_, agent_entry) in &manifest.agent_servers { + if let Some(icon_path) = &agent_entry.icon { + let source_icon = extension_path.join(icon_path); + let dest_icon = output_dir.join(icon_path); + + // Create parent directory if needed + if let Some(parent) = dest_icon.parent() { + fs::create_dir_all(parent)?; + } + + fs::copy(&source_icon, &dest_icon) + .with_context(|| format!("failed to copy agent server icon '{}'", icon_path))?; + } + } + if !manifest.languages.is_empty() { let output_languages_dir = output_dir.join("languages"); fs::create_dir_all(&output_languages_dir)?; diff --git a/crates/extension_host/benches/extension_compilation_benchmark.rs b/crates/extension_host/benches/extension_compilation_benchmark.rs index 309e089758eab8bed1139e2d813bc99b1febb594..9cb57fc1fb800df3f20d277cff5c85ecddadf5ad 100644 --- a/crates/extension_host/benches/extension_compilation_benchmark.rs +++ b/crates/extension_host/benches/extension_compilation_benchmark.rs @@ -132,6 +132,7 @@ fn manifest() -> ExtensionManifest { .into_iter() .collect(), context_servers: BTreeMap::default(), + agent_servers: BTreeMap::default(), slash_commands: BTreeMap::default(), snippets: None, capabilities: vec![ExtensionCapability::ProcessExec( diff --git a/crates/extension_host/src/capability_granter.rs b/crates/extension_host/src/capability_granter.rs index 5491967e080fc4d12a52f0360dab1896b77e19d3..9f27b5e480bc3c22faefe67cd49a06af21614096 100644 --- a/crates/extension_host/src/capability_granter.rs +++ b/crates/extension_host/src/capability_granter.rs @@ -107,6 +107,7 @@ mod tests { grammars: BTreeMap::default(), language_servers: BTreeMap::default(), context_servers: BTreeMap::default(), + agent_servers: BTreeMap::default(), slash_commands: BTreeMap::default(), snippets: None, capabilities: vec![], diff --git a/crates/extension_host/src/extension_store_test.rs b/crates/extension_host/src/extension_store_test.rs index 509edc6845c6e99745a4b94944cf5f2b68ff9b93..41b7b35d463a520888d4419f141ffdeca332fdac 100644 --- a/crates/extension_host/src/extension_store_test.rs +++ b/crates/extension_host/src/extension_store_test.rs @@ -159,6 +159,7 @@ async fn test_extension_store(cx: &mut TestAppContext) { .collect(), language_servers: BTreeMap::default(), context_servers: BTreeMap::default(), + agent_servers: BTreeMap::default(), slash_commands: BTreeMap::default(), snippets: None, capabilities: Vec::new(), @@ -189,6 +190,7 @@ async fn test_extension_store(cx: &mut TestAppContext) { grammars: BTreeMap::default(), language_servers: BTreeMap::default(), context_servers: BTreeMap::default(), + agent_servers: BTreeMap::default(), slash_commands: BTreeMap::default(), snippets: None, capabilities: Vec::new(), @@ -368,6 +370,7 @@ async fn test_extension_store(cx: &mut TestAppContext) { grammars: BTreeMap::default(), language_servers: BTreeMap::default(), context_servers: BTreeMap::default(), + agent_servers: BTreeMap::default(), slash_commands: BTreeMap::default(), snippets: None, capabilities: Vec::new(), diff --git a/crates/extensions_ui/src/extensions_ui.rs b/crates/extensions_ui/src/extensions_ui.rs index 8ddf9841a12e1313f105b76cacdae2b571bb1fd0..1fc1384a133946651f16b3b9bdba742c2882b9a8 100644 --- a/crates/extensions_ui/src/extensions_ui.rs +++ b/crates/extensions_ui/src/extensions_ui.rs @@ -66,6 +66,7 @@ pub fn init(cx: &mut App) { ExtensionCategoryFilter::ContextServers => { ExtensionProvides::ContextServers } + ExtensionCategoryFilter::AgentServers => ExtensionProvides::AgentServers, ExtensionCategoryFilter::SlashCommands => ExtensionProvides::SlashCommands, ExtensionCategoryFilter::IndexedDocsProviders => { ExtensionProvides::IndexedDocsProviders @@ -189,6 +190,7 @@ fn extension_provides_label(provides: ExtensionProvides) -> &'static str { ExtensionProvides::Grammars => "Grammars", ExtensionProvides::LanguageServers => "Language Servers", ExtensionProvides::ContextServers => "MCP Servers", + ExtensionProvides::AgentServers => "Agent Servers", ExtensionProvides::SlashCommands => "Slash Commands", ExtensionProvides::IndexedDocsProviders => "Indexed Docs Providers", ExtensionProvides::Snippets => "Snippets", diff --git a/crates/project/src/agent_server_store.rs b/crates/project/src/agent_server_store.rs index e7f4e9ed22b00278da0f8295eede8f1cc5133489..8a950d2820c123b302cac23fd309df40528a3837 100644 --- a/crates/project/src/agent_server_store.rs +++ b/crates/project/src/agent_server_store.rs @@ -1,6 +1,7 @@ use std::{ any::Any, borrow::Borrow, + collections::HashSet, path::{Path, PathBuf}, str::FromStr as _, sync::Arc, @@ -126,13 +127,198 @@ enum AgentServerStoreState { pub struct AgentServerStore { state: AgentServerStoreState, external_agents: HashMap>, + agent_icons: HashMap, } pub struct AgentServersUpdated; impl EventEmitter for AgentServerStore {} +#[cfg(test)] +mod ext_agent_tests { + use super::*; + use std::fmt::Write as _; + + // Helper to build a store in Collab mode so we can mutate internal maps without + // needing to spin up a full project environment. + fn collab_store() -> AgentServerStore { + AgentServerStore { + state: AgentServerStoreState::Collab, + external_agents: HashMap::default(), + agent_icons: HashMap::default(), + } + } + + // A simple fake that implements ExternalAgentServer without needing async plumbing. + struct NoopExternalAgent; + + impl ExternalAgentServer for NoopExternalAgent { + fn get_command( + &mut self, + _root_dir: Option<&str>, + _extra_env: HashMap, + _status_tx: Option>, + _new_version_available_tx: Option>>, + _cx: &mut AsyncApp, + ) -> Task)>> { + Task::ready(Ok(( + AgentServerCommand { + path: PathBuf::from("noop"), + args: Vec::new(), + env: None, + }, + "".to_string(), + None, + ))) + } + + fn as_any_mut(&mut self) -> &mut dyn Any { + self + } + } + + #[test] + fn external_agent_server_name_display() { + let name = ExternalAgentServerName(SharedString::from("Ext: Tool")); + let mut s = String::new(); + write!(&mut s, "{name}").unwrap(); + assert_eq!(s, "Ext: Tool"); + } + + #[test] + fn sync_extension_agents_removes_previous_extension_entries() { + let mut store = collab_store(); + + // Seed with a couple of agents that will be replaced by extensions + store.external_agents.insert( + ExternalAgentServerName(SharedString::from("foo-agent")), + Box::new(NoopExternalAgent) as Box, + ); + store.external_agents.insert( + ExternalAgentServerName(SharedString::from("bar-agent")), + Box::new(NoopExternalAgent) as Box, + ); + store.external_agents.insert( + ExternalAgentServerName(SharedString::from("custom")), + Box::new(NoopExternalAgent) as Box, + ); + + // Simulate the removal phase: if we're syncing extensions that provide + // "foo-agent" and "bar-agent", those should be removed first + let extension_agent_names: HashSet = + ["foo-agent".to_string(), "bar-agent".to_string()] + .into_iter() + .collect(); + + let keys_to_remove: Vec<_> = store + .external_agents + .keys() + .filter(|name| extension_agent_names.contains(name.0.as_ref())) + .cloned() + .collect(); + + for key in keys_to_remove { + store.external_agents.remove(&key); + } + + // Only the custom entry should remain. + let remaining: Vec<_> = store + .external_agents + .keys() + .map(|k| k.0.to_string()) + .collect(); + assert_eq!(remaining, vec!["custom".to_string()]); + } +} + impl AgentServerStore { + /// Synchronizes extension-provided agent servers with the store. + pub fn sync_extension_agents<'a, I>( + &mut self, + manifests: I, + extensions_dir: PathBuf, + cx: &mut Context, + ) where + I: IntoIterator, + { + // Collect manifests first so we can iterate twice + let manifests: Vec<_> = manifests.into_iter().collect(); + + // Remove existing extension-provided agents by tracking which ones we're about to add + let extension_agent_names: HashSet<_> = manifests + .iter() + .flat_map(|(_, manifest)| manifest.agent_servers.keys().map(|k| k.to_string())) + .collect(); + + let keys_to_remove: Vec<_> = self + .external_agents + .keys() + .filter(|name| { + // Remove if it matches an extension agent name from any extension + extension_agent_names.contains(name.0.as_ref()) + }) + .cloned() + .collect(); + for key in &keys_to_remove { + self.external_agents.remove(key); + self.agent_icons.remove(key); + } + + // Insert agent servers from extension manifests + match &self.state { + AgentServerStoreState::Local { + project_environment, + fs, + http_client, + .. + } => { + for (ext_id, manifest) in manifests { + for (agent_name, agent_entry) in &manifest.agent_servers { + let display = SharedString::from(agent_entry.name.clone()); + + // Store absolute icon path if provided, resolving symlinks for dev extensions + if let Some(icon) = &agent_entry.icon { + let icon_path = extensions_dir.join(ext_id).join(icon); + // Canonicalize to resolve symlinks (dev extensions are symlinked) + let absolute_icon_path = icon_path + .canonicalize() + .unwrap_or(icon_path) + .to_string_lossy() + .to_string(); + self.agent_icons.insert( + ExternalAgentServerName(display.clone()), + SharedString::from(absolute_icon_path), + ); + } + + // Archive-based launcher (download from URL) + self.external_agents.insert( + ExternalAgentServerName(display), + Box::new(LocalExtensionArchiveAgent { + fs: fs.clone(), + http_client: http_client.clone(), + project_environment: project_environment.clone(), + extension_id: Arc::from(ext_id), + agent_id: agent_name.clone(), + targets: agent_entry.targets.clone(), + env: agent_entry.env.clone(), + }) as Box, + ); + } + } + } + _ => { + // Only local projects support local extension agents + } + } + + cx.emit(AgentServersUpdated); + } + + pub fn agent_icon(&self, name: &ExternalAgentServerName) -> Option { + self.agent_icons.get(name).cloned() + } + pub fn init_remote(session: &AnyProtoClient) { session.add_entity_message_handler(Self::handle_external_agents_updated); session.add_entity_message_handler(Self::handle_loading_status_updated); @@ -202,7 +388,7 @@ impl AgentServerStore { .gemini .as_ref() .and_then(|settings| settings.ignore_system_version) - .unwrap_or(true), + .unwrap_or(false), }), ); self.external_agents.insert( @@ -279,7 +465,9 @@ impl AgentServerStore { _subscriptions: [subscription], }, external_agents: Default::default(), + agent_icons: Default::default(), }; + if let Some(_events) = extension::ExtensionEvents::try_global(cx) {} this.agent_servers_settings_changed(cx); this } @@ -288,7 +476,7 @@ impl AgentServerStore { // Set up the builtin agents here so they're immediately available in // remote projects--we know that the HeadlessProject on the other end // will have them. - let external_agents = [ + let external_agents: [(ExternalAgentServerName, Box); 3] = [ ( CLAUDE_CODE_NAME.into(), Box::new(RemoteExternalAgentServer { @@ -319,16 +507,15 @@ impl AgentServerStore { new_version_available_tx: None, }) as Box, ), - ] - .into_iter() - .collect(); + ]; Self { state: AgentServerStoreState::Remote { project_id, upstream_client, }, - external_agents, + external_agents: external_agents.into_iter().collect(), + agent_icons: HashMap::default(), } } @@ -336,6 +523,7 @@ impl AgentServerStore { Self { state: AgentServerStoreState::Collab, external_agents: Default::default(), + agent_icons: Default::default(), } } @@ -392,7 +580,7 @@ impl AgentServerStore { envelope: TypedEnvelope, mut cx: AsyncApp, ) -> Result { - let (command, root_dir, login) = this + let (command, root_dir, login_command) = this .update(&mut cx, |this, cx| { let AgentServerStoreState::Local { downstream_client, .. @@ -466,7 +654,7 @@ impl AgentServerStore { .map(|env| env.into_iter().collect()) .unwrap_or_default(), root_dir: root_dir, - login: login.map(|login| login.to_proto()), + login: login_command.map(|cmd| cmd.to_proto()), }) } @@ -811,9 +999,7 @@ impl ExternalAgentServer for RemoteExternalAgentServer { env: Some(command.env), }, root_dir, - response - .login - .map(|login| task::SpawnInTerminal::from_proto(login)), + None, )) }) } @@ -959,7 +1145,7 @@ impl ExternalAgentServer for LocalClaudeCode { .unwrap_or_default(); env.insert("ANTHROPIC_API_KEY".into(), "".into()); - let (mut command, login) = if let Some(mut custom_command) = custom_command { + let (mut command, login_command) = if let Some(mut custom_command) = custom_command { env.extend(custom_command.env.unwrap_or_default()); custom_command.env = Some(env); (custom_command, None) @@ -1000,7 +1186,11 @@ impl ExternalAgentServer for LocalClaudeCode { }; command.env.get_or_insert_default().extend(extra_env); - Ok((command, root_dir.to_string_lossy().into_owned(), login)) + Ok(( + command, + root_dir.to_string_lossy().into_owned(), + login_command, + )) }) } @@ -1080,10 +1270,15 @@ impl ExternalAgentServer for LocalCodex { .into_iter() .find(|asset| asset.name == asset_name) .with_context(|| format!("no asset found matching `{asset_name:?}`"))?; + // Strip "sha256:" prefix from digest if present (GitHub API format) + let digest = asset + .digest + .as_deref() + .and_then(|d| d.strip_prefix("sha256:").or(Some(d))); ::http_client::github_download::download_server_binary( &*http, &asset.browser_download_url, - asset.digest.as_deref(), + digest, &version_dir, if cfg!(target_os = "windows") && cfg!(target_arch = "x86_64") { AssetKind::Zip @@ -1127,11 +1322,7 @@ impl ExternalAgentServer for LocalCodex { pub const CODEX_ACP_REPO: &str = "zed-industries/codex-acp"; -/// Assemble Codex release URL for the current OS/arch and the given version number. -/// Returns None if the current target is unsupported. -/// Example output: -/// https://github.com/zed-industries/codex-acp/releases/download/v{version}/codex-acp-{version}-{arch}-{platform}.{ext} -fn asset_name(version: &str) -> Option { +fn get_platform_info() -> Option<(&'static str, &'static str, &'static str)> { let arch = if cfg!(target_arch = "x86_64") { "x86_64" } else if cfg!(target_arch = "aarch64") { @@ -1157,14 +1348,220 @@ fn asset_name(version: &str) -> Option { "tar.gz" }; + Some((arch, platform, ext)) +} + +fn asset_name(version: &str) -> Option { + let (arch, platform, ext) = get_platform_info()?; Some(format!("codex-acp-{version}-{arch}-{platform}.{ext}")) } +struct LocalExtensionArchiveAgent { + fs: Arc, + http_client: Arc, + project_environment: Entity, + extension_id: Arc, + agent_id: Arc, + targets: HashMap, + env: HashMap, +} + struct LocalCustomAgent { project_environment: Entity, command: AgentServerCommand, } +impl ExternalAgentServer for LocalExtensionArchiveAgent { + fn get_command( + &mut self, + root_dir: Option<&str>, + extra_env: HashMap, + _status_tx: Option>, + _new_version_available_tx: Option>>, + cx: &mut AsyncApp, + ) -> Task)>> { + let fs = self.fs.clone(); + let http_client = self.http_client.clone(); + let project_environment = self.project_environment.downgrade(); + let extension_id = self.extension_id.clone(); + let agent_id = self.agent_id.clone(); + let targets = self.targets.clone(); + let base_env = self.env.clone(); + + let root_dir: Arc = root_dir + .map(|root_dir| Path::new(root_dir)) + .unwrap_or(paths::home_dir()) + .into(); + + cx.spawn(async move |cx| { + // Get project environment + let mut env = project_environment + .update(cx, |project_environment, cx| { + project_environment.get_local_directory_environment( + &Shell::System, + root_dir.clone(), + cx, + ) + })? + .await + .unwrap_or_default(); + + // Merge manifest env and extra env + env.extend(base_env); + env.extend(extra_env); + + let cache_key = format!("{}/{}", extension_id, agent_id); + let dir = paths::data_dir().join("external_agents").join(&cache_key); + fs.create_dir(&dir).await?; + + // Determine platform key + let os = if cfg!(target_os = "macos") { + "darwin" + } else if cfg!(target_os = "linux") { + "linux" + } else if cfg!(target_os = "windows") { + "windows" + } else { + anyhow::bail!("unsupported OS"); + }; + + let arch = if cfg!(target_arch = "aarch64") { + "aarch64" + } else if cfg!(target_arch = "x86_64") { + "x86_64" + } else { + anyhow::bail!("unsupported architecture"); + }; + + let platform_key = format!("{}-{}", os, arch); + let target_config = targets.get(&platform_key).with_context(|| { + format!( + "no target specified for platform '{}'. Available platforms: {}", + platform_key, + targets + .keys() + .map(|k| k.as_str()) + .collect::>() + .join(", ") + ) + })?; + + let archive_url = &target_config.archive; + + // Use URL as version identifier for caching + // Hash the URL to get a stable directory name + use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash, Hasher}; + let mut hasher = DefaultHasher::new(); + archive_url.hash(&mut hasher); + let url_hash = hasher.finish(); + let version_dir = dir.join(format!("v_{:x}", url_hash)); + + if !fs.is_dir(&version_dir).await { + // Determine SHA256 for verification + let sha256 = if let Some(provided_sha) = &target_config.sha256 { + // Use provided SHA256 + Some(provided_sha.clone()) + } else if archive_url.starts_with("https://github.com/") { + // Try to fetch SHA256 from GitHub API + // Parse URL to extract repo and tag/file info + // Format: https://github.com/owner/repo/releases/download/tag/file.zip + if let Some(caps) = archive_url.strip_prefix("https://github.com/") { + let parts: Vec<&str> = caps.split('/').collect(); + if parts.len() >= 6 && parts[2] == "releases" && parts[3] == "download" { + let repo = format!("{}/{}", parts[0], parts[1]); + let tag = parts[4]; + let filename = parts[5..].join("/"); + + // Try to get release info from GitHub + if let Ok(release) = ::http_client::github::get_release_by_tag_name( + &repo, + tag, + http_client.clone(), + ) + .await + { + // Find matching asset + if let Some(asset) = + release.assets.iter().find(|a| a.name == filename) + { + // Strip "sha256:" prefix if present + asset.digest.as_ref().and_then(|d| { + d.strip_prefix("sha256:") + .map(|s| s.to_string()) + .or_else(|| Some(d.clone())) + }) + } else { + None + } + } else { + None + } + } else { + None + } + } else { + None + } + } else { + None + }; + + // Determine archive type from URL + let asset_kind = if archive_url.ends_with(".zip") { + AssetKind::Zip + } else if archive_url.ends_with(".tar.gz") || archive_url.ends_with(".tgz") { + AssetKind::TarGz + } else { + anyhow::bail!("unsupported archive type in URL: {}", archive_url); + }; + + // Download and extract + ::http_client::github_download::download_server_binary( + &*http_client, + archive_url, + sha256.as_deref(), + &version_dir, + asset_kind, + ) + .await?; + } + + // Validate and resolve cmd path + let cmd = &target_config.cmd; + if cmd.contains("..") { + anyhow::bail!("command path cannot contain '..': {}", cmd); + } + + let cmd_path = if cmd.starts_with("./") || cmd.starts_with(".\\") { + // Relative to extraction directory + version_dir.join(&cmd[2..]) + } else { + // On PATH + anyhow::bail!("command must be relative (start with './'): {}", cmd); + }; + + anyhow::ensure!( + fs.is_file(&cmd_path).await, + "Missing command {} after extraction", + cmd_path.to_string_lossy() + ); + + let command = AgentServerCommand { + path: cmd_path, + args: target_config.args.clone(), + env: Some(env), + }; + + Ok((command, root_dir.to_string_lossy().into_owned(), None)) + }) + } + + fn as_any_mut(&mut self) -> &mut dyn Any { + self + } +} + impl ExternalAgentServer for LocalCustomAgent { fn get_command( &mut self, @@ -1203,42 +1600,6 @@ impl ExternalAgentServer for LocalCustomAgent { } } -#[cfg(test)] -mod tests { - #[test] - fn assembles_codex_release_url_for_current_target() { - let version_number = "0.1.0"; - - // This test fails the build if we are building a version of Zed - // which does not have a known build of codex-acp, to prevent us - // from accidentally doing a release on a new target without - // realizing that codex-acp support will not work on that target! - // - // Additionally, it verifies that our logic for assembling URLs - // correctly resolves to a known-good URL on each of our targets. - let allowed = [ - "codex-acp-0.1.0-aarch64-apple-darwin.tar.gz", - "codex-acp-0.1.0-aarch64-pc-windows-msvc.tar.gz", - "codex-acp-0.1.0-aarch64-unknown-linux-gnu.tar.gz", - "codex-acp-0.1.0-x86_64-apple-darwin.tar.gz", - "codex-acp-0.1.0-x86_64-pc-windows-msvc.zip", - "codex-acp-0.1.0-x86_64-unknown-linux-gnu.tar.gz", - ]; - - if let Some(url) = super::asset_name(version_number) { - assert!( - allowed.contains(&url.as_str()), - "Assembled asset name {} not in allowed list", - url - ); - } else { - panic!( - "This target does not have a known codex-acp release! We should fix this by building a release of codex-acp for this target, as otherwise codex-acp will not be usable with this Zed build." - ); - } - } -} - pub const GEMINI_NAME: &'static str = "gemini"; pub const CLAUDE_CODE_NAME: &'static str = "claude"; pub const CODEX_NAME: &'static str = "codex"; @@ -1331,3 +1692,200 @@ impl settings::Settings for AllAgentServersSettings { } } } + +#[cfg(test)] +mod extension_agent_tests { + use super::*; + use gpui::TestAppContext; + use std::sync::Arc; + + #[test] + fn extension_agent_constructs_proper_display_names() { + // Verify the display name format for extension-provided agents + let name1 = ExternalAgentServerName(SharedString::from("Extension: Agent")); + assert!(name1.0.contains(": ")); + + let name2 = ExternalAgentServerName(SharedString::from("MyExt: MyAgent")); + assert_eq!(name2.0, "MyExt: MyAgent"); + + // Non-extension agents shouldn't have the separator + let custom = ExternalAgentServerName(SharedString::from("custom")); + assert!(!custom.0.contains(": ")); + } + + struct NoopExternalAgent; + + impl ExternalAgentServer for NoopExternalAgent { + fn get_command( + &mut self, + _root_dir: Option<&str>, + _extra_env: HashMap, + _status_tx: Option>, + _new_version_available_tx: Option>>, + _cx: &mut AsyncApp, + ) -> Task)>> { + Task::ready(Ok(( + AgentServerCommand { + path: PathBuf::from("noop"), + args: Vec::new(), + env: None, + }, + "".to_string(), + None, + ))) + } + + fn as_any_mut(&mut self) -> &mut dyn Any { + self + } + } + + #[test] + fn sync_removes_only_extension_provided_agents() { + let mut store = AgentServerStore { + state: AgentServerStoreState::Collab, + external_agents: HashMap::default(), + agent_icons: HashMap::default(), + }; + + // Seed with extension agents (contain ": ") and custom agents (don't contain ": ") + store.external_agents.insert( + ExternalAgentServerName(SharedString::from("Ext1: Agent1")), + Box::new(NoopExternalAgent) as Box, + ); + store.external_agents.insert( + ExternalAgentServerName(SharedString::from("Ext2: Agent2")), + Box::new(NoopExternalAgent) as Box, + ); + store.external_agents.insert( + ExternalAgentServerName(SharedString::from("custom-agent")), + Box::new(NoopExternalAgent) as Box, + ); + + // Simulate removal phase + let keys_to_remove: Vec<_> = store + .external_agents + .keys() + .filter(|name| name.0.contains(": ")) + .cloned() + .collect(); + + for key in keys_to_remove { + store.external_agents.remove(&key); + } + + // Only custom-agent should remain + assert_eq!(store.external_agents.len(), 1); + assert!( + store + .external_agents + .contains_key(&ExternalAgentServerName(SharedString::from("custom-agent"))) + ); + } + + #[test] + fn archive_launcher_constructs_with_all_fields() { + use extension::AgentServerManifestEntry; + + let mut env = HashMap::default(); + env.insert("GITHUB_TOKEN".into(), "secret".into()); + + let mut targets = HashMap::default(); + targets.insert( + "darwin-aarch64".to_string(), + extension::TargetConfig { + archive: + "https://github.com/owner/repo/releases/download/v1.0.0/agent-darwin-arm64.zip" + .into(), + cmd: "./agent".into(), + args: vec![], + sha256: None, + }, + ); + + let _entry = AgentServerManifestEntry { + name: "GitHub Agent".into(), + targets, + env, + icon: None, + }; + + // Verify display name construction + let expected_name = ExternalAgentServerName(SharedString::from("GitHub Agent")); + assert_eq!(expected_name.0, "GitHub Agent"); + } + + #[gpui::test] + async fn archive_agent_uses_extension_and_agent_id_for_cache_key(cx: &mut TestAppContext) { + let fs = fs::FakeFs::new(cx.background_executor.clone()); + let http_client = http_client::FakeHttpClient::with_404_response(); + let project_environment = cx.new(|cx| crate::ProjectEnvironment::new(None, cx)); + + let agent = LocalExtensionArchiveAgent { + fs, + http_client, + project_environment, + extension_id: Arc::from("my-extension"), + agent_id: Arc::from("my-agent"), + targets: { + let mut map = HashMap::default(); + map.insert( + "darwin-aarch64".to_string(), + extension::TargetConfig { + archive: "https://example.com/my-agent-darwin-arm64.zip".into(), + cmd: "./my-agent".into(), + args: vec!["--serve".into()], + sha256: None, + }, + ); + map + }, + env: { + let mut map = HashMap::default(); + map.insert("PORT".into(), "8080".into()); + map + }, + }; + + // Verify agent is properly constructed + assert_eq!(agent.extension_id.as_ref(), "my-extension"); + assert_eq!(agent.agent_id.as_ref(), "my-agent"); + assert_eq!(agent.env.get("PORT"), Some(&"8080".to_string())); + assert!(agent.targets.contains_key("darwin-aarch64")); + } + + #[test] + fn sync_extension_agents_registers_archive_launcher() { + use extension::AgentServerManifestEntry; + + let expected_name = ExternalAgentServerName(SharedString::from("Release Agent")); + assert_eq!(expected_name.0, "Release Agent"); + + // Verify the manifest entry structure for archive-based installation + let mut env = HashMap::default(); + env.insert("API_KEY".into(), "secret".into()); + + let mut targets = HashMap::default(); + targets.insert( + "linux-x86_64".to_string(), + extension::TargetConfig { + archive: "https://github.com/org/project/releases/download/v2.1.0/release-agent-linux-x64.tar.gz".into(), + cmd: "./release-agent".into(), + args: vec!["serve".into()], + sha256: None, + }, + ); + + let manifest_entry = AgentServerManifestEntry { + name: "Release Agent".into(), + targets: targets.clone(), + env, + icon: None, + }; + + // Verify target config is present + assert!(manifest_entry.targets.contains_key("linux-x86_64")); + let target = manifest_entry.targets.get("linux-x86_64").unwrap(); + assert_eq!(target.cmd, "./release-agent"); + } +} diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 3a141da70aeaab466dc580ec69bae16fc48de0ed..910e217a67785249b4d83b7929b32c21b079a5d7 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -40,7 +40,7 @@ use crate::{ git_store::GitStore, lsp_store::{SymbolLocation, log_store::LogKind}, }; -pub use agent_server_store::{AgentServerStore, AgentServersUpdated}; +pub use agent_server_store::{AgentServerStore, AgentServersUpdated, ExternalAgentServerName}; pub use git_store::{ ConflictRegion, ConflictSet, ConflictSetSnapshot, ConflictSetUpdate, git_traversal::{ChildEntriesGitIter, GitEntry, GitEntryRef, GitTraversal}, diff --git a/crates/rpc/src/extension.rs b/crates/rpc/src/extension.rs index 1ee55d5ccef2e7b3aaa1d4d16e9bbad13cf11ade..1b00312bad033a542648252565e062e0209248bc 100644 --- a/crates/rpc/src/extension.rs +++ b/crates/rpc/src/extension.rs @@ -42,6 +42,7 @@ pub enum ExtensionProvides { Grammars, LanguageServers, ContextServers, + AgentServers, SlashCommands, IndexedDocsProviders, Snippets, diff --git a/crates/ui/src/components/context_menu.rs b/crates/ui/src/components/context_menu.rs index 72c16edfc58e27ef0d82573924a17505648ee291..8db7a9da07992ae6ba6a3a9f4fcec5ff4f9d5344 100644 --- a/crates/ui/src/components/context_menu.rs +++ b/crates/ui/src/components/context_menu.rs @@ -47,6 +47,7 @@ pub struct ContextMenuEntry { toggle: Option<(IconPosition, bool)>, label: SharedString, icon: Option, + custom_icon_path: Option, icon_position: IconPosition, icon_size: IconSize, icon_color: Option, @@ -66,6 +67,7 @@ impl ContextMenuEntry { toggle: None, label: label.into(), icon: None, + custom_icon_path: None, icon_position: IconPosition::Start, icon_size: IconSize::Small, icon_color: None, @@ -90,6 +92,12 @@ impl ContextMenuEntry { self } + pub fn custom_icon_path(mut self, path: impl Into) -> Self { + self.custom_icon_path = Some(path.into()); + self.icon = None; // Clear IconName if custom path is set + self + } + pub fn icon_position(mut self, position: IconPosition) -> Self { self.icon_position = position; self @@ -387,6 +395,7 @@ impl ContextMenu { label: label.into(), handler: Rc::new(move |_, window, cx| handler(window, cx)), icon: None, + custom_icon_path: None, icon_position: IconPosition::End, icon_size: IconSize::Small, icon_color: None, @@ -415,6 +424,7 @@ impl ContextMenu { label: label.into(), handler: Rc::new(move |_, window, cx| handler(window, cx)), icon: None, + custom_icon_path: None, icon_position: IconPosition::End, icon_size: IconSize::Small, icon_color: None, @@ -443,6 +453,7 @@ impl ContextMenu { label: label.into(), handler: Rc::new(move |_, window, cx| handler(window, cx)), icon: None, + custom_icon_path: None, icon_position: IconPosition::End, icon_size: IconSize::Small, icon_color: None, @@ -470,6 +481,7 @@ impl ContextMenu { label: label.into(), handler: Rc::new(move |_, window, cx| handler(window, cx)), icon: None, + custom_icon_path: None, icon_position: position, icon_size: IconSize::Small, icon_color: None, @@ -528,6 +540,7 @@ impl ContextMenu { window.dispatch_action(action.boxed_clone(), cx); }), icon: None, + custom_icon_path: None, icon_position: IconPosition::End, icon_size: IconSize::Small, icon_color: None, @@ -558,6 +571,7 @@ impl ContextMenu { window.dispatch_action(action.boxed_clone(), cx); }), icon: None, + custom_icon_path: None, icon_size: IconSize::Small, icon_position: IconPosition::End, icon_color: None, @@ -578,6 +592,7 @@ impl ContextMenu { action: Some(action.boxed_clone()), handler: Rc::new(move |_, window, cx| window.dispatch_action(action.boxed_clone(), cx)), icon: Some(IconName::ArrowUpRight), + custom_icon_path: None, icon_size: IconSize::XSmall, icon_position: IconPosition::End, icon_color: None, @@ -897,6 +912,7 @@ impl ContextMenu { label, handler, icon, + custom_icon_path, icon_position, icon_size, icon_color, @@ -927,7 +943,29 @@ impl ContextMenu { Color::Default }; - let label_element = if let Some(icon_name) = icon { + let label_element = if let Some(custom_path) = custom_icon_path { + h_flex() + .gap_1p5() + .when( + *icon_position == IconPosition::Start && toggle.is_none(), + |flex| { + flex.child( + Icon::from_path(custom_path.clone()) + .size(*icon_size) + .color(icon_color), + ) + }, + ) + .child(Label::new(label.clone()).color(label_color).truncate()) + .when(*icon_position == IconPosition::End, |flex| { + flex.child( + Icon::from_path(custom_path.clone()) + .size(*icon_size) + .color(icon_color), + ) + }) + .into_any_element() + } else if let Some(icon_name) = icon { h_flex() .gap_1p5() .when( diff --git a/crates/zed_actions/src/lib.rs b/crates/zed_actions/src/lib.rs index 13a2837efb3240a400151b3bcd5342d8300f8730..b246ed9c471f22195b1089a4916df77303ddee1f 100644 --- a/crates/zed_actions/src/lib.rs +++ b/crates/zed_actions/src/lib.rs @@ -70,6 +70,7 @@ pub enum ExtensionCategoryFilter { Grammars, LanguageServers, ContextServers, + AgentServers, SlashCommands, IndexedDocsProviders, Snippets,