1use anyhow::Result;
2use client::{Client, UserStore, zed_urls};
3use cloud_llm_client::UsageLimit;
4use codestral::CodestralCompletionProvider;
5use copilot::{Copilot, Status};
6use editor::{Editor, SelectionEffects, actions::ShowEditPrediction, scroll::Autoscroll};
7use feature_flags::{FeatureFlagAppExt, PredictEditsRateCompletionsFeatureFlag};
8use fs::Fs;
9use gpui::{
10 Action, Animation, AnimationExt, App, AsyncWindowContext, Corner, Entity, FocusHandle,
11 Focusable, IntoElement, ParentElement, Render, Subscription, WeakEntity, actions, div,
12 pulsating_between,
13};
14use indoc::indoc;
15use language::{
16 EditPredictionsMode, File, Language,
17 language_settings::{self, AllLanguageSettings, EditPredictionProvider, all_language_settings},
18};
19use project::DisableAiSettings;
20use regex::Regex;
21use settings::{
22 EXPERIMENTAL_SWEEP_EDIT_PREDICTION_PROVIDER_NAME, Settings, SettingsStore, update_settings_file,
23};
24use std::{
25 sync::{Arc, LazyLock},
26 time::Duration,
27};
28use supermaven::{AccountStatus, Supermaven};
29use sweep_ai::SweepFeatureFlag;
30use ui::{
31 Clickable, ContextMenu, ContextMenuEntry, DocumentationEdge, DocumentationSide, IconButton,
32 IconButtonShape, Indicator, PopoverMenu, PopoverMenuHandle, ProgressBar, Tooltip, prelude::*,
33};
34use workspace::{
35 StatusItemView, Toast, Workspace, create_and_open_local_file, item::ItemHandle,
36 notifications::NotificationId,
37};
38use zed_actions::OpenBrowser;
39use zeta::RateCompletions;
40
41actions!(
42 edit_prediction,
43 [
44 /// Toggles the edit prediction menu.
45 ToggleMenu
46 ]
47);
48
49const COPILOT_SETTINGS_PATH: &str = "/settings/copilot";
50const COPILOT_SETTINGS_URL: &str = concat!("https://github.com", "/settings/copilot");
51const PRIVACY_DOCS: &str = "https://zed.dev/docs/ai/privacy-and-security";
52
53struct CopilotErrorToast;
54
55pub struct EditPredictionButton {
56 editor_subscription: Option<(Subscription, usize)>,
57 editor_enabled: Option<bool>,
58 editor_show_predictions: bool,
59 editor_focus_handle: Option<FocusHandle>,
60 language: Option<Arc<Language>>,
61 file: Option<Arc<dyn File>>,
62 edit_prediction_provider: Option<Arc<dyn edit_prediction::EditPredictionProviderHandle>>,
63 fs: Arc<dyn Fs>,
64 user_store: Entity<UserStore>,
65 popover_menu_handle: PopoverMenuHandle<ContextMenu>,
66}
67
68enum SupermavenButtonStatus {
69 Ready,
70 Errored(String),
71 NeedsActivation(String),
72 Initializing,
73}
74
75impl Render for EditPredictionButton {
76 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
77 // Return empty div if AI is disabled
78 if DisableAiSettings::get_global(cx).disable_ai {
79 return div().hidden();
80 }
81
82 let all_language_settings = all_language_settings(None, cx);
83
84 match &all_language_settings.edit_predictions.provider {
85 EditPredictionProvider::None => div().hidden(),
86
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 EditPredictionProvider::Experimental(provider_name) => {
304 if *provider_name == EXPERIMENTAL_SWEEP_EDIT_PREDICTION_PROVIDER_NAME
305 && cx.has_flag::<SweepFeatureFlag>()
306 {
307 div().child(Icon::new(IconName::SweepAi))
308 } else {
309 div()
310 }
311 }
312
313 EditPredictionProvider::Zed => {
314 let enabled = self.editor_enabled.unwrap_or(true);
315
316 let zeta_icon = if enabled {
317 IconName::ZedPredict
318 } else {
319 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| this.build_zeta_context_menu(window, cx))
404 .ok()
405 })
406 .anchor(Corner::BottomRight)
407 .with_handle(self.popover_menu_handle.clone());
408
409 let is_refreshing = self
410 .edit_prediction_provider
411 .as_ref()
412 .is_some_and(|provider| provider.is_refreshing(cx));
413
414 if is_refreshing {
415 popover_menu = popover_menu.trigger(
416 icon_button.with_animation(
417 "pulsating-label",
418 Animation::new(Duration::from_secs(2))
419 .repeat()
420 .with_easing(pulsating_between(0.2, 1.0)),
421 |icon_button, delta| icon_button.alpha(delta),
422 ),
423 );
424 } else {
425 popover_menu = popover_menu.trigger(icon_button);
426 }
427
428 div().child(popover_menu.into_any_element())
429 }
430 }
431 }
432}
433
434impl EditPredictionButton {
435 pub fn new(
436 fs: Arc<dyn Fs>,
437 user_store: Entity<UserStore>,
438 popover_menu_handle: PopoverMenuHandle<ContextMenu>,
439 client: Arc<Client>,
440 cx: &mut Context<Self>,
441 ) -> Self {
442 if let Some(copilot) = Copilot::global(cx) {
443 cx.observe(&copilot, |_, _, cx| cx.notify()).detach()
444 }
445
446 cx.observe_global::<SettingsStore>(move |_, cx| cx.notify())
447 .detach();
448
449 CodestralCompletionProvider::ensure_api_key_loaded(client.http_client(), cx);
450
451 Self {
452 editor_subscription: None,
453 editor_enabled: None,
454 editor_show_predictions: true,
455 editor_focus_handle: None,
456 language: None,
457 file: None,
458 edit_prediction_provider: None,
459 user_store,
460 popover_menu_handle,
461 fs,
462 }
463 }
464
465 fn get_available_providers(&self, cx: &App) -> Vec<EditPredictionProvider> {
466 let mut providers = Vec::new();
467
468 providers.push(EditPredictionProvider::Zed);
469
470 if let Some(copilot) = Copilot::global(cx) {
471 if matches!(copilot.read(cx).status(), Status::Authorized) {
472 providers.push(EditPredictionProvider::Copilot);
473 }
474 }
475
476 if let Some(supermaven) = Supermaven::global(cx) {
477 if let Supermaven::Spawned(agent) = supermaven.read(cx) {
478 if matches!(agent.account_status, AccountStatus::Ready) {
479 providers.push(EditPredictionProvider::Supermaven);
480 }
481 }
482 }
483
484 if CodestralCompletionProvider::has_api_key(cx) {
485 providers.push(EditPredictionProvider::Codestral);
486 }
487
488 providers
489 }
490
491 fn add_provider_switching_section(
492 &self,
493 mut menu: ContextMenu,
494 current_provider: EditPredictionProvider,
495 cx: &App,
496 ) -> ContextMenu {
497 let available_providers = self.get_available_providers(cx);
498
499 let other_providers: Vec<_> = available_providers
500 .into_iter()
501 .filter(|p| *p != current_provider && *p != EditPredictionProvider::None)
502 .collect();
503
504 if !other_providers.is_empty() {
505 menu = menu.separator().header("Switch Providers");
506
507 for provider in other_providers {
508 let fs = self.fs.clone();
509
510 menu = match provider {
511 EditPredictionProvider::Zed => menu.item(
512 ContextMenuEntry::new("Zed AI")
513 .documentation_aside(
514 DocumentationSide::Left,
515 DocumentationEdge::Top,
516 |_| {
517 Label::new("Zed's edit prediction is powered by Zeta, an open-source, dataset mode.")
518 .into_any_element()
519 },
520 )
521 .handler(move |_, cx| {
522 set_completion_provider(fs.clone(), cx, provider);
523 }),
524 ),
525 EditPredictionProvider::Copilot => {
526 menu.entry("GitHub Copilot", None, move |_, cx| {
527 set_completion_provider(fs.clone(), cx, provider);
528 })
529 }
530 EditPredictionProvider::Supermaven => {
531 menu.entry("Supermaven", None, move |_, cx| {
532 set_completion_provider(fs.clone(), cx, provider);
533 })
534 }
535 EditPredictionProvider::Codestral => {
536 menu.entry("Codestral", None, move |_, cx| {
537 set_completion_provider(fs.clone(), cx, provider);
538 })
539 }
540 EditPredictionProvider::None | EditPredictionProvider::Experimental(_) => continue,
541 };
542 }
543 }
544
545 menu
546 }
547
548 pub fn build_copilot_start_menu(
549 &mut self,
550 window: &mut Window,
551 cx: &mut Context<Self>,
552 ) -> Entity<ContextMenu> {
553 let fs = self.fs.clone();
554 ContextMenu::build(window, cx, |menu, _, _| {
555 menu.entry("Sign In to Copilot", None, copilot::initiate_sign_in)
556 .entry("Disable Copilot", None, {
557 let fs = fs.clone();
558 move |_window, cx| hide_copilot(fs.clone(), cx)
559 })
560 .separator()
561 .entry("Use Zed AI", None, {
562 let fs = fs.clone();
563 move |_window, cx| {
564 set_completion_provider(fs.clone(), cx, EditPredictionProvider::Zed)
565 }
566 })
567 })
568 }
569
570 pub fn build_language_settings_menu(
571 &self,
572 mut menu: ContextMenu,
573 window: &Window,
574 cx: &mut App,
575 ) -> ContextMenu {
576 let fs = self.fs.clone();
577 let line_height = window.line_height();
578
579 menu = menu.header("Show Edit Predictions For");
580
581 let language_state = self.language.as_ref().map(|language| {
582 (
583 language.clone(),
584 language_settings::language_settings(Some(language.name()), None, cx)
585 .show_edit_predictions,
586 )
587 });
588
589 if let Some(editor_focus_handle) = self.editor_focus_handle.clone() {
590 let entry = ContextMenuEntry::new("This Buffer")
591 .toggleable(IconPosition::Start, self.editor_show_predictions)
592 .action(Box::new(editor::actions::ToggleEditPrediction))
593 .handler(move |window, cx| {
594 editor_focus_handle.dispatch_action(
595 &editor::actions::ToggleEditPrediction,
596 window,
597 cx,
598 );
599 });
600
601 match language_state.clone() {
602 Some((language, false)) => {
603 menu = menu.item(
604 entry
605 .disabled(true)
606 .documentation_aside(DocumentationSide::Left, DocumentationEdge::Top, move |_cx| {
607 Label::new(format!("Edit predictions cannot be toggled for this buffer because they are disabled for {}", language.name()))
608 .into_any_element()
609 })
610 );
611 }
612 Some(_) | None => menu = menu.item(entry),
613 }
614 }
615
616 if let Some((language, language_enabled)) = language_state {
617 let fs = fs.clone();
618
619 menu = menu.toggleable_entry(
620 language.name(),
621 language_enabled,
622 IconPosition::Start,
623 None,
624 move |_, cx| {
625 toggle_show_edit_predictions_for_language(language.clone(), fs.clone(), cx)
626 },
627 );
628 }
629
630 let settings = AllLanguageSettings::get_global(cx);
631
632 let globally_enabled = settings.show_edit_predictions(None, cx);
633 let entry = ContextMenuEntry::new("All Files")
634 .toggleable(IconPosition::Start, globally_enabled)
635 .action(workspace::ToggleEditPrediction.boxed_clone())
636 .handler(|window, cx| {
637 window.dispatch_action(workspace::ToggleEditPrediction.boxed_clone(), cx)
638 });
639 menu = menu.item(entry);
640
641 let provider = settings.edit_predictions.provider;
642 let current_mode = settings.edit_predictions_mode();
643 let subtle_mode = matches!(current_mode, EditPredictionsMode::Subtle);
644 let eager_mode = matches!(current_mode, EditPredictionsMode::Eager);
645
646 if matches!(
647 provider,
648 EditPredictionProvider::Zed
649 | EditPredictionProvider::Copilot
650 | EditPredictionProvider::Supermaven
651 | EditPredictionProvider::Codestral
652 ) {
653 menu = menu
654 .separator()
655 .header("Display Modes")
656 .item(
657 ContextMenuEntry::new("Eager")
658 .toggleable(IconPosition::Start, eager_mode)
659 .documentation_aside(DocumentationSide::Left, DocumentationEdge::Top, move |_| {
660 Label::new("Display predictions inline when there are no language server completions available.").into_any_element()
661 })
662 .handler({
663 let fs = fs.clone();
664 move |_, cx| {
665 toggle_edit_prediction_mode(fs.clone(), EditPredictionsMode::Eager, cx)
666 }
667 }),
668 )
669 .item(
670 ContextMenuEntry::new("Subtle")
671 .toggleable(IconPosition::Start, subtle_mode)
672 .documentation_aside(DocumentationSide::Left, DocumentationEdge::Top, move |_| {
673 Label::new("Display predictions inline only when holding a modifier key (alt by default).").into_any_element()
674 })
675 .handler({
676 let fs = fs.clone();
677 move |_, cx| {
678 toggle_edit_prediction_mode(fs.clone(), EditPredictionsMode::Subtle, cx)
679 }
680 }),
681 );
682 }
683
684 menu = menu.separator().header("Privacy");
685
686 if let Some(provider) = &self.edit_prediction_provider {
687 let data_collection = provider.data_collection_state(cx);
688
689 if data_collection.is_supported() {
690 let provider = provider.clone();
691 let enabled = data_collection.is_enabled();
692 let is_open_source = data_collection.is_project_open_source();
693 let is_collecting = data_collection.is_enabled();
694 let (icon_name, icon_color) = if is_open_source && is_collecting {
695 (IconName::Check, Color::Success)
696 } else {
697 (IconName::Check, Color::Accent)
698 };
699
700 menu = menu.item(
701 ContextMenuEntry::new("Training Data Collection")
702 .toggleable(IconPosition::Start, data_collection.is_enabled())
703 .icon(icon_name)
704 .icon_color(icon_color)
705 .documentation_aside(DocumentationSide::Left, DocumentationEdge::Top, move |cx| {
706 let (msg, label_color, icon_name, icon_color) = match (is_open_source, is_collecting) {
707 (true, true) => (
708 "Project identified as open source, and you're sharing data.",
709 Color::Default,
710 IconName::Check,
711 Color::Success,
712 ),
713 (true, false) => (
714 "Project identified as open source, but you're not sharing data.",
715 Color::Muted,
716 IconName::Close,
717 Color::Muted,
718 ),
719 (false, true) => (
720 "Project not identified as open source. No data captured.",
721 Color::Muted,
722 IconName::Close,
723 Color::Muted,
724 ),
725 (false, false) => (
726 "Project not identified as open source, and setting turned off.",
727 Color::Muted,
728 IconName::Close,
729 Color::Muted,
730 ),
731 };
732 v_flex()
733 .gap_2()
734 .child(
735 Label::new(indoc!{
736 "Help us improve our open dataset model by sharing data from open source repositories. \
737 Zed must detect a license file in your repo for this setting to take effect. \
738 Files with sensitive data and secrets are excluded by default."
739 })
740 )
741 .child(
742 h_flex()
743 .items_start()
744 .pt_2()
745 .pr_1()
746 .flex_1()
747 .gap_1p5()
748 .border_t_1()
749 .border_color(cx.theme().colors().border_variant)
750 .child(h_flex().flex_shrink_0().h(line_height).child(Icon::new(icon_name).size(IconSize::XSmall).color(icon_color)))
751 .child(div().child(msg).w_full().text_sm().text_color(label_color.color(cx)))
752 )
753 .into_any_element()
754 })
755 .handler(move |_, cx| {
756 provider.toggle_data_collection(cx);
757
758 if !enabled {
759 telemetry::event!(
760 "Data Collection Enabled",
761 source = "Edit Prediction Status Menu"
762 );
763 } else {
764 telemetry::event!(
765 "Data Collection Disabled",
766 source = "Edit Prediction Status Menu"
767 );
768 }
769 })
770 );
771
772 if is_collecting && !is_open_source {
773 menu = menu.item(
774 ContextMenuEntry::new("No data captured.")
775 .disabled(true)
776 .icon(IconName::Close)
777 .icon_color(Color::Error)
778 .icon_size(IconSize::Small),
779 );
780 }
781 }
782 }
783
784 menu = menu.item(
785 ContextMenuEntry::new("Configure Excluded Files")
786 .icon(IconName::LockOutlined)
787 .icon_color(Color::Muted)
788 .documentation_aside(DocumentationSide::Left, DocumentationEdge::Top, |_| {
789 Label::new(indoc!{"
790 Open your settings to add sensitive paths for which Zed will never predict edits."}).into_any_element()
791 })
792 .handler(move |window, cx| {
793 if let Some(workspace) = window.root().flatten() {
794 let workspace = workspace.downgrade();
795 window
796 .spawn(cx, async |cx| {
797 open_disabled_globs_setting_in_editor(
798 workspace,
799 cx,
800 ).await
801 })
802 .detach_and_log_err(cx);
803 }
804 }),
805 ).item(
806 ContextMenuEntry::new("View Docs")
807 .icon(IconName::FileGeneric)
808 .icon_color(Color::Muted)
809 .handler(move |_, cx| {
810 cx.open_url(PRIVACY_DOCS);
811 })
812 );
813
814 if !self.editor_enabled.unwrap_or(true) {
815 menu = menu.item(
816 ContextMenuEntry::new("This file is excluded.")
817 .disabled(true)
818 .icon(IconName::ZedPredictDisabled)
819 .icon_size(IconSize::Small),
820 );
821 }
822
823 if let Some(editor_focus_handle) = self.editor_focus_handle.clone() {
824 menu = menu
825 .separator()
826 .header("Actions")
827 .entry(
828 "Predict Edit at Cursor",
829 Some(Box::new(ShowEditPrediction)),
830 {
831 let editor_focus_handle = editor_focus_handle.clone();
832 move |window, cx| {
833 editor_focus_handle.dispatch_action(&ShowEditPrediction, window, cx);
834 }
835 },
836 )
837 .context(editor_focus_handle)
838 .when(
839 cx.has_flag::<PredictEditsRateCompletionsFeatureFlag>(),
840 |this| this.action("Rate Completions", RateCompletions.boxed_clone()),
841 );
842 }
843
844 menu
845 }
846
847 fn build_copilot_context_menu(
848 &self,
849 window: &mut Window,
850 cx: &mut Context<Self>,
851 ) -> Entity<ContextMenu> {
852 let all_language_settings = all_language_settings(None, cx);
853 let copilot_config = copilot::copilot_chat::CopilotChatConfiguration {
854 enterprise_uri: all_language_settings
855 .edit_predictions
856 .copilot
857 .enterprise_uri
858 .clone(),
859 };
860 let settings_url = copilot_settings_url(copilot_config.enterprise_uri.as_deref());
861
862 ContextMenu::build(window, cx, |menu, window, cx| {
863 let menu = self.build_language_settings_menu(menu, window, cx);
864 let menu =
865 self.add_provider_switching_section(menu, EditPredictionProvider::Copilot, cx);
866
867 menu.separator()
868 .link(
869 "Go to Copilot Settings",
870 OpenBrowser { url: settings_url }.boxed_clone(),
871 )
872 .action("Sign Out", copilot::SignOut.boxed_clone())
873 })
874 }
875
876 fn build_supermaven_context_menu(
877 &self,
878 window: &mut Window,
879 cx: &mut Context<Self>,
880 ) -> Entity<ContextMenu> {
881 ContextMenu::build(window, cx, |menu, window, cx| {
882 let menu = self.build_language_settings_menu(menu, window, cx);
883 let menu =
884 self.add_provider_switching_section(menu, EditPredictionProvider::Supermaven, cx);
885
886 menu.separator()
887 .action("Sign Out", supermaven::SignOut.boxed_clone())
888 })
889 }
890
891 fn build_codestral_context_menu(
892 &self,
893 window: &mut Window,
894 cx: &mut Context<Self>,
895 ) -> Entity<ContextMenu> {
896 ContextMenu::build(window, cx, |menu, window, cx| {
897 let menu = self.build_language_settings_menu(menu, window, cx);
898 let menu =
899 self.add_provider_switching_section(menu, EditPredictionProvider::Codestral, cx);
900
901 menu.separator()
902 .entry("Configure Codestral API Key", None, move |window, cx| {
903 window.dispatch_action(zed_actions::agent::OpenSettings.boxed_clone(), cx);
904 })
905 })
906 }
907
908 fn build_zeta_context_menu(
909 &self,
910 window: &mut Window,
911 cx: &mut Context<Self>,
912 ) -> Entity<ContextMenu> {
913 ContextMenu::build(window, cx, |mut menu, window, cx| {
914 if let Some(usage) = self
915 .edit_prediction_provider
916 .as_ref()
917 .and_then(|provider| provider.usage(cx))
918 {
919 menu = menu.header("Usage");
920 menu = menu
921 .custom_entry(
922 move |_window, cx| {
923 let used_percentage = match usage.limit {
924 UsageLimit::Limited(limit) => {
925 Some((usage.amount as f32 / limit as f32) * 100.)
926 }
927 UsageLimit::Unlimited => None,
928 };
929
930 h_flex()
931 .flex_1()
932 .gap_1p5()
933 .children(
934 used_percentage.map(|percent| {
935 ProgressBar::new("usage", percent, 100., cx)
936 }),
937 )
938 .child(
939 Label::new(match usage.limit {
940 UsageLimit::Limited(limit) => {
941 format!("{} / {limit}", usage.amount)
942 }
943 UsageLimit::Unlimited => format!("{} / ∞", usage.amount),
944 })
945 .size(LabelSize::Small)
946 .color(Color::Muted),
947 )
948 .into_any_element()
949 },
950 move |_, cx| cx.open_url(&zed_urls::account_url(cx)),
951 )
952 .when(usage.over_limit(), |menu| -> ContextMenu {
953 menu.entry("Subscribe to increase your limit", None, |_window, cx| {
954 cx.open_url(&zed_urls::account_url(cx))
955 })
956 })
957 .separator();
958 } else if self.user_store.read(cx).account_too_young() {
959 menu = menu
960 .custom_entry(
961 |_window, _cx| {
962 Label::new("Your GitHub account is less than 30 days old.")
963 .size(LabelSize::Small)
964 .color(Color::Warning)
965 .into_any_element()
966 },
967 |_window, cx| cx.open_url(&zed_urls::account_url(cx)),
968 )
969 .entry("Upgrade to Zed Pro or contact us.", None, |_window, cx| {
970 cx.open_url(&zed_urls::account_url(cx))
971 })
972 .separator();
973 } else if self.user_store.read(cx).has_overdue_invoices() {
974 menu = menu
975 .custom_entry(
976 |_window, _cx| {
977 Label::new("You have an outstanding invoice")
978 .size(LabelSize::Small)
979 .color(Color::Warning)
980 .into_any_element()
981 },
982 |_window, cx| {
983 cx.open_url(&zed_urls::account_url(cx))
984 },
985 )
986 .entry(
987 "Check your payment status or contact us at billing-support@zed.dev to continue using this feature.",
988 None,
989 |_window, cx| {
990 cx.open_url(&zed_urls::account_url(cx))
991 },
992 )
993 .separator();
994 }
995
996 let menu = self.build_language_settings_menu(menu, window, cx);
997 let menu = self.add_provider_switching_section(menu, EditPredictionProvider::Zed, cx);
998
999 menu
1000 })
1001 }
1002
1003 pub fn update_enabled(&mut self, editor: Entity<Editor>, cx: &mut Context<Self>) {
1004 let editor = editor.read(cx);
1005 let snapshot = editor.buffer().read(cx).snapshot(cx);
1006 let suggestion_anchor = editor.selections.newest_anchor().start;
1007 let language = snapshot.language_at(suggestion_anchor);
1008 let file = snapshot.file_at(suggestion_anchor).cloned();
1009 self.editor_enabled = {
1010 let file = file.as_ref();
1011 Some(
1012 file.map(|file| {
1013 all_language_settings(Some(file), cx)
1014 .edit_predictions_enabled_for_file(file, cx)
1015 })
1016 .unwrap_or(true),
1017 )
1018 };
1019 self.editor_show_predictions = editor.edit_predictions_enabled();
1020 self.edit_prediction_provider = editor.edit_prediction_provider();
1021 self.language = language.cloned();
1022 self.file = file;
1023 self.editor_focus_handle = Some(editor.focus_handle(cx));
1024
1025 cx.notify();
1026 }
1027}
1028
1029impl StatusItemView for EditPredictionButton {
1030 fn set_active_pane_item(
1031 &mut self,
1032 item: Option<&dyn ItemHandle>,
1033 _: &mut Window,
1034 cx: &mut Context<Self>,
1035 ) {
1036 if let Some(editor) = item.and_then(|item| item.act_as::<Editor>(cx)) {
1037 self.editor_subscription = Some((
1038 cx.observe(&editor, Self::update_enabled),
1039 editor.entity_id().as_u64() as usize,
1040 ));
1041 self.update_enabled(editor, cx);
1042 } else {
1043 self.language = None;
1044 self.editor_subscription = None;
1045 self.editor_enabled = None;
1046 }
1047 cx.notify();
1048 }
1049}
1050
1051impl SupermavenButtonStatus {
1052 fn to_icon(&self) -> IconName {
1053 match self {
1054 SupermavenButtonStatus::Ready => IconName::Supermaven,
1055 SupermavenButtonStatus::Errored(_) => IconName::SupermavenError,
1056 SupermavenButtonStatus::NeedsActivation(_) => IconName::SupermavenInit,
1057 SupermavenButtonStatus::Initializing => IconName::SupermavenInit,
1058 }
1059 }
1060
1061 fn to_tooltip(&self) -> String {
1062 match self {
1063 SupermavenButtonStatus::Ready => "Supermaven is ready".to_string(),
1064 SupermavenButtonStatus::Errored(error) => format!("Supermaven error: {}", error),
1065 SupermavenButtonStatus::NeedsActivation(_) => "Supermaven needs activation".to_string(),
1066 SupermavenButtonStatus::Initializing => "Supermaven initializing".to_string(),
1067 }
1068 }
1069
1070 fn has_menu(&self) -> bool {
1071 match self {
1072 SupermavenButtonStatus::Ready | SupermavenButtonStatus::NeedsActivation(_) => true,
1073 SupermavenButtonStatus::Errored(_) | SupermavenButtonStatus::Initializing => false,
1074 }
1075 }
1076}
1077
1078async fn open_disabled_globs_setting_in_editor(
1079 workspace: WeakEntity<Workspace>,
1080 cx: &mut AsyncWindowContext,
1081) -> Result<()> {
1082 let settings_editor = workspace
1083 .update_in(cx, |_, window, cx| {
1084 create_and_open_local_file(paths::settings_file(), window, cx, || {
1085 settings::initial_user_settings_content().as_ref().into()
1086 })
1087 })?
1088 .await?
1089 .downcast::<Editor>()
1090 .unwrap();
1091
1092 settings_editor
1093 .downgrade()
1094 .update_in(cx, |item, window, cx| {
1095 let text = item.buffer().read(cx).snapshot(cx).text();
1096
1097 let settings = cx.global::<SettingsStore>();
1098
1099 // Ensure that we always have "edit_predictions { "disabled_globs": [] }"
1100 let edits = settings.edits_for_update(&text, |file| {
1101 file.project
1102 .all_languages
1103 .edit_predictions
1104 .get_or_insert_with(Default::default)
1105 .disabled_globs
1106 .get_or_insert_with(Vec::new);
1107 });
1108
1109 if !edits.is_empty() {
1110 item.edit(edits, cx);
1111 }
1112
1113 let text = item.buffer().read(cx).snapshot(cx).text();
1114
1115 static DISABLED_GLOBS_REGEX: LazyLock<Regex> = LazyLock::new(|| {
1116 Regex::new(r#""disabled_globs":\s*\[\s*(?P<content>(?:.|\n)*?)\s*\]"#).unwrap()
1117 });
1118 // Only capture [...]
1119 let range = DISABLED_GLOBS_REGEX.captures(&text).and_then(|captures| {
1120 captures
1121 .name("content")
1122 .map(|inner_match| inner_match.start()..inner_match.end())
1123 });
1124 if let Some(range) = range {
1125 item.change_selections(
1126 SelectionEffects::scroll(Autoscroll::newest()),
1127 window,
1128 cx,
1129 |selections| {
1130 selections.select_ranges(vec![range]);
1131 },
1132 );
1133 }
1134 })?;
1135
1136 anyhow::Ok(())
1137}
1138
1139fn set_completion_provider(fs: Arc<dyn Fs>, cx: &mut App, provider: EditPredictionProvider) {
1140 update_settings_file(fs, cx, move |settings, _| {
1141 settings
1142 .project
1143 .all_languages
1144 .features
1145 .get_or_insert_default()
1146 .edit_prediction_provider = Some(provider);
1147 });
1148}
1149
1150fn toggle_show_edit_predictions_for_language(
1151 language: Arc<Language>,
1152 fs: Arc<dyn Fs>,
1153 cx: &mut App,
1154) {
1155 let show_edit_predictions =
1156 all_language_settings(None, cx).show_edit_predictions(Some(&language), cx);
1157 update_settings_file(fs, cx, move |settings, _| {
1158 settings
1159 .project
1160 .all_languages
1161 .languages
1162 .0
1163 .entry(language.name().0)
1164 .or_default()
1165 .show_edit_predictions = Some(!show_edit_predictions);
1166 });
1167}
1168
1169fn hide_copilot(fs: Arc<dyn Fs>, cx: &mut App) {
1170 update_settings_file(fs, cx, move |settings, _| {
1171 settings
1172 .project
1173 .all_languages
1174 .features
1175 .get_or_insert(Default::default())
1176 .edit_prediction_provider = Some(EditPredictionProvider::None);
1177 });
1178}
1179
1180fn toggle_edit_prediction_mode(fs: Arc<dyn Fs>, mode: EditPredictionsMode, cx: &mut App) {
1181 let settings = AllLanguageSettings::get_global(cx);
1182 let current_mode = settings.edit_predictions_mode();
1183
1184 if current_mode != mode {
1185 update_settings_file(fs, cx, move |settings, _cx| {
1186 if let Some(edit_predictions) = settings.project.all_languages.edit_predictions.as_mut()
1187 {
1188 edit_predictions.mode = Some(mode);
1189 } else {
1190 settings.project.all_languages.edit_predictions =
1191 Some(settings::EditPredictionSettingsContent {
1192 mode: Some(mode),
1193 ..Default::default()
1194 });
1195 }
1196 });
1197 }
1198}
1199
1200fn copilot_settings_url(enterprise_uri: Option<&str>) -> String {
1201 match enterprise_uri {
1202 Some(uri) => {
1203 format!("{}{}", uri.trim_end_matches('/'), COPILOT_SETTINGS_PATH)
1204 }
1205 None => COPILOT_SETTINGS_URL.to_string(),
1206 }
1207}
1208
1209#[cfg(test)]
1210mod tests {
1211 use super::*;
1212 use gpui::TestAppContext;
1213
1214 #[gpui::test]
1215 async fn test_copilot_settings_url_with_enterprise_uri(cx: &mut TestAppContext) {
1216 cx.update(|cx| {
1217 let settings_store = SettingsStore::test(cx);
1218 cx.set_global(settings_store);
1219 });
1220
1221 cx.update_global(|settings_store: &mut SettingsStore, cx| {
1222 settings_store
1223 .set_user_settings(
1224 r#"{"edit_predictions":{"copilot":{"enterprise_uri":"https://my-company.ghe.com"}}}"#,
1225 cx,
1226 )
1227 .unwrap();
1228 });
1229
1230 let url = cx.update(|cx| {
1231 let all_language_settings = all_language_settings(None, cx);
1232 copilot_settings_url(
1233 all_language_settings
1234 .edit_predictions
1235 .copilot
1236 .enterprise_uri
1237 .as_deref(),
1238 )
1239 });
1240
1241 assert_eq!(url, "https://my-company.ghe.com/settings/copilot");
1242 }
1243
1244 #[gpui::test]
1245 async fn test_copilot_settings_url_with_enterprise_uri_trailing_slash(cx: &mut TestAppContext) {
1246 cx.update(|cx| {
1247 let settings_store = SettingsStore::test(cx);
1248 cx.set_global(settings_store);
1249 });
1250
1251 cx.update_global(|settings_store: &mut SettingsStore, cx| {
1252 settings_store
1253 .set_user_settings(
1254 r#"{"edit_predictions":{"copilot":{"enterprise_uri":"https://my-company.ghe.com/"}}}"#,
1255 cx,
1256 )
1257 .unwrap();
1258 });
1259
1260 let url = cx.update(|cx| {
1261 let all_language_settings = all_language_settings(None, cx);
1262 copilot_settings_url(
1263 all_language_settings
1264 .edit_predictions
1265 .copilot
1266 .enterprise_uri
1267 .as_deref(),
1268 )
1269 });
1270
1271 assert_eq!(url, "https://my-company.ghe.com/settings/copilot");
1272 }
1273
1274 #[gpui::test]
1275 async fn test_copilot_settings_url_without_enterprise_uri(cx: &mut TestAppContext) {
1276 cx.update(|cx| {
1277 let settings_store = SettingsStore::test(cx);
1278 cx.set_global(settings_store);
1279 });
1280
1281 let url = cx.update(|cx| {
1282 let all_language_settings = all_language_settings(None, cx);
1283 copilot_settings_url(
1284 all_language_settings
1285 .edit_predictions
1286 .copilot
1287 .enterprise_uri
1288 .as_deref(),
1289 )
1290 });
1291
1292 assert_eq!(url, "https://github.com/settings/copilot");
1293 }
1294}