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