1mod add_llm_provider_modal;
2mod configure_context_server_modal;
3mod manage_profiles_modal;
4mod tool_picker;
5
6use std::{sync::Arc, time::Duration};
7
8use agent_servers::{AgentServerCommand, AllAgentServersSettings, Gemini};
9use agent_settings::AgentSettings;
10use assistant_tool::{ToolSource, ToolWorkingSet};
11use cloud_llm_client::Plan;
12use collections::HashMap;
13use context_server::ContextServerId;
14use extension::ExtensionManifest;
15use extension_host::ExtensionStore;
16use fs::Fs;
17use gpui::{
18 Action, Animation, AnimationExt as _, AnyView, App, Corner, Entity, EventEmitter, FocusHandle,
19 Focusable, Hsla, ScrollHandle, Subscription, Task, Transformation, WeakEntity, percentage,
20};
21use language::LanguageRegistry;
22use language_model::{
23 LanguageModelProvider, LanguageModelProviderId, LanguageModelRegistry, ZED_CLOUD_PROVIDER_ID,
24};
25use notifications::status_toast::{StatusToast, ToastIcon};
26use project::{
27 Project,
28 context_server_store::{ContextServerConfiguration, ContextServerStatus, ContextServerStore},
29 project_settings::{ContextServerSettings, ProjectSettings},
30};
31use settings::{Settings, SettingsStore, update_settings_file};
32use ui::{
33 Chip, ContextMenu, Disclosure, Divider, DividerColor, ElevationIndex, Indicator, PopoverMenu,
34 Scrollbar, ScrollbarState, Switch, SwitchColor, SwitchField, Tooltip, prelude::*,
35};
36use util::ResultExt as _;
37use workspace::Workspace;
38use zed_actions::ExtensionCategoryFilter;
39
40pub(crate) use configure_context_server_modal::ConfigureContextServerModal;
41pub(crate) use manage_profiles_modal::ManageProfilesModal;
42
43use crate::{
44 AddContextServer, ExternalAgent, NewExternalAgentThread,
45 agent_configuration::add_llm_provider_modal::{AddLlmProviderModal, LlmCompatibleProvider},
46};
47
48pub struct AgentConfiguration {
49 fs: Arc<dyn Fs>,
50 language_registry: Arc<LanguageRegistry>,
51 workspace: WeakEntity<Workspace>,
52 project: WeakEntity<Project>,
53 focus_handle: FocusHandle,
54 configuration_views_by_provider: HashMap<LanguageModelProviderId, AnyView>,
55 context_server_store: Entity<ContextServerStore>,
56 expanded_context_server_tools: HashMap<ContextServerId, bool>,
57 expanded_provider_configurations: HashMap<LanguageModelProviderId, bool>,
58 tools: Entity<ToolWorkingSet>,
59 _registry_subscription: Subscription,
60 scroll_handle: ScrollHandle,
61 scrollbar_state: ScrollbarState,
62 gemini_is_installed: bool,
63 _check_for_gemini: Task<()>,
64}
65
66impl AgentConfiguration {
67 pub fn new(
68 fs: Arc<dyn Fs>,
69 context_server_store: Entity<ContextServerStore>,
70 tools: Entity<ToolWorkingSet>,
71 language_registry: Arc<LanguageRegistry>,
72 workspace: WeakEntity<Workspace>,
73 project: WeakEntity<Project>,
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 cx.observe_global_in::<SettingsStore>(window, |this, _, cx| {
99 this.check_for_gemini(cx);
100 cx.notify();
101 })
102 .detach();
103
104 let scroll_handle = ScrollHandle::new();
105 let scrollbar_state = ScrollbarState::new(scroll_handle.clone());
106
107 let mut this = Self {
108 fs,
109 language_registry,
110 workspace,
111 project,
112 focus_handle,
113 configuration_views_by_provider: HashMap::default(),
114 context_server_store,
115 expanded_context_server_tools: HashMap::default(),
116 expanded_provider_configurations: HashMap::default(),
117 tools,
118 _registry_subscription: registry_subscription,
119 scroll_handle,
120 scrollbar_state,
121 gemini_is_installed: false,
122 _check_for_gemini: Task::ready(()),
123 };
124 this.build_provider_configuration_views(window, cx);
125 this.check_for_gemini(cx);
126 this
127 }
128
129 fn build_provider_configuration_views(&mut self, window: &mut Window, cx: &mut Context<Self>) {
130 let providers = LanguageModelRegistry::read_global(cx).providers();
131 for provider in providers {
132 self.add_provider_configuration_view(&provider, window, cx);
133 }
134 }
135
136 fn remove_provider_configuration_view(&mut self, provider_id: &LanguageModelProviderId) {
137 self.configuration_views_by_provider.remove(provider_id);
138 self.expanded_provider_configurations.remove(provider_id);
139 }
140
141 fn add_provider_configuration_view(
142 &mut self,
143 provider: &Arc<dyn LanguageModelProvider>,
144 window: &mut Window,
145 cx: &mut Context<Self>,
146 ) {
147 let configuration_view = provider.configuration_view(
148 language_model::ConfigurationViewTargetAgent::ZedAgent,
149 window,
150 cx,
151 );
152 self.configuration_views_by_provider
153 .insert(provider.id(), configuration_view);
154 }
155
156 fn check_for_gemini(&mut self, cx: &mut Context<Self>) {
157 let project = self.project.clone();
158 let settings = AllAgentServersSettings::get_global(cx).clone();
159 self._check_for_gemini = cx.spawn({
160 async move |this, cx| {
161 let Some(project) = project.upgrade() else {
162 return;
163 };
164 let gemini_is_installed = AgentServerCommand::resolve(
165 Gemini::binary_name(),
166 &[],
167 // TODO expose fallback path from the Gemini/CC types so we don't have to hardcode it again here
168 None,
169 settings.gemini,
170 &project,
171 cx,
172 )
173 .await
174 .is_some();
175 this.update(cx, |this, cx| {
176 this.gemini_is_installed = gemini_is_installed;
177 cx.notify();
178 })
179 .ok();
180 }
181 });
182 }
183}
184
185impl Focusable for AgentConfiguration {
186 fn focus_handle(&self, _: &App) -> FocusHandle {
187 self.focus_handle.clone()
188 }
189}
190
191pub enum AssistantConfigurationEvent {
192 NewThread(Arc<dyn LanguageModelProvider>),
193}
194
195impl EventEmitter<AssistantConfigurationEvent> for AgentConfiguration {}
196
197impl AgentConfiguration {
198 fn render_provider_configuration_block(
199 &mut self,
200 provider: &Arc<dyn LanguageModelProvider>,
201 cx: &mut Context<Self>,
202 ) -> impl IntoElement + use<> {
203 let provider_id = provider.id().0;
204 let provider_name = provider.name().0;
205 let provider_id_string = SharedString::from(format!("provider-disclosure-{provider_id}"));
206
207 let configuration_view = self
208 .configuration_views_by_provider
209 .get(&provider.id())
210 .cloned();
211
212 let is_expanded = self
213 .expanded_provider_configurations
214 .get(&provider.id())
215 .copied()
216 .unwrap_or(false);
217
218 let is_zed_provider = provider.id() == ZED_CLOUD_PROVIDER_ID;
219 let current_plan = if is_zed_provider {
220 self.workspace
221 .upgrade()
222 .and_then(|workspace| workspace.read(cx).user_store().read(cx).plan())
223 } else {
224 None
225 };
226
227 let is_signed_in = self
228 .workspace
229 .read_with(cx, |workspace, _| {
230 !workspace.client().status().borrow().is_signed_out()
231 })
232 .unwrap_or(false);
233
234 v_flex()
235 .w_full()
236 .when(is_expanded, |this| this.mb_2())
237 .child(
238 div()
239 .opacity(0.6)
240 .px_2()
241 .child(Divider::horizontal().color(DividerColor::Border)),
242 )
243 .child(
244 h_flex()
245 .map(|this| {
246 if is_expanded {
247 this.mt_2().mb_1()
248 } else {
249 this.my_2()
250 }
251 })
252 .w_full()
253 .justify_between()
254 .child(
255 h_flex()
256 .id(provider_id_string.clone())
257 .px_2()
258 .py_0p5()
259 .w_full()
260 .justify_between()
261 .rounded_sm()
262 .hover(|hover| hover.bg(cx.theme().colors().element_hover))
263 .child(
264 h_flex()
265 .w_full()
266 .gap_2()
267 .child(
268 Icon::new(provider.icon())
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 .when(provider.is_authenticated(cx), |parent| {
315 parent.child(
316 Button::new(
317 SharedString::from(format!("new-thread-{provider_id}")),
318 "Start New Thread",
319 )
320 .icon_position(IconPosition::Start)
321 .icon(IconName::Thread)
322 .icon_size(IconSize::Small)
323 .icon_color(Color::Muted)
324 .label_size(LabelSize::Small)
325 .on_click(cx.listener({
326 let provider = provider.clone();
327 move |_this, _event, _window, cx| {
328 cx.emit(AssistantConfigurationEvent::NewThread(
329 provider.clone(),
330 ))
331 }
332 })),
333 )
334 }),
335 )
336 .child(
337 div()
338 .w_full()
339 .px_2()
340 .when(is_expanded, |parent| match configuration_view {
341 Some(configuration_view) => parent.child(configuration_view),
342 None => parent.child(Label::new(format!(
343 "No configuration view for {provider_name}",
344 ))),
345 }),
346 )
347 }
348
349 fn render_provider_configuration_section(
350 &mut self,
351 cx: &mut Context<Self>,
352 ) -> impl IntoElement {
353 let providers = LanguageModelRegistry::read_global(cx).providers();
354
355 v_flex()
356 .w_full()
357 .child(
358 h_flex()
359 .p(DynamicSpacing::Base16.rems(cx))
360 .pr(DynamicSpacing::Base20.rems(cx))
361 .pb_0()
362 .mb_2p5()
363 .items_start()
364 .justify_between()
365 .child(
366 v_flex()
367 .w_full()
368 .gap_0p5()
369 .child(
370 h_flex()
371 .w_full()
372 .gap_2()
373 .justify_between()
374 .child(Headline::new("LLM Providers"))
375 .child(
376 PopoverMenu::new("add-provider-popover")
377 .trigger(
378 Button::new("add-provider", "Add Provider")
379 .icon_position(IconPosition::Start)
380 .icon(IconName::Plus)
381 .icon_size(IconSize::Small)
382 .icon_color(Color::Muted)
383 .label_size(LabelSize::Small),
384 )
385 .anchor(gpui::Corner::TopRight)
386 .menu({
387 let workspace = self.workspace.clone();
388 move |window, cx| {
389 Some(ContextMenu::build(
390 window,
391 cx,
392 |menu, _window, _cx| {
393 menu.header("Compatible APIs").entry(
394 "OpenAI",
395 None,
396 {
397 let workspace =
398 workspace.clone();
399 move |window, cx| {
400 workspace
401 .update(cx, |workspace, cx| {
402 AddLlmProviderModal::toggle(
403 LlmCompatibleProvider::OpenAi,
404 workspace,
405 window,
406 cx,
407 );
408 })
409 .log_err();
410 }
411 },
412 )
413 },
414 ))
415 }
416 }),
417 ),
418 )
419 .child(
420 Label::new("Add at least one provider to use AI-powered features with Zed's native agent.")
421 .color(Color::Muted),
422 ),
423 ),
424 )
425 .child(
426 div()
427 .w_full()
428 .pl(DynamicSpacing::Base08.rems(cx))
429 .pr(DynamicSpacing::Base20.rems(cx))
430 .children(
431 providers.into_iter().map(|provider| {
432 self.render_provider_configuration_block(&provider, cx)
433 }),
434 ),
435 )
436 }
437
438 fn render_command_permission(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
439 let always_allow_tool_actions = AgentSettings::get_global(cx).always_allow_tool_actions;
440 let fs = self.fs.clone();
441
442 SwitchField::new(
443 "always-allow-tool-actions-switch",
444 "Allow running commands without asking for confirmation",
445 Some(
446 "The agent can perform potentially destructive actions without asking for your confirmation.".into(),
447 ),
448 always_allow_tool_actions,
449 move |state, _window, cx| {
450 let allow = state == &ToggleState::Selected;
451 update_settings_file::<AgentSettings>(fs.clone(), cx, move |settings, _| {
452 settings.set_always_allow_tool_actions(allow);
453 });
454 },
455 )
456 }
457
458 fn render_single_file_review(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
459 let single_file_review = AgentSettings::get_global(cx).single_file_review;
460 let fs = self.fs.clone();
461
462 SwitchField::new(
463 "single-file-review",
464 "Enable single-file agent reviews",
465 Some("Agent edits are also displayed in single-file editors for review.".into()),
466 single_file_review,
467 move |state, _window, cx| {
468 let allow = state == &ToggleState::Selected;
469 update_settings_file::<AgentSettings>(fs.clone(), cx, move |settings, _| {
470 settings.set_single_file_review(allow);
471 });
472 },
473 )
474 }
475
476 fn render_sound_notification(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
477 let play_sound_when_agent_done = AgentSettings::get_global(cx).play_sound_when_agent_done;
478 let fs = self.fs.clone();
479
480 SwitchField::new(
481 "sound-notification",
482 "Play sound when finished generating",
483 Some(
484 "Hear a notification sound when the agent is done generating changes or needs your input.".into(),
485 ),
486 play_sound_when_agent_done,
487 move |state, _window, cx| {
488 let allow = state == &ToggleState::Selected;
489 update_settings_file::<AgentSettings>(fs.clone(), cx, move |settings, _| {
490 settings.set_play_sound_when_agent_done(allow);
491 });
492 },
493 )
494 }
495
496 fn render_modifier_to_send(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
497 let use_modifier_to_send = AgentSettings::get_global(cx).use_modifier_to_send;
498 let fs = self.fs.clone();
499
500 SwitchField::new(
501 "modifier-send",
502 "Use modifier to submit a message",
503 Some(
504 "Make a modifier (cmd-enter on macOS, ctrl-enter on Linux or Windows) required to send messages.".into(),
505 ),
506 use_modifier_to_send,
507 move |state, _window, cx| {
508 let allow = state == &ToggleState::Selected;
509 update_settings_file::<AgentSettings>(fs.clone(), cx, move |settings, _| {
510 settings.set_use_modifier_to_send(allow);
511 });
512 },
513 )
514 }
515
516 fn render_general_settings_section(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
517 v_flex()
518 .p(DynamicSpacing::Base16.rems(cx))
519 .pr(DynamicSpacing::Base20.rems(cx))
520 .gap_2p5()
521 .border_b_1()
522 .border_color(cx.theme().colors().border)
523 .child(Headline::new("General Settings"))
524 .child(self.render_command_permission(cx))
525 .child(self.render_single_file_review(cx))
526 .child(self.render_sound_notification(cx))
527 .child(self.render_modifier_to_send(cx))
528 }
529
530 fn render_zed_plan_info(&self, plan: Option<Plan>, cx: &mut Context<Self>) -> impl IntoElement {
531 if let Some(plan) = plan {
532 let free_chip_bg = cx
533 .theme()
534 .colors()
535 .editor_background
536 .opacity(0.5)
537 .blend(cx.theme().colors().text_accent.opacity(0.05));
538
539 let pro_chip_bg = cx
540 .theme()
541 .colors()
542 .editor_background
543 .opacity(0.5)
544 .blend(cx.theme().colors().text_accent.opacity(0.2));
545
546 let (plan_name, label_color, bg_color) = match plan {
547 Plan::ZedFree => ("Free", Color::Default, free_chip_bg),
548 Plan::ZedProTrial => ("Pro Trial", Color::Accent, pro_chip_bg),
549 Plan::ZedPro => ("Pro", Color::Accent, pro_chip_bg),
550 };
551
552 Chip::new(plan_name.to_string())
553 .bg_color(bg_color)
554 .label_color(label_color)
555 .into_any_element()
556 } else {
557 div().into_any_element()
558 }
559 }
560
561 fn card_item_bg_color(&self, cx: &mut Context<Self>) -> Hsla {
562 cx.theme().colors().background.opacity(0.25)
563 }
564
565 fn card_item_border_color(&self, cx: &mut Context<Self>) -> Hsla {
566 cx.theme().colors().border.opacity(0.6)
567 }
568
569 fn render_context_servers_section(
570 &mut self,
571 window: &mut Window,
572 cx: &mut Context<Self>,
573 ) -> impl IntoElement {
574 let context_server_ids = self.context_server_store.read(cx).configured_server_ids();
575
576 v_flex()
577 .p(DynamicSpacing::Base16.rems(cx))
578 .pr(DynamicSpacing::Base20.rems(cx))
579 .gap_2()
580 .border_b_1()
581 .border_color(cx.theme().colors().border)
582 .child(
583 v_flex()
584 .gap_0p5()
585 .child(Headline::new("Model Context Protocol (MCP) Servers"))
586 .child(
587 Label::new(
588 "All context servers connected through the Model Context Protocol.",
589 )
590 .color(Color::Muted),
591 ),
592 )
593 .children(
594 context_server_ids.into_iter().map(|context_server_id| {
595 self.render_context_server(context_server_id, window, cx)
596 }),
597 )
598 .child(
599 h_flex()
600 .justify_between()
601 .gap_1p5()
602 .child(
603 h_flex().w_full().child(
604 Button::new("add-context-server", "Add Custom Server")
605 .style(ButtonStyle::Filled)
606 .layer(ElevationIndex::ModalSurface)
607 .full_width()
608 .icon(IconName::Plus)
609 .icon_size(IconSize::Small)
610 .icon_position(IconPosition::Start)
611 .on_click(|_event, window, cx| {
612 window.dispatch_action(AddContextServer.boxed_clone(), cx)
613 }),
614 ),
615 )
616 .child(
617 h_flex().w_full().child(
618 Button::new(
619 "install-context-server-extensions",
620 "Install MCP Extensions",
621 )
622 .style(ButtonStyle::Filled)
623 .layer(ElevationIndex::ModalSurface)
624 .full_width()
625 .icon(IconName::ToolHammer)
626 .icon_size(IconSize::Small)
627 .icon_position(IconPosition::Start)
628 .on_click(|_event, window, cx| {
629 window.dispatch_action(
630 zed_actions::Extensions {
631 category_filter: Some(
632 ExtensionCategoryFilter::ContextServers,
633 ),
634 id: None,
635 }
636 .boxed_clone(),
637 cx,
638 )
639 }),
640 ),
641 ),
642 )
643 }
644
645 fn render_context_server(
646 &self,
647 context_server_id: ContextServerId,
648 window: &mut Window,
649 cx: &mut Context<Self>,
650 ) -> impl use<> + IntoElement {
651 let tools_by_source = self.tools.read(cx).tools_by_source(cx);
652 let server_status = self
653 .context_server_store
654 .read(cx)
655 .status_for_server(&context_server_id)
656 .unwrap_or(ContextServerStatus::Stopped);
657 let server_configuration = self
658 .context_server_store
659 .read(cx)
660 .configuration_for_server(&context_server_id);
661
662 let is_running = matches!(server_status, ContextServerStatus::Running);
663 let item_id = SharedString::from(context_server_id.0.clone());
664 let is_from_extension = server_configuration
665 .as_ref()
666 .map(|config| {
667 matches!(
668 config.as_ref(),
669 ContextServerConfiguration::Extension { .. }
670 )
671 })
672 .unwrap_or(false);
673
674 let error = if let ContextServerStatus::Error(error) = server_status.clone() {
675 Some(error)
676 } else {
677 None
678 };
679
680 let are_tools_expanded = self
681 .expanded_context_server_tools
682 .get(&context_server_id)
683 .copied()
684 .unwrap_or_default();
685 let tools = tools_by_source
686 .get(&ToolSource::ContextServer {
687 id: context_server_id.0.clone().into(),
688 })
689 .map_or([].as_slice(), |tools| tools.as_slice());
690 let tool_count = tools.len();
691
692 let (source_icon, source_tooltip) = if is_from_extension {
693 (
694 IconName::ZedMcpExtension,
695 "This MCP server was installed from an extension.",
696 )
697 } else {
698 (
699 IconName::ZedMcpCustom,
700 "This custom MCP server was installed directly.",
701 )
702 };
703
704 let (status_indicator, tooltip_text) = match server_status {
705 ContextServerStatus::Starting => (
706 Icon::new(IconName::LoadCircle)
707 .size(IconSize::XSmall)
708 .color(Color::Accent)
709 .with_animation(
710 SharedString::from(format!("{}-starting", context_server_id.0,)),
711 Animation::new(Duration::from_secs(3)).repeat(),
712 |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
713 )
714 .into_any_element(),
715 "Server is starting.",
716 ),
717 ContextServerStatus::Running => (
718 Indicator::dot().color(Color::Success).into_any_element(),
719 "Server is active.",
720 ),
721 ContextServerStatus::Error(_) => (
722 Indicator::dot().color(Color::Error).into_any_element(),
723 "Server has an error.",
724 ),
725 ContextServerStatus::Stopped => (
726 Indicator::dot().color(Color::Muted).into_any_element(),
727 "Server is stopped.",
728 ),
729 };
730
731 let context_server_configuration_menu = PopoverMenu::new("context-server-config-menu")
732 .trigger_with_tooltip(
733 IconButton::new("context-server-config-menu", IconName::Settings)
734 .icon_color(Color::Muted)
735 .icon_size(IconSize::Small),
736 Tooltip::text("Open MCP server options"),
737 )
738 .anchor(Corner::TopRight)
739 .menu({
740 let fs = self.fs.clone();
741 let context_server_id = context_server_id.clone();
742 let language_registry = self.language_registry.clone();
743 let context_server_store = self.context_server_store.clone();
744 let workspace = self.workspace.clone();
745 move |window, cx| {
746 Some(ContextMenu::build(window, cx, |menu, _window, _cx| {
747 menu.entry("Configure Server", None, {
748 let context_server_id = context_server_id.clone();
749 let language_registry = language_registry.clone();
750 let workspace = workspace.clone();
751 move |window, cx| {
752 ConfigureContextServerModal::show_modal_for_existing_server(
753 context_server_id.clone(),
754 language_registry.clone(),
755 workspace.clone(),
756 window,
757 cx,
758 )
759 .detach_and_log_err(cx);
760 }
761 })
762 .separator()
763 .entry("Uninstall", None, {
764 let fs = fs.clone();
765 let context_server_id = context_server_id.clone();
766 let context_server_store = context_server_store.clone();
767 let workspace = workspace.clone();
768 move |_, cx| {
769 let is_provided_by_extension = context_server_store
770 .read(cx)
771 .configuration_for_server(&context_server_id)
772 .as_ref()
773 .map(|config| {
774 matches!(
775 config.as_ref(),
776 ContextServerConfiguration::Extension { .. }
777 )
778 })
779 .unwrap_or(false);
780
781 let uninstall_extension_task = match (
782 is_provided_by_extension,
783 resolve_extension_for_context_server(&context_server_id, cx),
784 ) {
785 (true, Some((id, manifest))) => {
786 if extension_only_provides_context_server(manifest.as_ref())
787 {
788 ExtensionStore::global(cx).update(cx, |store, cx| {
789 store.uninstall_extension(id, cx)
790 })
791 } else {
792 workspace.update(cx, |workspace, cx| {
793 show_unable_to_uninstall_extension_with_context_server(workspace, context_server_id.clone(), cx);
794 }).log_err();
795 Task::ready(Ok(()))
796 }
797 }
798 _ => Task::ready(Ok(())),
799 };
800
801 cx.spawn({
802 let fs = fs.clone();
803 let context_server_id = context_server_id.clone();
804 async move |cx| {
805 uninstall_extension_task.await?;
806 cx.update(|cx| {
807 update_settings_file::<ProjectSettings>(
808 fs.clone(),
809 cx,
810 {
811 let context_server_id =
812 context_server_id.clone();
813 move |settings, _| {
814 settings
815 .context_servers
816 .remove(&context_server_id.0);
817 }
818 },
819 )
820 })
821 }
822 })
823 .detach_and_log_err(cx);
824 }
825 })
826 }))
827 }
828 });
829
830 v_flex()
831 .id(item_id.clone())
832 .border_1()
833 .rounded_md()
834 .border_color(self.card_item_border_color(cx))
835 .bg(self.card_item_bg_color(cx))
836 .overflow_hidden()
837 .child(
838 h_flex()
839 .p_1()
840 .justify_between()
841 .when(
842 error.is_some() || are_tools_expanded && tool_count >= 1,
843 |element| {
844 element
845 .border_b_1()
846 .border_color(self.card_item_border_color(cx))
847 },
848 )
849 .child(
850 h_flex()
851 .child(
852 Disclosure::new(
853 "tool-list-disclosure",
854 are_tools_expanded || error.is_some(),
855 )
856 .disabled(tool_count == 0)
857 .on_click(cx.listener({
858 let context_server_id = context_server_id.clone();
859 move |this, _event, _window, _cx| {
860 let is_open = this
861 .expanded_context_server_tools
862 .entry(context_server_id.clone())
863 .or_insert(false);
864
865 *is_open = !*is_open;
866 }
867 })),
868 )
869 .child(
870 h_flex()
871 .id(SharedString::from(format!("tooltip-{}", item_id)))
872 .h_full()
873 .w_3()
874 .mx_1()
875 .justify_center()
876 .tooltip(Tooltip::text(tooltip_text))
877 .child(status_indicator),
878 )
879 .child(Label::new(item_id).ml_0p5())
880 .child(
881 div()
882 .id("extension-source")
883 .mt_0p5()
884 .mx_1()
885 .tooltip(Tooltip::text(source_tooltip))
886 .child(
887 Icon::new(source_icon)
888 .size(IconSize::Small)
889 .color(Color::Muted),
890 ),
891 )
892 .when(is_running, |this| {
893 this.child(
894 Label::new(if tool_count == 1 {
895 SharedString::from("1 tool")
896 } else {
897 SharedString::from(format!("{} tools", tool_count))
898 })
899 .color(Color::Muted)
900 .size(LabelSize::Small),
901 )
902 }),
903 )
904 .child(
905 h_flex()
906 .gap_1()
907 .child(context_server_configuration_menu)
908 .child(
909 Switch::new("context-server-switch", is_running.into())
910 .color(SwitchColor::Accent)
911 .on_click({
912 let context_server_manager =
913 self.context_server_store.clone();
914 let fs = self.fs.clone();
915
916 move |state, _window, cx| {
917 let is_enabled = match state {
918 ToggleState::Unselected
919 | ToggleState::Indeterminate => {
920 context_server_manager.update(
921 cx,
922 |this, cx| {
923 this.stop_server(
924 &context_server_id,
925 cx,
926 )
927 .log_err();
928 },
929 );
930 false
931 }
932 ToggleState::Selected => {
933 context_server_manager.update(
934 cx,
935 |this, cx| {
936 if let Some(server) =
937 this.get_server(&context_server_id)
938 {
939 this.start_server(server, cx);
940 }
941 },
942 );
943 true
944 }
945 };
946 update_settings_file::<ProjectSettings>(
947 fs.clone(),
948 cx,
949 {
950 let context_server_id =
951 context_server_id.clone();
952
953 move |settings, _| {
954 settings
955 .context_servers
956 .entry(context_server_id.0)
957 .or_insert_with(|| {
958 ContextServerSettings::Extension {
959 enabled: is_enabled,
960 settings: serde_json::json!({}),
961 }
962 })
963 .set_enabled(is_enabled);
964 }
965 },
966 );
967 }
968 }),
969 ),
970 ),
971 )
972 .map(|parent| {
973 if let Some(error) = error {
974 return parent.child(
975 h_flex()
976 .p_2()
977 .gap_2()
978 .items_start()
979 .child(
980 h_flex()
981 .flex_none()
982 .h(window.line_height() / 1.6_f32)
983 .justify_center()
984 .child(
985 Icon::new(IconName::XCircle)
986 .size(IconSize::XSmall)
987 .color(Color::Error),
988 ),
989 )
990 .child(
991 div().w_full().child(
992 Label::new(error)
993 .buffer_font(cx)
994 .color(Color::Muted)
995 .size(LabelSize::Small),
996 ),
997 ),
998 );
999 }
1000
1001 if !are_tools_expanded || tools.is_empty() {
1002 return parent;
1003 }
1004
1005 parent.child(v_flex().py_1p5().px_1().gap_1().children(
1006 tools.iter().enumerate().map(|(ix, tool)| {
1007 h_flex()
1008 .id(("tool-item", ix))
1009 .px_1()
1010 .gap_2()
1011 .justify_between()
1012 .hover(|style| style.bg(cx.theme().colors().element_hover))
1013 .rounded_sm()
1014 .child(
1015 Label::new(tool.name())
1016 .buffer_font(cx)
1017 .size(LabelSize::Small),
1018 )
1019 .child(
1020 Icon::new(IconName::Info)
1021 .size(IconSize::Small)
1022 .color(Color::Ignored),
1023 )
1024 .tooltip(Tooltip::text(tool.description()))
1025 }),
1026 ))
1027 })
1028 }
1029
1030 fn render_agent_servers_section(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
1031 let settings = AllAgentServersSettings::get_global(cx).clone();
1032 let user_defined_agents = settings
1033 .custom
1034 .iter()
1035 .map(|(name, settings)| {
1036 self.render_agent_server(
1037 IconName::Ai,
1038 name.clone(),
1039 ExternalAgent::Custom {
1040 name: name.clone(),
1041 settings: settings.clone(),
1042 },
1043 None,
1044 cx,
1045 )
1046 .into_any_element()
1047 })
1048 .collect::<Vec<_>>();
1049
1050 v_flex()
1051 .border_b_1()
1052 .border_color(cx.theme().colors().border)
1053 .child(
1054 v_flex()
1055 .p(DynamicSpacing::Base16.rems(cx))
1056 .pr(DynamicSpacing::Base20.rems(cx))
1057 .gap_2()
1058 .child(
1059 v_flex()
1060 .gap_0p5()
1061 .child(Headline::new("External Agents"))
1062 .child(
1063 Label::new(
1064 "Use the full power of Zed's UI with your favorite agent, connected via the Agent Client Protocol.",
1065 )
1066 .color(Color::Muted),
1067 ),
1068 )
1069 .child(self.render_agent_server(
1070 IconName::AiGemini,
1071 "Gemini CLI",
1072 ExternalAgent::Gemini,
1073 (!self.gemini_is_installed).then_some(Gemini::install_command().into()),
1074 cx,
1075 ))
1076 // TODO add CC
1077 .children(user_defined_agents),
1078 )
1079 }
1080
1081 fn render_agent_server(
1082 &self,
1083 icon: IconName,
1084 name: impl Into<SharedString>,
1085 agent: ExternalAgent,
1086 install_command: Option<SharedString>,
1087 cx: &mut Context<Self>,
1088 ) -> impl IntoElement {
1089 let name = name.into();
1090 h_flex()
1091 .p_1()
1092 .pl_2()
1093 .gap_1p5()
1094 .justify_between()
1095 .border_1()
1096 .rounded_md()
1097 .border_color(self.card_item_border_color(cx))
1098 .bg(self.card_item_bg_color(cx))
1099 .overflow_hidden()
1100 .child(
1101 h_flex()
1102 .gap_1p5()
1103 .child(Icon::new(icon).size(IconSize::Small).color(Color::Muted))
1104 .child(Label::new(name.clone())),
1105 )
1106 .map(|this| {
1107 if let Some(install_command) = install_command {
1108 this.child(
1109 Button::new(
1110 SharedString::from(format!("install_external_agent-{name}")),
1111 "Install Agent",
1112 )
1113 .label_size(LabelSize::Small)
1114 .icon(IconName::Plus)
1115 .icon_position(IconPosition::Start)
1116 .icon_size(IconSize::XSmall)
1117 .icon_color(Color::Muted)
1118 .tooltip(Tooltip::text(install_command.clone()))
1119 .on_click(cx.listener(
1120 move |this, _, window, cx| {
1121 let Some(project) = this.project.upgrade() else {
1122 return;
1123 };
1124 let Some(workspace) = this.workspace.upgrade() else {
1125 return;
1126 };
1127 let cwd = project.read(cx).first_project_directory(cx);
1128 let shell =
1129 project.read(cx).terminal_settings(&cwd, cx).shell.clone();
1130 let spawn_in_terminal = task::SpawnInTerminal {
1131 id: task::TaskId(install_command.to_string()),
1132 full_label: install_command.to_string(),
1133 label: install_command.to_string(),
1134 command: Some(install_command.to_string()),
1135 args: Vec::new(),
1136 command_label: install_command.to_string(),
1137 cwd,
1138 env: Default::default(),
1139 use_new_terminal: true,
1140 allow_concurrent_runs: true,
1141 reveal: Default::default(),
1142 reveal_target: Default::default(),
1143 hide: Default::default(),
1144 shell,
1145 show_summary: true,
1146 show_command: true,
1147 show_rerun: false,
1148 };
1149 let task = workspace.update(cx, |workspace, cx| {
1150 workspace.spawn_in_terminal(spawn_in_terminal, window, cx)
1151 });
1152 cx.spawn(async move |this, cx| {
1153 task.await;
1154 this.update(cx, |this, cx| {
1155 this.check_for_gemini(cx);
1156 })
1157 .ok();
1158 })
1159 .detach();
1160 },
1161 )),
1162 )
1163 } else {
1164 this.child(
1165 h_flex().gap_1().child(
1166 Button::new(
1167 SharedString::from(format!("start_acp_thread-{name}")),
1168 "Start New Thread",
1169 )
1170 .label_size(LabelSize::Small)
1171 .icon(IconName::Thread)
1172 .icon_position(IconPosition::Start)
1173 .icon_size(IconSize::XSmall)
1174 .icon_color(Color::Muted)
1175 .on_click(move |_, window, cx| {
1176 window.dispatch_action(
1177 NewExternalAgentThread {
1178 agent: Some(agent.clone()),
1179 }
1180 .boxed_clone(),
1181 cx,
1182 );
1183 }),
1184 ),
1185 )
1186 }
1187 })
1188 }
1189}
1190
1191impl Render for AgentConfiguration {
1192 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1193 v_flex()
1194 .id("assistant-configuration")
1195 .key_context("AgentConfiguration")
1196 .track_focus(&self.focus_handle(cx))
1197 .relative()
1198 .size_full()
1199 .pb_8()
1200 .bg(cx.theme().colors().panel_background)
1201 .child(
1202 v_flex()
1203 .id("assistant-configuration-content")
1204 .track_scroll(&self.scroll_handle)
1205 .size_full()
1206 .overflow_y_scroll()
1207 .child(self.render_general_settings_section(cx))
1208 .child(self.render_agent_servers_section(cx))
1209 .child(self.render_context_servers_section(window, cx))
1210 .child(self.render_provider_configuration_section(cx)),
1211 )
1212 .child(
1213 div()
1214 .id("assistant-configuration-scrollbar")
1215 .occlude()
1216 .absolute()
1217 .right(px(3.))
1218 .top_0()
1219 .bottom_0()
1220 .pb_6()
1221 .w(px(12.))
1222 .cursor_default()
1223 .on_mouse_move(cx.listener(|_, _, _window, cx| {
1224 cx.notify();
1225 cx.stop_propagation()
1226 }))
1227 .on_hover(|_, _window, cx| {
1228 cx.stop_propagation();
1229 })
1230 .on_any_mouse_down(|_, _window, cx| {
1231 cx.stop_propagation();
1232 })
1233 .on_scroll_wheel(cx.listener(|_, _, _window, cx| {
1234 cx.notify();
1235 }))
1236 .children(Scrollbar::vertical(self.scrollbar_state.clone())),
1237 )
1238 }
1239}
1240
1241fn extension_only_provides_context_server(manifest: &ExtensionManifest) -> bool {
1242 manifest.context_servers.len() == 1
1243 && manifest.themes.is_empty()
1244 && manifest.icon_themes.is_empty()
1245 && manifest.languages.is_empty()
1246 && manifest.grammars.is_empty()
1247 && manifest.language_servers.is_empty()
1248 && manifest.slash_commands.is_empty()
1249 && manifest.snippets.is_none()
1250 && manifest.debug_locators.is_empty()
1251}
1252
1253pub(crate) fn resolve_extension_for_context_server(
1254 id: &ContextServerId,
1255 cx: &App,
1256) -> Option<(Arc<str>, Arc<ExtensionManifest>)> {
1257 ExtensionStore::global(cx)
1258 .read(cx)
1259 .installed_extensions()
1260 .iter()
1261 .find(|(_, entry)| entry.manifest.context_servers.contains_key(&id.0))
1262 .map(|(id, entry)| (id.clone(), entry.manifest.clone()))
1263}
1264
1265// This notification appears when trying to delete
1266// an MCP server extension that not only provides
1267// the server, but other things, too, like language servers and more.
1268fn show_unable_to_uninstall_extension_with_context_server(
1269 workspace: &mut Workspace,
1270 id: ContextServerId,
1271 cx: &mut App,
1272) {
1273 let workspace_handle = workspace.weak_handle();
1274 let context_server_id = id.clone();
1275
1276 let status_toast = StatusToast::new(
1277 format!(
1278 "The {} extension provides more than just the MCP server. Proceed to uninstall anyway?",
1279 id.0
1280 ),
1281 cx,
1282 move |this, _cx| {
1283 let workspace_handle = workspace_handle.clone();
1284
1285 this.icon(ToastIcon::new(IconName::Warning).color(Color::Warning))
1286 .dismiss_button(true)
1287 .action("Uninstall", move |_, _cx| {
1288 if let Some((extension_id, _)) =
1289 resolve_extension_for_context_server(&context_server_id, _cx)
1290 {
1291 ExtensionStore::global(_cx).update(_cx, |store, cx| {
1292 store
1293 .uninstall_extension(extension_id, cx)
1294 .detach_and_log_err(cx);
1295 });
1296
1297 workspace_handle
1298 .update(_cx, |workspace, cx| {
1299 let fs = workspace.app_state().fs.clone();
1300 cx.spawn({
1301 let context_server_id = context_server_id.clone();
1302 async move |_workspace_handle, cx| {
1303 cx.update(|cx| {
1304 update_settings_file::<ProjectSettings>(
1305 fs,
1306 cx,
1307 move |settings, _| {
1308 settings
1309 .context_servers
1310 .remove(&context_server_id.0);
1311 },
1312 );
1313 })?;
1314 anyhow::Ok(())
1315 }
1316 })
1317 .detach_and_log_err(cx);
1318 })
1319 .log_err();
1320 }
1321 })
1322 },
1323 );
1324
1325 workspace.toggle_status_toast(status_toast, cx);
1326}