1mod add_llm_provider_modal;
2pub mod configure_context_server_modal;
3mod configure_context_server_tools_modal;
4mod manage_profiles_modal;
5mod tool_picker;
6
7use std::{ops::Range, sync::Arc};
8
9use agent::ContextServerRegistry;
10use anyhow::Result;
11use cloud_api_types::Plan;
12use collections::HashMap;
13use context_server::ContextServerId;
14use editor::{Editor, MultiBufferOffset, SelectionEffects, scroll::Autoscroll};
15use extension::ExtensionManifest;
16use extension_host::ExtensionStore;
17use fs::Fs;
18use gpui::{
19 Action, AnyView, App, AsyncWindowContext, Corner, Entity, EventEmitter, FocusHandle, Focusable,
20 ScrollHandle, Subscription, Task, WeakEntity,
21};
22use itertools::Itertools;
23use language::LanguageRegistry;
24use language_model::{
25 IconOrSvg, LanguageModelProvider, LanguageModelProviderId, LanguageModelRegistry,
26 ZED_CLOUD_PROVIDER_ID,
27};
28use language_models::AllLanguageModelSettings;
29use notifications::status_toast::{StatusToast, ToastIcon};
30use project::{
31 agent_server_store::{AgentId, AgentServerStore, ExternalAgentSource},
32 context_server_store::{ContextServerConfiguration, ContextServerStatus, ContextServerStore},
33};
34use settings::{Settings, SettingsStore, update_settings_file};
35use ui::{
36 ButtonStyle, Chip, CommonAnimationExt, ContextMenu, ContextMenuEntry, Disclosure, Divider,
37 DividerColor, ElevationIndex, Indicator, LabelSize, PopoverMenu, Switch, Tooltip,
38 WithScrollbar, prelude::*,
39};
40use util::ResultExt as _;
41use workspace::{Workspace, create_and_open_local_file};
42use zed_actions::{ExtensionCategoryFilter, OpenBrowser};
43
44pub(crate) use configure_context_server_modal::ConfigureContextServerModal;
45pub(crate) use configure_context_server_tools_modal::ConfigureContextServerToolsModal;
46pub(crate) use manage_profiles_modal::ManageProfilesModal;
47
48use crate::agent_configuration::add_llm_provider_modal::{
49 AddLlmProviderModal, LlmCompatibleProvider,
50};
51
52pub struct AgentConfiguration {
53 fs: Arc<dyn Fs>,
54 language_registry: Arc<LanguageRegistry>,
55 agent_server_store: Entity<AgentServerStore>,
56 workspace: WeakEntity<Workspace>,
57 focus_handle: FocusHandle,
58 configuration_views_by_provider: HashMap<LanguageModelProviderId, AnyView>,
59 context_server_store: Entity<ContextServerStore>,
60 expanded_provider_configurations: HashMap<LanguageModelProviderId, bool>,
61 context_server_registry: Entity<ContextServerRegistry>,
62 _registry_subscription: Subscription,
63 scroll_handle: ScrollHandle,
64 _check_for_gemini: Task<()>,
65}
66
67impl AgentConfiguration {
68 pub fn new(
69 fs: Arc<dyn Fs>,
70 agent_server_store: Entity<AgentServerStore>,
71 context_server_store: Entity<ContextServerStore>,
72 context_server_registry: Entity<ContextServerRegistry>,
73 language_registry: Arc<LanguageRegistry>,
74 workspace: WeakEntity<Workspace>,
75 window: &mut Window,
76 cx: &mut Context<Self>,
77 ) -> Self {
78 let focus_handle = cx.focus_handle();
79
80 let registry_subscription = cx.subscribe_in(
81 &LanguageModelRegistry::global(cx),
82 window,
83 |this, _, event: &language_model::Event, window, cx| match event {
84 language_model::Event::AddedProvider(provider_id) => {
85 let provider = LanguageModelRegistry::read_global(cx).provider(provider_id);
86 if let Some(provider) = provider {
87 this.add_provider_configuration_view(&provider, window, cx);
88 }
89 }
90 language_model::Event::RemovedProvider(provider_id) => {
91 this.remove_provider_configuration_view(provider_id);
92 }
93 _ => {}
94 },
95 );
96
97 cx.subscribe(&context_server_store, |_, _, _, cx| cx.notify())
98 .detach();
99
100 let mut this = Self {
101 fs,
102 language_registry,
103 workspace,
104 focus_handle,
105 configuration_views_by_provider: HashMap::default(),
106 agent_server_store,
107 context_server_store,
108 expanded_provider_configurations: HashMap::default(),
109 context_server_registry,
110 _registry_subscription: registry_subscription,
111 scroll_handle: ScrollHandle::new(),
112 _check_for_gemini: Task::ready(()),
113 };
114 this.build_provider_configuration_views(window, cx);
115 this
116 }
117
118 fn build_provider_configuration_views(&mut self, window: &mut Window, cx: &mut Context<Self>) {
119 let providers = LanguageModelRegistry::read_global(cx).visible_providers();
120 for provider in providers {
121 self.add_provider_configuration_view(&provider, window, cx);
122 }
123 }
124
125 fn remove_provider_configuration_view(&mut self, provider_id: &LanguageModelProviderId) {
126 self.configuration_views_by_provider.remove(provider_id);
127 self.expanded_provider_configurations.remove(provider_id);
128 }
129
130 fn add_provider_configuration_view(
131 &mut self,
132 provider: &Arc<dyn LanguageModelProvider>,
133 window: &mut Window,
134 cx: &mut Context<Self>,
135 ) {
136 let configuration_view = provider.configuration_view(
137 language_model::ConfigurationViewTargetAgent::ZedAgent,
138 window,
139 cx,
140 );
141 self.configuration_views_by_provider
142 .insert(provider.id(), configuration_view);
143 }
144}
145
146impl Focusable for AgentConfiguration {
147 fn focus_handle(&self, _: &App) -> FocusHandle {
148 self.focus_handle.clone()
149 }
150}
151
152pub enum AssistantConfigurationEvent {
153 NewThread(Arc<dyn LanguageModelProvider>),
154}
155
156impl EventEmitter<AssistantConfigurationEvent> for AgentConfiguration {}
157
158enum AgentIcon {
159 Name(IconName),
160 Path(SharedString),
161}
162
163impl AgentConfiguration {
164 fn render_section_title(
165 &mut self,
166 title: impl Into<SharedString>,
167 description: impl Into<SharedString>,
168 menu: AnyElement,
169 ) -> impl IntoElement {
170 h_flex()
171 .p_4()
172 .pb_0()
173 .mb_2p5()
174 .items_start()
175 .justify_between()
176 .child(
177 v_flex()
178 .w_full()
179 .gap_0p5()
180 .child(
181 h_flex()
182 .pr_1()
183 .w_full()
184 .gap_2()
185 .justify_between()
186 .flex_wrap()
187 .child(Headline::new(title.into()))
188 .child(menu),
189 )
190 .child(Label::new(description.into()).color(Color::Muted)),
191 )
192 }
193
194 fn render_provider_configuration_block(
195 &mut self,
196 provider: &Arc<dyn LanguageModelProvider>,
197 cx: &mut Context<Self>,
198 ) -> impl IntoElement + use<> {
199 let provider_id = provider.id().0;
200 let provider_name = provider.name().0;
201 let provider_id_string = SharedString::from(format!("provider-disclosure-{provider_id}"));
202
203 let configuration_view = self
204 .configuration_views_by_provider
205 .get(&provider.id())
206 .cloned();
207
208 let is_expanded = self
209 .expanded_provider_configurations
210 .get(&provider.id())
211 .copied()
212 .unwrap_or(false);
213
214 let is_zed_provider = provider.id() == ZED_CLOUD_PROVIDER_ID;
215 let current_plan = if is_zed_provider {
216 self.workspace
217 .upgrade()
218 .and_then(|workspace| workspace.read(cx).user_store().read(cx).plan())
219 } else {
220 None
221 };
222
223 let is_signed_in = self
224 .workspace
225 .read_with(cx, |workspace, _| {
226 !workspace.client().status().borrow().is_signed_out()
227 })
228 .unwrap_or(false);
229
230 v_flex()
231 .min_w_0()
232 .w_full()
233 .when(is_expanded, |this| this.mb_2())
234 .child(
235 div()
236 .px_2()
237 .child(Divider::horizontal().color(DividerColor::BorderFaded)),
238 )
239 .child(
240 h_flex()
241 .map(|this| {
242 if is_expanded {
243 this.mt_2().mb_1()
244 } else {
245 this.my_2()
246 }
247 })
248 .w_full()
249 .justify_between()
250 .child(
251 h_flex()
252 .id(provider_id_string.clone())
253 .px_2()
254 .py_0p5()
255 .w_full()
256 .justify_between()
257 .rounded_sm()
258 .hover(|hover| hover.bg(cx.theme().colors().element_hover))
259 .child(
260 h_flex()
261 .w_full()
262 .gap_1p5()
263 .child(
264 match provider.icon() {
265 IconOrSvg::Svg(path) => Icon::from_external_svg(path),
266 IconOrSvg::Icon(name) => Icon::new(name),
267 }
268 .size(IconSize::Small)
269 .color(Color::Muted),
270 )
271 .child(
272 h_flex()
273 .w_full()
274 .gap_1()
275 .child(Label::new(provider_name.clone()))
276 .map(|this| {
277 if is_zed_provider && is_signed_in {
278 this.child(
279 self.render_zed_plan_info(current_plan, cx),
280 )
281 } else {
282 this.when(
283 provider.is_authenticated(cx)
284 && !is_expanded,
285 |parent| {
286 parent.child(
287 Icon::new(IconName::Check)
288 .color(Color::Success),
289 )
290 },
291 )
292 }
293 }),
294 ),
295 )
296 .child(
297 Disclosure::new(provider_id_string, is_expanded)
298 .opened_icon(IconName::ChevronUp)
299 .closed_icon(IconName::ChevronDown),
300 )
301 .on_click(cx.listener({
302 let provider_id = provider.id();
303 move |this, _event, _window, _cx| {
304 let is_expanded = this
305 .expanded_provider_configurations
306 .entry(provider_id.clone())
307 .or_insert(false);
308
309 *is_expanded = !*is_expanded;
310 }
311 })),
312 ),
313 )
314 .child(
315 v_flex()
316 .min_w_0()
317 .w_full()
318 .px_2()
319 .gap_1()
320 .when(is_expanded, |parent| match configuration_view {
321 Some(configuration_view) => parent.child(configuration_view),
322 None => parent.child(Label::new(format!(
323 "No configuration view for {provider_name}",
324 ))),
325 })
326 .when(is_expanded && provider.is_authenticated(cx), |parent| {
327 parent.child(
328 Button::new(
329 SharedString::from(format!("new-thread-{provider_id}")),
330 "Start New Thread",
331 )
332 .full_width()
333 .style(ButtonStyle::Outlined)
334 .layer(ElevationIndex::ModalSurface)
335 .start_icon(
336 Icon::new(IconName::Thread)
337 .size(IconSize::Small)
338 .color(Color::Muted),
339 )
340 .label_size(LabelSize::Small)
341 .on_click(cx.listener({
342 let provider = provider.clone();
343 move |_this, _event, _window, cx| {
344 cx.emit(AssistantConfigurationEvent::NewThread(
345 provider.clone(),
346 ))
347 }
348 })),
349 )
350 })
351 .when(
352 is_expanded && is_removable_provider(&provider.id(), cx),
353 |this| {
354 this.child(
355 Button::new(
356 SharedString::from(format!("delete-provider-{provider_id}")),
357 "Remove Provider",
358 )
359 .full_width()
360 .style(ButtonStyle::Outlined)
361 .start_icon(
362 Icon::new(IconName::Trash)
363 .size(IconSize::Small)
364 .color(Color::Muted),
365 )
366 .label_size(LabelSize::Small)
367 .on_click(cx.listener({
368 let provider = provider.clone();
369 move |this, _event, window, cx| {
370 this.delete_provider(provider.clone(), window, cx);
371 }
372 })),
373 )
374 },
375 ),
376 )
377 }
378
379 fn delete_provider(
380 &mut self,
381 provider: Arc<dyn LanguageModelProvider>,
382 window: &mut Window,
383 cx: &mut Context<Self>,
384 ) {
385 let fs = self.fs.clone();
386 let provider_id = provider.id();
387
388 cx.spawn_in(window, async move |_, cx| {
389 cx.update(|_window, cx| {
390 update_settings_file(fs.clone(), cx, {
391 let provider_id = provider_id.clone();
392 move |settings, _| {
393 if let Some(ref mut openai_compatible) = settings
394 .language_models
395 .as_mut()
396 .and_then(|lm| lm.openai_compatible.as_mut())
397 {
398 let key_to_remove: Arc<str> = Arc::from(provider_id.0.as_ref());
399 openai_compatible.remove(&key_to_remove);
400 }
401 }
402 });
403 })
404 .log_err();
405
406 cx.update(|_window, cx| {
407 LanguageModelRegistry::global(cx).update(cx, {
408 let provider_id = provider_id.clone();
409 move |registry, cx| {
410 registry.unregister_provider(provider_id, cx);
411 }
412 })
413 })
414 .log_err();
415
416 anyhow::Ok(())
417 })
418 .detach_and_log_err(cx);
419 }
420
421 fn render_provider_configuration_section(
422 &mut self,
423 cx: &mut Context<Self>,
424 ) -> impl IntoElement {
425 let providers = LanguageModelRegistry::read_global(cx).visible_providers();
426
427 let popover_menu = PopoverMenu::new("add-provider-popover")
428 .trigger(
429 Button::new("add-provider", "Add Provider")
430 .style(ButtonStyle::Outlined)
431 .start_icon(
432 Icon::new(IconName::Plus)
433 .size(IconSize::Small)
434 .color(Color::Muted),
435 )
436 .label_size(LabelSize::Small),
437 )
438 .menu({
439 let workspace = self.workspace.clone();
440 move |window, cx| {
441 Some(ContextMenu::build(window, cx, |menu, _window, _cx| {
442 menu.header("Compatible APIs").entry("OpenAI", None, {
443 let workspace = workspace.clone();
444 move |window, cx| {
445 workspace
446 .update(cx, |workspace, cx| {
447 AddLlmProviderModal::toggle(
448 LlmCompatibleProvider::OpenAi,
449 workspace,
450 window,
451 cx,
452 );
453 })
454 .log_err();
455 }
456 })
457 }))
458 }
459 })
460 .anchor(gpui::Corner::TopRight)
461 .offset(gpui::Point {
462 x: px(0.0),
463 y: px(2.0),
464 });
465
466 v_flex()
467 .min_w_0()
468 .w_full()
469 .child(self.render_section_title(
470 "LLM Providers",
471 "Add at least one provider to use AI-powered features with Zed's native agent.",
472 popover_menu.into_any_element(),
473 ))
474 .child(
475 div()
476 .w_full()
477 .pl(DynamicSpacing::Base08.rems(cx))
478 .pr(DynamicSpacing::Base20.rems(cx))
479 .children(
480 providers.into_iter().map(|provider| {
481 self.render_provider_configuration_block(&provider, cx)
482 }),
483 ),
484 )
485 }
486
487 fn render_zed_plan_info(&self, plan: Option<Plan>, cx: &mut Context<Self>) -> impl IntoElement {
488 if let Some(plan) = plan {
489 let free_chip_bg = cx
490 .theme()
491 .colors()
492 .editor_background
493 .opacity(0.5)
494 .blend(cx.theme().colors().text_accent.opacity(0.05));
495
496 let pro_chip_bg = cx
497 .theme()
498 .colors()
499 .editor_background
500 .opacity(0.5)
501 .blend(cx.theme().colors().text_accent.opacity(0.2));
502
503 let (plan_name, label_color, bg_color) = match plan {
504 Plan::ZedFree => ("Free", Color::Default, free_chip_bg),
505 Plan::ZedProTrial => ("Pro Trial", Color::Accent, pro_chip_bg),
506 Plan::ZedPro => ("Pro", Color::Accent, pro_chip_bg),
507 Plan::ZedBusiness => ("Business", Color::Accent, pro_chip_bg),
508 Plan::ZedStudent => ("Student", Color::Accent, pro_chip_bg),
509 };
510
511 Chip::new(plan_name.to_string())
512 .bg_color(bg_color)
513 .label_color(label_color)
514 .into_any_element()
515 } else {
516 div().into_any_element()
517 }
518 }
519
520 fn render_context_servers_section(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
521 let context_server_ids = self.context_server_store.read(cx).server_ids();
522
523 let add_server_popover = PopoverMenu::new("add-server-popover")
524 .trigger(
525 Button::new("add-server", "Add Server")
526 .style(ButtonStyle::Outlined)
527 .start_icon(
528 Icon::new(IconName::Plus)
529 .size(IconSize::Small)
530 .color(Color::Muted),
531 )
532 .label_size(LabelSize::Small),
533 )
534 .menu({
535 move |window, cx| {
536 Some(ContextMenu::build(window, cx, |menu, _window, _cx| {
537 menu.entry("Add Custom Server", None, {
538 |window, cx| {
539 window.dispatch_action(crate::AddContextServer.boxed_clone(), cx)
540 }
541 })
542 .entry("Install from Extensions", None, {
543 |window, cx| {
544 window.dispatch_action(
545 zed_actions::Extensions {
546 category_filter: Some(
547 ExtensionCategoryFilter::ContextServers,
548 ),
549 id: None,
550 }
551 .boxed_clone(),
552 cx,
553 )
554 }
555 })
556 }))
557 }
558 })
559 .anchor(gpui::Corner::TopRight)
560 .offset(gpui::Point {
561 x: px(0.0),
562 y: px(2.0),
563 });
564
565 v_flex()
566 .min_w_0()
567 .border_b_1()
568 .border_color(cx.theme().colors().border)
569 .child(self.render_section_title(
570 "Model Context Protocol (MCP) Servers",
571 "All MCP servers connected directly or via a Zed extension.",
572 add_server_popover.into_any_element(),
573 ))
574 .child(
575 v_flex()
576 .pl_4()
577 .pb_4()
578 .pr_5()
579 .w_full()
580 .gap_1()
581 .map(|parent| {
582 if context_server_ids.is_empty() {
583 parent.child(
584 h_flex()
585 .p_4()
586 .justify_center()
587 .border_1()
588 .border_dashed()
589 .border_color(cx.theme().colors().border.opacity(0.6))
590 .rounded_sm()
591 .child(
592 Label::new("No MCP servers added yet.")
593 .color(Color::Muted)
594 .size(LabelSize::Small),
595 ),
596 )
597 } else {
598 parent.children(itertools::intersperse_with(
599 context_server_ids.iter().cloned().map(|context_server_id| {
600 self.render_context_server(context_server_id, cx)
601 .into_any_element()
602 }),
603 || {
604 Divider::horizontal()
605 .color(DividerColor::BorderFaded)
606 .into_any_element()
607 },
608 ))
609 }
610 }),
611 )
612 }
613
614 fn render_context_server(
615 &self,
616 context_server_id: ContextServerId,
617 cx: &Context<Self>,
618 ) -> impl use<> + IntoElement {
619 let server_status = self
620 .context_server_store
621 .read(cx)
622 .status_for_server(&context_server_id)
623 .unwrap_or(ContextServerStatus::Stopped);
624 let server_configuration = self
625 .context_server_store
626 .read(cx)
627 .configuration_for_server(&context_server_id);
628
629 let is_running = matches!(server_status, ContextServerStatus::Running);
630 let item_id = SharedString::from(context_server_id.0.clone());
631 // Servers without a configuration can only be provided by extensions.
632 let provided_by_extension = server_configuration.as_ref().is_none_or(|config| {
633 matches!(
634 config.as_ref(),
635 ContextServerConfiguration::Extension { .. }
636 )
637 });
638
639 let error = if let ContextServerStatus::Error(error) = server_status.clone() {
640 Some(error)
641 } else {
642 None
643 };
644 let auth_required = matches!(server_status, ContextServerStatus::AuthRequired);
645 let authenticating = matches!(server_status, ContextServerStatus::Authenticating);
646 let context_server_store = self.context_server_store.clone();
647
648 let tool_count = self
649 .context_server_registry
650 .read(cx)
651 .tools_for_server(&context_server_id)
652 .count();
653
654 let (source_icon, source_tooltip) = if provided_by_extension {
655 (
656 IconName::ZedSrcExtension,
657 "This MCP server was installed from an extension.",
658 )
659 } else {
660 (
661 IconName::ZedSrcCustom,
662 "This custom MCP server was installed directly.",
663 )
664 };
665
666 let (status_indicator, tooltip_text) = match server_status {
667 ContextServerStatus::Starting => (
668 Icon::new(IconName::LoadCircle)
669 .size(IconSize::XSmall)
670 .color(Color::Accent)
671 .with_keyed_rotate_animation(
672 SharedString::from(format!("{}-starting", context_server_id.0)),
673 3,
674 )
675 .into_any_element(),
676 "Server is starting.",
677 ),
678 ContextServerStatus::Running => (
679 Indicator::dot().color(Color::Success).into_any_element(),
680 "Server is active.",
681 ),
682 ContextServerStatus::Error(_) => (
683 Indicator::dot().color(Color::Error).into_any_element(),
684 "Server has an error.",
685 ),
686 ContextServerStatus::Stopped => (
687 Indicator::dot().color(Color::Muted).into_any_element(),
688 "Server is stopped.",
689 ),
690 ContextServerStatus::AuthRequired => (
691 Indicator::dot().color(Color::Warning).into_any_element(),
692 "Authentication required.",
693 ),
694 ContextServerStatus::Authenticating => (
695 Icon::new(IconName::LoadCircle)
696 .size(IconSize::XSmall)
697 .color(Color::Accent)
698 .with_keyed_rotate_animation(
699 SharedString::from(format!("{}-authenticating", context_server_id.0)),
700 3,
701 )
702 .into_any_element(),
703 "Waiting for authorization...",
704 ),
705 };
706
707 let is_remote = server_configuration
708 .as_ref()
709 .map(|config| matches!(config.as_ref(), ContextServerConfiguration::Http { .. }))
710 .unwrap_or(false);
711
712 let should_show_logout_button = server_configuration.as_ref().is_some_and(|config| {
713 matches!(config.as_ref(), ContextServerConfiguration::Http { .. })
714 && !config.has_static_auth_header()
715 });
716
717 let context_server_configuration_menu = PopoverMenu::new("context-server-config-menu")
718 .trigger_with_tooltip(
719 IconButton::new("context-server-config-menu", IconName::Settings)
720 .icon_color(Color::Muted)
721 .icon_size(IconSize::Small),
722 Tooltip::text("Configure MCP Server"),
723 )
724 .anchor(Corner::TopRight)
725 .menu({
726 let fs = self.fs.clone();
727 let context_server_id = context_server_id.clone();
728 let language_registry = self.language_registry.clone();
729 let workspace = self.workspace.clone();
730 let context_server_registry = self.context_server_registry.clone();
731 let context_server_store = context_server_store.clone();
732
733 move |window, cx| {
734 Some(ContextMenu::build(window, cx, |menu, _window, _cx| {
735 menu.entry("Configure Server", None, {
736 let context_server_id = context_server_id.clone();
737 let language_registry = language_registry.clone();
738 let workspace = workspace.clone();
739 move |window, cx| {
740 if is_remote {
741 crate::agent_configuration::configure_context_server_modal::ConfigureContextServerModal::show_modal_for_existing_server(
742 context_server_id.clone(),
743 language_registry.clone(),
744 workspace.clone(),
745 window,
746 cx,
747 )
748 .detach();
749 } else {
750 ConfigureContextServerModal::show_modal_for_existing_server(
751 context_server_id.clone(),
752 language_registry.clone(),
753 workspace.clone(),
754 window,
755 cx,
756 )
757 .detach();
758 }
759 }
760 }).when(tool_count > 0, |this| this.entry("View Tools", None, {
761 let context_server_id = context_server_id.clone();
762 let context_server_registry = context_server_registry.clone();
763 let workspace = workspace.clone();
764 move |window, cx| {
765 let context_server_id = context_server_id.clone();
766 workspace.update(cx, |workspace, cx| {
767 ConfigureContextServerToolsModal::toggle(
768 context_server_id,
769 context_server_registry.clone(),
770 workspace,
771 window,
772 cx,
773 );
774 })
775 .ok();
776 }
777 }))
778 .when(should_show_logout_button, |this| {
779 this.entry("Log Out", None, {
780 let context_server_store = context_server_store.clone();
781 let context_server_id = context_server_id.clone();
782 move |_window, cx| {
783 context_server_store.update(cx, |store, cx| {
784 store.logout_server(&context_server_id, cx).log_err();
785 });
786 }
787 })
788 })
789 .separator()
790 .entry("Uninstall", None, {
791 let fs = fs.clone();
792 let context_server_id = context_server_id.clone();
793 let workspace = workspace.clone();
794 move |_, cx| {
795 let uninstall_extension_task = match (
796 provided_by_extension,
797 resolve_extension_for_context_server(&context_server_id, cx),
798 ) {
799 (true, Some((id, manifest))) => {
800 if extension_only_provides_context_server(manifest.as_ref())
801 {
802 ExtensionStore::global(cx).update(cx, |store, cx| {
803 store.uninstall_extension(id, cx)
804 })
805 } else {
806 workspace.update(cx, |workspace, cx| {
807 show_unable_to_uninstall_extension_with_context_server(workspace, context_server_id.clone(), cx);
808 }).log_err();
809 Task::ready(Ok(()))
810 }
811 }
812 _ => Task::ready(Ok(())),
813 };
814
815 cx.spawn({
816 let fs = fs.clone();
817 let context_server_id = context_server_id.clone();
818 async move |cx| {
819 uninstall_extension_task.await?;
820 cx.update(|cx| {
821 update_settings_file(
822 fs.clone(),
823 cx,
824 {
825 let context_server_id =
826 context_server_id.clone();
827 move |settings, _| {
828 settings.project
829 .context_servers
830 .remove(&context_server_id.0);
831 }
832 },
833 )
834 });
835 anyhow::Ok(())
836 }
837 })
838 .detach_and_log_err(cx);
839 }
840 })
841 }))
842 }
843 });
844
845 let feedback_base_container =
846 || h_flex().py_1().min_w_0().w_full().gap_1().justify_between();
847
848 v_flex()
849 .min_w_0()
850 .id(item_id.clone())
851 .child(
852 h_flex()
853 .min_w_0()
854 .w_full()
855 .justify_between()
856 .child(
857 h_flex()
858 .flex_1()
859 .min_w_0()
860 .child(
861 h_flex()
862 .id(format!("tooltip-{}", item_id))
863 .h_full()
864 .w_3()
865 .mr_2()
866 .justify_center()
867 .tooltip(Tooltip::text(tooltip_text))
868 .child(status_indicator),
869 )
870 .child(Label::new(item_id).flex_shrink_0().truncate())
871 .child(
872 div()
873 .id("extension-source")
874 .min_w_0()
875 .mt_0p5()
876 .mx_1()
877 .tooltip(Tooltip::text(source_tooltip))
878 .child(
879 Icon::new(source_icon)
880 .size(IconSize::Small)
881 .color(Color::Muted),
882 ),
883 )
884 .when(is_running, |this| {
885 this.child(
886 Label::new(if tool_count == 1 {
887 SharedString::from("1 tool")
888 } else {
889 SharedString::from(format!("{} tools", tool_count))
890 })
891 .color(Color::Muted)
892 .size(LabelSize::Small),
893 )
894 }),
895 )
896 .child(
897 h_flex()
898 .gap_0p5()
899 .flex_none()
900 .child(context_server_configuration_menu)
901 .child(
902 Switch::new("context-server-switch", is_running.into())
903 .on_click({
904 let context_server_manager = self.context_server_store.clone();
905 let fs = self.fs.clone();
906 let context_server_id = context_server_id.clone();
907
908 move |state, _window, cx| {
909 let is_enabled = match state {
910 ToggleState::Unselected
911 | ToggleState::Indeterminate => {
912 context_server_manager.update(cx, |this, cx| {
913 this.stop_server(&context_server_id, cx)
914 .log_err();
915 });
916 false
917 }
918 ToggleState::Selected => {
919 context_server_manager.update(cx, |this, cx| {
920 if let Some(server) =
921 this.get_server(&context_server_id)
922 {
923 this.start_server(server, cx);
924 }
925 });
926 true
927 }
928 };
929 update_settings_file(fs.clone(), cx, {
930 let context_server_id = context_server_id.clone();
931
932 move |settings, _| {
933 settings
934 .project
935 .context_servers
936 .entry(context_server_id.0)
937 .or_insert_with(|| {
938 settings::ContextServerSettingsContent::Extension {
939 enabled: is_enabled,
940 remote: false,
941 settings: serde_json::json!({}),
942 }
943 })
944 .set_enabled(is_enabled);
945 }
946 });
947 }
948 }),
949 ),
950 ),
951 )
952 .map(|parent| {
953 if let Some(error) = error {
954 return parent
955 .child(
956 feedback_base_container()
957 .child(
958 h_flex()
959 .pr_4()
960 .min_w_0()
961 .w_full()
962 .gap_2()
963 .child(
964 Icon::new(IconName::XCircle)
965 .size(IconSize::XSmall)
966 .color(Color::Error),
967 )
968 .child(
969 div().min_w_0().flex_1().child(
970 Label::new(error)
971 .color(Color::Muted)
972 .size(LabelSize::Small),
973 ),
974 ),
975 )
976 .when(should_show_logout_button, |this| {
977 this.child(
978 Button::new("error-logout-server", "Log Out")
979 .style(ButtonStyle::Outlined)
980 .label_size(LabelSize::Small)
981 .on_click({
982 let context_server_store =
983 context_server_store.clone();
984 let context_server_id =
985 context_server_id.clone();
986 move |_event, _window, cx| {
987 context_server_store.update(
988 cx,
989 |store, cx| {
990 store
991 .logout_server(
992 &context_server_id,
993 cx,
994 )
995 .log_err();
996 },
997 );
998 }
999 }),
1000 )
1001 }),
1002 );
1003 }
1004 if auth_required {
1005 return parent.child(
1006 feedback_base_container()
1007 .child(
1008 h_flex()
1009 .pr_4()
1010 .min_w_0()
1011 .w_full()
1012 .gap_2()
1013 .child(
1014 Icon::new(IconName::Info)
1015 .size(IconSize::XSmall)
1016 .color(Color::Muted),
1017 )
1018 .child(
1019 Label::new("Authenticate to connect this server")
1020 .color(Color::Muted)
1021 .size(LabelSize::Small),
1022 ),
1023 )
1024 .child(
1025 Button::new("error-logout-server", "Authenticate")
1026 .style(ButtonStyle::Outlined)
1027 .label_size(LabelSize::Small)
1028 .on_click({
1029 let context_server_store = context_server_store.clone();
1030 let context_server_id = context_server_id.clone();
1031 move |_event, _window, cx| {
1032 context_server_store.update(cx, |store, cx| {
1033 store
1034 .authenticate_server(&context_server_id, cx)
1035 .log_err();
1036 });
1037 }
1038 }),
1039 ),
1040 );
1041 }
1042 if authenticating {
1043 return parent.child(
1044 h_flex()
1045 .mt_1()
1046 .pr_4()
1047 .min_w_0()
1048 .w_full()
1049 .gap_2()
1050 .child(
1051 div().size_3().flex_shrink_0(), // Alignment Div
1052 )
1053 .child(
1054 Label::new("Authenticating…")
1055 .color(Color::Muted)
1056 .size(LabelSize::Small),
1057 ),
1058
1059 );
1060 }
1061 parent
1062 })
1063 }
1064
1065 fn render_agent_servers_section(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
1066 let agent_server_store = self.agent_server_store.read(cx);
1067
1068 let user_defined_agents = agent_server_store
1069 .external_agents()
1070 .cloned()
1071 .collect::<Vec<_>>();
1072
1073 let user_defined_agents: Vec<_> = user_defined_agents
1074 .into_iter()
1075 .map(|name| {
1076 let icon = if let Some(icon_path) = agent_server_store.agent_icon(&name) {
1077 AgentIcon::Path(icon_path)
1078 } else {
1079 AgentIcon::Name(IconName::Sparkle)
1080 };
1081 let display_name = agent_server_store
1082 .agent_display_name(&name)
1083 .unwrap_or_else(|| name.0.clone());
1084 let source = agent_server_store.agent_source(&name).unwrap_or_default();
1085 (name, icon, display_name, source)
1086 })
1087 .sorted_unstable_by_key(|(_, _, display_name, _)| display_name.to_lowercase())
1088 .collect();
1089
1090 let add_agent_popover = PopoverMenu::new("add-agent-server-popover")
1091 .trigger(
1092 Button::new("add-agent", "Add Agent")
1093 .style(ButtonStyle::Outlined)
1094 .start_icon(
1095 Icon::new(IconName::Plus)
1096 .size(IconSize::Small)
1097 .color(Color::Muted),
1098 )
1099 .label_size(LabelSize::Small),
1100 )
1101 .menu({
1102 move |window, cx| {
1103 Some(ContextMenu::build(window, cx, |menu, _window, _cx| {
1104 menu.entry("Install from Registry", None, {
1105 |window, cx| {
1106 window.dispatch_action(Box::new(zed_actions::AcpRegistry), cx)
1107 }
1108 })
1109 .entry("Add Custom Agent", None, {
1110 move |window, cx| {
1111 if let Some(workspace) = Workspace::for_window(window, cx) {
1112 let workspace = workspace.downgrade();
1113 window
1114 .spawn(cx, async |cx| {
1115 open_new_agent_servers_entry_in_settings_editor(
1116 workspace, cx,
1117 )
1118 .await
1119 })
1120 .detach_and_log_err(cx);
1121 }
1122 }
1123 })
1124 .separator()
1125 .header("Learn More")
1126 .item(
1127 ContextMenuEntry::new("ACP Docs")
1128 .icon(IconName::ArrowUpRight)
1129 .icon_color(Color::Muted)
1130 .icon_position(IconPosition::End)
1131 .handler({
1132 move |window, cx| {
1133 window.dispatch_action(
1134 Box::new(OpenBrowser {
1135 url: "https://agentclientprotocol.com/".into(),
1136 }),
1137 cx,
1138 );
1139 }
1140 }),
1141 )
1142 }))
1143 }
1144 })
1145 .anchor(gpui::Corner::TopRight)
1146 .offset(gpui::Point {
1147 x: px(0.0),
1148 y: px(2.0),
1149 });
1150
1151 v_flex()
1152 .min_w_0()
1153 .border_b_1()
1154 .border_color(cx.theme().colors().border)
1155 .child(
1156 v_flex()
1157 .child(self.render_section_title(
1158 "External Agents",
1159 "All agents connected through the Agent Client Protocol.",
1160 add_agent_popover.into_any_element(),
1161 ))
1162 .child(v_flex().p_4().pt_0().gap_2().map(|mut parent| {
1163 let mut first = true;
1164 for (name, icon, display_name, source) in user_defined_agents {
1165 if !first {
1166 parent = parent
1167 .child(Divider::horizontal().color(DividerColor::BorderFaded));
1168 }
1169 first = false;
1170 parent = parent.child(self.render_agent_server(
1171 icon,
1172 name,
1173 display_name,
1174 source,
1175 cx,
1176 ));
1177 }
1178 parent
1179 })),
1180 )
1181 }
1182
1183 fn render_agent_server(
1184 &self,
1185 icon: AgentIcon,
1186 id: impl Into<SharedString>,
1187 display_name: impl Into<SharedString>,
1188 source: ExternalAgentSource,
1189 cx: &mut Context<Self>,
1190 ) -> impl IntoElement {
1191 let id = id.into();
1192 let display_name = display_name.into();
1193
1194 let icon = match icon {
1195 AgentIcon::Name(icon_name) => Icon::new(icon_name)
1196 .size(IconSize::Small)
1197 .color(Color::Muted),
1198 AgentIcon::Path(icon_path) => Icon::from_external_svg(icon_path)
1199 .size(IconSize::Small)
1200 .color(Color::Muted),
1201 };
1202
1203 let source_badge = match source {
1204 ExternalAgentSource::Extension => Some((
1205 SharedString::new(format!("agent-source-{}", id)),
1206 SharedString::from(format!(
1207 "The {} agent was installed from an extension.",
1208 display_name
1209 )),
1210 IconName::ZedSrcExtension,
1211 )),
1212 ExternalAgentSource::Registry => Some((
1213 SharedString::new(format!("agent-source-{}", id)),
1214 SharedString::from(format!(
1215 "The {} agent was installed from the ACP registry.",
1216 display_name
1217 )),
1218 IconName::AcpRegistry,
1219 )),
1220 ExternalAgentSource::Custom => None,
1221 };
1222
1223 let agent_server_name = AgentId(id.clone());
1224
1225 let uninstall_button = match source {
1226 ExternalAgentSource::Extension => Some(
1227 IconButton::new(
1228 SharedString::from(format!("uninstall-{}", id)),
1229 IconName::Trash,
1230 )
1231 .icon_color(Color::Muted)
1232 .icon_size(IconSize::Small)
1233 .tooltip(Tooltip::text("Uninstall Agent Extension"))
1234 .on_click(cx.listener(move |this, _, _window, cx| {
1235 let agent_name = agent_server_name.clone();
1236
1237 if let Some(ext_id) = this.agent_server_store.update(cx, |store, _cx| {
1238 store.get_extension_id_for_agent(&agent_name)
1239 }) {
1240 ExtensionStore::global(cx)
1241 .update(cx, |store, cx| store.uninstall_extension(ext_id, cx))
1242 .detach_and_log_err(cx);
1243 }
1244 })),
1245 ),
1246 ExternalAgentSource::Registry => {
1247 let fs = self.fs.clone();
1248 Some(
1249 IconButton::new(
1250 SharedString::from(format!("uninstall-{}", id)),
1251 IconName::Trash,
1252 )
1253 .icon_color(Color::Muted)
1254 .icon_size(IconSize::Small)
1255 .tooltip(Tooltip::text("Remove Registry Agent"))
1256 .on_click(cx.listener(move |_, _, _window, cx| {
1257 let agent_name = agent_server_name.clone();
1258 update_settings_file(fs.clone(), cx, move |settings, _| {
1259 let Some(agent_servers) = settings.agent_servers.as_mut() else {
1260 return;
1261 };
1262 if let Some(entry) = agent_servers.get(agent_name.0.as_ref())
1263 && matches!(
1264 entry,
1265 settings::CustomAgentServerSettings::Registry { .. }
1266 )
1267 {
1268 agent_servers.remove(agent_name.0.as_ref());
1269 }
1270 });
1271 })),
1272 )
1273 }
1274 ExternalAgentSource::Custom => {
1275 let fs = self.fs.clone();
1276 Some(
1277 IconButton::new(
1278 SharedString::from(format!("uninstall-{}", id)),
1279 IconName::Trash,
1280 )
1281 .icon_color(Color::Muted)
1282 .icon_size(IconSize::Small)
1283 .tooltip(Tooltip::text("Remove Custom Agent"))
1284 .on_click(cx.listener(move |_, _, _window, cx| {
1285 let agent_name = agent_server_name.clone();
1286 update_settings_file(fs.clone(), cx, move |settings, _| {
1287 let Some(agent_servers) = settings.agent_servers.as_mut() else {
1288 return;
1289 };
1290 if let Some(entry) = agent_servers.get(agent_name.0.as_ref())
1291 && matches!(
1292 entry,
1293 settings::CustomAgentServerSettings::Custom { .. }
1294 )
1295 {
1296 agent_servers.remove(agent_name.0.as_ref());
1297 }
1298 });
1299 })),
1300 )
1301 }
1302 };
1303
1304 h_flex()
1305 .gap_1()
1306 .justify_between()
1307 .child(
1308 h_flex()
1309 .gap_1p5()
1310 .child(icon)
1311 .child(Label::new(display_name))
1312 .when_some(source_badge, |this, (tooltip_id, tooltip_message, icon)| {
1313 this.child(
1314 div()
1315 .id(tooltip_id)
1316 .flex_none()
1317 .tooltip(Tooltip::text(tooltip_message))
1318 .child(Icon::new(icon).size(IconSize::Small).color(Color::Muted)),
1319 )
1320 })
1321 .child(
1322 Icon::new(IconName::Check)
1323 .color(Color::Success)
1324 .size(IconSize::Small),
1325 ),
1326 )
1327 .when_some(uninstall_button, |this, uninstall_button| {
1328 this.child(uninstall_button)
1329 })
1330 }
1331}
1332
1333impl Render for AgentConfiguration {
1334 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1335 v_flex()
1336 .id("assistant-configuration")
1337 .key_context("AgentConfiguration")
1338 .track_focus(&self.focus_handle(cx))
1339 .relative()
1340 .size_full()
1341 .pb_8()
1342 .bg(cx.theme().colors().panel_background)
1343 .child(
1344 div()
1345 .size_full()
1346 .child(
1347 v_flex()
1348 .id("assistant-configuration-content")
1349 .track_scroll(&self.scroll_handle)
1350 .size_full()
1351 .min_w_0()
1352 .overflow_y_scroll()
1353 .child(self.render_agent_servers_section(cx))
1354 .child(self.render_context_servers_section(cx))
1355 .child(self.render_provider_configuration_section(cx)),
1356 )
1357 .vertical_scrollbar_for(&self.scroll_handle, window, cx),
1358 )
1359 }
1360}
1361
1362fn extension_only_provides_context_server(manifest: &ExtensionManifest) -> bool {
1363 manifest.context_servers.len() == 1
1364 && manifest.themes.is_empty()
1365 && manifest.icon_themes.is_empty()
1366 && manifest.languages.is_empty()
1367 && manifest.grammars.is_empty()
1368 && manifest.language_servers.is_empty()
1369 && manifest.slash_commands.is_empty()
1370 && manifest.snippets.is_none()
1371 && manifest.debug_locators.is_empty()
1372}
1373
1374pub(crate) fn resolve_extension_for_context_server(
1375 id: &ContextServerId,
1376 cx: &App,
1377) -> Option<(Arc<str>, Arc<ExtensionManifest>)> {
1378 ExtensionStore::global(cx)
1379 .read(cx)
1380 .installed_extensions()
1381 .iter()
1382 .find(|(_, entry)| entry.manifest.context_servers.contains_key(&id.0))
1383 .map(|(id, entry)| (id.clone(), entry.manifest.clone()))
1384}
1385
1386// This notification appears when trying to delete
1387// an MCP server extension that not only provides
1388// the server, but other things, too, like language servers and more.
1389fn show_unable_to_uninstall_extension_with_context_server(
1390 workspace: &mut Workspace,
1391 id: ContextServerId,
1392 cx: &mut App,
1393) {
1394 let workspace_handle = workspace.weak_handle();
1395 let context_server_id = id.clone();
1396
1397 let status_toast = StatusToast::new(
1398 format!(
1399 "The {} extension provides more than just the MCP server. Proceed to uninstall anyway?",
1400 id.0
1401 ),
1402 cx,
1403 move |this, _cx| {
1404 let workspace_handle = workspace_handle.clone();
1405
1406 this.icon(ToastIcon::new(IconName::Warning).color(Color::Warning))
1407 .dismiss_button(true)
1408 .action("Uninstall", move |_, _cx| {
1409 if let Some((extension_id, _)) =
1410 resolve_extension_for_context_server(&context_server_id, _cx)
1411 {
1412 ExtensionStore::global(_cx).update(_cx, |store, cx| {
1413 store
1414 .uninstall_extension(extension_id, cx)
1415 .detach_and_log_err(cx);
1416 });
1417
1418 workspace_handle
1419 .update(_cx, |workspace, cx| {
1420 let fs = workspace.app_state().fs.clone();
1421 cx.spawn({
1422 let context_server_id = context_server_id.clone();
1423 async move |_workspace_handle, cx| {
1424 cx.update(|cx| {
1425 update_settings_file(fs, cx, move |settings, _| {
1426 settings
1427 .project
1428 .context_servers
1429 .remove(&context_server_id.0);
1430 });
1431 });
1432 anyhow::Ok(())
1433 }
1434 })
1435 .detach_and_log_err(cx);
1436 })
1437 .log_err();
1438 }
1439 })
1440 },
1441 );
1442
1443 workspace.toggle_status_toast(status_toast, cx);
1444}
1445
1446async fn open_new_agent_servers_entry_in_settings_editor(
1447 workspace: WeakEntity<Workspace>,
1448 cx: &mut AsyncWindowContext,
1449) -> Result<()> {
1450 let settings_editor = workspace
1451 .update_in(cx, |_, window, cx| {
1452 create_and_open_local_file(paths::settings_file(), window, cx, || {
1453 settings::initial_user_settings_content().as_ref().into()
1454 })
1455 })?
1456 .await?
1457 .downcast::<Editor>()
1458 .unwrap();
1459
1460 settings_editor
1461 .downgrade()
1462 .update_in(cx, |item, window, cx| {
1463 let text = item.buffer().read(cx).snapshot(cx).text();
1464
1465 let settings = cx.global::<SettingsStore>();
1466
1467 let mut unique_server_name = None;
1468 let edits = settings.edits_for_update(&text, |settings| {
1469 let server_name: Option<String> = (0..u8::MAX)
1470 .map(|i| {
1471 if i == 0 {
1472 "your_agent".to_string()
1473 } else {
1474 format!("your_agent_{}", i)
1475 }
1476 })
1477 .find(|name| {
1478 !settings
1479 .agent_servers
1480 .as_ref()
1481 .is_some_and(|agent_servers| agent_servers.contains_key(name.as_str()))
1482 });
1483 if let Some(server_name) = server_name {
1484 unique_server_name = Some(SharedString::from(server_name.clone()));
1485 settings.agent_servers.get_or_insert_default().insert(
1486 server_name,
1487 settings::CustomAgentServerSettings::Custom {
1488 path: "path_to_executable".into(),
1489 args: vec![],
1490 env: HashMap::default(),
1491 default_mode: None,
1492 default_model: None,
1493 favorite_models: vec![],
1494 default_config_options: Default::default(),
1495 favorite_config_option_values: Default::default(),
1496 },
1497 );
1498 }
1499 });
1500
1501 if edits.is_empty() {
1502 return;
1503 }
1504
1505 let ranges = edits
1506 .iter()
1507 .map(|(range, _)| range.clone())
1508 .collect::<Vec<_>>();
1509
1510 item.edit(
1511 edits.into_iter().map(|(range, s)| {
1512 (
1513 MultiBufferOffset(range.start)..MultiBufferOffset(range.end),
1514 s,
1515 )
1516 }),
1517 cx,
1518 );
1519 if let Some((unique_server_name, buffer)) =
1520 unique_server_name.zip(item.buffer().read(cx).as_singleton())
1521 {
1522 let snapshot = buffer.read(cx).snapshot();
1523 if let Some(range) =
1524 find_text_in_buffer(&unique_server_name, ranges[0].start, &snapshot)
1525 {
1526 item.change_selections(
1527 SelectionEffects::scroll(Autoscroll::newest()),
1528 window,
1529 cx,
1530 |selections| {
1531 selections.select_ranges(vec![
1532 MultiBufferOffset(range.start)..MultiBufferOffset(range.end),
1533 ]);
1534 },
1535 );
1536 }
1537 }
1538 })
1539}
1540
1541fn find_text_in_buffer(
1542 text: &str,
1543 start: usize,
1544 snapshot: &language::BufferSnapshot,
1545) -> Option<Range<usize>> {
1546 let chars = text.chars().collect::<Vec<char>>();
1547
1548 let mut offset = start;
1549 let mut char_offset = 0;
1550 for c in snapshot.chars_at(start) {
1551 if char_offset >= chars.len() {
1552 break;
1553 }
1554 offset += 1;
1555
1556 if c == chars[char_offset] {
1557 char_offset += 1;
1558 } else {
1559 char_offset = 0;
1560 }
1561 }
1562
1563 if char_offset == chars.len() {
1564 Some(offset.saturating_sub(chars.len())..offset)
1565 } else {
1566 None
1567 }
1568}
1569
1570// OpenAI-compatible providers are user-configured and can be removed,
1571// whereas built-in providers (like Anthropic, OpenAI, Google, etc.) can't.
1572//
1573// If in the future we have more "API-compatible-type" of providers,
1574// they should be included here as removable providers.
1575fn is_removable_provider(provider_id: &LanguageModelProviderId, cx: &App) -> bool {
1576 AllLanguageModelSettings::get_global(cx)
1577 .openai_compatible
1578 .contains_key(provider_id.0.as_ref())
1579}