@@ -4,7 +4,7 @@ mod configure_context_server_tools_modal;
mod manage_profiles_modal;
mod tool_picker;
-use std::{ops::Range, sync::Arc};
+use std::{ops::Range, rc::Rc, sync::Arc};
use agent::ContextServerRegistry;
use anyhow::Result;
@@ -33,9 +33,9 @@ use project::{
};
use settings::{Settings, SettingsStore, update_settings_file};
use ui::{
- ButtonStyle, Chip, CommonAnimationExt, ContextMenu, ContextMenuEntry, Disclosure, Divider,
- DividerColor, ElevationIndex, Indicator, LabelSize, PopoverMenu, Switch, Tooltip,
- WithScrollbar, prelude::*,
+ AiSettingItem, AiSettingItemSource, AiSettingItemStatus, ButtonStyle, Chip, ContextMenu,
+ ContextMenuEntry, Disclosure, Divider, DividerColor, ElevationIndex, LabelSize, PopoverMenu,
+ Switch, Tooltip, WithScrollbar, prelude::*,
};
use util::ResultExt as _;
use workspace::{Workspace, create_and_open_local_file};
@@ -45,29 +45,32 @@ pub(crate) use configure_context_server_modal::ConfigureContextServerModal;
pub(crate) use configure_context_server_tools_modal::ConfigureContextServerToolsModal;
pub(crate) use manage_profiles_modal::ManageProfilesModal;
-use crate::agent_configuration::add_llm_provider_modal::{
- AddLlmProviderModal, LlmCompatibleProvider,
+use crate::{
+ Agent,
+ agent_configuration::add_llm_provider_modal::{AddLlmProviderModal, LlmCompatibleProvider},
+ agent_connection_store::{AgentConnectionStatus, AgentConnectionStore},
};
pub struct AgentConfiguration {
fs: Arc<dyn Fs>,
language_registry: Arc<LanguageRegistry>,
agent_server_store: Entity<AgentServerStore>,
+ agent_connection_store: Entity<AgentConnectionStore>,
workspace: WeakEntity<Workspace>,
focus_handle: FocusHandle,
configuration_views_by_provider: HashMap<LanguageModelProviderId, AnyView>,
context_server_store: Entity<ContextServerStore>,
expanded_provider_configurations: HashMap<LanguageModelProviderId, bool>,
context_server_registry: Entity<ContextServerRegistry>,
- _registry_subscription: Subscription,
+ _subscriptions: Vec<Subscription>,
scroll_handle: ScrollHandle,
- _check_for_gemini: Task<()>,
}
impl AgentConfiguration {
pub fn new(
fs: Arc<dyn Fs>,
agent_server_store: Entity<AgentServerStore>,
+ agent_connection_store: Entity<AgentConnectionStore>,
context_server_store: Entity<ContextServerStore>,
context_server_registry: Entity<ContextServerRegistry>,
language_registry: Arc<LanguageRegistry>,
@@ -77,25 +80,27 @@ impl AgentConfiguration {
) -> Self {
let focus_handle = cx.focus_handle();
- let registry_subscription = cx.subscribe_in(
- &LanguageModelRegistry::global(cx),
- window,
- |this, _, event: &language_model::Event, window, cx| match event {
- language_model::Event::AddedProvider(provider_id) => {
- let provider = LanguageModelRegistry::read_global(cx).provider(provider_id);
- if let Some(provider) = provider {
- this.add_provider_configuration_view(&provider, window, cx);
+ let subscriptions = vec![
+ cx.subscribe_in(
+ &LanguageModelRegistry::global(cx),
+ window,
+ |this, _, event: &language_model::Event, window, cx| match event {
+ language_model::Event::AddedProvider(provider_id) => {
+ let provider = LanguageModelRegistry::read_global(cx).provider(provider_id);
+ if let Some(provider) = provider {
+ this.add_provider_configuration_view(&provider, window, cx);
+ }
}
- }
- language_model::Event::RemovedProvider(provider_id) => {
- this.remove_provider_configuration_view(provider_id);
- }
- _ => {}
- },
- );
-
- cx.subscribe(&context_server_store, |_, _, _, cx| cx.notify())
- .detach();
+ language_model::Event::RemovedProvider(provider_id) => {
+ this.remove_provider_configuration_view(provider_id);
+ }
+ _ => {}
+ },
+ ),
+ cx.subscribe(&agent_server_store, |_, _, _, cx| cx.notify()),
+ cx.observe(&agent_connection_store, |_, _, cx| cx.notify()),
+ cx.subscribe(&context_server_store, |_, _, _, cx| cx.notify()),
+ ];
let mut this = Self {
fs,
@@ -104,13 +109,14 @@ impl AgentConfiguration {
focus_handle,
configuration_views_by_provider: HashMap::default(),
agent_server_store,
+ agent_connection_store,
context_server_store,
expanded_provider_configurations: HashMap::default(),
context_server_registry,
- _registry_subscription: registry_subscription,
+ _subscriptions: subscriptions,
scroll_handle: ScrollHandle::new(),
- _check_for_gemini: Task::ready(()),
};
+
this.build_provider_configuration_views(window, cx);
this
}
@@ -636,6 +642,22 @@ impl AgentConfiguration {
)
});
+ let display_name = if provided_by_extension {
+ resolve_extension_for_context_server(&context_server_id, cx)
+ .map(|(_, manifest)| {
+ let name = manifest.name.as_str();
+ let stripped = name
+ .strip_suffix(" MCP Server")
+ .or_else(|| name.strip_suffix(" MCP"))
+ .or_else(|| name.strip_suffix(" Context Server"))
+ .unwrap_or(name);
+ SharedString::from(stripped.to_string())
+ })
+ .unwrap_or_else(|| item_id.clone())
+ } else {
+ item_id.clone()
+ };
+
let error = if let ContextServerStatus::Error(error) = server_status.clone() {
Some(error)
} else {
@@ -651,57 +673,19 @@ impl AgentConfiguration {
.tools_for_server(&context_server_id)
.count();
- let (source_icon, source_tooltip) = if provided_by_extension {
- (
- IconName::ZedSrcExtension,
- "This MCP server was installed from an extension.",
- )
+ let source = if provided_by_extension {
+ AiSettingItemSource::Extension
} else {
- (
- IconName::ZedSrcCustom,
- "This custom MCP server was installed directly.",
- )
+ AiSettingItemSource::Custom
};
- let (status_indicator, tooltip_text) = match server_status {
- ContextServerStatus::Starting => (
- Icon::new(IconName::LoadCircle)
- .size(IconSize::XSmall)
- .color(Color::Accent)
- .with_keyed_rotate_animation(
- SharedString::from(format!("{}-starting", context_server_id.0)),
- 3,
- )
- .into_any_element(),
- "Server is starting.",
- ),
- ContextServerStatus::Running => (
- Indicator::dot().color(Color::Success).into_any_element(),
- "Server is active.",
- ),
- ContextServerStatus::Error(_) => (
- Indicator::dot().color(Color::Error).into_any_element(),
- "Server has an error.",
- ),
- ContextServerStatus::Stopped => (
- Indicator::dot().color(Color::Muted).into_any_element(),
- "Server is stopped.",
- ),
- ContextServerStatus::AuthRequired => (
- Indicator::dot().color(Color::Warning).into_any_element(),
- "Authentication required.",
- ),
- ContextServerStatus::Authenticating => (
- Icon::new(IconName::LoadCircle)
- .size(IconSize::XSmall)
- .color(Color::Accent)
- .with_keyed_rotate_animation(
- SharedString::from(format!("{}-authenticating", context_server_id.0)),
- 3,
- )
- .into_any_element(),
- "Waiting for authorization...",
- ),
+ let status = match server_status {
+ ContextServerStatus::Starting => AiSettingItemStatus::Starting,
+ ContextServerStatus::Running => AiSettingItemStatus::Running,
+ ContextServerStatus::Error(_) => AiSettingItemStatus::Error,
+ ContextServerStatus::Stopped => AiSettingItemStatus::Stopped,
+ ContextServerStatus::AuthRequired => AiSettingItemStatus::AuthRequired,
+ ContextServerStatus::Authenticating => AiSettingItemStatus::Authenticating,
};
let is_remote = server_configuration
@@ -845,232 +829,165 @@ impl AgentConfiguration {
let feedback_base_container =
|| h_flex().py_1().min_w_0().w_full().gap_1().justify_between();
- v_flex()
- .min_w_0()
- .id(item_id.clone())
- .child(
- h_flex()
- .min_w_0()
- .w_full()
- .justify_between()
+ let details: Option<AnyElement> = if let Some(error) = error {
+ Some(
+ feedback_base_container()
.child(
h_flex()
- .flex_1()
+ .pr_4()
.min_w_0()
+ .w_full()
+ .gap_2()
.child(
- h_flex()
- .id(format!("tooltip-{}", item_id))
- .h_full()
- .w_3()
- .mr_2()
- .justify_center()
- .tooltip(Tooltip::text(tooltip_text))
- .child(status_indicator),
- )
- .child(Label::new(item_id).flex_shrink_0().truncate())
- .child(
- div()
- .id("extension-source")
- .min_w_0()
- .mt_0p5()
- .mx_1()
- .tooltip(Tooltip::text(source_tooltip))
- .child(
- Icon::new(source_icon)
- .size(IconSize::Small)
- .color(Color::Muted),
- ),
+ Icon::new(IconName::XCircle)
+ .size(IconSize::XSmall)
+ .color(Color::Error),
)
- .when(is_running, |this| {
- this.child(
- Label::new(if tool_count == 1 {
- SharedString::from("1 tool")
- } else {
- SharedString::from(format!("{} tools", tool_count))
- })
- .color(Color::Muted)
- .size(LabelSize::Small),
- )
- }),
+ .child(div().min_w_0().flex_1().child(
+ Label::new(error).color(Color::Muted).size(LabelSize::Small),
+ )),
)
- .child(
- h_flex()
- .gap_0p5()
- .flex_none()
- .child(context_server_configuration_menu)
- .child(
- Switch::new("context-server-switch", is_running.into())
+ .when(should_show_logout_button, |this| {
+ this.child(
+ Button::new("error-logout-server", "Log Out")
+ .style(ButtonStyle::Outlined)
+ .label_size(LabelSize::Small)
.on_click({
- let context_server_manager = self.context_server_store.clone();
- let fs = self.fs.clone();
+ let context_server_store = context_server_store.clone();
let context_server_id = context_server_id.clone();
-
- move |state, _window, cx| {
- let is_enabled = match state {
- ToggleState::Unselected
- | ToggleState::Indeterminate => {
- context_server_manager.update(cx, |this, cx| {
- this.stop_server(&context_server_id, cx)
- .log_err();
- });
- false
- }
- ToggleState::Selected => {
- context_server_manager.update(cx, |this, cx| {
- if let Some(server) =
- this.get_server(&context_server_id)
- {
- this.start_server(server, cx);
- }
- });
- true
- }
- };
- update_settings_file(fs.clone(), cx, {
- let context_server_id = context_server_id.clone();
-
- move |settings, _| {
- settings
- .project
- .context_servers
- .entry(context_server_id.0)
- .or_insert_with(|| {
- settings::ContextServerSettingsContent::Extension {
- enabled: is_enabled,
- remote: false,
- settings: serde_json::json!({}),
- }
- })
- .set_enabled(is_enabled);
- }
+ move |_event, _window, cx| {
+ context_server_store.update(cx, |store, cx| {
+ store.logout_server(&context_server_id, cx).log_err();
});
}
}),
- ),
- ),
+ )
+ })
+ .into_any_element(),
)
- .map(|parent| {
- if let Some(error) = error {
- return parent
- .child(
- feedback_base_container()
- .child(
- h_flex()
- .pr_4()
- .min_w_0()
- .w_full()
- .gap_2()
- .child(
- Icon::new(IconName::XCircle)
- .size(IconSize::XSmall)
- .color(Color::Error),
- )
- .child(
- div().min_w_0().flex_1().child(
- Label::new(error)
- .color(Color::Muted)
- .size(LabelSize::Small),
- ),
- ),
- )
- .when(should_show_logout_button, |this| {
- this.child(
- Button::new("error-logout-server", "Log Out")
- .style(ButtonStyle::Outlined)
- .label_size(LabelSize::Small)
- .on_click({
- let context_server_store =
- context_server_store.clone();
- let context_server_id =
- context_server_id.clone();
- move |_event, _window, cx| {
- context_server_store.update(
- cx,
- |store, cx| {
- store
- .logout_server(
- &context_server_id,
- cx,
- )
- .log_err();
- },
- );
- }
- }),
- )
- }),
- );
- }
- if auth_required {
- return parent.child(
- feedback_base_container()
- .child(
- h_flex()
- .pr_4()
- .min_w_0()
- .w_full()
- .gap_2()
- .child(
- Icon::new(IconName::Info)
- .size(IconSize::XSmall)
- .color(Color::Muted),
- )
- .child(
- Label::new("Authenticate to connect this server")
- .color(Color::Muted)
- .size(LabelSize::Small),
- ),
- )
- .child(
- Button::new("error-logout-server", "Authenticate")
- .style(ButtonStyle::Outlined)
- .label_size(LabelSize::Small)
- .on_click({
- let context_server_store = context_server_store.clone();
- let context_server_id = context_server_id.clone();
- move |_event, _window, cx| {
- context_server_store.update(cx, |store, cx| {
- store
- .authenticate_server(&context_server_id, cx)
- .log_err();
- });
- }
- }),
- ),
- );
- }
- if authenticating {
- return parent.child(
+ } else if auth_required {
+ Some(
+ feedback_base_container()
+ .child(
h_flex()
- .mt_1()
.pr_4()
.min_w_0()
.w_full()
.gap_2()
.child(
- div().size_3().flex_shrink_0(), // Alignment Div
+ Icon::new(IconName::Info)
+ .size(IconSize::XSmall)
+ .color(Color::Muted),
)
.child(
- Label::new("Authenticating…")
+ Label::new("Authenticate to connect this server")
.color(Color::Muted)
.size(LabelSize::Small),
),
+ )
+ .child(
+ Button::new("error-logout-server", "Authenticate")
+ .style(ButtonStyle::Outlined)
+ .label_size(LabelSize::Small)
+ .on_click({
+ let context_server_id = context_server_id.clone();
+ move |_event, _window, cx| {
+ context_server_store.update(cx, |store, cx| {
+ store.authenticate_server(&context_server_id, cx).log_err();
+ });
+ }
+ }),
+ )
+ .into_any_element(),
+ )
+ } else if authenticating {
+ Some(
+ h_flex()
+ .mt_1()
+ .pr_4()
+ .min_w_0()
+ .w_full()
+ .gap_2()
+ .child(div().size_3().flex_shrink_0())
+ .child(
+ Label::new("Authenticating…")
+ .color(Color::Muted)
+ .size(LabelSize::Small),
+ )
+ .into_any_element(),
+ )
+ } else {
+ None
+ };
- );
- }
- parent
+ let tool_label = if is_running {
+ Some(if tool_count == 1 {
+ SharedString::from("1 tool")
+ } else {
+ SharedString::from(format!("{} tools", tool_count))
})
+ } else {
+ None
+ };
+
+ AiSettingItem::new(item_id, display_name, status, source)
+ .action(context_server_configuration_menu)
+ .action(
+ Switch::new("context-server-switch", is_running.into()).on_click({
+ let context_server_manager = self.context_server_store.clone();
+ let fs = self.fs.clone();
+
+ move |state, _window, cx| {
+ let is_enabled = match state {
+ ToggleState::Unselected | ToggleState::Indeterminate => {
+ context_server_manager.update(cx, |this, cx| {
+ this.stop_server(&context_server_id, cx).log_err();
+ });
+ false
+ }
+ ToggleState::Selected => {
+ context_server_manager.update(cx, |this, cx| {
+ if let Some(server) = this.get_server(&context_server_id) {
+ this.start_server(server, cx);
+ }
+ });
+ true
+ }
+ };
+ update_settings_file(fs.clone(), cx, {
+ let context_server_id = context_server_id.clone();
+
+ move |settings, _| {
+ settings
+ .project
+ .context_servers
+ .entry(context_server_id.0)
+ .or_insert_with(|| {
+ settings::ContextServerSettingsContent::Extension {
+ enabled: is_enabled,
+ remote: false,
+ settings: serde_json::json!({}),
+ }
+ })
+ .set_enabled(is_enabled);
+ }
+ });
+ }
+ }),
+ )
+ .when_some(tool_label, |this, label| this.detail_label(label))
+ .when_some(details, |this, details| this.details(details))
}
fn render_agent_servers_section(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
let agent_server_store = self.agent_server_store.read(cx);
- let user_defined_agents = agent_server_store
+ let agents = agent_server_store
.external_agents()
.cloned()
.collect::<Vec<_>>();
- let user_defined_agents: Vec<_> = user_defined_agents
+ let agents: Vec<_> = agents
.into_iter()
.map(|name| {
let icon = if let Some(icon_path) = agent_server_store.agent_icon(&name) {
@@ -1159,24 +1076,31 @@ impl AgentConfiguration {
"All agents connected through the Agent Client Protocol.",
add_agent_popover.into_any_element(),
))
- .child(v_flex().p_4().pt_0().gap_2().map(|mut parent| {
- let mut first = true;
- for (name, icon, display_name, source) in user_defined_agents {
- if !first {
- parent = parent
- .child(Divider::horizontal().color(DividerColor::BorderFaded));
- }
- first = false;
- parent = parent.child(self.render_agent_server(
- icon,
- name,
- display_name,
- source,
- cx,
- ));
- }
- parent
- })),
+ .child(
+ v_flex()
+ .p_4()
+ .pt_0()
+ .gap_2()
+ .children(Itertools::intersperse_with(
+ agents
+ .into_iter()
+ .map(|(name, icon, display_name, source)| {
+ self.render_agent_server(
+ icon,
+ name,
+ display_name,
+ source,
+ cx,
+ )
+ .into_any_element()
+ }),
+ || {
+ Divider::horizontal()
+ .color(DividerColor::BorderFaded)
+ .into_any_element()
+ },
+ )),
+ ),
)
}
@@ -1200,27 +1124,46 @@ impl AgentConfiguration {
.color(Color::Muted),
};
- 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::Custom => None,
+ let source_kind = match source {
+ ExternalAgentSource::Extension => AiSettingItemSource::Extension,
+ ExternalAgentSource::Registry => AiSettingItemSource::Registry,
+ ExternalAgentSource::Custom => AiSettingItemSource::Custom,
};
let agent_server_name = AgentId(id.clone());
+ let agent = Agent::Custom {
+ id: agent_server_name.clone(),
+ };
+
+ let connection_status = self
+ .agent_connection_store
+ .read(cx)
+ .connection_status(&agent, cx);
+
+ let restart_button = matches!(
+ connection_status,
+ AgentConnectionStatus::Connected | AgentConnectionStatus::Connecting
+ )
+ .then(|| {
+ IconButton::new(
+ SharedString::from(format!("restart-{}", id)),
+ IconName::RotateCw,
+ )
+ .disabled(connection_status == AgentConnectionStatus::Connecting)
+ .icon_color(Color::Muted)
+ .icon_size(IconSize::Small)
+ .tooltip(Tooltip::text("Restart Agent Connection"))
+ .on_click(cx.listener({
+ let agent = agent.clone();
+ move |this, _, _window, cx| {
+ let server: Rc<dyn agent_servers::AgentServer> =
+ Rc::new(agent_servers::CustomAgentServer::new(agent.id()));
+ this.agent_connection_store.update(cx, |store, cx| {
+ store.restart_connection(agent.clone(), server, cx);
+ });
+ }
+ }))
+ });
let uninstall_button = match source {
ExternalAgentSource::Extension => Some(
@@ -1301,32 +1244,16 @@ impl AgentConfiguration {
}
};
- h_flex()
- .gap_1()
- .justify_between()
- .child(
- h_flex()
- .gap_1p5()
- .child(icon)
- .child(Label::new(display_name))
- .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(icon).size(IconSize::Small).color(Color::Muted)),
- )
- })
- .child(
- Icon::new(IconName::Check)
- .color(Color::Success)
- .size(IconSize::Small),
- ),
- )
- .when_some(uninstall_button, |this, uninstall_button| {
- this.child(uninstall_button)
- })
+ let status = match connection_status {
+ AgentConnectionStatus::Disconnected => AiSettingItemStatus::Stopped,
+ AgentConnectionStatus::Connecting => AiSettingItemStatus::Starting,
+ AgentConnectionStatus::Connected => AiSettingItemStatus::Running,
+ };
+
+ AiSettingItem::new(id, display_name, status, source_kind)
+ .icon(icon)
+ .when_some(restart_button, |this, button| this.action(button))
+ .when_some(uninstall_button, |this, button| this.action(button))
}
}
@@ -5,7 +5,8 @@ use agent_servers::{AgentServer, AgentServerDelegate};
use anyhow::Result;
use collections::HashMap;
use futures::{FutureExt, future::Shared};
-use gpui::{AppContext, Context, Entity, EventEmitter, SharedString, Subscription, Task};
+use gpui::{App, AppContext, Context, Entity, EventEmitter, SharedString, Subscription, Task};
+
use project::{AgentServerStore, AgentServersUpdated, Project};
use watch::Receiver;
@@ -27,6 +28,13 @@ pub struct AgentConnectedState {
pub history: Option<Entity<ThreadHistory>>,
}
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum AgentConnectionStatus {
+ Disconnected,
+ Connecting,
+ Connected,
+}
+
impl AgentConnectionEntry {
pub fn wait_for_connection(&self) -> Shared<Task<Result<AgentConnectedState, LoadError>>> {
match self {
@@ -42,6 +50,14 @@ impl AgentConnectionEntry {
_ => None,
}
}
+
+ pub fn status(&self) -> AgentConnectionStatus {
+ match self {
+ AgentConnectionEntry::Connecting { .. } => AgentConnectionStatus::Connecting,
+ AgentConnectionEntry::Connected(_) => AgentConnectionStatus::Connected,
+ AgentConnectionEntry::Error { .. } => AgentConnectionStatus::Disconnected,
+ }
+ }
}
pub enum AgentConnectionEntryEvent {
@@ -71,66 +87,124 @@ impl AgentConnectionStore {
self.entries.get(key)
}
+ pub fn connection_status(&self, key: &Agent, cx: &App) -> AgentConnectionStatus {
+ self.entries
+ .get(key)
+ .map(|entry| entry.read(cx).status())
+ .unwrap_or(AgentConnectionStatus::Disconnected)
+ }
+
+ pub fn restart_connection(
+ &mut self,
+ key: Agent,
+ server: Rc<dyn AgentServer>,
+ cx: &mut Context<Self>,
+ ) -> Entity<AgentConnectionEntry> {
+ if let Some(entry) = self.entries.get(&key) {
+ if matches!(entry.read(cx), AgentConnectionEntry::Connecting { .. }) {
+ return entry.clone();
+ }
+ }
+
+ self.entries.remove(&key);
+ self.request_connection(key, server, cx)
+ }
+
pub fn request_connection(
&mut self,
key: Agent,
server: Rc<dyn AgentServer>,
cx: &mut Context<Self>,
) -> Entity<AgentConnectionEntry> {
- self.entries.get(&key).cloned().unwrap_or_else(|| {
- let (mut new_version_rx, connect_task) = self.start_connection(server.clone(), cx);
- let connect_task = connect_task.shared();
-
- let entry = cx.new(|_cx| AgentConnectionEntry::Connecting {
- connect_task: connect_task.clone(),
- });
-
- self.entries.insert(key.clone(), entry.clone());
-
- cx.spawn({
- let key = key.clone();
- let entry = entry.clone();
- async move |this, cx| match connect_task.await {
- Ok(connected_state) => {
- entry.update(cx, |entry, cx| {
- if let AgentConnectionEntry::Connecting { .. } = entry {
- *entry = AgentConnectionEntry::Connected(connected_state);
- cx.notify();
- }
- });
- }
- Err(error) => {
- entry.update(cx, |entry, cx| {
- if let AgentConnectionEntry::Connecting { .. } = entry {
- *entry = AgentConnectionEntry::Error { error };
- cx.notify();
- }
- });
- this.update(cx, |this, _cx| this.entries.remove(&key)).ok();
- }
+ if let Some(entry) = self.entries.get(&key) {
+ return entry.clone();
+ }
+
+ let (mut new_version_rx, connect_task) = self.start_connection(server, cx);
+ let connect_task = connect_task.shared();
+
+ let entry = cx.new(|_cx| AgentConnectionEntry::Connecting {
+ connect_task: connect_task.clone(),
+ });
+
+ self.entries.insert(key.clone(), entry.clone());
+ cx.notify();
+
+ cx.spawn({
+ let key = key.clone();
+ let entry = entry.downgrade();
+ async move |this, cx| match connect_task.await {
+ Ok(connected_state) => {
+ this.update(cx, move |this, cx| {
+ if this.entries.get(&key) != entry.upgrade().as_ref() {
+ return;
+ }
+
+ entry
+ .update(cx, move |entry, cx| {
+ if let AgentConnectionEntry::Connecting { .. } = entry {
+ *entry = AgentConnectionEntry::Connected(connected_state);
+ cx.notify();
+ }
+ })
+ .ok();
+ })
+ .ok();
}
- })
- .detach();
-
- cx.spawn({
- let entry = entry.clone();
- async move |this, cx| {
- while let Ok(version) = new_version_rx.recv().await {
- if let Some(version) = version {
- entry.update(cx, |_entry, cx| {
- cx.emit(AgentConnectionEntryEvent::NewVersionAvailable(
- version.clone().into(),
- ));
- });
- this.update(cx, |this, _cx| this.entries.remove(&key)).ok();
+ Err(error) => {
+ this.update(cx, move |this, cx| {
+ if this.entries.get(&key) != entry.upgrade().as_ref() {
+ return;
}
- }
+
+ entry
+ .update(cx, move |entry, cx| {
+ if let AgentConnectionEntry::Connecting { .. } = entry {
+ *entry = AgentConnectionEntry::Error { error };
+ cx.notify();
+ }
+ })
+ .ok();
+ this.entries.remove(&key);
+ cx.notify();
+ })
+ .ok();
}
- })
- .detach();
+ }
+ })
+ .detach();
+
+ cx.spawn({
+ let entry = entry.downgrade();
+ async move |this, cx| {
+ while let Ok(version) = new_version_rx.recv().await {
+ let Some(version) = version else {
+ continue;
+ };
+
+ this.update(cx, move |this, cx| {
+ if this.entries.get(&key) != entry.upgrade().as_ref() {
+ return;
+ }
- entry
+ entry
+ .update(cx, move |_entry, cx| {
+ cx.emit(AgentConnectionEntryEvent::NewVersionAvailable(
+ version.into(),
+ ));
+ })
+ .ok();
+ this.entries.remove(&key);
+ cx.notify();
+ })
+ .ok();
+ break;
+ }
+ }
})
+ .detach();
+
+ entry
}
fn handle_agent_servers_updated(
@@ -0,0 +1,406 @@
+use crate::{IconDecoration, IconDecorationKind, Tooltip, prelude::*};
+use gpui::{Animation, AnimationExt, SharedString, pulsating_between};
+use std::time::Duration;
+
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
+pub enum AiSettingItemStatus {
+ #[default]
+ Stopped,
+ Starting,
+ Running,
+ Error,
+ AuthRequired,
+ Authenticating,
+}
+
+impl AiSettingItemStatus {
+ fn tooltip_text(&self) -> &'static str {
+ match self {
+ Self::Stopped => "Server is stopped.",
+ Self::Starting => "Server is starting.",
+ Self::Running => "Server is active.",
+ Self::Error => "Server has an error.",
+ Self::AuthRequired => "Authentication required.",
+ Self::Authenticating => "Waiting for authorization…",
+ }
+ }
+
+ fn indicator_color(&self) -> Option<Color> {
+ match self {
+ Self::Stopped => None,
+ Self::Starting | Self::Authenticating => Some(Color::Muted),
+ Self::Running => Some(Color::Success),
+ Self::Error => Some(Color::Error),
+ Self::AuthRequired => Some(Color::Warning),
+ }
+ }
+
+ fn is_animated(&self) -> bool {
+ matches!(self, Self::Starting | Self::Authenticating)
+ }
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum AiSettingItemSource {
+ Extension,
+ Custom,
+ Registry,
+}
+
+impl AiSettingItemSource {
+ fn icon_name(&self) -> IconName {
+ match self {
+ Self::Extension => IconName::ZedSrcExtension,
+ Self::Custom => IconName::ZedSrcCustom,
+ Self::Registry => IconName::AcpRegistry,
+ }
+ }
+
+ fn tooltip_text(&self, label: &str) -> String {
+ match self {
+ Self::Extension => format!("{label} was installed from an extension."),
+ Self::Registry => format!("{label} was installed from the ACP registry."),
+ Self::Custom => format!("{label} was configured manually."),
+ }
+ }
+}
+
+/// A reusable setting item row for AI-related configuration lists.
+#[derive(IntoElement, RegisterComponent)]
+pub struct AiSettingItem {
+ id: ElementId,
+ status: AiSettingItemStatus,
+ source: AiSettingItemSource,
+ icon: Option<AnyElement>,
+ label: SharedString,
+ detail_label: Option<SharedString>,
+ actions: Vec<AnyElement>,
+ details: Option<AnyElement>,
+}
+
+impl AiSettingItem {
+ pub fn new(
+ id: impl Into<ElementId>,
+ label: impl Into<SharedString>,
+ status: AiSettingItemStatus,
+ source: AiSettingItemSource,
+ ) -> Self {
+ Self {
+ id: id.into(),
+ status,
+ source,
+ icon: None,
+ label: label.into(),
+ detail_label: None,
+ actions: Vec::new(),
+ details: None,
+ }
+ }
+
+ pub fn icon(mut self, element: impl IntoElement) -> Self {
+ self.icon = Some(element.into_any_element());
+ self
+ }
+
+ pub fn detail_label(mut self, detail: impl Into<SharedString>) -> Self {
+ self.detail_label = Some(detail.into());
+ self
+ }
+
+ pub fn action(mut self, element: impl IntoElement) -> Self {
+ self.actions.push(element.into_any_element());
+ self
+ }
+
+ pub fn details(mut self, element: impl IntoElement) -> Self {
+ self.details = Some(element.into_any_element());
+ self
+ }
+}
+
+impl RenderOnce for AiSettingItem {
+ fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
+ let Self {
+ id,
+ status,
+ source,
+ icon,
+ label,
+ detail_label,
+ actions,
+ details,
+ } = self;
+
+ let source_id = format!("source-{}", id);
+ let icon_id = format!("icon-{}", id);
+ let status_tooltip = status.tooltip_text();
+ let source_tooltip = source.tooltip_text(&label);
+
+ let icon_element = icon.unwrap_or_else(|| {
+ let letter = label.chars().next().unwrap_or('?').to_ascii_uppercase();
+
+ h_flex()
+ .size_5()
+ .flex_none()
+ .justify_center()
+ .rounded_sm()
+ .border_1()
+ .border_color(cx.theme().colors().border_variant)
+ .bg(cx.theme().colors().element_active.opacity(0.2))
+ .child(
+ Label::new(SharedString::from(letter.to_string()))
+ .size(LabelSize::Small)
+ .color(Color::Muted)
+ .buffer_font(cx),
+ )
+ .into_any_element()
+ });
+
+ let icon_child = if status.is_animated() {
+ div()
+ .child(icon_element)
+ .with_animation(
+ format!("icon-pulse-{}", id),
+ Animation::new(Duration::from_secs(2))
+ .repeat()
+ .with_easing(pulsating_between(0.4, 0.8)),
+ |element, delta| element.opacity(delta),
+ )
+ .into_any_element()
+ } else {
+ icon_element.into_any_element()
+ };
+
+ let icon_container = div()
+ .id(icon_id)
+ .relative()
+ .flex_none()
+ .tooltip(Tooltip::text(status_tooltip))
+ .child(icon_child)
+ .when_some(status.indicator_color(), |this, color| {
+ this.child(
+ IconDecoration::new(
+ IconDecorationKind::Dot,
+ cx.theme().colors().panel_background,
+ cx,
+ )
+ .size(px(12.))
+ .color(color.color(cx))
+ .position(gpui::Point {
+ x: px(-3.),
+ y: px(-3.),
+ }),
+ )
+ });
+
+ v_flex()
+ .id(id)
+ .min_w_0()
+ .child(
+ h_flex()
+ .min_w_0()
+ .w_full()
+ .gap_1p5()
+ .justify_between()
+ .child(
+ h_flex()
+ .flex_1()
+ .min_w_0()
+ .gap_1p5()
+ .child(icon_container)
+ .child(Label::new(label).flex_shrink_0().truncate())
+ .child(
+ div()
+ .id(source_id)
+ .min_w_0()
+ .flex_none()
+ .tooltip(Tooltip::text(source_tooltip))
+ .child(
+ Icon::new(source.icon_name())
+ .size(IconSize::Small)
+ .color(Color::Muted),
+ ),
+ )
+ .when_some(detail_label, |this, detail| {
+ this.child(
+ Label::new(detail)
+ .color(Color::Muted)
+ .size(LabelSize::Small),
+ )
+ }),
+ )
+ .when(!actions.is_empty(), |this| {
+ this.child(h_flex().gap_0p5().flex_none().children(actions))
+ }),
+ )
+ .children(details)
+ }
+}
+
+impl Component for AiSettingItem {
+ fn scope() -> ComponentScope {
+ ComponentScope::Agent
+ }
+
+ fn preview(_window: &mut Window, cx: &mut App) -> Option<AnyElement> {
+ let container = || {
+ v_flex()
+ .w_80()
+ .p_2()
+ .gap_2()
+ .border_1()
+ .border_color(cx.theme().colors().border_variant)
+ .bg(cx.theme().colors().panel_background)
+ };
+
+ let details_row = |icon_name: IconName, icon_color: Color, message: &str| {
+ h_flex()
+ .py_1()
+ .min_w_0()
+ .w_full()
+ .gap_2()
+ .justify_between()
+ .child(
+ h_flex()
+ .pr_4()
+ .min_w_0()
+ .w_full()
+ .gap_2()
+ .child(
+ Icon::new(icon_name)
+ .size(IconSize::XSmall)
+ .color(icon_color),
+ )
+ .child(
+ div().min_w_0().flex_1().child(
+ Label::new(SharedString::from(message.to_string()))
+ .color(Color::Muted)
+ .size(LabelSize::Small),
+ ),
+ ),
+ )
+ };
+
+ let examples = vec![
+ single_example(
+ "MCP server with letter avatar (running)",
+ container()
+ .child(
+ AiSettingItem::new(
+ "ext-mcp",
+ "Postgres",
+ AiSettingItemStatus::Running,
+ AiSettingItemSource::Extension,
+ )
+ .detail_label("3 tools")
+ .action(
+ IconButton::new("menu", IconName::Settings)
+ .icon_size(IconSize::Small)
+ .icon_color(Color::Muted),
+ )
+ .action(
+ IconButton::new("toggle", IconName::Check)
+ .icon_size(IconSize::Small)
+ .icon_color(Color::Muted),
+ ),
+ )
+ .into_any_element(),
+ ),
+ single_example(
+ "MCP server (stopped)",
+ container()
+ .child(AiSettingItem::new(
+ "custom-mcp",
+ "my-local-server",
+ AiSettingItemStatus::Stopped,
+ AiSettingItemSource::Custom,
+ ))
+ .into_any_element(),
+ ),
+ single_example(
+ "MCP server (starting, animated)",
+ container()
+ .child(AiSettingItem::new(
+ "starting-mcp",
+ "Context7",
+ AiSettingItemStatus::Starting,
+ AiSettingItemSource::Extension,
+ ))
+ .into_any_element(),
+ ),
+ single_example(
+ "Agent with icon (running)",
+ container()
+ .child(
+ AiSettingItem::new(
+ "ext-agent",
+ "Claude Agent",
+ AiSettingItemStatus::Running,
+ AiSettingItemSource::Extension,
+ )
+ .icon(
+ Icon::new(IconName::AiClaude)
+ .size(IconSize::Small)
+ .color(Color::Muted),
+ )
+ .action(
+ IconButton::new("restart", IconName::RotateCw)
+ .icon_size(IconSize::Small)
+ .icon_color(Color::Muted),
+ )
+ .action(
+ IconButton::new("delete", IconName::Trash)
+ .icon_size(IconSize::Small)
+ .icon_color(Color::Muted),
+ ),
+ )
+ .into_any_element(),
+ ),
+ single_example(
+ "Registry agent (starting, animated)",
+ container()
+ .child(
+ AiSettingItem::new(
+ "reg-agent",
+ "Devin Agent",
+ AiSettingItemStatus::Starting,
+ AiSettingItemSource::Registry,
+ )
+ .icon(
+ Icon::new(IconName::ZedAssistant)
+ .size(IconSize::Small)
+ .color(Color::Muted),
+ ),
+ )
+ .into_any_element(),
+ ),
+ single_example(
+ "Error with details",
+ container()
+ .child(
+ AiSettingItem::new(
+ "error-mcp",
+ "Amplitude",
+ AiSettingItemStatus::Error,
+ AiSettingItemSource::Extension,
+ )
+ .details(
+ details_row(
+ IconName::XCircle,
+ Color::Error,
+ "Failed to connect: connection refused",
+ )
+ .child(
+ Button::new("logout", "Log Out")
+ .style(ButtonStyle::Outlined)
+ .label_size(LabelSize::Small),
+ ),
+ ),
+ )
+ .into_any_element(),
+ ),
+ ];
+
+ Some(example_group(examples).vertical().into_any_element())
+ }
+}