ACP Extensions (#40663)

Adds the ability to install ACP agents via extensions

Release Notes:

- N/A *or* Added/Fixed/Improved ...
This commit is contained in:
Richard Feldman 2025-10-24 07:52:51 -04:00 committed by GitHub
parent 7644e797fe
commit 8de4b360e8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 982 additions and 118 deletions

View file

@ -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()

View file

@ -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,

View file

@ -0,0 +1,2 @@
alter table extension_versions
add column provides_agent_servers bool not null default false

View file

@ -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));
}

View file

@ -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);
}

View file

@ -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());

View file

@ -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"]);
}
}

View file

@ -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)?;

View file

@ -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(

View file

@ -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![],

View file

@ -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(),

View file

@ -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",

View file

@ -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");
}
}

View file

@ -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},

View file

@ -42,6 +42,7 @@ pub enum ExtensionProvides {
Grammars,
LanguageServers,
ContextServers,
AgentServers,
SlashCommands,
IndexedDocsProviders,
Snippets,

View file

@ -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(

View file

@ -70,6 +70,7 @@ pub enum ExtensionCategoryFilter {
Grammars,
LanguageServers,
ContextServers,
AgentServers,
SlashCommands,
IndexedDocsProviders,
Snippets,