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 context_server_ids = self
547 .context_server_store
548 .read(cx)
549 .server_ids(cx)
550 .into_iter()
551 .collect::<Vec<_>>();
552
553 // Sort context servers: ones without mcp-server- prefix first, then prefixed ones
554 context_server_ids.sort_by(|a, b| {
555 const MCP_PREFIX: &str = "mcp-server-";
556 match (a.0.strip_prefix(MCP_PREFIX), b.0.strip_prefix(MCP_PREFIX)) {
557 // If one has mcp-server- prefix and other doesn't, non-mcp comes first
558 (Some(_), None) => std::cmp::Ordering::Greater,
559 (None, Some(_)) => std::cmp::Ordering::Less,
560 // If both have same prefix status, sort by appropriate key
561 (Some(a), Some(b)) => a.cmp(b),
562 (None, None) => a.0.cmp(&b.0),
563 }
564 });
565
566 let add_server_popover = PopoverMenu::new("add-server-popover")
567 .trigger(
568 Button::new("add-server", "Add Server")
569 .style(ButtonStyle::Filled)
570 .layer(ElevationIndex::ModalSurface)
571 .icon_position(IconPosition::Start)
572 .icon(IconName::Plus)
573 .icon_size(IconSize::Small)
574 .icon_color(Color::Muted)
575 .label_size(LabelSize::Small),
576 )
577 .anchor(gpui::Corner::TopRight)
578 .menu({
579 move |window, cx| {
580 Some(ContextMenu::build(window, cx, |menu, _window, _cx| {
581 menu.entry("Add Custom Server", None, {
582 |window, cx| window.dispatch_action(AddContextServer.boxed_clone(), cx)
583 })
584 .entry("Install from Extensions", None, {
585 |window, cx| {
586 window.dispatch_action(
587 zed_actions::Extensions {
588 category_filter: Some(
589 ExtensionCategoryFilter::ContextServers,
590 ),
591 id: None,
592 }
593 .boxed_clone(),
594 cx,
595 )
596 }
597 })
598 }))
599 }
600 });
601
602 v_flex()
603 .p(DynamicSpacing::Base16.rems(cx))
604 .pr(DynamicSpacing::Base20.rems(cx))
605 .gap_2()
606 .border_b_1()
607 .border_color(cx.theme().colors().border)
608 .child(
609 h_flex()
610 .w_full()
611 .items_start()
612 .justify_between()
613 .gap_1()
614 .child(
615 v_flex()
616 .gap_0p5()
617 .child(Headline::new("Model Context Protocol (MCP) Servers"))
618 .child(
619 Label::new(
620 "All MCP servers connected directly or via a Zed extension.",
621 )
622 .color(Color::Muted),
623 ),
624 )
625 .child(add_server_popover),
626 )
627 .child(v_flex().w_full().gap_1().map(|mut parent| {
628 if context_server_ids.is_empty() {
629 parent.child(
630 h_flex()
631 .p_4()
632 .justify_center()
633 .border_1()
634 .border_dashed()
635 .border_color(cx.theme().colors().border.opacity(0.6))
636 .rounded_sm()
637 .child(
638 Label::new("No MCP servers added yet.")
639 .color(Color::Muted)
640 .size(LabelSize::Small),
641 ),
642 )
643 } else {
644 for (index, context_server_id) in context_server_ids.into_iter().enumerate() {
645 if index > 0 {
646 parent = parent.child(
647 Divider::horizontal()
648 .color(DividerColor::BorderFaded)
649 .into_any_element(),
650 );
651 }
652 parent =
653 parent.child(self.render_context_server(context_server_id, window, cx));
654 }
655 parent
656 }
657 }))
658 }
659
660 fn render_context_server(
661 &self,
662 context_server_id: ContextServerId,
663 window: &mut Window,
664 cx: &mut Context<Self>,
665 ) -> impl use<> + IntoElement {
666 let tools_by_source = self.tools.read(cx).tools_by_source(cx);
667 let server_status = self
668 .context_server_store
669 .read(cx)
670 .status_for_server(&context_server_id)
671 .unwrap_or(ContextServerStatus::Stopped);
672 let server_configuration = self
673 .context_server_store
674 .read(cx)
675 .configuration_for_server(&context_server_id);
676
677 let is_running = matches!(server_status, ContextServerStatus::Running);
678 let item_id = SharedString::from(context_server_id.0.clone());
679 let is_from_extension = server_configuration
680 .as_ref()
681 .map(|config| {
682 matches!(
683 config.as_ref(),
684 ContextServerConfiguration::Extension { .. }
685 )
686 })
687 .unwrap_or(false);
688
689 let error = if let ContextServerStatus::Error(error) = server_status.clone() {
690 Some(error)
691 } else {
692 None
693 };
694
695 let are_tools_expanded = self
696 .expanded_context_server_tools
697 .get(&context_server_id)
698 .copied()
699 .unwrap_or_default();
700 let tools = tools_by_source
701 .get(&ToolSource::ContextServer {
702 id: context_server_id.0.clone().into(),
703 })
704 .map_or([].as_slice(), |tools| tools.as_slice());
705 let tool_count = tools.len();
706
707 let (source_icon, source_tooltip) = if is_from_extension {
708 (
709 IconName::ZedMcpExtension,
710 "This MCP server was installed from an extension.",
711 )
712 } else {
713 (
714 IconName::ZedMcpCustom,
715 "This custom MCP server was installed directly.",
716 )
717 };
718
719 let (status_indicator, tooltip_text) = match server_status {
720 ContextServerStatus::Starting => (
721 Icon::new(IconName::LoadCircle)
722 .size(IconSize::XSmall)
723 .color(Color::Accent)
724 .with_keyed_rotate_animation(
725 SharedString::from(format!("{}-starting", context_server_id.0)),
726 3,
727 )
728 .into_any_element(),
729 "Server is starting.",
730 ),
731 ContextServerStatus::Running => (
732 Indicator::dot().color(Color::Success).into_any_element(),
733 "Server is active.",
734 ),
735 ContextServerStatus::Error(_) => (
736 Indicator::dot().color(Color::Error).into_any_element(),
737 "Server has an error.",
738 ),
739 ContextServerStatus::Stopped => (
740 Indicator::dot().color(Color::Muted).into_any_element(),
741 "Server is stopped.",
742 ),
743 };
744
745 let context_server_configuration_menu = PopoverMenu::new("context-server-config-menu")
746 .trigger_with_tooltip(
747 IconButton::new("context-server-config-menu", IconName::Settings)
748 .icon_color(Color::Muted)
749 .icon_size(IconSize::Small),
750 Tooltip::text("Configure MCP Server"),
751 )
752 .anchor(Corner::TopRight)
753 .menu({
754 let fs = self.fs.clone();
755 let context_server_id = context_server_id.clone();
756 let language_registry = self.language_registry.clone();
757 let context_server_store = self.context_server_store.clone();
758 let workspace = self.workspace.clone();
759 let tools = self.tools.clone();
760
761 move |window, cx| {
762 Some(ContextMenu::build(window, cx, |menu, _window, _cx| {
763 menu.entry("Configure Server", None, {
764 let context_server_id = context_server_id.clone();
765 let language_registry = language_registry.clone();
766 let workspace = workspace.clone();
767 move |window, cx| {
768 ConfigureContextServerModal::show_modal_for_existing_server(
769 context_server_id.clone(),
770 language_registry.clone(),
771 workspace.clone(),
772 window,
773 cx,
774 )
775 .detach_and_log_err(cx);
776 }
777 }).when(tool_count >= 1, |this| this.entry("View Tools", None, {
778 let context_server_id = context_server_id.clone();
779 let tools = tools.clone();
780 let workspace = workspace.clone();
781
782 move |window, cx| {
783 let context_server_id = context_server_id.clone();
784 let tools = tools.clone();
785 let workspace = workspace.clone();
786
787 workspace.update(cx, |workspace, cx| {
788 ConfigureContextServerToolsModal::toggle(
789 context_server_id,
790 tools,
791 workspace,
792 window,
793 cx,
794 );
795 })
796 .ok();
797 }
798 }))
799 .separator()
800 .entry("Uninstall", None, {
801 let fs = fs.clone();
802 let context_server_id = context_server_id.clone();
803 let context_server_store = context_server_store.clone();
804 let workspace = workspace.clone();
805 move |_, cx| {
806 let is_provided_by_extension = context_server_store
807 .read(cx)
808 .configuration_for_server(&context_server_id)
809 .as_ref()
810 .map(|config| {
811 matches!(
812 config.as_ref(),
813 ContextServerConfiguration::Extension { .. }
814 )
815 })
816 .unwrap_or(false);
817
818 let uninstall_extension_task = match (
819 is_provided_by_extension,
820 resolve_extension_for_context_server(&context_server_id, cx),
821 ) {
822 (true, Some((id, manifest))) => {
823 if extension_only_provides_context_server(manifest.as_ref())
824 {
825 ExtensionStore::global(cx).update(cx, |store, cx| {
826 store.uninstall_extension(id, cx)
827 })
828 } else {
829 workspace.update(cx, |workspace, cx| {
830 show_unable_to_uninstall_extension_with_context_server(workspace, context_server_id.clone(), cx);
831 }).log_err();
832 Task::ready(Ok(()))
833 }
834 }
835 _ => Task::ready(Ok(())),
836 };
837
838 cx.spawn({
839 let fs = fs.clone();
840 let context_server_id = context_server_id.clone();
841 async move |cx| {
842 uninstall_extension_task.await?;
843 cx.update(|cx| {
844 update_settings_file(
845 fs.clone(),
846 cx,
847 {
848 let context_server_id =
849 context_server_id.clone();
850 move |settings, _| {
851 settings.project
852 .context_servers
853 .remove(&context_server_id.0);
854 }
855 },
856 )
857 })
858 }
859 })
860 .detach_and_log_err(cx);
861 }
862 })
863 }))
864 }
865 });
866
867 v_flex()
868 .id(item_id.clone())
869 .child(
870 h_flex()
871 .justify_between()
872 .when(
873 error.is_none() && are_tools_expanded && tool_count >= 1,
874 |element| {
875 element
876 .border_b_1()
877 .border_color(self.card_item_border_color(cx))
878 },
879 )
880 .child(
881 h_flex()
882 .flex_1()
883 .min_w_0()
884 .child(
885 h_flex()
886 .id(SharedString::from(format!("tooltip-{}", item_id)))
887 .h_full()
888 .w_3()
889 .mr_2()
890 .justify_center()
891 .tooltip(Tooltip::text(tooltip_text))
892 .child(status_indicator),
893 )
894 .child(Label::new(item_id).truncate())
895 .child(
896 div()
897 .id("extension-source")
898 .mt_0p5()
899 .mx_1()
900 .flex_none()
901 .tooltip(Tooltip::text(source_tooltip))
902 .child(
903 Icon::new(source_icon)
904 .size(IconSize::Small)
905 .color(Color::Muted),
906 ),
907 )
908 .when(is_running, |this| {
909 this.child(
910 Label::new(if tool_count == 1 {
911 SharedString::from("1 tool")
912 } else {
913 SharedString::from(format!("{} tools", tool_count))
914 })
915 .color(Color::Muted)
916 .size(LabelSize::Small),
917 )
918 }),
919 )
920 .child(
921 h_flex()
922 .gap_0p5()
923 .flex_none()
924 .child(context_server_configuration_menu)
925 .child(
926 Switch::new("context-server-switch", is_running.into())
927 .color(SwitchColor::Accent)
928 .on_click({
929 let context_server_manager = self.context_server_store.clone();
930 let fs = self.fs.clone();
931
932 move |state, _window, cx| {
933 let is_enabled = match state {
934 ToggleState::Unselected
935 | ToggleState::Indeterminate => {
936 context_server_manager.update(cx, |this, cx| {
937 this.stop_server(&context_server_id, cx)
938 .log_err();
939 });
940 false
941 }
942 ToggleState::Selected => {
943 context_server_manager.update(cx, |this, cx| {
944 if let Some(server) =
945 this.get_server(&context_server_id)
946 {
947 this.start_server(server, cx);
948 }
949 });
950 true
951 }
952 };
953 update_settings_file(fs.clone(), cx, {
954 let context_server_id = context_server_id.clone();
955
956 move |settings, _| {
957 settings
958 .project
959 .context_servers
960 .entry(context_server_id.0)
961 .or_insert_with(|| {
962 settings::ContextServerSettingsContent::Extension {
963 enabled: is_enabled,
964 settings: serde_json::json!({}),
965 }
966 })
967 .set_enabled(is_enabled);
968 }
969 });
970 }
971 }),
972 ),
973 ),
974 )
975 .map(|parent| {
976 if let Some(error) = error {
977 return parent.child(
978 h_flex()
979 .gap_2()
980 .pr_4()
981 .items_start()
982 .child(
983 h_flex()
984 .flex_none()
985 .h(window.line_height() / 1.6_f32)
986 .justify_center()
987 .child(
988 Icon::new(IconName::XCircle)
989 .size(IconSize::XSmall)
990 .color(Color::Error),
991 ),
992 )
993 .child(
994 div().w_full().child(
995 Label::new(error)
996 .buffer_font(cx)
997 .color(Color::Muted)
998 .size(LabelSize::Small),
999 ),
1000 ),
1001 );
1002 }
1003
1004 if !are_tools_expanded || tools.is_empty() {
1005 return parent;
1006 }
1007
1008 parent
1009 })
1010 }
1011
1012 fn render_agent_servers_section(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
1013 let user_defined_agents = self
1014 .agent_server_store
1015 .read(cx)
1016 .external_agents()
1017 .filter(|name| name.0 != GEMINI_NAME && name.0 != CLAUDE_CODE_NAME)
1018 .cloned()
1019 .collect::<Vec<_>>();
1020
1021 let user_defined_agents = user_defined_agents
1022 .into_iter()
1023 .map(|name| {
1024 self.render_agent_server(IconName::Ai, name)
1025 .into_any_element()
1026 })
1027 .collect::<Vec<_>>();
1028
1029 v_flex()
1030 .border_b_1()
1031 .border_color(cx.theme().colors().border)
1032 .child(
1033 v_flex()
1034 .p(DynamicSpacing::Base16.rems(cx))
1035 .pr(DynamicSpacing::Base20.rems(cx))
1036 .gap_2()
1037 .child(
1038 v_flex()
1039 .gap_0p5()
1040 .child(
1041 h_flex()
1042 .pr_1()
1043 .w_full()
1044 .gap_2()
1045 .justify_between()
1046 .child(Headline::new("External Agents"))
1047 .child(
1048 Button::new("add-agent", "Add Agent")
1049 .style(ButtonStyle::Filled)
1050 .layer(ElevationIndex::ModalSurface)
1051 .icon_position(IconPosition::Start)
1052 .icon(IconName::Plus)
1053 .icon_size(IconSize::Small)
1054 .icon_color(Color::Muted)
1055 .label_size(LabelSize::Small)
1056 .on_click(
1057 move |_, window, cx| {
1058 if let Some(workspace) = window.root().flatten() {
1059 let workspace = workspace.downgrade();
1060 window
1061 .spawn(cx, async |cx| {
1062 open_new_agent_servers_entry_in_settings_editor(
1063 workspace,
1064 cx,
1065 ).await
1066 })
1067 .detach_and_log_err(cx);
1068 }
1069 }
1070 ),
1071 )
1072 )
1073 .child(
1074 Label::new(
1075 "All agents connected through the Agent Client Protocol.",
1076 )
1077 .color(Color::Muted),
1078 ),
1079 )
1080 .child(self.render_agent_server(
1081 IconName::AiGemini,
1082 "Gemini CLI",
1083 ))
1084 .child(Divider::horizontal().color(DividerColor::BorderFaded))
1085 .child(self.render_agent_server(
1086 IconName::AiClaude,
1087 "Claude Code",
1088 ))
1089 .map(|mut parent| {
1090 for agent in user_defined_agents {
1091 parent = parent.child(Divider::horizontal().color(DividerColor::BorderFaded))
1092 .child(agent);
1093 }
1094 parent
1095 })
1096 )
1097 }
1098
1099 fn render_agent_server(
1100 &self,
1101 icon: IconName,
1102 name: impl Into<SharedString>,
1103 ) -> impl IntoElement {
1104 h_flex().gap_1p5().justify_between().child(
1105 h_flex()
1106 .gap_1p5()
1107 .child(Icon::new(icon).size(IconSize::Small).color(Color::Muted))
1108 .child(Label::new(name.into()))
1109 .child(
1110 Icon::new(IconName::Check)
1111 .size(IconSize::Small)
1112 .color(Color::Success),
1113 ),
1114 )
1115 }
1116}
1117
1118impl Render for AgentConfiguration {
1119 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1120 v_flex()
1121 .id("assistant-configuration")
1122 .key_context("AgentConfiguration")
1123 .track_focus(&self.focus_handle(cx))
1124 .relative()
1125 .size_full()
1126 .pb_8()
1127 .bg(cx.theme().colors().panel_background)
1128 .child(
1129 div()
1130 .size_full()
1131 .child(
1132 v_flex()
1133 .id("assistant-configuration-content")
1134 .track_scroll(&self.scroll_handle)
1135 .size_full()
1136 .overflow_y_scroll()
1137 .child(self.render_general_settings_section(cx))
1138 .child(self.render_agent_servers_section(cx))
1139 .child(self.render_context_servers_section(window, cx))
1140 .child(self.render_provider_configuration_section(cx)),
1141 )
1142 .vertical_scrollbar_for(self.scroll_handle.clone(), window, cx),
1143 )
1144 }
1145}
1146
1147fn extension_only_provides_context_server(manifest: &ExtensionManifest) -> bool {
1148 manifest.context_servers.len() == 1
1149 && manifest.themes.is_empty()
1150 && manifest.icon_themes.is_empty()
1151 && manifest.languages.is_empty()
1152 && manifest.grammars.is_empty()
1153 && manifest.language_servers.is_empty()
1154 && manifest.slash_commands.is_empty()
1155 && manifest.snippets.is_none()
1156 && manifest.debug_locators.is_empty()
1157}
1158
1159pub(crate) fn resolve_extension_for_context_server(
1160 id: &ContextServerId,
1161 cx: &App,
1162) -> Option<(Arc<str>, Arc<ExtensionManifest>)> {
1163 ExtensionStore::global(cx)
1164 .read(cx)
1165 .installed_extensions()
1166 .iter()
1167 .find(|(_, entry)| entry.manifest.context_servers.contains_key(&id.0))
1168 .map(|(id, entry)| (id.clone(), entry.manifest.clone()))
1169}
1170
1171// This notification appears when trying to delete
1172// an MCP server extension that not only provides
1173// the server, but other things, too, like language servers and more.
1174fn show_unable_to_uninstall_extension_with_context_server(
1175 workspace: &mut Workspace,
1176 id: ContextServerId,
1177 cx: &mut App,
1178) {
1179 let workspace_handle = workspace.weak_handle();
1180 let context_server_id = id.clone();
1181
1182 let status_toast = StatusToast::new(
1183 format!(
1184 "The {} extension provides more than just the MCP server. Proceed to uninstall anyway?",
1185 id.0
1186 ),
1187 cx,
1188 move |this, _cx| {
1189 let workspace_handle = workspace_handle.clone();
1190
1191 this.icon(ToastIcon::new(IconName::Warning).color(Color::Warning))
1192 .dismiss_button(true)
1193 .action("Uninstall", move |_, _cx| {
1194 if let Some((extension_id, _)) =
1195 resolve_extension_for_context_server(&context_server_id, _cx)
1196 {
1197 ExtensionStore::global(_cx).update(_cx, |store, cx| {
1198 store
1199 .uninstall_extension(extension_id, cx)
1200 .detach_and_log_err(cx);
1201 });
1202
1203 workspace_handle
1204 .update(_cx, |workspace, cx| {
1205 let fs = workspace.app_state().fs.clone();
1206 cx.spawn({
1207 let context_server_id = context_server_id.clone();
1208 async move |_workspace_handle, cx| {
1209 cx.update(|cx| {
1210 update_settings_file(fs, cx, move |settings, _| {
1211 settings
1212 .project
1213 .context_servers
1214 .remove(&context_server_id.0);
1215 });
1216 })?;
1217 anyhow::Ok(())
1218 }
1219 })
1220 .detach_and_log_err(cx);
1221 })
1222 .log_err();
1223 }
1224 })
1225 },
1226 );
1227
1228 workspace.toggle_status_toast(status_toast, cx);
1229}
1230
1231async fn open_new_agent_servers_entry_in_settings_editor(
1232 workspace: WeakEntity<Workspace>,
1233 cx: &mut AsyncWindowContext,
1234) -> Result<()> {
1235 let settings_editor = workspace
1236 .update_in(cx, |_, window, cx| {
1237 create_and_open_local_file(paths::settings_file(), window, cx, || {
1238 settings::initial_user_settings_content().as_ref().into()
1239 })
1240 })?
1241 .await?
1242 .downcast::<Editor>()
1243 .unwrap();
1244
1245 settings_editor
1246 .downgrade()
1247 .update_in(cx, |item, window, cx| {
1248 let text = item.buffer().read(cx).snapshot(cx).text();
1249
1250 let settings = cx.global::<SettingsStore>();
1251
1252 let mut unique_server_name = None;
1253 let edits = settings.edits_for_update(&text, |settings| {
1254 let server_name: Option<SharedString> = (0..u8::MAX)
1255 .map(|i| {
1256 if i == 0 {
1257 "your_agent".into()
1258 } else {
1259 format!("your_agent_{}", i).into()
1260 }
1261 })
1262 .find(|name| {
1263 !settings
1264 .agent_servers
1265 .as_ref()
1266 .is_some_and(|agent_servers| agent_servers.custom.contains_key(name))
1267 });
1268 if let Some(server_name) = server_name {
1269 unique_server_name = Some(server_name.clone());
1270 settings
1271 .agent_servers
1272 .get_or_insert_default()
1273 .custom
1274 .insert(
1275 server_name,
1276 settings::CustomAgentServerSettings {
1277 path: "path_to_executable".into(),
1278 args: vec![],
1279 env: Some(HashMap::default()),
1280 default_mode: None,
1281 },
1282 );
1283 }
1284 });
1285
1286 if edits.is_empty() {
1287 return;
1288 }
1289
1290 let ranges = edits
1291 .iter()
1292 .map(|(range, _)| range.clone())
1293 .collect::<Vec<_>>();
1294
1295 item.edit(edits, cx);
1296 if let Some((unique_server_name, buffer)) =
1297 unique_server_name.zip(item.buffer().read(cx).as_singleton())
1298 {
1299 let snapshot = buffer.read(cx).snapshot();
1300 if let Some(range) =
1301 find_text_in_buffer(&unique_server_name, ranges[0].start, &snapshot)
1302 {
1303 item.change_selections(
1304 SelectionEffects::scroll(Autoscroll::newest()),
1305 window,
1306 cx,
1307 |selections| {
1308 selections.select_ranges(vec![range]);
1309 },
1310 );
1311 }
1312 }
1313 })
1314}
1315
1316fn find_text_in_buffer(
1317 text: &str,
1318 start: usize,
1319 snapshot: &language::BufferSnapshot,
1320) -> Option<Range<usize>> {
1321 let chars = text.chars().collect::<Vec<char>>();
1322
1323 let mut offset = start;
1324 let mut char_offset = 0;
1325 for c in snapshot.chars_at(start) {
1326 if char_offset >= chars.len() {
1327 break;
1328 }
1329 offset += 1;
1330
1331 if c == chars[char_offset] {
1332 char_offset += 1;
1333 } else {
1334 char_offset = 0;
1335 }
1336 }
1337
1338 if char_offset == chars.len() {
1339 Some(offset.saturating_sub(chars.len())..offset)
1340 } else {
1341 None
1342 }
1343}