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