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