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