1use anyhow::Result;
2use client::{Client, UserStore, zed_urls};
3use cloud_llm_client::UsageLimit;
4use codestral::CodestralCompletionProvider;
5use copilot::{Copilot, Status};
6use editor::{
7 Editor, MultiBufferOffset, SelectionEffects, actions::ShowEditPrediction, scroll::Autoscroll,
8};
9use feature_flags::{FeatureFlagAppExt, PredictEditsRateCompletionsFeatureFlag};
10use fs::Fs;
11use gpui::{
12 Action, Animation, AnimationExt, App, AsyncWindowContext, Corner, Entity, FocusHandle,
13 Focusable, IntoElement, ParentElement, Render, Subscription, WeakEntity, actions, div,
14 pulsating_between,
15};
16use indoc::indoc;
17use language::{
18 EditPredictionsMode, File, Language,
19 language_settings::{self, AllLanguageSettings, EditPredictionProvider, all_language_settings},
20};
21use project::DisableAiSettings;
22use regex::Regex;
23use settings::{
24 EXPERIMENTAL_SWEEP_EDIT_PREDICTION_PROVIDER_NAME, Settings, SettingsStore, update_settings_file,
25};
26use std::{
27 sync::{Arc, LazyLock},
28 time::Duration,
29};
30use supermaven::{AccountStatus, Supermaven};
31use ui::{
32 Clickable, ContextMenu, ContextMenuEntry, DocumentationEdge, DocumentationSide, IconButton,
33 IconButtonShape, Indicator, PopoverMenu, PopoverMenuHandle, ProgressBar, Tooltip, prelude::*,
34};
35use workspace::{
36 StatusItemView, Toast, Workspace, create_and_open_local_file, item::ItemHandle,
37 notifications::NotificationId,
38};
39use zed_actions::OpenBrowser;
40use zeta::RateCompletions;
41use zeta2::SweepFeatureFlag;
42
43actions!(
44 edit_prediction,
45 [
46 /// Toggles the edit prediction menu.
47 ToggleMenu
48 ]
49);
50
51const COPILOT_SETTINGS_PATH: &str = "/settings/copilot";
52const COPILOT_SETTINGS_URL: &str = concat!("https://github.com", "/settings/copilot");
53const PRIVACY_DOCS: &str = "https://zed.dev/docs/ai/privacy-and-security";
54
55struct CopilotErrorToast;
56
57pub struct EditPredictionButton {
58 editor_subscription: Option<(Subscription, usize)>,
59 editor_enabled: Option<bool>,
60 editor_show_predictions: bool,
61 editor_focus_handle: Option<FocusHandle>,
62 language: Option<Arc<Language>>,
63 file: Option<Arc<dyn File>>,
64 edit_prediction_provider: Option<Arc<dyn edit_prediction::EditPredictionProviderHandle>>,
65 fs: Arc<dyn Fs>,
66 user_store: Entity<UserStore>,
67 popover_menu_handle: PopoverMenuHandle<ContextMenu>,
68}
69
70enum SupermavenButtonStatus {
71 Ready,
72 Errored(String),
73 NeedsActivation(String),
74 Initializing,
75}
76
77impl Render for EditPredictionButton {
78 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
79 // Return empty div if AI is disabled
80 if DisableAiSettings::get_global(cx).disable_ai {
81 return div().hidden();
82 }
83
84 let all_language_settings = all_language_settings(None, cx);
85
86 match all_language_settings.edit_predictions.provider {
87 EditPredictionProvider::Copilot => {
88 let Some(copilot) = Copilot::global(cx) else {
89 return div().hidden();
90 };
91 let status = copilot.read(cx).status();
92
93 let enabled = self.editor_enabled.unwrap_or(false);
94
95 let icon = match status {
96 Status::Error(_) => IconName::CopilotError,
97 Status::Authorized => {
98 if enabled {
99 IconName::Copilot
100 } else {
101 IconName::CopilotDisabled
102 }
103 }
104 _ => IconName::CopilotInit,
105 };
106
107 if let Status::Error(e) = status {
108 return div().child(
109 IconButton::new("copilot-error", icon)
110 .icon_size(IconSize::Small)
111 .on_click(cx.listener(move |_, _, window, cx| {
112 if let Some(workspace) = window.root::<Workspace>().flatten() {
113 workspace.update(cx, |workspace, cx| {
114 workspace.show_toast(
115 Toast::new(
116 NotificationId::unique::<CopilotErrorToast>(),
117 format!("Copilot can't be started: {}", e),
118 )
119 .on_click(
120 "Reinstall Copilot",
121 |window, cx| {
122 copilot::reinstall_and_sign_in(window, cx)
123 },
124 ),
125 cx,
126 );
127 });
128 }
129 }))
130 .tooltip(|_window, cx| {
131 Tooltip::for_action("GitHub Copilot", &ToggleMenu, cx)
132 }),
133 );
134 }
135 let this = cx.weak_entity();
136
137 div().child(
138 PopoverMenu::new("copilot")
139 .menu(move |window, cx| {
140 let current_status = Copilot::global(cx)?.read(cx).status();
141 match current_status {
142 Status::Authorized => this.update(cx, |this, cx| {
143 this.build_copilot_context_menu(window, cx)
144 }),
145 _ => this.update(cx, |this, cx| {
146 this.build_copilot_start_menu(window, cx)
147 }),
148 }
149 .ok()
150 })
151 .anchor(Corner::BottomRight)
152 .trigger_with_tooltip(
153 IconButton::new("copilot-icon", icon),
154 |_window, cx| Tooltip::for_action("GitHub Copilot", &ToggleMenu, cx),
155 )
156 .with_handle(self.popover_menu_handle.clone()),
157 )
158 }
159
160 EditPredictionProvider::Supermaven => {
161 let Some(supermaven) = Supermaven::global(cx) else {
162 return div();
163 };
164
165 let supermaven = supermaven.read(cx);
166
167 let status = match supermaven {
168 Supermaven::Starting => SupermavenButtonStatus::Initializing,
169 Supermaven::FailedDownload { error } => {
170 SupermavenButtonStatus::Errored(error.to_string())
171 }
172 Supermaven::Spawned(agent) => {
173 let account_status = agent.account_status.clone();
174 match account_status {
175 AccountStatus::NeedsActivation { activate_url } => {
176 SupermavenButtonStatus::NeedsActivation(activate_url)
177 }
178 AccountStatus::Unknown => SupermavenButtonStatus::Initializing,
179 AccountStatus::Ready => SupermavenButtonStatus::Ready,
180 }
181 }
182 Supermaven::Error { error } => {
183 SupermavenButtonStatus::Errored(error.to_string())
184 }
185 };
186
187 let icon = status.to_icon();
188 let tooltip_text = status.to_tooltip();
189 let has_menu = status.has_menu();
190 let this = cx.weak_entity();
191 let fs = self.fs.clone();
192
193 div().child(
194 PopoverMenu::new("supermaven")
195 .menu(move |window, cx| match &status {
196 SupermavenButtonStatus::NeedsActivation(activate_url) => {
197 Some(ContextMenu::build(window, cx, |menu, _, _| {
198 let fs = fs.clone();
199 let activate_url = activate_url.clone();
200
201 menu.entry("Sign In", None, move |_, cx| {
202 cx.open_url(activate_url.as_str())
203 })
204 .entry(
205 "Use Zed AI",
206 None,
207 move |_, cx| {
208 set_completion_provider(
209 fs.clone(),
210 cx,
211 EditPredictionProvider::Zed,
212 )
213 },
214 )
215 }))
216 }
217 SupermavenButtonStatus::Ready => this
218 .update(cx, |this, cx| {
219 this.build_supermaven_context_menu(window, cx)
220 })
221 .ok(),
222 _ => None,
223 })
224 .anchor(Corner::BottomRight)
225 .trigger_with_tooltip(
226 IconButton::new("supermaven-icon", icon),
227 move |window, cx| {
228 if has_menu {
229 Tooltip::for_action(tooltip_text.clone(), &ToggleMenu, cx)
230 } else {
231 Tooltip::text(tooltip_text.clone())(window, cx)
232 }
233 },
234 )
235 .with_handle(self.popover_menu_handle.clone()),
236 )
237 }
238
239 EditPredictionProvider::Codestral => {
240 let enabled = self.editor_enabled.unwrap_or(true);
241 let has_api_key = CodestralCompletionProvider::has_api_key(cx);
242 let fs = self.fs.clone();
243 let this = cx.weak_entity();
244
245 div().child(
246 PopoverMenu::new("codestral")
247 .menu(move |window, cx| {
248 if has_api_key {
249 this.update(cx, |this, cx| {
250 this.build_codestral_context_menu(window, cx)
251 })
252 .ok()
253 } else {
254 Some(ContextMenu::build(window, cx, |menu, _, _| {
255 let fs = fs.clone();
256
257 menu.entry(
258 "Configure Codestral API Key",
259 None,
260 move |window, cx| {
261 window.dispatch_action(
262 zed_actions::agent::OpenSettings.boxed_clone(),
263 cx,
264 );
265 },
266 )
267 .separator()
268 .entry(
269 "Use Zed AI instead",
270 None,
271 move |_, cx| {
272 set_completion_provider(
273 fs.clone(),
274 cx,
275 EditPredictionProvider::Zed,
276 )
277 },
278 )
279 }))
280 }
281 })
282 .anchor(Corner::BottomRight)
283 .trigger_with_tooltip(
284 IconButton::new("codestral-icon", IconName::AiMistral)
285 .shape(IconButtonShape::Square)
286 .when(!has_api_key, |this| {
287 this.indicator(Indicator::dot().color(Color::Error))
288 .indicator_border_color(Some(
289 cx.theme().colors().status_bar_background,
290 ))
291 })
292 .when(has_api_key && !enabled, |this| {
293 this.indicator(Indicator::dot().color(Color::Ignored))
294 .indicator_border_color(Some(
295 cx.theme().colors().status_bar_background,
296 ))
297 }),
298 move |_window, cx| Tooltip::for_action("Codestral", &ToggleMenu, cx),
299 )
300 .with_handle(self.popover_menu_handle.clone()),
301 )
302 }
303 provider @ (EditPredictionProvider::Experimental(
304 EXPERIMENTAL_SWEEP_EDIT_PREDICTION_PROVIDER_NAME,
305 )
306 | EditPredictionProvider::Zed) => {
307 let enabled = self.editor_enabled.unwrap_or(true);
308
309 let is_sweep = matches!(
310 provider,
311 EditPredictionProvider::Experimental(
312 EXPERIMENTAL_SWEEP_EDIT_PREDICTION_PROVIDER_NAME
313 )
314 );
315
316 let zeta_icon = match (is_sweep, enabled) {
317 (true, _) => IconName::SweepAi,
318 (false, true) => IconName::ZedPredict,
319 (false, false) => IconName::ZedPredictDisabled,
320 };
321
322 if zeta::should_show_upsell_modal() {
323 let tooltip_meta = if self.user_store.read(cx).current_user().is_some() {
324 "Choose a Plan"
325 } else {
326 "Sign In"
327 };
328
329 return div().child(
330 IconButton::new("zed-predict-pending-button", zeta_icon)
331 .shape(IconButtonShape::Square)
332 .indicator(Indicator::dot().color(Color::Muted))
333 .indicator_border_color(Some(cx.theme().colors().status_bar_background))
334 .tooltip(move |_window, cx| {
335 Tooltip::with_meta("Edit Predictions", None, tooltip_meta, cx)
336 })
337 .on_click(cx.listener(move |_, _, window, cx| {
338 telemetry::event!(
339 "Pending ToS Clicked",
340 source = "Edit Prediction Status Button"
341 );
342 window.dispatch_action(
343 zed_actions::OpenZedPredictOnboarding.boxed_clone(),
344 cx,
345 );
346 })),
347 );
348 }
349
350 let mut over_limit = false;
351
352 if let Some(usage) = self
353 .edit_prediction_provider
354 .as_ref()
355 .and_then(|provider| provider.usage(cx))
356 {
357 over_limit = usage.over_limit()
358 }
359
360 let show_editor_predictions = self.editor_show_predictions;
361
362 let icon_button = IconButton::new("zed-predict-pending-button", zeta_icon)
363 .shape(IconButtonShape::Square)
364 .when(
365 enabled && (!show_editor_predictions || over_limit),
366 |this| {
367 this.indicator(Indicator::dot().when_else(
368 over_limit,
369 |dot| dot.color(Color::Error),
370 |dot| dot.color(Color::Muted),
371 ))
372 .indicator_border_color(Some(cx.theme().colors().status_bar_background))
373 },
374 )
375 .when(!self.popover_menu_handle.is_deployed(), |element| {
376 element.tooltip(move |_window, cx| {
377 if enabled {
378 if show_editor_predictions {
379 Tooltip::for_action("Edit Prediction", &ToggleMenu, cx)
380 } else {
381 Tooltip::with_meta(
382 "Edit Prediction",
383 Some(&ToggleMenu),
384 "Hidden For This File",
385 cx,
386 )
387 }
388 } else {
389 Tooltip::with_meta(
390 "Edit Prediction",
391 Some(&ToggleMenu),
392 "Disabled For This File",
393 cx,
394 )
395 }
396 })
397 });
398
399 let this = cx.weak_entity();
400
401 let mut popover_menu = PopoverMenu::new("zeta")
402 .menu(move |window, cx| {
403 this.update(cx, |this, cx| {
404 this.build_zeta_context_menu(provider, window, cx)
405 })
406 .ok()
407 })
408 .anchor(Corner::BottomRight)
409 .with_handle(self.popover_menu_handle.clone());
410
411 let is_refreshing = self
412 .edit_prediction_provider
413 .as_ref()
414 .is_some_and(|provider| provider.is_refreshing(cx));
415
416 if is_refreshing {
417 popover_menu = popover_menu.trigger(
418 icon_button.with_animation(
419 "pulsating-label",
420 Animation::new(Duration::from_secs(2))
421 .repeat()
422 .with_easing(pulsating_between(0.2, 1.0)),
423 |icon_button, delta| icon_button.alpha(delta),
424 ),
425 );
426 } else {
427 popover_menu = popover_menu.trigger(icon_button);
428 }
429
430 div().child(popover_menu.into_any_element())
431 }
432
433 EditPredictionProvider::None | EditPredictionProvider::Experimental(_) => {
434 div().hidden()
435 }
436 }
437 }
438}
439
440impl EditPredictionButton {
441 pub fn new(
442 fs: Arc<dyn Fs>,
443 user_store: Entity<UserStore>,
444 popover_menu_handle: PopoverMenuHandle<ContextMenu>,
445 client: Arc<Client>,
446 cx: &mut Context<Self>,
447 ) -> Self {
448 if let Some(copilot) = Copilot::global(cx) {
449 cx.observe(&copilot, |_, _, cx| cx.notify()).detach()
450 }
451
452 cx.observe_global::<SettingsStore>(move |_, cx| cx.notify())
453 .detach();
454
455 CodestralCompletionProvider::ensure_api_key_loaded(client.http_client(), cx);
456
457 Self {
458 editor_subscription: None,
459 editor_enabled: None,
460 editor_show_predictions: true,
461 editor_focus_handle: None,
462 language: None,
463 file: None,
464 edit_prediction_provider: None,
465 user_store,
466 popover_menu_handle,
467 fs,
468 }
469 }
470
471 fn get_available_providers(&self, cx: &App) -> Vec<EditPredictionProvider> {
472 let mut providers = Vec::new();
473
474 providers.push(EditPredictionProvider::Zed);
475
476 if let Some(copilot) = Copilot::global(cx) {
477 if matches!(copilot.read(cx).status(), Status::Authorized) {
478 providers.push(EditPredictionProvider::Copilot);
479 }
480 }
481
482 if let Some(supermaven) = Supermaven::global(cx) {
483 if let Supermaven::Spawned(agent) = supermaven.read(cx) {
484 if matches!(agent.account_status, AccountStatus::Ready) {
485 providers.push(EditPredictionProvider::Supermaven);
486 }
487 }
488 }
489
490 if CodestralCompletionProvider::has_api_key(cx) {
491 providers.push(EditPredictionProvider::Codestral);
492 }
493
494 if cx.has_flag::<SweepFeatureFlag>() {
495 providers.push(EditPredictionProvider::Experimental(
496 EXPERIMENTAL_SWEEP_EDIT_PREDICTION_PROVIDER_NAME,
497 ));
498 }
499
500 providers
501 }
502
503 fn add_provider_switching_section(
504 &self,
505 mut menu: ContextMenu,
506 current_provider: EditPredictionProvider,
507 cx: &App,
508 ) -> ContextMenu {
509 let available_providers = self.get_available_providers(cx);
510
511 const ZED_AI_CALLOUT: &str =
512 "Zed's edit prediction is powered by Zeta, an open-source, dataset mode.";
513 const USE_SWEEP_API_TOKEN_CALLOUT: &str =
514 "Set the SWEEP_API_TOKEN environment variable to use Sweep";
515
516 let other_providers: Vec<_> = available_providers
517 .into_iter()
518 .filter(|p| *p != current_provider && *p != EditPredictionProvider::None)
519 .collect();
520
521 if !other_providers.is_empty() {
522 menu = menu.separator().header("Switch Providers");
523
524 for provider in other_providers {
525 let fs = self.fs.clone();
526
527 menu = match provider {
528 EditPredictionProvider::Zed => menu.item(
529 ContextMenuEntry::new("Zed AI")
530 .documentation_aside(
531 DocumentationSide::Left,
532 DocumentationEdge::Bottom,
533 |_| Label::new(ZED_AI_CALLOUT).into_any_element(),
534 )
535 .handler(move |_, cx| {
536 set_completion_provider(fs.clone(), cx, provider);
537 }),
538 ),
539 EditPredictionProvider::Copilot => {
540 menu.entry("GitHub Copilot", None, move |_, cx| {
541 set_completion_provider(fs.clone(), cx, provider);
542 })
543 }
544 EditPredictionProvider::Supermaven => {
545 menu.entry("Supermaven", None, move |_, cx| {
546 set_completion_provider(fs.clone(), cx, provider);
547 })
548 }
549 EditPredictionProvider::Codestral => {
550 menu.entry("Codestral", None, move |_, cx| {
551 set_completion_provider(fs.clone(), cx, provider);
552 })
553 }
554 EditPredictionProvider::Experimental(
555 EXPERIMENTAL_SWEEP_EDIT_PREDICTION_PROVIDER_NAME,
556 ) => {
557 let has_api_token = zeta2::Zeta::try_global(cx)
558 .map_or(false, |zeta| zeta.read(cx).has_sweep_api_token());
559
560 let entry = ContextMenuEntry::new("Sweep")
561 .when(!has_api_token, |this| {
562 this.disabled(true).documentation_aside(
563 DocumentationSide::Left,
564 DocumentationEdge::Bottom,
565 |_| Label::new(USE_SWEEP_API_TOKEN_CALLOUT).into_any_element(),
566 )
567 })
568 .handler(move |_, cx| {
569 set_completion_provider(fs.clone(), cx, provider);
570 });
571
572 menu.item(entry)
573 }
574 EditPredictionProvider::None | EditPredictionProvider::Experimental(_) => {
575 continue;
576 }
577 };
578 }
579 }
580
581 menu
582 }
583
584 pub fn build_copilot_start_menu(
585 &mut self,
586 window: &mut Window,
587 cx: &mut Context<Self>,
588 ) -> Entity<ContextMenu> {
589 let fs = self.fs.clone();
590 ContextMenu::build(window, cx, |menu, _, _| {
591 menu.entry("Sign In to Copilot", None, copilot::initiate_sign_in)
592 .entry("Disable Copilot", None, {
593 let fs = fs.clone();
594 move |_window, cx| hide_copilot(fs.clone(), cx)
595 })
596 .separator()
597 .entry("Use Zed AI", None, {
598 let fs = fs.clone();
599 move |_window, cx| {
600 set_completion_provider(fs.clone(), cx, EditPredictionProvider::Zed)
601 }
602 })
603 })
604 }
605
606 pub fn build_language_settings_menu(
607 &self,
608 mut menu: ContextMenu,
609 window: &Window,
610 cx: &mut App,
611 ) -> ContextMenu {
612 let fs = self.fs.clone();
613 let line_height = window.line_height();
614
615 menu = menu.header("Show Edit Predictions For");
616
617 let language_state = self.language.as_ref().map(|language| {
618 (
619 language.clone(),
620 language_settings::language_settings(Some(language.name()), None, cx)
621 .show_edit_predictions,
622 )
623 });
624
625 if let Some(editor_focus_handle) = self.editor_focus_handle.clone() {
626 let entry = ContextMenuEntry::new("This Buffer")
627 .toggleable(IconPosition::Start, self.editor_show_predictions)
628 .action(Box::new(editor::actions::ToggleEditPrediction))
629 .handler(move |window, cx| {
630 editor_focus_handle.dispatch_action(
631 &editor::actions::ToggleEditPrediction,
632 window,
633 cx,
634 );
635 });
636
637 match language_state.clone() {
638 Some((language, false)) => {
639 menu = menu.item(
640 entry
641 .disabled(true)
642 .documentation_aside(DocumentationSide::Left, DocumentationEdge::Top, move |_cx| {
643 Label::new(format!("Edit predictions cannot be toggled for this buffer because they are disabled for {}", language.name()))
644 .into_any_element()
645 })
646 );
647 }
648 Some(_) | None => menu = menu.item(entry),
649 }
650 }
651
652 if let Some((language, language_enabled)) = language_state {
653 let fs = fs.clone();
654
655 menu = menu.toggleable_entry(
656 language.name(),
657 language_enabled,
658 IconPosition::Start,
659 None,
660 move |_, cx| {
661 toggle_show_edit_predictions_for_language(language.clone(), fs.clone(), cx)
662 },
663 );
664 }
665
666 let settings = AllLanguageSettings::get_global(cx);
667
668 let globally_enabled = settings.show_edit_predictions(None, cx);
669 let entry = ContextMenuEntry::new("All Files")
670 .toggleable(IconPosition::Start, globally_enabled)
671 .action(workspace::ToggleEditPrediction.boxed_clone())
672 .handler(|window, cx| {
673 window.dispatch_action(workspace::ToggleEditPrediction.boxed_clone(), cx)
674 });
675 menu = menu.item(entry);
676
677 let provider = settings.edit_predictions.provider;
678 let current_mode = settings.edit_predictions_mode();
679 let subtle_mode = matches!(current_mode, EditPredictionsMode::Subtle);
680 let eager_mode = matches!(current_mode, EditPredictionsMode::Eager);
681
682 if matches!(
683 provider,
684 EditPredictionProvider::Zed
685 | EditPredictionProvider::Copilot
686 | EditPredictionProvider::Supermaven
687 | EditPredictionProvider::Codestral
688 ) {
689 menu = menu
690 .separator()
691 .header("Display Modes")
692 .item(
693 ContextMenuEntry::new("Eager")
694 .toggleable(IconPosition::Start, eager_mode)
695 .documentation_aside(DocumentationSide::Left, DocumentationEdge::Top, move |_| {
696 Label::new("Display predictions inline when there are no language server completions available.").into_any_element()
697 })
698 .handler({
699 let fs = fs.clone();
700 move |_, cx| {
701 toggle_edit_prediction_mode(fs.clone(), EditPredictionsMode::Eager, cx)
702 }
703 }),
704 )
705 .item(
706 ContextMenuEntry::new("Subtle")
707 .toggleable(IconPosition::Start, subtle_mode)
708 .documentation_aside(DocumentationSide::Left, DocumentationEdge::Top, move |_| {
709 Label::new("Display predictions inline only when holding a modifier key (alt by default).").into_any_element()
710 })
711 .handler({
712 let fs = fs.clone();
713 move |_, cx| {
714 toggle_edit_prediction_mode(fs.clone(), EditPredictionsMode::Subtle, cx)
715 }
716 }),
717 );
718 }
719
720 menu = menu.separator().header("Privacy");
721
722 if let Some(provider) = &self.edit_prediction_provider {
723 let data_collection = provider.data_collection_state(cx);
724
725 if data_collection.is_supported() {
726 let provider = provider.clone();
727 let enabled = data_collection.is_enabled();
728 let is_open_source = data_collection.is_project_open_source();
729 let is_collecting = data_collection.is_enabled();
730 let (icon_name, icon_color) = if is_open_source && is_collecting {
731 (IconName::Check, Color::Success)
732 } else {
733 (IconName::Check, Color::Accent)
734 };
735
736 menu = menu.item(
737 ContextMenuEntry::new("Training Data Collection")
738 .toggleable(IconPosition::Start, data_collection.is_enabled())
739 .icon(icon_name)
740 .icon_color(icon_color)
741 .documentation_aside(DocumentationSide::Left, DocumentationEdge::Top, move |cx| {
742 let (msg, label_color, icon_name, icon_color) = match (is_open_source, is_collecting) {
743 (true, true) => (
744 "Project identified as open source, and you're sharing data.",
745 Color::Default,
746 IconName::Check,
747 Color::Success,
748 ),
749 (true, false) => (
750 "Project identified as open source, but you're not sharing data.",
751 Color::Muted,
752 IconName::Close,
753 Color::Muted,
754 ),
755 (false, true) => (
756 "Project not identified as open source. No data captured.",
757 Color::Muted,
758 IconName::Close,
759 Color::Muted,
760 ),
761 (false, false) => (
762 "Project not identified as open source, and setting turned off.",
763 Color::Muted,
764 IconName::Close,
765 Color::Muted,
766 ),
767 };
768 v_flex()
769 .gap_2()
770 .child(
771 Label::new(indoc!{
772 "Help us improve our open dataset model by sharing data from open source repositories. \
773 Zed must detect a license file in your repo for this setting to take effect. \
774 Files with sensitive data and secrets are excluded by default."
775 })
776 )
777 .child(
778 h_flex()
779 .items_start()
780 .pt_2()
781 .pr_1()
782 .flex_1()
783 .gap_1p5()
784 .border_t_1()
785 .border_color(cx.theme().colors().border_variant)
786 .child(h_flex().flex_shrink_0().h(line_height).child(Icon::new(icon_name).size(IconSize::XSmall).color(icon_color)))
787 .child(div().child(msg).w_full().text_sm().text_color(label_color.color(cx)))
788 )
789 .into_any_element()
790 })
791 .handler(move |_, cx| {
792 provider.toggle_data_collection(cx);
793
794 if !enabled {
795 telemetry::event!(
796 "Data Collection Enabled",
797 source = "Edit Prediction Status Menu"
798 );
799 } else {
800 telemetry::event!(
801 "Data Collection Disabled",
802 source = "Edit Prediction Status Menu"
803 );
804 }
805 })
806 );
807
808 if is_collecting && !is_open_source {
809 menu = menu.item(
810 ContextMenuEntry::new("No data captured.")
811 .disabled(true)
812 .icon(IconName::Close)
813 .icon_color(Color::Error)
814 .icon_size(IconSize::Small),
815 );
816 }
817 }
818 }
819
820 menu = menu.item(
821 ContextMenuEntry::new("Configure Excluded Files")
822 .icon(IconName::LockOutlined)
823 .icon_color(Color::Muted)
824 .documentation_aside(DocumentationSide::Left, DocumentationEdge::Top, |_| {
825 Label::new(indoc!{"
826 Open your settings to add sensitive paths for which Zed will never predict edits."}).into_any_element()
827 })
828 .handler(move |window, cx| {
829 if let Some(workspace) = window.root().flatten() {
830 let workspace = workspace.downgrade();
831 window
832 .spawn(cx, async |cx| {
833 open_disabled_globs_setting_in_editor(
834 workspace,
835 cx,
836 ).await
837 })
838 .detach_and_log_err(cx);
839 }
840 }),
841 ).item(
842 ContextMenuEntry::new("View Docs")
843 .icon(IconName::FileGeneric)
844 .icon_color(Color::Muted)
845 .handler(move |_, cx| {
846 cx.open_url(PRIVACY_DOCS);
847 })
848 );
849
850 if !self.editor_enabled.unwrap_or(true) {
851 menu = menu.item(
852 ContextMenuEntry::new("This file is excluded.")
853 .disabled(true)
854 .icon(IconName::ZedPredictDisabled)
855 .icon_size(IconSize::Small),
856 );
857 }
858
859 if let Some(editor_focus_handle) = self.editor_focus_handle.clone() {
860 menu = menu
861 .separator()
862 .header("Actions")
863 .entry(
864 "Predict Edit at Cursor",
865 Some(Box::new(ShowEditPrediction)),
866 {
867 let editor_focus_handle = editor_focus_handle.clone();
868 move |window, cx| {
869 editor_focus_handle.dispatch_action(&ShowEditPrediction, window, cx);
870 }
871 },
872 )
873 .context(editor_focus_handle)
874 .when(
875 cx.has_flag::<PredictEditsRateCompletionsFeatureFlag>(),
876 |this| this.action("Rate Completions", RateCompletions.boxed_clone()),
877 );
878 }
879
880 menu
881 }
882
883 fn build_copilot_context_menu(
884 &self,
885 window: &mut Window,
886 cx: &mut Context<Self>,
887 ) -> Entity<ContextMenu> {
888 let all_language_settings = all_language_settings(None, cx);
889 let copilot_config = copilot::copilot_chat::CopilotChatConfiguration {
890 enterprise_uri: all_language_settings
891 .edit_predictions
892 .copilot
893 .enterprise_uri
894 .clone(),
895 };
896 let settings_url = copilot_settings_url(copilot_config.enterprise_uri.as_deref());
897
898 ContextMenu::build(window, cx, |menu, window, cx| {
899 let menu = self.build_language_settings_menu(menu, window, cx);
900 let menu =
901 self.add_provider_switching_section(menu, EditPredictionProvider::Copilot, cx);
902
903 menu.separator()
904 .link(
905 "Go to Copilot Settings",
906 OpenBrowser { url: settings_url }.boxed_clone(),
907 )
908 .action("Sign Out", copilot::SignOut.boxed_clone())
909 })
910 }
911
912 fn build_supermaven_context_menu(
913 &self,
914 window: &mut Window,
915 cx: &mut Context<Self>,
916 ) -> Entity<ContextMenu> {
917 ContextMenu::build(window, cx, |menu, window, cx| {
918 let menu = self.build_language_settings_menu(menu, window, cx);
919 let menu =
920 self.add_provider_switching_section(menu, EditPredictionProvider::Supermaven, cx);
921
922 menu.separator()
923 .action("Sign Out", supermaven::SignOut.boxed_clone())
924 })
925 }
926
927 fn build_codestral_context_menu(
928 &self,
929 window: &mut Window,
930 cx: &mut Context<Self>,
931 ) -> Entity<ContextMenu> {
932 ContextMenu::build(window, cx, |menu, window, cx| {
933 let menu = self.build_language_settings_menu(menu, window, cx);
934 let menu =
935 self.add_provider_switching_section(menu, EditPredictionProvider::Codestral, cx);
936
937 menu.separator()
938 .entry("Configure Codestral API Key", None, move |window, cx| {
939 window.dispatch_action(zed_actions::agent::OpenSettings.boxed_clone(), cx);
940 })
941 })
942 }
943
944 fn build_zeta_context_menu(
945 &self,
946 provider: EditPredictionProvider,
947 window: &mut Window,
948 cx: &mut Context<Self>,
949 ) -> Entity<ContextMenu> {
950 ContextMenu::build(window, cx, |mut menu, window, cx| {
951 if let Some(usage) = self
952 .edit_prediction_provider
953 .as_ref()
954 .and_then(|provider| provider.usage(cx))
955 {
956 menu = menu.header("Usage");
957 menu = menu
958 .custom_entry(
959 move |_window, cx| {
960 let used_percentage = match usage.limit {
961 UsageLimit::Limited(limit) => {
962 Some((usage.amount as f32 / limit as f32) * 100.)
963 }
964 UsageLimit::Unlimited => None,
965 };
966
967 h_flex()
968 .flex_1()
969 .gap_1p5()
970 .children(
971 used_percentage.map(|percent| {
972 ProgressBar::new("usage", percent, 100., cx)
973 }),
974 )
975 .child(
976 Label::new(match usage.limit {
977 UsageLimit::Limited(limit) => {
978 format!("{} / {limit}", usage.amount)
979 }
980 UsageLimit::Unlimited => format!("{} / ∞", usage.amount),
981 })
982 .size(LabelSize::Small)
983 .color(Color::Muted),
984 )
985 .into_any_element()
986 },
987 move |_, cx| cx.open_url(&zed_urls::account_url(cx)),
988 )
989 .when(usage.over_limit(), |menu| -> ContextMenu {
990 menu.entry("Subscribe to increase your limit", None, |_window, cx| {
991 cx.open_url(&zed_urls::account_url(cx))
992 })
993 })
994 .separator();
995 } else if self.user_store.read(cx).account_too_young() {
996 menu = menu
997 .custom_entry(
998 |_window, _cx| {
999 Label::new("Your GitHub account is less than 30 days old.")
1000 .size(LabelSize::Small)
1001 .color(Color::Warning)
1002 .into_any_element()
1003 },
1004 |_window, cx| cx.open_url(&zed_urls::account_url(cx)),
1005 )
1006 .entry("Upgrade to Zed Pro or contact us.", None, |_window, cx| {
1007 cx.open_url(&zed_urls::account_url(cx))
1008 })
1009 .separator();
1010 } else if self.user_store.read(cx).has_overdue_invoices() {
1011 menu = menu
1012 .custom_entry(
1013 |_window, _cx| {
1014 Label::new("You have an outstanding invoice")
1015 .size(LabelSize::Small)
1016 .color(Color::Warning)
1017 .into_any_element()
1018 },
1019 |_window, cx| {
1020 cx.open_url(&zed_urls::account_url(cx))
1021 },
1022 )
1023 .entry(
1024 "Check your payment status or contact us at billing-support@zed.dev to continue using this feature.",
1025 None,
1026 |_window, cx| {
1027 cx.open_url(&zed_urls::account_url(cx))
1028 },
1029 )
1030 .separator();
1031 }
1032
1033 let menu = self.build_language_settings_menu(menu, window, cx);
1034 let menu = self.add_provider_switching_section(menu, provider, cx);
1035
1036 menu
1037 })
1038 }
1039
1040 pub fn update_enabled(&mut self, editor: Entity<Editor>, cx: &mut Context<Self>) {
1041 let editor = editor.read(cx);
1042 let snapshot = editor.buffer().read(cx).snapshot(cx);
1043 let suggestion_anchor = editor.selections.newest_anchor().start;
1044 let language = snapshot.language_at(suggestion_anchor);
1045 let file = snapshot.file_at(suggestion_anchor).cloned();
1046 self.editor_enabled = {
1047 let file = file.as_ref();
1048 Some(
1049 file.map(|file| {
1050 all_language_settings(Some(file), cx)
1051 .edit_predictions_enabled_for_file(file, cx)
1052 })
1053 .unwrap_or(true),
1054 )
1055 };
1056 self.editor_show_predictions = editor.edit_predictions_enabled();
1057 self.edit_prediction_provider = editor.edit_prediction_provider();
1058 self.language = language.cloned();
1059 self.file = file;
1060 self.editor_focus_handle = Some(editor.focus_handle(cx));
1061
1062 cx.notify();
1063 }
1064}
1065
1066impl StatusItemView for EditPredictionButton {
1067 fn set_active_pane_item(
1068 &mut self,
1069 item: Option<&dyn ItemHandle>,
1070 _: &mut Window,
1071 cx: &mut Context<Self>,
1072 ) {
1073 if let Some(editor) = item.and_then(|item| item.act_as::<Editor>(cx)) {
1074 self.editor_subscription = Some((
1075 cx.observe(&editor, Self::update_enabled),
1076 editor.entity_id().as_u64() as usize,
1077 ));
1078 self.update_enabled(editor, cx);
1079 } else {
1080 self.language = None;
1081 self.editor_subscription = None;
1082 self.editor_enabled = None;
1083 }
1084 cx.notify();
1085 }
1086}
1087
1088impl SupermavenButtonStatus {
1089 fn to_icon(&self) -> IconName {
1090 match self {
1091 SupermavenButtonStatus::Ready => IconName::Supermaven,
1092 SupermavenButtonStatus::Errored(_) => IconName::SupermavenError,
1093 SupermavenButtonStatus::NeedsActivation(_) => IconName::SupermavenInit,
1094 SupermavenButtonStatus::Initializing => IconName::SupermavenInit,
1095 }
1096 }
1097
1098 fn to_tooltip(&self) -> String {
1099 match self {
1100 SupermavenButtonStatus::Ready => "Supermaven is ready".to_string(),
1101 SupermavenButtonStatus::Errored(error) => format!("Supermaven error: {}", error),
1102 SupermavenButtonStatus::NeedsActivation(_) => "Supermaven needs activation".to_string(),
1103 SupermavenButtonStatus::Initializing => "Supermaven initializing".to_string(),
1104 }
1105 }
1106
1107 fn has_menu(&self) -> bool {
1108 match self {
1109 SupermavenButtonStatus::Ready | SupermavenButtonStatus::NeedsActivation(_) => true,
1110 SupermavenButtonStatus::Errored(_) | SupermavenButtonStatus::Initializing => false,
1111 }
1112 }
1113}
1114
1115async fn open_disabled_globs_setting_in_editor(
1116 workspace: WeakEntity<Workspace>,
1117 cx: &mut AsyncWindowContext,
1118) -> Result<()> {
1119 let settings_editor = workspace
1120 .update_in(cx, |_, window, cx| {
1121 create_and_open_local_file(paths::settings_file(), window, cx, || {
1122 settings::initial_user_settings_content().as_ref().into()
1123 })
1124 })?
1125 .await?
1126 .downcast::<Editor>()
1127 .unwrap();
1128
1129 settings_editor
1130 .downgrade()
1131 .update_in(cx, |item, window, cx| {
1132 let text = item.buffer().read(cx).snapshot(cx).text();
1133
1134 let settings = cx.global::<SettingsStore>();
1135
1136 // Ensure that we always have "edit_predictions { "disabled_globs": [] }"
1137 let edits = settings.edits_for_update(&text, |file| {
1138 file.project
1139 .all_languages
1140 .edit_predictions
1141 .get_or_insert_with(Default::default)
1142 .disabled_globs
1143 .get_or_insert_with(Vec::new);
1144 });
1145
1146 if !edits.is_empty() {
1147 item.edit(
1148 edits
1149 .into_iter()
1150 .map(|(r, s)| (MultiBufferOffset(r.start)..MultiBufferOffset(r.end), s)),
1151 cx,
1152 );
1153 }
1154
1155 let text = item.buffer().read(cx).snapshot(cx).text();
1156
1157 static DISABLED_GLOBS_REGEX: LazyLock<Regex> = LazyLock::new(|| {
1158 Regex::new(r#""disabled_globs":\s*\[\s*(?P<content>(?:.|\n)*?)\s*\]"#).unwrap()
1159 });
1160 // Only capture [...]
1161 let range = DISABLED_GLOBS_REGEX.captures(&text).and_then(|captures| {
1162 captures
1163 .name("content")
1164 .map(|inner_match| inner_match.start()..inner_match.end())
1165 });
1166 if let Some(range) = range {
1167 let range = MultiBufferOffset(range.start)..MultiBufferOffset(range.end);
1168 item.change_selections(
1169 SelectionEffects::scroll(Autoscroll::newest()),
1170 window,
1171 cx,
1172 |selections| {
1173 selections.select_ranges(vec![range]);
1174 },
1175 );
1176 }
1177 })?;
1178
1179 anyhow::Ok(())
1180}
1181
1182fn set_completion_provider(fs: Arc<dyn Fs>, cx: &mut App, provider: EditPredictionProvider) {
1183 update_settings_file(fs, cx, move |settings, _| {
1184 settings
1185 .project
1186 .all_languages
1187 .features
1188 .get_or_insert_default()
1189 .edit_prediction_provider = Some(provider);
1190 });
1191}
1192
1193fn toggle_show_edit_predictions_for_language(
1194 language: Arc<Language>,
1195 fs: Arc<dyn Fs>,
1196 cx: &mut App,
1197) {
1198 let show_edit_predictions =
1199 all_language_settings(None, cx).show_edit_predictions(Some(&language), cx);
1200 update_settings_file(fs, cx, move |settings, _| {
1201 settings
1202 .project
1203 .all_languages
1204 .languages
1205 .0
1206 .entry(language.name().0)
1207 .or_default()
1208 .show_edit_predictions = Some(!show_edit_predictions);
1209 });
1210}
1211
1212fn hide_copilot(fs: Arc<dyn Fs>, cx: &mut App) {
1213 update_settings_file(fs, cx, move |settings, _| {
1214 settings
1215 .project
1216 .all_languages
1217 .features
1218 .get_or_insert(Default::default())
1219 .edit_prediction_provider = Some(EditPredictionProvider::None);
1220 });
1221}
1222
1223fn toggle_edit_prediction_mode(fs: Arc<dyn Fs>, mode: EditPredictionsMode, cx: &mut App) {
1224 let settings = AllLanguageSettings::get_global(cx);
1225 let current_mode = settings.edit_predictions_mode();
1226
1227 if current_mode != mode {
1228 update_settings_file(fs, cx, move |settings, _cx| {
1229 if let Some(edit_predictions) = settings.project.all_languages.edit_predictions.as_mut()
1230 {
1231 edit_predictions.mode = Some(mode);
1232 } else {
1233 settings.project.all_languages.edit_predictions =
1234 Some(settings::EditPredictionSettingsContent {
1235 mode: Some(mode),
1236 ..Default::default()
1237 });
1238 }
1239 });
1240 }
1241}
1242
1243fn copilot_settings_url(enterprise_uri: Option<&str>) -> String {
1244 match enterprise_uri {
1245 Some(uri) => {
1246 format!("{}{}", uri.trim_end_matches('/'), COPILOT_SETTINGS_PATH)
1247 }
1248 None => COPILOT_SETTINGS_URL.to_string(),
1249 }
1250}
1251
1252#[cfg(test)]
1253mod tests {
1254 use super::*;
1255 use gpui::TestAppContext;
1256
1257 #[gpui::test]
1258 async fn test_copilot_settings_url_with_enterprise_uri(cx: &mut TestAppContext) {
1259 cx.update(|cx| {
1260 let settings_store = SettingsStore::test(cx);
1261 cx.set_global(settings_store);
1262 });
1263
1264 cx.update_global(|settings_store: &mut SettingsStore, cx| {
1265 settings_store
1266 .set_user_settings(
1267 r#"{"edit_predictions":{"copilot":{"enterprise_uri":"https://my-company.ghe.com"}}}"#,
1268 cx,
1269 )
1270 .unwrap();
1271 });
1272
1273 let url = cx.update(|cx| {
1274 let all_language_settings = all_language_settings(None, cx);
1275 copilot_settings_url(
1276 all_language_settings
1277 .edit_predictions
1278 .copilot
1279 .enterprise_uri
1280 .as_deref(),
1281 )
1282 });
1283
1284 assert_eq!(url, "https://my-company.ghe.com/settings/copilot");
1285 }
1286
1287 #[gpui::test]
1288 async fn test_copilot_settings_url_with_enterprise_uri_trailing_slash(cx: &mut TestAppContext) {
1289 cx.update(|cx| {
1290 let settings_store = SettingsStore::test(cx);
1291 cx.set_global(settings_store);
1292 });
1293
1294 cx.update_global(|settings_store: &mut SettingsStore, cx| {
1295 settings_store
1296 .set_user_settings(
1297 r#"{"edit_predictions":{"copilot":{"enterprise_uri":"https://my-company.ghe.com/"}}}"#,
1298 cx,
1299 )
1300 .unwrap();
1301 });
1302
1303 let url = cx.update(|cx| {
1304 let all_language_settings = all_language_settings(None, cx);
1305 copilot_settings_url(
1306 all_language_settings
1307 .edit_predictions
1308 .copilot
1309 .enterprise_uri
1310 .as_deref(),
1311 )
1312 });
1313
1314 assert_eq!(url, "https://my-company.ghe.com/settings/copilot");
1315 }
1316
1317 #[gpui::test]
1318 async fn test_copilot_settings_url_without_enterprise_uri(cx: &mut TestAppContext) {
1319 cx.update(|cx| {
1320 let settings_store = SettingsStore::test(cx);
1321 cx.set_global(settings_store);
1322 });
1323
1324 let url = cx.update(|cx| {
1325 let all_language_settings = all_language_settings(None, cx);
1326 copilot_settings_url(
1327 all_language_settings
1328 .edit_predictions
1329 .copilot
1330 .enterprise_uri
1331 .as_deref(),
1332 )
1333 });
1334
1335 assert_eq!(url, "https://github.com/settings/copilot");
1336 }
1337}