Detailed changes
@@ -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<ContextMenu>,
agent_navigation_menu_handle: PopoverMenuHandle<ContextMenu>,
agent_navigation_menu: Option<Entity<ContextMenu>>,
+ _extension_subscription: Option<Subscription>,
width: Option<Pixels>,
height: Option<Pixels>,
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<Self>) {
+ 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::<Vec<_>>();
- let custom_settings = cx.global::<SettingsStore>().get::<AllAgentServersSettings>(None).custom.clone();
+ let custom_settings = cx
+ .global::<SettingsStore>()
+ .get::<AllAgentServersSettings>(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::<AgentPanel>(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::<AgentPanel>(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()
@@ -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,
@@ -0,0 +1,2 @@
+alter table extension_versions
+add column provides_agent_servers bool not null default false
@@ -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));
}
@@ -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);
}
@@ -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<Database>) {
+ // 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<Database>) {
let versions = db.get_known_extension_versions().await.unwrap();
assert!(versions.is_empty());
@@ -82,6 +82,8 @@ pub struct ExtensionManifest {
#[serde(default)]
pub context_servers: BTreeMap<Arc<str>, ContextServerManifestEntry>,
#[serde(default)]
+ pub agent_servers: BTreeMap<Arc<str>, AgentServerManifestEntry>,
+ #[serde(default)]
pub slash_commands: BTreeMap<Arc<str>, SlashCommandManifestEntry>,
#[serde(default)]
pub snippets: Option<PathBuf>,
@@ -138,6 +140,48 @@ pub struct LibManifestEntry {
pub version: Option<SemanticVersion>,
}
+#[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<String, String>,
+ /// 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<String>,
+ /// 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<String, TargetConfig>,
+}
+
+#[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<String>,
+ /// 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<String>,
+}
+
#[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"]);
+ }
}
@@ -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)?;
@@ -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(
@@ -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![],
@@ -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(),
@@ -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",
@@ -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<ExternalAgentServerName, Box<dyn ExternalAgentServer>>,
+ agent_icons: HashMap<ExternalAgentServerName, SharedString>,
}
pub struct AgentServersUpdated;
impl EventEmitter<AgentServersUpdated> 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<String, String>,
+ _status_tx: Option<watch::Sender<SharedString>>,
+ _new_version_available_tx: Option<watch::Sender<Option<String>>>,
+ _cx: &mut AsyncApp,
+ ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
+ 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<dyn ExternalAgentServer>,
+ );
+ store.external_agents.insert(
+ ExternalAgentServerName(SharedString::from("bar-agent")),
+ Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
+ );
+ store.external_agents.insert(
+ ExternalAgentServerName(SharedString::from("custom")),
+ Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
+ );
+
+ // 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<String> =
+ ["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<Self>,
+ ) where
+ I: IntoIterator<Item = (&'a str, &'a extension::ExtensionManifest)>,
+ {
+ // 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<dyn ExternalAgentServer>,
+ );
+ }
+ }
+ }
+ _ => {
+ // Only local projects support local extension agents
+ }
+ }
+
+ cx.emit(AgentServersUpdated);
+ }
+
+ pub fn agent_icon(&self, name: &ExternalAgentServerName) -> Option<SharedString> {
+ 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<dyn ExternalAgentServer>); 3] = [
(
CLAUDE_CODE_NAME.into(),
Box::new(RemoteExternalAgentServer {
@@ -319,16 +507,15 @@ impl AgentServerStore {
new_version_available_tx: None,
}) as Box<dyn ExternalAgentServer>,
),
- ]
- .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<proto::GetAgentServerCommand>,
mut cx: AsyncApp,
) -> Result<proto::AgentServerCommand> {
- 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<String> {
+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<String> {
"tar.gz"
};
+ Some((arch, platform, ext))
+}
+
+fn asset_name(version: &str) -> Option<String> {
+ let (arch, platform, ext) = get_platform_info()?;
Some(format!("codex-acp-{version}-{arch}-{platform}.{ext}"))
}
+struct LocalExtensionArchiveAgent {
+ fs: Arc<dyn Fs>,
+ http_client: Arc<dyn HttpClient>,
+ project_environment: Entity<ProjectEnvironment>,
+ extension_id: Arc<str>,
+ agent_id: Arc<str>,
+ targets: HashMap<String, extension::TargetConfig>,
+ env: HashMap<String, String>,
+}
+
struct LocalCustomAgent {
project_environment: Entity<ProjectEnvironment>,
command: AgentServerCommand,
}
+impl ExternalAgentServer for LocalExtensionArchiveAgent {
+ fn get_command(
+ &mut self,
+ root_dir: Option<&str>,
+ extra_env: HashMap<String, String>,
+ _status_tx: Option<watch::Sender<SharedString>>,
+ _new_version_available_tx: Option<watch::Sender<Option<String>>>,
+ cx: &mut AsyncApp,
+ ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
+ 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<Path> = 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::<Vec<_>>()
+ .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<String, String>,
+ _status_tx: Option<watch::Sender<SharedString>>,
+ _new_version_available_tx: Option<watch::Sender<Option<String>>>,
+ _cx: &mut AsyncApp,
+ ) -> Task<Result<(AgentServerCommand, String, Option<task::SpawnInTerminal>)>> {
+ 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<dyn ExternalAgentServer>,
+ );
+ store.external_agents.insert(
+ ExternalAgentServerName(SharedString::from("Ext2: Agent2")),
+ Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
+ );
+ store.external_agents.insert(
+ ExternalAgentServerName(SharedString::from("custom-agent")),
+ Box::new(NoopExternalAgent) as Box<dyn ExternalAgentServer>,
+ );
+
+ // 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");
+ }
+}
@@ -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},
@@ -42,6 +42,7 @@ pub enum ExtensionProvides {
Grammars,
LanguageServers,
ContextServers,
+ AgentServers,
SlashCommands,
IndexedDocsProviders,
Snippets,
@@ -47,6 +47,7 @@ pub struct ContextMenuEntry {
toggle: Option<(IconPosition, bool)>,
label: SharedString,
icon: Option<IconName>,
+ custom_icon_path: Option<SharedString>,
icon_position: IconPosition,
icon_size: IconSize,
icon_color: Option<Color>,
@@ -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<SharedString>) -> 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(
@@ -70,6 +70,7 @@ pub enum ExtensionCategoryFilter {
Grammars,
LanguageServers,
ContextServers,
+ AgentServers,
SlashCommands,
IndexedDocsProviders,
Snippets,