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