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