diff --git a/assets/icons/acp_registry.svg b/assets/icons/acp_registry.svg new file mode 100644 index 0000000000000000000000000000000000000000..fb64ea6fbcfe2febe69cdff9773b9383a8bec67a --- /dev/null +++ b/assets/icons/acp_registry.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/agent_servers/src/custom.rs b/crates/agent_servers/src/custom.rs index 29cfd3d5c1224f516c1deb15762ded45352ae4e4..c507c3b53f0878c2c117865892f94e2e0d26fd19 100644 --- a/crates/agent_servers/src/custom.rs +++ b/crates/agent_servers/src/custom.rs @@ -104,6 +104,10 @@ impl AgentServer for CustomAgentServer { | settings::CustomAgentServerSettings::Extension { favorite_config_option_values, .. + } + | settings::CustomAgentServerSettings::Registry { + favorite_config_option_values, + .. } => { let entry = favorite_config_option_values .entry(config_id.clone()) @@ -142,7 +146,8 @@ impl AgentServer for CustomAgentServer { match settings { settings::CustomAgentServerSettings::Custom { default_mode, .. } - | settings::CustomAgentServerSettings::Extension { default_mode, .. } => { + | settings::CustomAgentServerSettings::Extension { default_mode, .. } + | settings::CustomAgentServerSettings::Registry { default_mode, .. } => { *default_mode = mode_id.map(|m| m.to_string()); } } @@ -181,7 +186,8 @@ impl AgentServer for CustomAgentServer { match settings { settings::CustomAgentServerSettings::Custom { default_model, .. } - | settings::CustomAgentServerSettings::Extension { default_model, .. } => { + | settings::CustomAgentServerSettings::Extension { default_model, .. } + | settings::CustomAgentServerSettings::Registry { default_model, .. } => { *default_model = model_id.map(|m| m.to_string()); } } @@ -236,6 +242,9 @@ impl AgentServer for CustomAgentServer { } | settings::CustomAgentServerSettings::Extension { favorite_models, .. + } + | settings::CustomAgentServerSettings::Registry { + favorite_models, .. } => favorite_models, }; @@ -296,6 +305,10 @@ impl AgentServer for CustomAgentServer { | settings::CustomAgentServerSettings::Extension { default_config_options, .. + } + | settings::CustomAgentServerSettings::Registry { + default_config_options, + .. } => { if let Some(value) = value_id.clone() { default_config_options.insert(config_id.clone(), value); @@ -331,6 +344,10 @@ impl AgentServer for CustomAgentServer { | project::agent_server_store::CustomAgentServerSettings::Extension { default_config_options, .. + } + | project::agent_server_store::CustomAgentServerSettings::Registry { + default_config_options, + .. } => default_config_options.clone(), }) .unwrap_or_default() diff --git a/crates/agent_ui/src/agent_configuration.rs b/crates/agent_ui/src/agent_configuration.rs index 5351085795f3ea88b0f50c0da80c98edb8dc6b36..8a9dcf8f9f68a405aef8728d29a315d0d82dd508 100644 --- a/crates/agent_ui/src/agent_configuration.rs +++ b/crates/agent_ui/src/agent_configuration.rs @@ -15,6 +15,7 @@ use context_server::ContextServerId; use editor::{Editor, MultiBufferOffset, SelectionEffects, scroll::Autoscroll}; use extension::ExtensionManifest; use extension_host::ExtensionStore; +use feature_flags::{AcpBetaFeatureFlag, FeatureFlagAppExt as _}; use fs::Fs; use gpui::{ Action, AnyView, App, AsyncWindowContext, Corner, Entity, EventEmitter, FocusHandle, Focusable, @@ -29,7 +30,8 @@ use language_models::AllLanguageModelSettings; use notifications::status_toast::{StatusToast, ToastIcon}; use project::{ agent_server_store::{ - AgentServerStore, CLAUDE_CODE_NAME, CODEX_NAME, ExternalAgentServerName, GEMINI_NAME, + AgentServerStore, CLAUDE_CODE_NAME, CODEX_NAME, ExternalAgentServerName, + ExternalAgentSource, GEMINI_NAME, }, context_server_store::{ContextServerConfiguration, ContextServerStatus, ContextServerStore}, }; @@ -980,7 +982,8 @@ impl AgentConfiguration { let display_name = agent_server_store .agent_display_name(&name) .unwrap_or_else(|| name.0.clone()); - (name, icon, display_name) + let source = agent_server_store.agent_source(&name).unwrap_or_default(); + (name, icon, display_name, source) }) .collect(); @@ -996,7 +999,14 @@ impl AgentConfiguration { ) .menu({ move |window, cx| { - Some(ContextMenu::build(window, cx, |menu, _window, _cx| { + Some(ContextMenu::build(window, cx, |mut menu, _window, _cx| { + if _cx.has_flag::() { + menu = menu.entry("Install from Registry", None, { + |window, cx| { + window.dispatch_action(Box::new(zed_actions::AgentRegistry), cx) + } + }); + } menu.entry("Install from Extensions", None, { |window, cx| { window.dispatch_action( @@ -1088,7 +1098,7 @@ impl AgentConfiguration { AgentIcon::Name(IconName::AiClaude), "Claude Code", "Claude Code", - false, + ExternalAgentSource::Builtin, cx, )) .child(Divider::horizontal().color(DividerColor::BorderFaded)) @@ -1096,7 +1106,7 @@ impl AgentConfiguration { AgentIcon::Name(IconName::AiOpenAi), "Codex CLI", "Codex CLI", - false, + ExternalAgentSource::Builtin, cx, )) .child(Divider::horizontal().color(DividerColor::BorderFaded)) @@ -1104,11 +1114,11 @@ impl AgentConfiguration { AgentIcon::Name(IconName::AiGemini), "Gemini CLI", "Gemini CLI", - false, + ExternalAgentSource::Builtin, cx, )) .map(|mut parent| { - for (name, icon, display_name) in user_defined_agents { + for (name, icon, display_name, source) in user_defined_agents { parent = parent .child( Divider::horizontal().color(DividerColor::BorderFaded), @@ -1117,7 +1127,7 @@ impl AgentConfiguration { icon, name, display_name, - true, + source, cx, )); } @@ -1132,7 +1142,7 @@ impl AgentConfiguration { icon: AgentIcon, id: impl Into, display_name: impl Into, - external: bool, + source: ExternalAgentSource, cx: &mut Context, ) -> impl IntoElement { let id = id.into(); @@ -1147,30 +1157,79 @@ impl AgentConfiguration { .color(Color::Muted), }; - let tooltip_id = SharedString::new(format!("agent-source-{}", id)); - let tooltip_message = format!( - "The {} agent was installed from an extension.", - display_name - ); + let source_badge = match source { + ExternalAgentSource::Extension => Some(( + SharedString::new(format!("agent-source-{}", id)), + SharedString::from(format!( + "The {} agent was installed from an extension.", + display_name + )), + IconName::ZedSrcExtension, + )), + ExternalAgentSource::Registry => Some(( + SharedString::new(format!("agent-source-{}", id)), + SharedString::from(format!( + "The {} agent was installed from the ACP registry.", + display_name + )), + IconName::AcpRegistry, + )), + ExternalAgentSource::Builtin | ExternalAgentSource::Custom => None, + }; let agent_server_name = ExternalAgentServerName(id.clone()); - let uninstall_btn_id = SharedString::from(format!("uninstall-{}", id)); - let uninstall_button = IconButton::new(uninstall_btn_id, IconName::Trash) - .icon_color(Color::Muted) - .icon_size(IconSize::Small) - .tooltip(Tooltip::text("Uninstall Agent Extension")) - .on_click(cx.listener(move |this, _, _window, cx| { - let agent_name = agent_server_name.clone(); - - if let Some(ext_id) = this.agent_server_store.update(cx, |store, _cx| { - store.get_extension_id_for_agent(&agent_name) - }) { - ExtensionStore::global(cx) - .update(cx, |store, cx| store.uninstall_extension(ext_id, cx)) - .detach_and_log_err(cx); - } - })); + let uninstall_button = match source { + ExternalAgentSource::Extension => Some( + IconButton::new( + SharedString::from(format!("uninstall-{}", id)), + IconName::Trash, + ) + .icon_color(Color::Muted) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Uninstall Agent Extension")) + .on_click(cx.listener(move |this, _, _window, cx| { + let agent_name = agent_server_name.clone(); + + if let Some(ext_id) = this.agent_server_store.update(cx, |store, _cx| { + store.get_extension_id_for_agent(&agent_name) + }) { + ExtensionStore::global(cx) + .update(cx, |store, cx| store.uninstall_extension(ext_id, cx)) + .detach_and_log_err(cx); + } + })), + ), + ExternalAgentSource::Registry => { + let fs = self.fs.clone(); + Some( + IconButton::new( + SharedString::from(format!("uninstall-{}", id)), + IconName::Trash, + ) + .icon_color(Color::Muted) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Remove Registry Agent")) + .on_click(cx.listener(move |_, _, _window, cx| { + let agent_name = agent_server_name.clone(); + update_settings_file(fs.clone(), cx, move |settings, _| { + let Some(agent_servers) = settings.agent_servers.as_mut() else { + return; + }; + if let Some(entry) = agent_servers.custom.get(agent_name.0.as_ref()) + && matches!( + entry, + settings::CustomAgentServerSettings::Registry { .. } + ) + { + agent_servers.custom.remove(agent_name.0.as_ref()); + } + }); + })), + ) + } + ExternalAgentSource::Builtin | ExternalAgentSource::Custom => None, + }; h_flex() .gap_1() @@ -1180,17 +1239,13 @@ impl AgentConfiguration { .gap_1p5() .child(icon) .child(Label::new(display_name)) - .when(external, |this| { + .when_some(source_badge, |this, (tooltip_id, tooltip_message, icon)| { this.child( div() .id(tooltip_id) .flex_none() .tooltip(Tooltip::text(tooltip_message)) - .child( - Icon::new(IconName::ZedSrcExtension) - .size(IconSize::Small) - .color(Color::Muted), - ), + .child(Icon::new(icon).size(IconSize::Small).color(Color::Muted)), ) }) .child( @@ -1199,7 +1254,9 @@ impl AgentConfiguration { .size(IconSize::Small), ), ) - .when(external, |this| this.child(uninstall_button)) + .when_some(uninstall_button, |this, uninstall_button| { + this.child(uninstall_button) + }) } } diff --git a/crates/agent_ui/src/agent_registry_ui.rs b/crates/agent_ui/src/agent_registry_ui.rs new file mode 100644 index 0000000000000000000000000000000000000000..da0f291fa830252456f320cd6a7aa4e3d5531b40 --- /dev/null +++ b/crates/agent_ui/src/agent_registry_ui.rs @@ -0,0 +1,632 @@ +use std::ops::Range; + +use collections::HashMap; +use editor::{Editor, EditorElement, EditorStyle}; +use fs::Fs; +use gpui::{ + AnyElement, App, Context, Entity, EventEmitter, Focusable, KeyContext, ParentElement, Render, + RenderOnce, SharedString, Styled, TextStyle, UniformListScrollHandle, Window, point, + uniform_list, +}; +use project::agent_server_store::{AllAgentServersSettings, CustomAgentServerSettings}; +use project::{AgentRegistryStore, RegistryAgent}; +use settings::{Settings, SettingsStore, update_settings_file}; +use theme::ThemeSettings; +use ui::{ + ButtonStyle, ScrollableHandle, ToggleButtonGroup, ToggleButtonGroupSize, + ToggleButtonGroupStyle, ToggleButtonSimple, Tooltip, WithScrollbar, prelude::*, +}; +use workspace::{ + Workspace, + item::{Item, ItemEvent}, +}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum RegistryFilter { + All, + Installed, + NotInstalled, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum RegistryInstallStatus { + NotInstalled, + InstalledRegistry, + InstalledCustom, + InstalledExtension, +} + +#[derive(IntoElement)] +struct AgentRegistryCard { + children: Vec, +} + +impl AgentRegistryCard { + fn new() -> Self { + Self { + children: Vec::new(), + } + } +} + +impl ParentElement for AgentRegistryCard { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements) + } +} + +impl RenderOnce for AgentRegistryCard { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + div().w_full().child( + v_flex() + .mt_4() + .w_full() + .min_h(rems_from_px(110.)) + .p_3() + .gap_2() + .bg(cx.theme().colors().elevated_surface_background.opacity(0.5)) + .border_1() + .border_color(cx.theme().colors().border_variant) + .rounded_md() + .children(self.children), + ) + } +} + +pub struct AgentRegistryPage { + registry_store: Entity, + list: UniformListScrollHandle, + registry_agents: Vec, + filtered_registry_indices: Vec, + installed_statuses: HashMap, + query_editor: Entity, + filter: RegistryFilter, + _subscriptions: Vec, +} + +impl AgentRegistryPage { + pub fn new( + _workspace: &Workspace, + window: &mut Window, + cx: &mut Context, + ) -> Entity { + cx.new(|cx| { + let registry_store = AgentRegistryStore::global(cx); + let query_editor = cx.new(|cx| { + let mut input = Editor::single_line(window, cx); + input.set_placeholder_text("Search agents...", window, cx); + input + }); + cx.subscribe(&query_editor, Self::on_query_change).detach(); + + let mut subscriptions = Vec::new(); + subscriptions.push(cx.observe(®istry_store, |this, _, cx| { + this.reload_registry_agents(cx); + })); + subscriptions.push(cx.observe_global::(|this, cx| { + this.filter_registry_agents(cx); + })); + + let mut this = Self { + registry_store, + list: UniformListScrollHandle::new(), + registry_agents: Vec::new(), + filtered_registry_indices: Vec::new(), + installed_statuses: HashMap::default(), + query_editor, + filter: RegistryFilter::All, + _subscriptions: subscriptions, + }; + + this.reload_registry_agents(cx); + this.registry_store + .update(cx, |store, cx| store.refresh(cx)); + + this + }) + } + + fn reload_registry_agents(&mut self, cx: &mut Context) { + self.registry_agents = self.registry_store.read(cx).agents().to_vec(); + self.registry_agents.sort_by(|left, right| { + left.name + .as_ref() + .cmp(right.name.as_ref()) + .then_with(|| left.id.as_ref().cmp(right.id.as_ref())) + }); + self.filter_registry_agents(cx); + } + + fn refresh_installed_statuses(&mut self, cx: &mut Context) { + let settings = cx + .global::() + .get::(None); + self.installed_statuses.clear(); + for (id, settings) in &settings.custom { + let status = match settings { + CustomAgentServerSettings::Registry { .. } => { + RegistryInstallStatus::InstalledRegistry + } + CustomAgentServerSettings::Custom { .. } => RegistryInstallStatus::InstalledCustom, + CustomAgentServerSettings::Extension { .. } => { + RegistryInstallStatus::InstalledExtension + } + }; + self.installed_statuses.insert(id.clone(), status); + } + } + + fn install_status(&self, id: &str) -> RegistryInstallStatus { + self.installed_statuses + .get(id) + .copied() + .unwrap_or(RegistryInstallStatus::NotInstalled) + } + + fn search_query(&self, cx: &mut App) -> Option { + let search = self.query_editor.read(cx).text(cx); + if search.trim().is_empty() { + None + } else { + Some(search) + } + } + + fn filter_registry_agents(&mut self, cx: &mut Context) { + self.refresh_installed_statuses(cx); + let search = self.search_query(cx).map(|search| search.to_lowercase()); + let filter = self.filter; + let installed_statuses = self.installed_statuses.clone(); + + let filtered_indices = self + .registry_agents + .iter() + .enumerate() + .filter(|(_, agent)| { + let matches_search = search.as_ref().is_none_or(|query| { + let query = query.as_str(); + agent.id.as_ref().to_lowercase().contains(query) + || agent.name.as_ref().to_lowercase().contains(query) + || agent.description.as_ref().to_lowercase().contains(query) + }); + + let install_status = installed_statuses + .get(agent.id.as_ref()) + .copied() + .unwrap_or(RegistryInstallStatus::NotInstalled); + let matches_filter = match filter { + RegistryFilter::All => true, + RegistryFilter::Installed => { + install_status != RegistryInstallStatus::NotInstalled + } + RegistryFilter::NotInstalled => { + install_status == RegistryInstallStatus::NotInstalled + } + }; + + matches_search && matches_filter + }) + .map(|(index, _)| index) + .collect(); + + self.filtered_registry_indices = filtered_indices; + + cx.notify(); + } + + fn scroll_to_top(&mut self, cx: &mut Context) { + self.list.set_offset(point(px(0.), px(0.))); + cx.notify(); + } + + fn on_query_change( + &mut self, + _: Entity, + event: &editor::EditorEvent, + cx: &mut Context, + ) { + if let editor::EditorEvent::Edited { .. } = event { + self.filter_registry_agents(cx); + self.scroll_to_top(cx); + } + } + + fn render_search(&self, cx: &mut Context) -> Div { + let mut key_context = KeyContext::new_with_defaults(); + key_context.add("BufferSearchBar"); + + h_flex() + .key_context(key_context) + .h_8() + .min_w(rems_from_px(384.)) + .flex_1() + .pl_1p5() + .pr_2() + .gap_2() + .border_1() + .border_color(cx.theme().colors().border) + .rounded_md() + .child(Icon::new(IconName::MagnifyingGlass).color(Color::Muted)) + .child(self.render_text_input(&self.query_editor, cx)) + } + + fn render_text_input( + &self, + editor: &Entity, + cx: &mut Context, + ) -> impl IntoElement { + let settings = ThemeSettings::get_global(cx); + let text_style = TextStyle { + color: if editor.read(cx).read_only(cx) { + cx.theme().colors().text_disabled + } else { + cx.theme().colors().text + }, + font_family: settings.ui_font.family.clone(), + font_features: settings.ui_font.features.clone(), + font_fallbacks: settings.ui_font.fallbacks.clone(), + font_size: rems(0.875).into(), + font_weight: settings.ui_font.weight, + line_height: relative(1.3), + ..Default::default() + }; + + EditorElement::new( + editor, + EditorStyle { + background: cx.theme().colors().editor_background, + local_player: cx.theme().players().local(), + text: text_style, + ..Default::default() + }, + ) + } + + fn render_empty_state(&self, cx: &mut Context) -> impl IntoElement { + let has_search = self.search_query(cx).is_some(); + let registry_store = self.registry_store.read(cx); + + let message = if registry_store.is_fetching() { + "Loading registry..." + } else if registry_store.fetch_error().is_some() { + "Failed to load the agent registry. Please check your connection and try again." + } else { + match self.filter { + RegistryFilter::All => { + if has_search { + "No agents match your search." + } else { + "No agents available." + } + } + RegistryFilter::Installed => { + if has_search { + "No installed agents match your search." + } else { + "No installed agents." + } + } + RegistryFilter::NotInstalled => { + if has_search { + "No uninstalled agents match your search." + } else { + "No uninstalled agents." + } + } + } + }; + + h_flex() + .py_4() + .gap_1p5() + .when(registry_store.fetch_error().is_some(), |this| { + this.child( + Icon::new(IconName::Warning) + .size(IconSize::Small) + .color(Color::Warning), + ) + }) + .child(Label::new(message)) + } + + fn render_agents( + &mut self, + range: Range, + _: &mut Window, + cx: &mut Context, + ) -> Vec { + range + .map(|index| { + let Some(agent_index) = self.filtered_registry_indices.get(index).copied() else { + return self.render_missing_agent(); + }; + let Some(agent) = self.registry_agents.get(agent_index) else { + return self.render_missing_agent(); + }; + self.render_registry_agent(agent, cx) + }) + .collect() + } + + fn render_missing_agent(&self) -> AgentRegistryCard { + AgentRegistryCard::new().child( + Label::new("Missing registry entry.") + .size(LabelSize::Small) + .color(Color::Muted), + ) + } + + fn render_registry_agent( + &self, + agent: &RegistryAgent, + cx: &mut Context, + ) -> AgentRegistryCard { + let install_status = self.install_status(agent.id.as_ref()); + let supports_current_platform = agent.supports_current_platform; + + let icon = match agent.icon_path.as_ref() { + Some(icon_path) => Icon::from_external_svg(icon_path.clone()), + None => Icon::new(IconName::Sparkle), + } + .size(IconSize::Medium) + .color(Color::Muted); + + let install_button = + self.install_button(agent, install_status, supports_current_platform, cx); + + let repository_button = agent.repository.as_ref().map(|repository| { + let repository = repository.clone(); + IconButton::new( + SharedString::from(format!("agent-repo-{}", agent.id)), + IconName::Link, + ) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Visit agent repository")) + .on_click(move |_, _, cx| { + cx.open_url(repository.as_ref()); + }) + }); + + AgentRegistryCard::new() + .child( + h_flex() + .justify_between() + .items_start() + .child( + h_flex().gap_2().items_center().child(icon).child( + v_flex().gap_0p5().child( + h_flex() + .gap_2() + .items_end() + .child( + Headline::new(agent.name.clone()).size(HeadlineSize::Small), + ) + .child( + Headline::new(format!("v{}", agent.version)) + .size(HeadlineSize::XSmall), + ), + ), + ), + ) + .child(install_button), + ) + .child( + h_flex() + .gap_2() + .justify_between() + .child( + Label::new(agent.description.clone()) + .size(LabelSize::Small) + .color(Color::Default) + .truncate(), + ) + .when_some(repository_button, |this, button| this.child(button)), + ) + .child( + h_flex() + .gap_2() + .justify_between() + .child( + Label::new(format!("ID: {}", agent.id)) + .size(LabelSize::Small) + .color(Color::Muted) + .truncate(), + ) + .when(!supports_current_platform, |this| { + this.child( + Label::new("Not supported on this platform") + .size(LabelSize::Small) + .color(Color::Warning), + ) + }), + ) + } + + fn install_button( + &self, + agent: &RegistryAgent, + install_status: RegistryInstallStatus, + supports_current_platform: bool, + cx: &mut Context, + ) -> Button { + let button_id = SharedString::from(format!("install-agent-{}", agent.id)); + + if !supports_current_platform { + return Button::new(button_id, "Unavailable") + .style(ButtonStyle::OutlinedGhost) + .disabled(true); + } + + match install_status { + RegistryInstallStatus::NotInstalled => { + let fs = ::global(cx); + let agent_id = agent.id.to_string(); + Button::new(button_id, "Install") + .style(ButtonStyle::Tinted(ui::TintColor::Accent)) + .icon(IconName::Download) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .icon_position(IconPosition::Start) + .on_click(move |_, _, cx| { + let agent_id = agent_id.clone(); + update_settings_file(fs.clone(), cx, move |settings, _| { + let agent_servers = settings.agent_servers.get_or_insert_default(); + agent_servers.custom.entry(agent_id).or_insert_with(|| { + settings::CustomAgentServerSettings::Registry { + default_mode: None, + default_model: None, + favorite_models: Vec::new(), + default_config_options: HashMap::default(), + favorite_config_option_values: HashMap::default(), + } + }); + }); + }) + } + RegistryInstallStatus::InstalledRegistry => { + let fs = ::global(cx); + let agent_id = agent.id.to_string(); + Button::new(button_id, "Remove") + .style(ButtonStyle::OutlinedGhost) + .on_click(move |_, _, cx| { + let agent_id = agent_id.clone(); + update_settings_file(fs.clone(), cx, move |settings, _| { + let Some(agent_servers) = settings.agent_servers.as_mut() else { + return; + }; + if let Some(entry) = agent_servers.custom.get(agent_id.as_str()) + && matches!( + entry, + settings::CustomAgentServerSettings::Registry { .. } + ) + { + agent_servers.custom.remove(agent_id.as_str()); + } + }); + }) + } + RegistryInstallStatus::InstalledCustom => Button::new(button_id, "Installed") + .style(ButtonStyle::OutlinedGhost) + .disabled(true), + RegistryInstallStatus::InstalledExtension => Button::new(button_id, "Installed") + .style(ButtonStyle::OutlinedGhost) + .disabled(true), + } + } +} + +impl Render for AgentRegistryPage { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + v_flex() + .size_full() + .bg(cx.theme().colors().editor_background) + .child( + v_flex() + .gap_4() + .pt_4() + .px_4() + .bg(cx.theme().colors().editor_background) + .child( + h_flex() + .w_full() + .gap_1p5() + .justify_between() + .child(Headline::new("ACP Agent Registry").size(HeadlineSize::XLarge)), + ) + .child( + h_flex() + .w_full() + .flex_wrap() + .gap_2() + .child(self.render_search(cx)) + .child( + div().child( + ToggleButtonGroup::single_row( + "registry-filter-buttons", + [ + ToggleButtonSimple::new( + "All", + cx.listener(|this, _event, _, cx| { + this.filter = RegistryFilter::All; + this.filter_registry_agents(cx); + this.scroll_to_top(cx); + }), + ), + ToggleButtonSimple::new( + "Installed", + cx.listener(|this, _event, _, cx| { + this.filter = RegistryFilter::Installed; + this.filter_registry_agents(cx); + this.scroll_to_top(cx); + }), + ), + ToggleButtonSimple::new( + "Not Installed", + cx.listener(|this, _event, _, cx| { + this.filter = RegistryFilter::NotInstalled; + this.filter_registry_agents(cx); + this.scroll_to_top(cx); + }), + ), + ], + ) + .style(ToggleButtonGroupStyle::Outlined) + .size(ToggleButtonGroupSize::Custom(rems_from_px(30.))) + .label_size(LabelSize::Default) + .auto_width() + .selected_index(match self.filter { + RegistryFilter::All => 0, + RegistryFilter::Installed => 1, + RegistryFilter::NotInstalled => 2, + }) + .into_any_element(), + ), + ), + ), + ) + .child(v_flex().px_4().size_full().overflow_y_hidden().map(|this| { + let count = self.filtered_registry_indices.len(); + if count == 0 { + this.child(self.render_empty_state(cx)).into_any_element() + } else { + let scroll_handle = &self.list; + this.child( + uniform_list("registry-entries", count, cx.processor(Self::render_agents)) + .flex_grow() + .pb_4() + .track_scroll(scroll_handle), + ) + .vertical_scrollbar_for(scroll_handle, window, cx) + .into_any_element() + } + })) + } +} + +impl EventEmitter for AgentRegistryPage {} + +impl Focusable for AgentRegistryPage { + fn focus_handle(&self, cx: &App) -> gpui::FocusHandle { + self.query_editor.read(cx).focus_handle(cx) + } +} + +impl Item for AgentRegistryPage { + type Event = ItemEvent; + + fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString { + "ACP Agent Registry".into() + } + + fn telemetry_event_text(&self) -> Option<&'static str> { + Some("ACP Agent Registry Page Opened") + } + + fn show_toolbar(&self) -> bool { + false + } + + fn to_item_events(event: &Self::Event, mut f: impl FnMut(workspace::item::ItemEvent)) { + f(*event) + } +} diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index 6d8648c9c418b2eda775c671b64a28eb6937dc10..ceb159cbc287fd2cdb82c00cf70c2f4865b49cd9 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -3,6 +3,7 @@ mod agent_configuration; mod agent_diff; mod agent_model_selector; mod agent_panel; +mod agent_registry_ui; mod buffer_codegen; mod completion_provider; mod context; @@ -28,9 +29,9 @@ use agent_settings::{AgentProfileId, AgentSettings}; use assistant_slash_command::SlashCommandRegistry; use client::Client; use command_palette_hooks::CommandPaletteFilter; -use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt as _}; +use feature_flags::{AcpBetaFeatureFlag, AgentV2FeatureFlag, FeatureFlagAppExt as _}; use fs::Fs; -use gpui::{Action, App, Entity, SharedString, actions}; +use gpui::{Action, App, Context, Entity, SharedString, Window, actions}; use language::{ LanguageRegistry, language_settings::{AllLanguageSettings, EditPredictionProvider}, @@ -44,9 +45,11 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use settings::{LanguageModelSelection, Settings as _, SettingsStore}; use std::any::TypeId; +use workspace::Workspace; use crate::agent_configuration::{ConfigureContextServerModal, ManageProfilesModal}; pub use crate::agent_panel::{AgentPanel, ConcreteAssistantPanelDelegate}; +use crate::agent_registry_ui::AgentRegistryPage; pub use crate::inline_assistant::InlineAssistant; pub use agent_diff::{AgentDiffPane, AgentDiffToolbar}; pub use text_thread_editor::{AgentPanelDelegate, TextThreadEditor}; @@ -267,6 +270,37 @@ pub fn init( ConfigureContextServerModal::register(workspace, language_registry.clone(), window, cx) }) .detach(); + cx.observe_new(|workspace: &mut Workspace, _window, _cx| { + workspace.register_action( + move |workspace: &mut Workspace, + _: &zed_actions::AgentRegistry, + window: &mut Window, + cx: &mut Context| { + if !cx.has_flag::() { + return; + } + let existing = workspace + .active_pane() + .read(cx) + .items() + .find_map(|item| item.downcast::()); + + if let Some(existing) = existing { + workspace.activate_item(&existing, true, true, window, cx); + } else { + let registry_page = AgentRegistryPage::new(workspace, window, cx); + workspace.add_item_to_active_pane( + Box::new(registry_page), + None, + true, + window, + cx, + ); + } + }, + ); + }) + .detach(); cx.observe_new(ManageProfilesModal::register).detach(); // Update command palette filter based on AI settings diff --git a/crates/icons/src/icons.rs b/crates/icons/src/icons.rs index ea0fa5da8f19690a3c8f3c52dc0b6fae9a3221f3..386414e7d7ddc223394a91381babf2f446a48be3 100644 --- a/crates/icons/src/icons.rs +++ b/crates/icons/src/icons.rs @@ -8,6 +8,7 @@ use strum::{EnumIter, EnumString, IntoStaticStr}; )] #[strum(serialize_all = "snake_case")] pub enum IconName { + AcpRegistry, Ai, AiAnthropic, AiBedrock, diff --git a/crates/project/src/agent_registry_store.rs b/crates/project/src/agent_registry_store.rs new file mode 100644 index 0000000000000000000000000000000000000000..2a59c77fb550fe068bc0fb17271cfe954b2b07ad --- /dev/null +++ b/crates/project/src/agent_registry_store.rs @@ -0,0 +1,460 @@ +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::{Context as _, Result, bail}; +use collections::HashMap; +use fs::Fs; +use futures::AsyncReadExt; +use gpui::{App, AppContext as _, Context, Entity, Global, SharedString, Task}; +use http_client::{AsyncBody, HttpClient}; +use serde::Deserialize; + +const REGISTRY_URL: &str = + "https://github.com/agentclientprotocol/registry/releases/latest/download/registry.json"; +const REGISTRY_REFRESH_INTERVAL: Duration = Duration::from_secs(60 * 60); + +#[derive(Clone, Debug)] +pub struct RegistryAgent { + pub id: SharedString, + pub name: SharedString, + pub description: SharedString, + pub version: SharedString, + pub repository: Option, + pub icon_path: Option, + pub targets: HashMap, + pub supports_current_platform: bool, +} + +#[derive(Clone, Debug)] +pub struct RegistryTargetConfig { + pub archive: String, + pub cmd: String, + pub args: Vec, + pub sha256: Option, + pub env: HashMap, +} + +struct GlobalAgentRegistryStore(Entity); + +impl Global for GlobalAgentRegistryStore {} + +pub struct AgentRegistryStore { + fs: Arc, + http_client: Arc, + agents: Vec, + is_fetching: bool, + fetch_error: Option, + pending_refresh: Option>, + _poll_task: Task>, +} + +impl AgentRegistryStore { + pub fn init_global(cx: &mut App) -> Entity { + if let Some(store) = Self::try_global(cx) { + return store; + } + + let fs = ::global(cx); + let http_client: Arc = cx.http_client(); + + let store = cx.new(|cx| Self::new(fs, http_client, cx)); + store.update(cx, |store, cx| { + store.refresh(cx); + store.start_polling(cx); + }); + cx.set_global(GlobalAgentRegistryStore(store.clone())); + store + } + + pub fn global(cx: &App) -> Entity { + cx.global::().0.clone() + } + + pub fn try_global(cx: &App) -> Option> { + cx.try_global::() + .map(|store| store.0.clone()) + } + + pub fn agents(&self) -> &[RegistryAgent] { + &self.agents + } + + pub fn agent(&self, id: &str) -> Option<&RegistryAgent> { + self.agents.iter().find(|agent| agent.id == id) + } + + pub fn is_fetching(&self) -> bool { + self.is_fetching + } + + pub fn fetch_error(&self) -> Option { + self.fetch_error.clone() + } + + pub fn refresh(&mut self, cx: &mut Context) { + if self.pending_refresh.is_some() { + return; + } + + self.is_fetching = true; + self.fetch_error = None; + cx.notify(); + + let fs = self.fs.clone(); + let http_client = self.http_client.clone(); + + self.pending_refresh = Some(cx.spawn(async move |this, cx| { + let result = match fetch_registry_index(http_client.clone()).await { + Ok(data) => { + build_registry_agents(fs.clone(), http_client, data.index, data.raw_body, true) + .await + } + Err(error) => Err(error), + }; + + this.update(cx, |this, cx| { + this.pending_refresh = None; + this.is_fetching = false; + match result { + Ok(agents) => { + this.agents = agents; + this.fetch_error = None; + } + Err(error) => { + this.fetch_error = Some(SharedString::from(error.to_string())); + } + } + cx.notify(); + }) + .ok(); + })); + } + + fn new(fs: Arc, http_client: Arc, cx: &mut Context) -> Self { + let mut store = Self { + fs: fs.clone(), + http_client, + agents: Vec::new(), + is_fetching: false, + fetch_error: None, + pending_refresh: None, + _poll_task: Task::ready(Ok(())), + }; + + store.load_cached_registry(fs, store.http_client.clone(), cx); + + store + } + + fn load_cached_registry( + &mut self, + fs: Arc, + http_client: Arc, + cx: &mut Context, + ) { + cx.spawn(async move |this, cx| -> Result<()> { + let cache_path = registry_cache_path(); + if !fs.is_file(&cache_path).await { + return Ok(()); + } + + let bytes = fs + .load_bytes(&cache_path) + .await + .context("reading cached registry")?; + let index: RegistryIndex = + serde_json::from_slice(&bytes).context("parsing cached registry")?; + + let agents = build_registry_agents(fs, http_client, index, bytes, false).await?; + + this.update(cx, |this, cx| { + this.agents = agents; + cx.notify(); + })?; + + Ok(()) + }) + .detach_and_log_err(cx); + } + + fn start_polling(&mut self, cx: &mut Context) { + self._poll_task = cx.spawn(async move |this, cx| -> Result<()> { + loop { + this.update(cx, |this, cx| this.refresh(cx))?; + cx.background_executor() + .timer(REGISTRY_REFRESH_INTERVAL) + .await; + } + }); + } +} + +struct RegistryFetchResult { + index: RegistryIndex, + raw_body: Vec, +} + +async fn fetch_registry_index(http_client: Arc) -> Result { + let mut response = http_client + .get(REGISTRY_URL, AsyncBody::default(), true) + .await + .context("requesting ACP registry")?; + + let mut body = Vec::new(); + response + .body_mut() + .read_to_end(&mut body) + .await + .context("reading ACP registry response")?; + + if response.status().is_client_error() { + let text = String::from_utf8_lossy(body.as_slice()); + bail!( + "registry status error {}, response: {text:?}", + response.status().as_u16() + ); + } + + let index: RegistryIndex = serde_json::from_slice(&body).context("parsing ACP registry")?; + Ok(RegistryFetchResult { + index, + raw_body: body, + }) +} + +async fn build_registry_agents( + fs: Arc, + http_client: Arc, + index: RegistryIndex, + raw_body: Vec, + update_cache: bool, +) -> Result> { + let cache_dir = registry_cache_dir(); + fs.create_dir(&cache_dir).await?; + + let cache_path = cache_dir.join("registry.json"); + if update_cache { + fs.write(&cache_path, &raw_body).await?; + } + + let icons_dir = cache_dir.join("icons"); + if update_cache { + fs.create_dir(&icons_dir).await?; + } + + let current_platform = current_platform_key(); + + let mut agents = Vec::new(); + for entry in index.agents { + let Some(binary) = entry.distribution.binary.as_ref() else { + continue; + }; + + if binary.is_empty() { + continue; + } + + let mut targets = HashMap::default(); + for (platform, target) in binary.iter() { + targets.insert( + platform.clone(), + RegistryTargetConfig { + archive: target.archive.clone(), + cmd: target.cmd.clone(), + args: target.args.clone(), + sha256: None, + env: target.env.clone(), + }, + ); + } + + let supports_current_platform = current_platform + .as_ref() + .is_some_and(|platform| targets.contains_key(*platform)); + + let icon_path = resolve_icon_path( + &entry, + &icons_dir, + update_cache, + fs.clone(), + http_client.clone(), + ) + .await?; + + agents.push(RegistryAgent { + id: entry.id.into(), + name: entry.name.into(), + description: entry.description.into(), + version: entry.version.into(), + repository: entry.repository.map(Into::into), + icon_path, + targets, + supports_current_platform, + }); + } + + Ok(agents) +} + +async fn resolve_icon_path( + entry: &RegistryEntry, + icons_dir: &Path, + update_cache: bool, + fs: Arc, + http_client: Arc, +) -> Result> { + let icon_url = resolve_icon_url(entry); + let Some(icon_url) = icon_url else { + return Ok(None); + }; + + let icon_path = icons_dir.join(format!("{}.svg", entry.id)); + if update_cache && !fs.is_file(&icon_path).await { + if let Err(error) = download_icon(fs.clone(), http_client, &icon_url, entry).await { + log::warn!( + "Failed to download ACP registry icon for {}: {error:#}", + entry.id + ); + } + } + + if fs.is_file(&icon_path).await { + Ok(Some(SharedString::from( + icon_path.to_string_lossy().into_owned(), + ))) + } else { + Ok(None) + } +} + +async fn download_icon( + fs: Arc, + http_client: Arc, + icon_url: &str, + entry: &RegistryEntry, +) -> Result<()> { + let mut response = http_client + .get(icon_url, AsyncBody::default(), true) + .await + .with_context(|| format!("requesting icon for {}", entry.id))?; + + let mut body = Vec::new(); + response + .body_mut() + .read_to_end(&mut body) + .await + .with_context(|| format!("reading icon for {}", entry.id))?; + + if response.status().is_client_error() { + let text = String::from_utf8_lossy(body.as_slice()); + bail!( + "icon status error {}, response: {text:?}", + response.status().as_u16() + ); + } + + let icon_path = registry_cache_dir() + .join("icons") + .join(format!("{}.svg", entry.id)); + fs.write(&icon_path, &body).await?; + Ok(()) +} + +fn resolve_icon_url(entry: &RegistryEntry) -> Option { + let icon = entry.icon.as_ref()?; + if icon.starts_with("https://") || icon.starts_with("http://") { + return Some(icon.to_string()); + } + + let relative_icon = icon.trim_start_matches("./"); + Some(format!( + "https://raw.githubusercontent.com/agentclientprotocol/registry/main/{}/{relative_icon}", + entry.id + )) +} + +fn current_platform_key() -> Option<&'static str> { + let os = if cfg!(target_os = "macos") { + "darwin" + } else if cfg!(target_os = "linux") { + "linux" + } else if cfg!(target_os = "windows") { + "windows" + } else { + return None; + }; + + let arch = if cfg!(target_arch = "aarch64") { + "aarch64" + } else if cfg!(target_arch = "x86_64") { + "x86_64" + } else { + return None; + }; + + Some(match os { + "darwin" => match arch { + "aarch64" => "darwin-aarch64", + "x86_64" => "darwin-x86_64", + _ => return None, + }, + "linux" => match arch { + "aarch64" => "linux-aarch64", + "x86_64" => "linux-x86_64", + _ => return None, + }, + "windows" => match arch { + "aarch64" => "windows-aarch64", + "x86_64" => "windows-x86_64", + _ => return None, + }, + _ => return None, + }) +} + +fn registry_cache_dir() -> PathBuf { + paths::external_agents_dir().join("registry") +} + +fn registry_cache_path() -> PathBuf { + registry_cache_dir().join("registry.json") +} + +#[derive(Deserialize)] +struct RegistryIndex { + #[serde(rename = "version")] + _version: String, + agents: Vec, + #[serde(rename = "extensions")] + _extensions: Vec, +} + +#[derive(Deserialize)] +struct RegistryEntry { + id: String, + name: String, + version: String, + description: String, + #[serde(default)] + repository: Option, + #[serde(default)] + icon: Option, + distribution: RegistryDistribution, +} + +#[derive(Deserialize)] +struct RegistryDistribution { + #[serde(default)] + binary: Option>, +} + +#[derive(Deserialize)] +struct RegistryBinaryTarget { + archive: String, + cmd: String, + #[serde(default)] + args: Vec, + #[serde(default)] + env: HashMap, +} diff --git a/crates/project/src/agent_server_store.rs b/crates/project/src/agent_server_store.rs index e9ff05403bde91b67904942db8cb5d5e17fa4817..5200a92e9e31488d1bbdd9684552b709000cc133 100644 --- a/crates/project/src/agent_server_store.rs +++ b/crates/project/src/agent_server_store.rs @@ -30,6 +30,7 @@ use task::{Shell, SpawnInTerminal}; use util::{ResultExt as _, debug_panic}; use crate::ProjectEnvironment; +use crate::agent_registry_store::{AgentRegistryStore, RegistryTargetConfig}; #[derive(Deserialize, Serialize, Clone, PartialEq, Eq, JsonSchema)] pub struct AgentServerCommand { @@ -92,6 +93,15 @@ impl Borrow for ExternalAgentServerName { } } +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum ExternalAgentSource { + Builtin, + #[default] + Custom, + Extension, + Registry, +} + pub trait ExternalAgentServer { fn get_command( &mut self, @@ -125,8 +135,9 @@ enum AgentServerStoreState { HashMap, HashMap, Option, + Option, )>, - _subscriptions: [Subscription; 1], + _subscriptions: Vec, }, Remote { project_id: u64, @@ -135,11 +146,32 @@ enum AgentServerStoreState { Collab, } +struct ExternalAgentEntry { + server: Box, + icon: Option, + display_name: Option, + source: ExternalAgentSource, +} + +impl ExternalAgentEntry { + fn new( + server: Box, + source: ExternalAgentSource, + icon: Option, + display_name: Option, + ) -> Self { + Self { + server, + icon, + display_name, + source, + } + } +} + pub struct AgentServerStore { state: AgentServerStoreState, - external_agents: HashMap>, - agent_icons: HashMap, - agent_display_names: HashMap, + external_agents: HashMap, } pub struct AgentServersUpdated; @@ -157,8 +189,6 @@ mod ext_agent_tests { AgentServerStore { state: AgentServerStoreState::Collab, external_agents: HashMap::default(), - agent_icons: HashMap::default(), - agent_display_names: HashMap::default(), } } @@ -205,15 +235,30 @@ mod ext_agent_tests { // 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, + ExternalAgentEntry::new( + Box::new(NoopExternalAgent) as Box, + ExternalAgentSource::Custom, + None, + None, + ), ); store.external_agents.insert( ExternalAgentServerName(SharedString::from("bar-agent")), - Box::new(NoopExternalAgent) as Box, + ExternalAgentEntry::new( + Box::new(NoopExternalAgent) as Box, + ExternalAgentSource::Custom, + None, + None, + ), ); store.external_agents.insert( ExternalAgentServerName(SharedString::from("custom")), - Box::new(NoopExternalAgent) as Box, + ExternalAgentEntry::new( + Box::new(NoopExternalAgent) as Box, + ExternalAgentSource::Custom, + None, + None, + ), ); // Simulate the removal phase: if we're syncing extensions that provide @@ -376,17 +421,8 @@ impl AgentServerStore { // Remove all extension-provided agents // (They will be re-added below if they're in the currently installed extensions) - self.external_agents.retain(|name, agent| { - if agent.downcast_mut::().is_some() { - self.agent_icons.remove(name); - self.agent_display_names.remove(name); - false - } else { - // Keep the hardcoded external agents that don't come from extensions - // (In the future we may move these over to being extensions too.) - true - } - }); + self.external_agents + .retain(|_, entry| entry.source != ExternalAgentSource::Extension); // Insert agent servers from extension manifests match &mut self.state { @@ -396,27 +432,10 @@ impl AgentServerStore { extension_agents.clear(); for (ext_id, manifest) in manifests { for (agent_name, agent_entry) in &manifest.agent_servers { - // Store display name from manifest - self.agent_display_names.insert( - ExternalAgentServerName(agent_name.clone().into()), - SharedString::from(agent_entry.name.clone()), - ); - - let icon_path = if let Some(icon) = &agent_entry.icon { - if let Some(absolute_icon_path) = - resolve_extension_icon_path(&extensions_dir, ext_id, icon) - { - self.agent_icons.insert( - ExternalAgentServerName(agent_name.clone().into()), - SharedString::from(absolute_icon_path.clone()), - ); - Some(absolute_icon_path) - } else { - None - } - } else { - None - }; + let display_name = SharedString::from(agent_entry.name.clone()); + let icon_path = agent_entry.icon.as_ref().and_then(|icon| { + resolve_extension_icon_path(&extensions_dir, ext_id, icon) + }); extension_agents.push(( agent_name.clone(), @@ -424,6 +443,7 @@ impl AgentServerStore { agent_entry.targets.clone(), agent_entry.env.clone(), icon_path, + Some(display_name), )); } } @@ -436,27 +456,22 @@ impl AgentServerStore { let mut agents = vec![]; for (ext_id, manifest) in manifests { for (agent_name, agent_entry) in &manifest.agent_servers { - // Store display name from manifest - self.agent_display_names.insert( - ExternalAgentServerName(agent_name.clone().into()), - SharedString::from(agent_entry.name.clone()), - ); - - let icon = if let Some(icon) = &agent_entry.icon { - if let Some(absolute_icon_path) = - resolve_extension_icon_path(&extensions_dir, ext_id, icon) - { - self.agent_icons.insert( - ExternalAgentServerName(agent_name.clone().into()), - SharedString::from(absolute_icon_path.clone()), - ); - Some(absolute_icon_path) - } else { - None - } - } else { - None - }; + let display_name = SharedString::from(agent_entry.name.clone()); + let icon_path = agent_entry.icon.as_ref().and_then(|icon| { + resolve_extension_icon_path(&extensions_dir, ext_id, icon) + }); + let icon_shared = icon_path + .as_ref() + .map(|path| SharedString::from(path.clone())); + let icon = icon_path; + let agent_server_name = ExternalAgentServerName(agent_name.clone().into()); + self.external_agents + .entry(agent_server_name) + .and_modify(|entry| { + entry.icon = icon_shared.clone(); + entry.display_name = Some(display_name.clone()); + entry.source = ExternalAgentSource::Extension; + }); agents.push(ExternalExtensionAgent { name: agent_name.to_string(), @@ -493,7 +508,13 @@ impl AgentServerStore { } pub fn agent_icon(&self, name: &ExternalAgentServerName) -> Option { - self.agent_icons.get(name).cloned() + self.external_agents + .get(name) + .and_then(|entry| entry.icon.clone()) + } + + pub fn agent_source(&self, name: &ExternalAgentServerName) -> Option { + self.external_agents.get(name).map(|entry| entry.source) } } @@ -539,7 +560,9 @@ fn resolve_extension_icon_path( impl AgentServerStore { pub fn agent_display_name(&self, name: &ExternalAgentServerName) -> Option { - self.agent_display_names.get(name).cloned() + self.external_agents + .get(name) + .and_then(|entry| entry.display_name.clone()) } pub fn init_remote(session: &AnyProtoClient) { @@ -601,88 +624,153 @@ impl AgentServerStore { self.external_agents.clear(); self.external_agents.insert( GEMINI_NAME.into(), - Box::new(LocalGemini { - fs: fs.clone(), - node_runtime: node_runtime.clone(), - project_environment: project_environment.clone(), - custom_command: new_settings - .gemini - .clone() - .and_then(|settings| settings.custom_command()), - settings_env: new_settings - .gemini - .as_ref() - .and_then(|settings| settings.env.clone()), - ignore_system_version: new_settings - .gemini - .as_ref() - .and_then(|settings| settings.ignore_system_version) - .unwrap_or(true), - }), + ExternalAgentEntry::new( + Box::new(LocalGemini { + fs: fs.clone(), + node_runtime: node_runtime.clone(), + project_environment: project_environment.clone(), + custom_command: new_settings + .gemini + .clone() + .and_then(|settings| settings.custom_command()), + settings_env: new_settings + .gemini + .as_ref() + .and_then(|settings| settings.env.clone()), + ignore_system_version: new_settings + .gemini + .as_ref() + .and_then(|settings| settings.ignore_system_version) + .unwrap_or(true), + }), + ExternalAgentSource::Builtin, + None, + None, + ), ); self.external_agents.insert( CODEX_NAME.into(), - Box::new(LocalCodex { - fs: fs.clone(), - project_environment: project_environment.clone(), - custom_command: new_settings - .codex - .clone() - .and_then(|settings| settings.custom_command()), - settings_env: new_settings - .codex - .as_ref() - .and_then(|settings| settings.env.clone()), - http_client: http_client.clone(), - no_browser: downstream_client - .as_ref() - .is_some_and(|(_, client)| !client.has_wsl_interop()), - }), + ExternalAgentEntry::new( + Box::new(LocalCodex { + fs: fs.clone(), + project_environment: project_environment.clone(), + custom_command: new_settings + .codex + .clone() + .and_then(|settings| settings.custom_command()), + settings_env: new_settings + .codex + .as_ref() + .and_then(|settings| settings.env.clone()), + http_client: http_client.clone(), + no_browser: downstream_client + .as_ref() + .is_some_and(|(_, client)| !client.has_wsl_interop()), + }), + ExternalAgentSource::Builtin, + None, + None, + ), ); self.external_agents.insert( CLAUDE_CODE_NAME.into(), - Box::new(LocalClaudeCode { - fs: fs.clone(), - node_runtime: node_runtime.clone(), - project_environment: project_environment.clone(), - custom_command: new_settings - .claude - .clone() - .and_then(|settings| settings.custom_command()), - settings_env: new_settings - .claude - .as_ref() - .and_then(|settings| settings.env.clone()), - }), + ExternalAgentEntry::new( + Box::new(LocalClaudeCode { + fs: fs.clone(), + node_runtime: node_runtime.clone(), + project_environment: project_environment.clone(), + custom_command: new_settings + .claude + .clone() + .and_then(|settings| settings.custom_command()), + settings_env: new_settings + .claude + .as_ref() + .and_then(|settings| settings.env.clone()), + }), + ExternalAgentSource::Builtin, + None, + None, + ), ); - self.external_agents - .extend( - new_settings - .custom + + let registry_store = AgentRegistryStore::try_global(cx); + let registry_agents_by_id = registry_store + .as_ref() + .map(|store| { + store + .read(cx) + .agents() .iter() - .filter_map(|(name, settings)| match settings { - CustomAgentServerSettings::Custom { command, .. } => Some(( - ExternalAgentServerName(name.clone().into()), + .cloned() + .map(|agent| (agent.id.to_string(), agent)) + .collect::>() + }) + .unwrap_or_default(); + + for (name, settings) in &new_settings.custom { + match settings { + CustomAgentServerSettings::Custom { command, .. } => { + let agent_name = ExternalAgentServerName(name.clone().into()); + self.external_agents.insert( + agent_name.clone(), + ExternalAgentEntry::new( Box::new(LocalCustomAgent { command: command.clone(), project_environment: project_environment.clone(), }) as Box, - )), - CustomAgentServerSettings::Extension { .. } => None, - }), - ); - self.external_agents.extend(extension_agents.iter().map( - |(agent_name, ext_id, targets, env, icon_path)| { - let name = ExternalAgentServerName(agent_name.clone().into()); - - // Restore icon if present - if let Some(icon) = icon_path { - self.agent_icons - .insert(name.clone(), SharedString::from(icon.clone())); + ExternalAgentSource::Custom, + None, + None, + ), + ); } + CustomAgentServerSettings::Registry { .. } => { + let Some(agent) = registry_agents_by_id.get(name) else { + if registry_store.is_some() { + log::warn!("Registry agent '{}' not found in ACP registry", name); + } + continue; + }; + if !agent.supports_current_platform { + log::warn!( + "Registry agent '{}' has no compatible binary for this platform", + name + ); + continue; + } - ( - name, + let agent_name = ExternalAgentServerName(name.clone().into()); + self.external_agents.insert( + agent_name.clone(), + ExternalAgentEntry::new( + Box::new(LocalRegistryArchiveAgent { + fs: fs.clone(), + http_client: http_client.clone(), + node_runtime: node_runtime.clone(), + project_environment: project_environment.clone(), + registry_id: Arc::from(name.as_str()), + targets: agent.targets.clone(), + }) as Box, + ExternalAgentSource::Registry, + agent.icon_path.clone(), + Some(agent.name.clone()), + ), + ); + } + CustomAgentServerSettings::Extension { .. } => {} + } + } + + for (agent_name, ext_id, targets, env, icon_path, display_name) in extension_agents.iter() { + let name = ExternalAgentServerName(agent_name.clone().into()); + let icon = icon_path + .as_ref() + .map(|path| SharedString::from(path.clone())); + + self.external_agents.insert( + name.clone(), + ExternalAgentEntry::new( Box::new(LocalExtensionArchiveAgent { fs: fs.clone(), http_client: http_client.clone(), @@ -693,11 +781,14 @@ impl AgentServerStore { env: env.clone(), agent_id: agent_name.clone(), }) as Box, - ) - }, - )); + ExternalAgentSource::Extension, + icon, + display_name.clone(), + ), + ); + } - *old_settings = Some(new_settings.clone()); + *old_settings = Some(new_settings); if let Some((project_id, downstream_client)) = downstream_client { downstream_client @@ -728,9 +819,14 @@ impl AgentServerStore { http_client: Arc, cx: &mut Context, ) -> Self { - let subscription = cx.observe_global::(|this, cx| { + let mut subscriptions = vec![cx.observe_global::(|this, cx| { this.agent_servers_settings_changed(cx); - }); + })]; + if let Some(registry_store) = AgentRegistryStore::try_global(cx) { + subscriptions.push(cx.observe(®istry_store, |this, _, cx| { + this.reregister_agents(cx); + })); + } let mut this = Self { state: AgentServerStoreState::Local { node_runtime, @@ -740,11 +836,9 @@ impl AgentServerStore { downstream_client: None, settings: None, extension_agents: vec![], - _subscriptions: [subscription], + _subscriptions: subscriptions, }, external_agents: Default::default(), - agent_icons: Default::default(), - agent_display_names: Default::default(), }; if let Some(_events) = extension::ExtensionEvents::try_global(cx) {} this.agent_servers_settings_changed(cx); @@ -755,36 +849,51 @@ 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: [(ExternalAgentServerName, Box); 3] = [ + let external_agents: [(ExternalAgentServerName, ExternalAgentEntry); 3] = [ ( CLAUDE_CODE_NAME.into(), - Box::new(RemoteExternalAgentServer { - project_id, - upstream_client: upstream_client.clone(), - name: CLAUDE_CODE_NAME.into(), - status_tx: None, - new_version_available_tx: None, - }) as Box, + ExternalAgentEntry::new( + Box::new(RemoteExternalAgentServer { + project_id, + upstream_client: upstream_client.clone(), + name: CLAUDE_CODE_NAME.into(), + status_tx: None, + new_version_available_tx: None, + }) as Box, + ExternalAgentSource::Builtin, + None, + None, + ), ), ( CODEX_NAME.into(), - Box::new(RemoteExternalAgentServer { - project_id, - upstream_client: upstream_client.clone(), - name: CODEX_NAME.into(), - status_tx: None, - new_version_available_tx: None, - }) as Box, + ExternalAgentEntry::new( + Box::new(RemoteExternalAgentServer { + project_id, + upstream_client: upstream_client.clone(), + name: CODEX_NAME.into(), + status_tx: None, + new_version_available_tx: None, + }) as Box, + ExternalAgentSource::Builtin, + None, + None, + ), ), ( GEMINI_NAME.into(), - Box::new(RemoteExternalAgentServer { - project_id, - upstream_client: upstream_client.clone(), - name: GEMINI_NAME.into(), - status_tx: None, - new_version_available_tx: None, - }) as Box, + ExternalAgentEntry::new( + Box::new(RemoteExternalAgentServer { + project_id, + upstream_client: upstream_client.clone(), + name: GEMINI_NAME.into(), + status_tx: None, + new_version_available_tx: None, + }) as Box, + ExternalAgentSource::Builtin, + None, + None, + ), ), ]; @@ -794,8 +903,6 @@ impl AgentServerStore { upstream_client, }, external_agents: external_agents.into_iter().collect(), - agent_icons: HashMap::default(), - agent_display_names: HashMap::default(), } } @@ -803,8 +910,6 @@ impl AgentServerStore { Self { state: AgentServerStoreState::Collab, external_agents: Default::default(), - agent_icons: Default::default(), - agent_display_names: Default::default(), } } @@ -820,8 +925,7 @@ impl AgentServerStore { cx.spawn(async move |this, cx| { cx.background_executor().timer(Duration::from_secs(1)).await; let names = this.update(cx, |this, _| { - this.external_agents - .keys() + this.external_agents() .map(|name| name.to_string()) .collect() })?; @@ -849,7 +953,7 @@ impl AgentServerStore { ) -> Option<&mut (dyn ExternalAgentServer + 'static)> { self.external_agents .get_mut(name) - .map(|agent| agent.as_mut()) + .map(|entry| entry.server.as_mut()) } pub fn external_agents(&self) -> impl Iterator { @@ -873,6 +977,7 @@ impl AgentServerStore { let agent = this .external_agents .get_mut(&*envelope.payload.name) + .map(|entry| entry.server.as_mut()) .with_context(|| format!("agent `{}` not found", envelope.payload.name))?; let (status_tx, new_version_available_tx) = downstream_client .clone() @@ -956,50 +1061,59 @@ impl AgentServerStore { bail!("unexpected ExternalAgentsUpdated message") }; - let mut status_txs = this - .external_agents - .iter_mut() - .filter_map(|(name, agent)| { - Some(( - name.clone(), - agent - .downcast_mut::()? - .status_tx - .take(), - )) - }) - .collect::>(); - let mut new_version_available_txs = this - .external_agents - .iter_mut() - .filter_map(|(name, agent)| { - Some(( - name.clone(), - agent - .downcast_mut::()? - .new_version_available_tx - .take(), - )) - }) - .collect::>(); + let mut previous_entries = std::mem::take(&mut this.external_agents); + let mut status_txs = HashMap::default(); + let mut new_version_available_txs = HashMap::default(); + let mut metadata = HashMap::default(); + + for (name, mut entry) in previous_entries.drain() { + if let Some(agent) = entry.server.downcast_mut::() { + status_txs.insert(name.clone(), agent.status_tx.take()); + new_version_available_txs + .insert(name.clone(), agent.new_version_available_tx.take()); + } + + metadata.insert(name, (entry.icon, entry.display_name, entry.source)); + } this.external_agents = envelope .payload .names .into_iter() .map(|name| { + let agent_name = ExternalAgentServerName(name.clone().into()); + let fallback_source = + if name == GEMINI_NAME || name == CLAUDE_CODE_NAME || name == CODEX_NAME { + ExternalAgentSource::Builtin + } else { + ExternalAgentSource::Custom + }; + let (icon, display_name, source) = + metadata + .remove(&agent_name) + .unwrap_or((None, None, fallback_source)); + let source = if fallback_source == ExternalAgentSource::Builtin { + ExternalAgentSource::Builtin + } else { + source + }; let agent = RemoteExternalAgentServer { project_id: *project_id, upstream_client: upstream_client.clone(), - name: ExternalAgentServerName(name.clone().into()), - status_tx: status_txs.remove(&*name).flatten(), + name: agent_name.clone(), + status_tx: status_txs.remove(&agent_name).flatten(), new_version_available_tx: new_version_available_txs - .remove(&*name) + .remove(&agent_name) .flatten(), }; ( - ExternalAgentServerName(name.into()), - Box::new(agent) as Box, + agent_name, + ExternalAgentEntry::new( + Box::new(agent) as Box, + source, + icon, + display_name, + ), ) }) .collect(); @@ -1032,13 +1146,6 @@ impl AgentServerStore { env, } in envelope.payload.agents { - let icon_path_string = icon_path.clone(); - if let Some(icon_path) = icon_path { - this.agent_icons.insert( - ExternalAgentServerName(name.clone().into()), - icon_path.into(), - ); - } extension_agents.push(( Arc::from(&*name), extension_id, @@ -1047,7 +1154,8 @@ impl AgentServerStore { .map(|(k, v)| (k, extension::TargetConfig::from_proto(v))) .collect(), env.into_iter().collect(), - icon_path_string, + icon_path, + None, )); } @@ -1064,7 +1172,7 @@ impl AgentServerStore { ) -> Result<()> { this.update(&mut cx, |this, _| { if let Some(agent) = this.external_agents.get_mut(&*envelope.payload.name) - && let Some(agent) = agent.downcast_mut::() + && let Some(agent) = agent.server.downcast_mut::() && let Some(status_tx) = &mut agent.status_tx { status_tx.send(envelope.payload.status.into()).ok(); @@ -1080,7 +1188,7 @@ impl AgentServerStore { ) -> Result<()> { this.update(&mut cx, |this, _| { if let Some(agent) = this.external_agents.get_mut(&*envelope.payload.name) - && let Some(agent) = agent.downcast_mut::() + && let Some(agent) = agent.server.downcast_mut::() && let Some(new_version_available_tx) = &mut agent.new_version_available_tx { new_version_available_tx @@ -1095,8 +1203,9 @@ impl AgentServerStore { &mut self, name: &ExternalAgentServerName, ) -> Option> { - self.external_agents.get_mut(name).and_then(|agent| { - agent + self.external_agents.get_mut(name).and_then(|entry| { + entry + .server .as_any_mut() .downcast_ref::() .map(|ext_agent| ext_agent.extension_id.clone()) @@ -1785,11 +1894,6 @@ struct LocalExtensionArchiveAgent { env: HashMap, } -struct LocalCustomAgent { - project_environment: Entity, - command: AgentServerCommand, -} - impl ExternalAgentServer for LocalExtensionArchiveAgent { fn get_command( &mut self, @@ -1988,6 +2092,198 @@ impl ExternalAgentServer for LocalExtensionArchiveAgent { } } +struct LocalRegistryArchiveAgent { + fs: Arc, + http_client: Arc, + node_runtime: NodeRuntime, + project_environment: Entity, + registry_id: Arc, + targets: HashMap, +} + +impl ExternalAgentServer for LocalRegistryArchiveAgent { + 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 node_runtime = self.node_runtime.clone(); + let project_environment = self.project_environment.downgrade(); + let registry_id = self.registry_id.clone(); + let targets = self.targets.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| { + let mut env = project_environment + .update(cx, |project_environment, cx| { + project_environment.local_directory_environment( + &Shell::System, + root_dir.clone(), + cx, + ) + })? + .await + .unwrap_or_default(); + + let dir = paths::external_agents_dir() + .join("registry") + .join(registry_id.as_ref()); + fs.create_dir(&dir).await?; + + 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(", ") + ) + })?; + + env.extend(target_config.env.clone()); + env.extend(extra_env); + + let archive_url = &target_config.archive; + + 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 { + let sha256 = if let Some(provided_sha) = &target_config.sha256 { + Some(provided_sha.clone()) + } else if archive_url.starts_with("https://github.com/") { + 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("/"); + + if let Ok(release) = ::http_client::github::get_release_by_tag_name( + &repo, + tag, + http_client.clone(), + ) + .await + { + if let Some(asset) = + release.assets.iter().find(|a| a.name == filename) + { + 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 + }; + + 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); + }; + + ::http_client::github_download::download_server_binary( + &*http_client, + archive_url, + sha256.as_deref(), + &version_dir, + asset_kind, + ) + .await?; + } + + let cmd = &target_config.cmd; + + let cmd_path = if cmd == "node" { + node_runtime.binary_path().await? + } else { + if cmd.contains("..") { + anyhow::bail!("command path cannot contain '..': {}", cmd); + } + + if cmd.starts_with("./") || cmd.starts_with(".\\") { + let cmd_path = version_dir.join(&cmd[2..]); + anyhow::ensure!( + fs.is_file(&cmd_path).await, + "Missing command {} after extraction", + cmd_path.to_string_lossy() + ); + cmd_path + } else { + anyhow::bail!("command must be relative (start with './'): {}", cmd); + } + }; + + let command = AgentServerCommand { + path: cmd_path, + args: target_config.args.clone(), + env: Some(env), + }; + + Ok((command, version_dir.to_string_lossy().into_owned(), None)) + }) + } + + fn as_any_mut(&mut self) -> &mut dyn Any { + self + } +} + +struct LocalCustomAgent { + project_environment: Entity, + command: AgentServerCommand, +} + impl ExternalAgentServer for LocalCustomAgent { fn get_command( &mut self, @@ -2153,29 +2449,60 @@ pub enum CustomAgentServerSettings { /// Default: {} favorite_config_option_values: HashMap>, }, + Registry { + /// The default mode to use for this agent. + /// + /// Note: Not only all agents support modes. + /// + /// Default: None + default_mode: Option, + /// The default model to use for this agent. + /// + /// This should be the model ID as reported by the agent. + /// + /// Default: None + default_model: Option, + /// The favorite models for this agent. + /// + /// Default: [] + favorite_models: Vec, + /// Default values for session config options. + /// + /// This is a map from config option ID to value ID. + /// + /// Default: {} + default_config_options: HashMap, + /// Favorited values for session config options. + /// + /// This is a map from config option ID to a list of favorited value IDs. + /// + /// Default: {} + favorite_config_option_values: HashMap>, + }, } impl CustomAgentServerSettings { pub fn command(&self) -> Option<&AgentServerCommand> { match self { CustomAgentServerSettings::Custom { command, .. } => Some(command), - CustomAgentServerSettings::Extension { .. } => None, + CustomAgentServerSettings::Extension { .. } + | CustomAgentServerSettings::Registry { .. } => None, } } pub fn default_mode(&self) -> Option<&str> { match self { CustomAgentServerSettings::Custom { default_mode, .. } - | CustomAgentServerSettings::Extension { default_mode, .. } => default_mode.as_deref(), + | CustomAgentServerSettings::Extension { default_mode, .. } + | CustomAgentServerSettings::Registry { default_mode, .. } => default_mode.as_deref(), } } pub fn default_model(&self) -> Option<&str> { match self { CustomAgentServerSettings::Custom { default_model, .. } - | CustomAgentServerSettings::Extension { default_model, .. } => { - default_model.as_deref() - } + | CustomAgentServerSettings::Extension { default_model, .. } + | CustomAgentServerSettings::Registry { default_model, .. } => default_model.as_deref(), } } @@ -2186,6 +2513,9 @@ impl CustomAgentServerSettings { } | CustomAgentServerSettings::Extension { favorite_models, .. + } + | CustomAgentServerSettings::Registry { + favorite_models, .. } => favorite_models, } } @@ -2199,6 +2529,10 @@ impl CustomAgentServerSettings { | CustomAgentServerSettings::Extension { default_config_options, .. + } + | CustomAgentServerSettings::Registry { + default_config_options, + .. } => default_config_options.get(config_id).map(|s| s.as_str()), } } @@ -2212,6 +2546,10 @@ impl CustomAgentServerSettings { | CustomAgentServerSettings::Extension { favorite_config_option_values, .. + } + | CustomAgentServerSettings::Registry { + favorite_config_option_values, + .. } => favorite_config_option_values .get(config_id) .map(|v| v.as_slice()), @@ -2256,6 +2594,19 @@ impl From for CustomAgentServerSettings { favorite_models, favorite_config_option_values, }, + settings::CustomAgentServerSettings::Registry { + default_mode, + default_model, + default_config_options, + favorite_models, + favorite_config_option_values, + } => CustomAgentServerSettings::Registry { + default_mode, + default_model, + default_config_options, + favorite_models, + favorite_config_option_values, + }, } } } @@ -2330,35 +2681,41 @@ mod extension_agent_tests { let mut store = AgentServerStore { state: AgentServerStoreState::Collab, external_agents: HashMap::default(), - agent_icons: HashMap::default(), - agent_display_names: 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, + ExternalAgentEntry::new( + Box::new(NoopExternalAgent) as Box, + ExternalAgentSource::Extension, + None, + None, + ), ); store.external_agents.insert( ExternalAgentServerName(SharedString::from("Ext2: Agent2")), - Box::new(NoopExternalAgent) as Box, + ExternalAgentEntry::new( + Box::new(NoopExternalAgent) as Box, + ExternalAgentSource::Extension, + None, + None, + ), ); store.external_agents.insert( ExternalAgentServerName(SharedString::from("custom-agent")), - Box::new(NoopExternalAgent) as Box, + ExternalAgentEntry::new( + Box::new(NoopExternalAgent) as Box, + ExternalAgentSource::Custom, + None, + None, + ), ); // Simulate removal phase - let keys_to_remove: Vec<_> = store + store .external_agents - .keys() - .filter(|name| name.0.contains(": ")) - .cloned() - .collect(); - - for key in keys_to_remove { - store.external_agents.remove(&key); - } + .retain(|_, entry| entry.source != ExternalAgentSource::Extension); // Only custom-agent should remain assert_eq!(store.external_agents.len(), 1); diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 1f01f6b0322da38de502380c9c9c5e86a09296c7..870a578e372494c3c758772c3b5429c8e3f5bd17 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -1,3 +1,4 @@ +pub mod agent_registry_store; pub mod agent_server_store; pub mod buffer_store; mod color_extractor; @@ -43,7 +44,10 @@ use crate::{ project_search::SearchResultsHandle, trusted_worktrees::{PathTrust, RemoteHostLocation, TrustedWorktrees}, }; -pub use agent_server_store::{AgentServerStore, AgentServersUpdated, ExternalAgentServerName}; +pub use agent_registry_store::{AgentRegistryStore, RegistryAgent}; +pub use agent_server_store::{ + AgentServerStore, AgentServersUpdated, ExternalAgentServerName, ExternalAgentSource, +}; pub use git_store::{ ConflictRegion, ConflictSet, ConflictSetSnapshot, ConflictSetUpdate, git_traversal::{ChildEntriesGitIter, GitEntry, GitEntryRef, GitTraversal}, diff --git a/crates/settings/Cargo.toml b/crates/settings/Cargo.toml index a0d75e5b76fd4a0066ff606585088f61a23d19a1..27e8182d37ba1c67700d3a41dbdfc1c4ce27e4d6 100644 --- a/crates/settings/Cargo.toml +++ b/crates/settings/Cargo.toml @@ -27,7 +27,7 @@ log.workspace = true migrator.workspace = true paths.workspace = true release_channel.workspace = true -rust-embed.workspace = true +rust-embed = { workspace = true, features = ["debug-embed"] } schemars.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/settings_content/src/agent.rs b/crates/settings_content/src/agent.rs index 3b643f7e575e1d5178e2b206e4b42054333e242f..1f9e1b6f9e7c15c4d9577e0b4cbae6bc93b974a5 100644 --- a/crates/settings_content/src/agent.rs +++ b/crates/settings_content/src/agent.rs @@ -502,6 +502,41 @@ pub enum CustomAgentServerSettings { #[serde(default)] favorite_config_option_values: HashMap>, }, + Registry { + /// The default mode to use for this agent. + /// + /// Note: Not only all agents support modes. + /// + /// Default: None + default_mode: Option, + /// The default model to use for this agent. + /// + /// This should be the model ID as reported by the agent. + /// + /// Default: None + default_model: Option, + /// The favorite models for this agent. + /// + /// These are the model IDs as reported by the agent. + /// + /// Default: [] + #[serde(default)] + favorite_models: Vec, + /// Default values for session config options. + /// + /// This is a map from config option ID to value ID. + /// + /// Default: {} + #[serde(default)] + default_config_options: HashMap, + /// Favorited values for session config options. + /// + /// This is a map from config option ID to a list of favorited value IDs. + /// + /// Default: {} + #[serde(default)] + favorite_config_option_values: HashMap>, + }, } #[with_fallible_options] diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 7309ba01b86f094590ef9e8608d55622d5b50171..3e54c3f10baf73dd813720b462c5bc9c9eb52897 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -17,6 +17,7 @@ use crashes::InitCrashHandler; use db::kvp::{GLOBAL_KEY_VALUE_STORE, KEY_VALUE_STORE}; use editor::Editor; use extension::ExtensionHostProxy; +use feature_flags::{AcpBetaFeatureFlag, FeatureFlagAppExt as _}; use fs::{Fs, RealFs}; use futures::{StreamExt, channel::oneshot, future}; use git::GitHostingProviderRegistry; @@ -617,6 +618,15 @@ fn main() { snippet_provider::init(cx); edit_prediction_registry::init(app_state.client.clone(), app_state.user_store.clone(), cx); let prompt_builder = PromptBuilder::load(app_state.fs.clone(), stdout_is_a_pty(), cx); + if cx.has_flag::() { + project::AgentRegistryStore::init_global(cx); + } + cx.observe_flag::(|is_enabled, cx| { + if is_enabled { + project::AgentRegistryStore::init_global(cx); + } + }) + .detach(); agent_ui::init( app_state.fs.clone(), app_state.client.clone(), diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index c8ac687bbaad551ec7b9e533599163ea1731ecd3..2becee37d105300dc58554ef594d4e2844332a74 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -25,7 +25,7 @@ use collections::VecDeque; use debugger_ui::debugger_panel::DebugPanel; use editor::{Editor, MultiBuffer}; use extension_host::ExtensionStore; -use feature_flags::{FeatureFlagAppExt, PanicFeatureFlag}; +use feature_flags::{FeatureFlagAppExt as _, PanicFeatureFlag}; use fs::Fs; use futures::FutureExt as _; use futures::future::Either; @@ -4968,6 +4968,7 @@ mod tests { web_search::init(cx); web_search_providers::init(app_state.client.clone(), cx); let prompt_builder = PromptBuilder::load(app_state.fs.clone(), false, cx); + project::AgentRegistryStore::init_global(cx); agent_ui::init( app_state.fs.clone(), app_state.client.clone(), diff --git a/crates/zed_actions/src/lib.rs b/crates/zed_actions/src/lib.rs index a1ee7c18c13fa35c9c81b55d0be8c9f40a78fe61..cf31756fa93e27fe1594d4c5098754b2db4c9434 100644 --- a/crates/zed_actions/src/lib.rs +++ b/crates/zed_actions/src/lib.rs @@ -104,6 +104,12 @@ pub struct Extensions { pub id: Option, } +/// Opens the external agent registry. +#[derive(PartialEq, Clone, Default, Debug, Deserialize, JsonSchema, Action)] +#[action(namespace = zed)] +#[serde(deny_unknown_fields)] +pub struct AgentRegistry; + /// Decreases the font size in the editor buffer. #[derive(PartialEq, Clone, Default, Debug, Deserialize, JsonSchema, Action)] #[action(namespace = zed)]