1use anyhow::Result;
2use client::{Client, UserStore, zed_urls};
3use cloud_llm_client::UsageLimit;
4use codestral::CodestralEditPredictionDelegate;
5use copilot::Status;
6use edit_prediction::{EditPredictionStore, Zeta2FeatureFlag};
7use edit_prediction_types::EditPredictionDelegateHandle;
8use editor::{
9 Editor, MultiBufferOffset, SelectionEffects, actions::ShowEditPrediction, scroll::Autoscroll,
10};
11use feature_flags::FeatureFlagAppExt;
12use fs::Fs;
13use gpui::{
14 Action, Animation, AnimationExt, App, AsyncWindowContext, Corner, Entity, FocusHandle,
15 Focusable, IntoElement, ParentElement, Render, Subscription, WeakEntity, actions, div,
16 ease_in_out, pulsating_between,
17};
18use indoc::indoc;
19use language::{
20 EditPredictionsMode, File, Language,
21 language_settings::{self, AllLanguageSettings, EditPredictionProvider, all_language_settings},
22};
23use project::{DisableAiSettings, Project};
24use regex::Regex;
25use settings::{
26 EXPERIMENTAL_ZETA2_EDIT_PREDICTION_PROVIDER_NAME, Settings, SettingsStore, update_settings_file,
27};
28use std::{
29 rc::Rc,
30 sync::{Arc, LazyLock},
31 time::Duration,
32};
33use supermaven::{AccountStatus, Supermaven};
34use ui::{
35 Clickable, ContextMenu, ContextMenuEntry, DocumentationSide, IconButton, IconButtonShape,
36 Indicator, PopoverMenu, PopoverMenuHandle, ProgressBar, Tooltip, prelude::*,
37};
38use util::ResultExt as _;
39
40use workspace::{
41 StatusItemView, Toast, Workspace, create_and_open_local_file, item::ItemHandle,
42 notifications::NotificationId,
43};
44use zed_actions::{OpenBrowser, OpenSettingsAt};
45
46use crate::{
47 CaptureExample, RatePredictions, rate_prediction_modal::PredictEditsRatePredictionsFeatureFlag,
48};
49
50actions!(
51 edit_prediction,
52 [
53 /// Toggles the edit prediction menu.
54 ToggleMenu
55 ]
56);
57
58const COPILOT_SETTINGS_PATH: &str = "/settings/copilot";
59const COPILOT_SETTINGS_URL: &str = concat!("https://github.com", "/settings/copilot");
60const PRIVACY_DOCS: &str = "https://zed.dev/docs/ai/privacy-and-security";
61
62struct CopilotErrorToast;
63
64pub struct EditPredictionButton {
65 editor_subscription: Option<(Subscription, usize)>,
66 editor_enabled: Option<bool>,
67 editor_show_predictions: bool,
68 editor_focus_handle: Option<FocusHandle>,
69 language: Option<Arc<Language>>,
70 file: Option<Arc<dyn File>>,
71 edit_prediction_provider: Option<Arc<dyn EditPredictionDelegateHandle>>,
72 fs: Arc<dyn Fs>,
73 user_store: Entity<UserStore>,
74 popover_menu_handle: PopoverMenuHandle<ContextMenu>,
75 project: WeakEntity<Project>,
76}
77
78enum SupermavenButtonStatus {
79 Ready,
80 Errored(String),
81 NeedsActivation(String),
82 Initializing,
83}
84
85impl Render for EditPredictionButton {
86 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
87 // Return empty div if AI is disabled
88 if DisableAiSettings::get_global(cx).disable_ai {
89 return div().hidden();
90 }
91
92 let language_settings = all_language_settings(None, cx);
93
94 match language_settings.edit_predictions.provider {
95 EditPredictionProvider::Copilot => {
96 let Some(copilot) = EditPredictionStore::try_global(cx)
97 .and_then(|store| store.read(cx).copilot_for_project(&self.project.upgrade()?))
98 else {
99 return div().hidden();
100 };
101 let status = copilot.read(cx).status();
102
103 let enabled = self.editor_enabled.unwrap_or(false);
104
105 let icon = match status {
106 Status::Error(_) => IconName::CopilotError,
107 Status::Authorized => {
108 if enabled {
109 IconName::Copilot
110 } else {
111 IconName::CopilotDisabled
112 }
113 }
114 _ => IconName::CopilotInit,
115 };
116
117 if let Status::Error(e) = status {
118 return div().child(
119 IconButton::new("copilot-error", icon)
120 .icon_size(IconSize::Small)
121 .on_click(cx.listener(move |_, _, window, cx| {
122 if let Some(workspace) = window.root::<Workspace>().flatten() {
123 workspace.update(cx, |workspace, cx| {
124 let copilot = copilot.clone();
125 workspace.show_toast(
126 Toast::new(
127 NotificationId::unique::<CopilotErrorToast>(),
128 format!("Copilot can't be started: {}", e),
129 )
130 .on_click(
131 "Reinstall Copilot",
132 move |window, cx| {
133 copilot_ui::reinstall_and_sign_in(
134 copilot.clone(),
135 window,
136 cx,
137 )
138 },
139 ),
140 cx,
141 );
142 });
143 }
144 }))
145 .tooltip(|_window, cx| {
146 Tooltip::for_action("GitHub Copilot", &ToggleMenu, cx)
147 }),
148 );
149 }
150 let this = cx.weak_entity();
151 let project = self.project.clone();
152 let file = self.file.clone();
153 let language = self.language.clone();
154 div().child(
155 PopoverMenu::new("copilot")
156 .on_open({
157 let file = file.clone();
158 let language = language;
159 let project = project.clone();
160 Rc::new(move |_window, cx| {
161 emit_edit_prediction_menu_opened(
162 "copilot", &file, &language, &project, cx,
163 );
164 })
165 })
166 .menu(move |window, cx| {
167 let current_status = EditPredictionStore::try_global(cx)
168 .and_then(|store| {
169 store.read(cx).copilot_for_project(&project.upgrade()?)
170 })?
171 .read(cx)
172 .status();
173 match current_status {
174 Status::Authorized => this.update(cx, |this, cx| {
175 this.build_copilot_context_menu(window, cx)
176 }),
177 _ => this.update(cx, |this, cx| {
178 this.build_copilot_start_menu(window, cx)
179 }),
180 }
181 .ok()
182 })
183 .anchor(Corner::BottomRight)
184 .trigger_with_tooltip(
185 IconButton::new("copilot-icon", icon),
186 |_window, cx| Tooltip::for_action("GitHub Copilot", &ToggleMenu, cx),
187 )
188 .with_handle(self.popover_menu_handle.clone()),
189 )
190 }
191
192 EditPredictionProvider::Supermaven => {
193 let Some(supermaven) = Supermaven::global(cx) else {
194 return div();
195 };
196
197 let supermaven = supermaven.read(cx);
198
199 let status = match supermaven {
200 Supermaven::Starting => SupermavenButtonStatus::Initializing,
201 Supermaven::FailedDownload { error } => {
202 SupermavenButtonStatus::Errored(error.to_string())
203 }
204 Supermaven::Spawned(agent) => {
205 let account_status = agent.account_status.clone();
206 match account_status {
207 AccountStatus::NeedsActivation { activate_url } => {
208 SupermavenButtonStatus::NeedsActivation(activate_url)
209 }
210 AccountStatus::Unknown => SupermavenButtonStatus::Initializing,
211 AccountStatus::Ready => SupermavenButtonStatus::Ready,
212 }
213 }
214 Supermaven::Error { error } => {
215 SupermavenButtonStatus::Errored(error.to_string())
216 }
217 };
218
219 let icon = status.to_icon();
220 let tooltip_text = status.to_tooltip();
221 let has_menu = status.has_menu();
222 let this = cx.weak_entity();
223 let fs = self.fs.clone();
224 let file = self.file.clone();
225 let language = self.language.clone();
226 let project = self.project.clone();
227
228 div().child(
229 PopoverMenu::new("supermaven")
230 .on_open({
231 let file = file.clone();
232 let language = language;
233 let project = project;
234 Rc::new(move |_window, cx| {
235 emit_edit_prediction_menu_opened(
236 "supermaven",
237 &file,
238 &language,
239 &project,
240 cx,
241 );
242 })
243 })
244 .menu(move |window, cx| match &status {
245 SupermavenButtonStatus::NeedsActivation(activate_url) => {
246 Some(ContextMenu::build(window, cx, |menu, _, _| {
247 let fs = fs.clone();
248 let activate_url = activate_url.clone();
249
250 menu.entry("Sign In", None, move |_, cx| {
251 cx.open_url(activate_url.as_str())
252 })
253 .entry(
254 "Use Zed AI",
255 None,
256 move |_, cx| {
257 set_completion_provider(
258 fs.clone(),
259 cx,
260 EditPredictionProvider::Zed,
261 )
262 },
263 )
264 }))
265 }
266 SupermavenButtonStatus::Ready => this
267 .update(cx, |this, cx| {
268 this.build_supermaven_context_menu(window, cx)
269 })
270 .ok(),
271 _ => None,
272 })
273 .anchor(Corner::BottomRight)
274 .trigger_with_tooltip(
275 IconButton::new("supermaven-icon", icon),
276 move |window, cx| {
277 if has_menu {
278 Tooltip::for_action(tooltip_text.clone(), &ToggleMenu, cx)
279 } else {
280 Tooltip::text(tooltip_text.clone())(window, cx)
281 }
282 },
283 )
284 .with_handle(self.popover_menu_handle.clone()),
285 )
286 }
287
288 EditPredictionProvider::Codestral => {
289 let enabled = self.editor_enabled.unwrap_or(true);
290 let has_api_key = CodestralEditPredictionDelegate::has_api_key(cx);
291 let this = cx.weak_entity();
292 let file = self.file.clone();
293 let language = self.language.clone();
294 let project = self.project.clone();
295
296 let tooltip_meta = if has_api_key {
297 "Powered by Codestral"
298 } else {
299 "Missing API key for Codestral"
300 };
301
302 div().child(
303 PopoverMenu::new("codestral")
304 .on_open({
305 let file = file.clone();
306 let language = language;
307 let project = project;
308 Rc::new(move |_window, cx| {
309 emit_edit_prediction_menu_opened(
310 "codestral",
311 &file,
312 &language,
313 &project,
314 cx,
315 );
316 })
317 })
318 .menu(move |window, cx| {
319 this.update(cx, |this, cx| {
320 this.build_codestral_context_menu(window, cx)
321 })
322 .ok()
323 })
324 .anchor(Corner::BottomRight)
325 .trigger_with_tooltip(
326 IconButton::new("codestral-icon", IconName::AiMistral)
327 .shape(IconButtonShape::Square)
328 .when(!has_api_key, |this| {
329 this.indicator(Indicator::dot().color(Color::Error))
330 .indicator_border_color(Some(
331 cx.theme().colors().status_bar_background,
332 ))
333 })
334 .when(has_api_key && !enabled, |this| {
335 this.indicator(Indicator::dot().color(Color::Ignored))
336 .indicator_border_color(Some(
337 cx.theme().colors().status_bar_background,
338 ))
339 }),
340 move |_window, cx| {
341 Tooltip::with_meta(
342 "Edit Prediction",
343 Some(&ToggleMenu),
344 tooltip_meta,
345 cx,
346 )
347 },
348 )
349 .with_handle(self.popover_menu_handle.clone()),
350 )
351 }
352 EditPredictionProvider::Ollama => {
353 let enabled = self.editor_enabled.unwrap_or(true);
354 let this = cx.weak_entity();
355
356 div().child(
357 PopoverMenu::new("ollama")
358 .menu(move |window, cx| {
359 this.update(cx, |this, cx| {
360 this.build_edit_prediction_context_menu(
361 EditPredictionProvider::Ollama,
362 window,
363 cx,
364 )
365 })
366 .ok()
367 })
368 .anchor(Corner::BottomRight)
369 .trigger_with_tooltip(
370 IconButton::new("ollama-icon", IconName::AiOllama)
371 .shape(IconButtonShape::Square)
372 .when(!enabled, |this| {
373 this.indicator(Indicator::dot().color(Color::Ignored))
374 .indicator_border_color(Some(
375 cx.theme().colors().status_bar_background,
376 ))
377 }),
378 move |_window, cx| {
379 let settings = all_language_settings(None, cx);
380 let tooltip_meta = match settings
381 .edit_predictions
382 .ollama
383 .model
384 .as_deref()
385 {
386 Some(model) if !model.trim().is_empty() => {
387 format!("Powered by Ollama ({model})")
388 }
389 _ => {
390 "Ollama model not configured — configure a model before use"
391 .to_string()
392 }
393 };
394
395 Tooltip::with_meta(
396 "Edit Prediction",
397 Some(&ToggleMenu),
398 tooltip_meta,
399 cx,
400 )
401 },
402 )
403 .with_handle(self.popover_menu_handle.clone()),
404 )
405 }
406 provider @ (EditPredictionProvider::Experimental(_)
407 | EditPredictionProvider::Zed
408 | EditPredictionProvider::Sweep
409 | EditPredictionProvider::Mercury) => {
410 let enabled = self.editor_enabled.unwrap_or(true);
411 let file = self.file.clone();
412 let language = self.language.clone();
413 let project = self.project.clone();
414 let provider_name: &'static str = match provider {
415 EditPredictionProvider::Experimental(name) => name,
416 EditPredictionProvider::Zed => "zed",
417 _ => "unknown",
418 };
419 let icons = self
420 .edit_prediction_provider
421 .as_ref()
422 .map(|p| p.icons(cx))
423 .unwrap_or_else(|| {
424 edit_prediction_types::EditPredictionIconSet::new(IconName::ZedPredict)
425 });
426
427 let ep_icon;
428 let tooltip_meta;
429 let mut missing_token = false;
430
431 match provider {
432 EditPredictionProvider::Sweep => {
433 missing_token = edit_prediction::EditPredictionStore::try_global(cx)
434 .is_some_and(|ep_store| !ep_store.read(cx).has_sweep_api_token(cx));
435 ep_icon = if enabled { icons.base } else { icons.disabled };
436 tooltip_meta = if missing_token {
437 "Missing API key for Sweep"
438 } else {
439 "Powered by Sweep"
440 };
441 }
442 EditPredictionProvider::Mercury => {
443 ep_icon = if enabled { icons.base } else { icons.disabled };
444 missing_token = edit_prediction::EditPredictionStore::try_global(cx)
445 .is_some_and(|ep_store| !ep_store.read(cx).has_mercury_api_token(cx));
446 tooltip_meta = if missing_token {
447 "Missing API key for Mercury"
448 } else {
449 "Powered by Mercury"
450 };
451 }
452 _ => {
453 ep_icon = if enabled { icons.base } else { icons.disabled };
454 tooltip_meta = "Powered by Zeta"
455 }
456 };
457
458 if edit_prediction::should_show_upsell_modal() {
459 let tooltip_meta = if self.user_store.read(cx).current_user().is_some() {
460 "Choose a Plan"
461 } else {
462 "Sign In To Use"
463 };
464
465 return div().child(
466 IconButton::new("zed-predict-pending-button", ep_icon)
467 .shape(IconButtonShape::Square)
468 .indicator(Indicator::dot().color(Color::Muted))
469 .indicator_border_color(Some(cx.theme().colors().status_bar_background))
470 .tooltip(move |_window, cx| {
471 Tooltip::with_meta("Edit Predictions", None, tooltip_meta, cx)
472 })
473 .on_click(cx.listener(move |_, _, window, cx| {
474 telemetry::event!(
475 "Pending ToS Clicked",
476 source = "Edit Prediction Status Button"
477 );
478 window.dispatch_action(
479 zed_actions::OpenZedPredictOnboarding.boxed_clone(),
480 cx,
481 );
482 })),
483 );
484 }
485
486 let mut over_limit = false;
487
488 if let Some(usage) = self
489 .edit_prediction_provider
490 .as_ref()
491 .and_then(|provider| provider.usage(cx))
492 {
493 over_limit = usage.over_limit()
494 }
495
496 let show_editor_predictions = self.editor_show_predictions;
497 let user = self.user_store.read(cx).current_user();
498
499 let indicator_color = if missing_token {
500 Some(Color::Error)
501 } else if enabled && (!show_editor_predictions || over_limit) {
502 Some(if over_limit {
503 Color::Error
504 } else {
505 Color::Muted
506 })
507 } else {
508 None
509 };
510
511 let icon_button = IconButton::new("zed-predict-pending-button", ep_icon)
512 .shape(IconButtonShape::Square)
513 .when_some(indicator_color, |this, color| {
514 this.indicator(Indicator::dot().color(color))
515 .indicator_border_color(Some(cx.theme().colors().status_bar_background))
516 })
517 .when(!self.popover_menu_handle.is_deployed(), |element| {
518 let user = user.clone();
519
520 element.tooltip(move |_window, cx| {
521 let description = if enabled {
522 if show_editor_predictions {
523 tooltip_meta
524 } else if user.is_none() {
525 "Sign In To Use"
526 } else {
527 "Hidden For This File"
528 }
529 } else {
530 "Disabled For This File"
531 };
532
533 Tooltip::with_meta(
534 "Edit Prediction",
535 Some(&ToggleMenu),
536 description,
537 cx,
538 )
539 })
540 });
541
542 let this = cx.weak_entity();
543
544 let mut popover_menu = PopoverMenu::new("edit-prediction")
545 .on_open({
546 let file = file.clone();
547 let language = language;
548 let project = project;
549 Rc::new(move |_window, cx| {
550 emit_edit_prediction_menu_opened(
551 provider_name,
552 &file,
553 &language,
554 &project,
555 cx,
556 );
557 })
558 })
559 .map(|popover_menu| {
560 let this = this.clone();
561 popover_menu.menu(move |window, cx| {
562 this.update(cx, |this, cx| {
563 this.build_edit_prediction_context_menu(provider, window, cx)
564 })
565 .ok()
566 })
567 })
568 .anchor(Corner::BottomRight)
569 .with_handle(self.popover_menu_handle.clone());
570
571 let is_refreshing = self
572 .edit_prediction_provider
573 .as_ref()
574 .is_some_and(|provider| provider.is_refreshing(cx));
575
576 if is_refreshing {
577 popover_menu = popover_menu.trigger(
578 icon_button.with_animation(
579 "pulsating-label",
580 Animation::new(Duration::from_secs(2))
581 .repeat()
582 .with_easing(pulsating_between(0.2, 1.0)),
583 |icon_button, delta| icon_button.alpha(delta),
584 ),
585 );
586 } else {
587 popover_menu = popover_menu.trigger(icon_button);
588 }
589
590 div().child(popover_menu.into_any_element())
591 }
592
593 EditPredictionProvider::None => div().hidden(),
594 }
595 }
596}
597
598impl EditPredictionButton {
599 pub fn new(
600 fs: Arc<dyn Fs>,
601 user_store: Entity<UserStore>,
602 popover_menu_handle: PopoverMenuHandle<ContextMenu>,
603 client: Arc<Client>,
604 project: Entity<Project>,
605 cx: &mut Context<Self>,
606 ) -> Self {
607 let copilot = EditPredictionStore::try_global(cx).and_then(|store| {
608 store.update(cx, |this, cx| this.start_copilot_for_project(&project, cx))
609 });
610 if let Some(copilot) = copilot {
611 cx.observe(&copilot, |_, _, cx| cx.notify()).detach()
612 }
613
614 cx.observe_global::<SettingsStore>(move |_, cx| cx.notify())
615 .detach();
616
617 cx.observe_global::<EditPredictionStore>(move |_, cx| cx.notify())
618 .detach();
619
620 edit_prediction::ollama::ensure_authenticated(cx);
621 let sweep_api_token_task = edit_prediction::sweep_ai::load_sweep_api_token(cx);
622 let mercury_api_token_task = edit_prediction::mercury::load_mercury_api_token(cx);
623
624 cx.spawn(async move |this, cx| {
625 _ = futures::join!(sweep_api_token_task, mercury_api_token_task);
626 this.update(cx, |_, cx| {
627 cx.notify();
628 })
629 .ok();
630 })
631 .detach();
632
633 CodestralEditPredictionDelegate::ensure_api_key_loaded(client.http_client(), cx);
634
635 Self {
636 editor_subscription: None,
637 editor_enabled: None,
638 editor_show_predictions: true,
639 editor_focus_handle: None,
640 language: None,
641 file: None,
642 edit_prediction_provider: None,
643 user_store,
644 popover_menu_handle,
645 project: project.downgrade(),
646 fs,
647 }
648 }
649
650 fn add_provider_switching_section(
651 &self,
652 mut menu: ContextMenu,
653 current_provider: EditPredictionProvider,
654 cx: &mut App,
655 ) -> ContextMenu {
656 let available_providers = get_available_providers(cx);
657
658 let providers: Vec<_> = available_providers
659 .into_iter()
660 .filter(|p| *p != EditPredictionProvider::None)
661 .collect();
662
663 if !providers.is_empty() {
664 menu = menu.separator().header("Providers");
665
666 for provider in providers {
667 let Some(name) = provider.display_name() else {
668 continue;
669 };
670 let is_current = provider == current_provider;
671 let fs = self.fs.clone();
672
673 menu = menu.item(
674 ContextMenuEntry::new(name)
675 .toggleable(IconPosition::Start, is_current)
676 .handler(move |_, cx| {
677 set_completion_provider(fs.clone(), cx, provider);
678 }),
679 )
680 }
681 }
682
683 menu
684 }
685
686 pub fn build_copilot_start_menu(
687 &mut self,
688 window: &mut Window,
689 cx: &mut Context<Self>,
690 ) -> Entity<ContextMenu> {
691 let fs = self.fs.clone();
692 let project = self.project.clone();
693 ContextMenu::build(window, cx, |menu, _, _| {
694 menu.entry("Sign In to Copilot", None, move |window, cx| {
695 telemetry::event!(
696 "Edit Prediction Menu Action",
697 action = "sign_in",
698 provider = "copilot",
699 );
700 if let Some(copilot) = EditPredictionStore::try_global(cx).and_then(|store| {
701 store.update(cx, |this, cx| {
702 this.start_copilot_for_project(&project.upgrade()?, cx)
703 })
704 }) {
705 copilot_ui::initiate_sign_in(copilot, window, cx);
706 }
707 })
708 .entry("Disable Copilot", None, {
709 let fs = fs.clone();
710 move |_window, cx| {
711 telemetry::event!(
712 "Edit Prediction Menu Action",
713 action = "disable_provider",
714 provider = "copilot",
715 );
716 hide_copilot(fs.clone(), cx)
717 }
718 })
719 .separator()
720 .entry("Use Zed AI", None, {
721 let fs = fs.clone();
722 move |_window, cx| {
723 set_completion_provider(fs.clone(), cx, EditPredictionProvider::Zed)
724 }
725 })
726 })
727 }
728
729 pub fn build_language_settings_menu(
730 &self,
731 mut menu: ContextMenu,
732 window: &Window,
733 cx: &mut App,
734 ) -> ContextMenu {
735 let fs = self.fs.clone();
736 let line_height = window.line_height();
737
738 menu = menu.header("Show Edit Predictions For");
739
740 let language_state = self.language.as_ref().map(|language| {
741 (
742 language.clone(),
743 language_settings::language_settings(Some(language.name()), None, cx)
744 .show_edit_predictions,
745 )
746 });
747
748 if let Some(editor_focus_handle) = self.editor_focus_handle.clone() {
749 let entry = ContextMenuEntry::new("This Buffer")
750 .toggleable(IconPosition::Start, self.editor_show_predictions)
751 .action(Box::new(editor::actions::ToggleEditPrediction))
752 .handler(move |window, cx| {
753 editor_focus_handle.dispatch_action(
754 &editor::actions::ToggleEditPrediction,
755 window,
756 cx,
757 );
758 });
759
760 match language_state.clone() {
761 Some((language, false)) => {
762 menu = menu.item(
763 entry
764 .disabled(true)
765 .documentation_aside(DocumentationSide::Left, move |_cx| {
766 Label::new(format!("Edit predictions cannot be toggled for this buffer because they are disabled for {}", language.name()))
767 .into_any_element()
768 })
769 );
770 }
771 Some(_) | None => menu = menu.item(entry),
772 }
773 }
774
775 if let Some((language, language_enabled)) = language_state {
776 let fs = fs.clone();
777 let language_name = language.name();
778
779 menu = menu.toggleable_entry(
780 language_name.clone(),
781 language_enabled,
782 IconPosition::Start,
783 None,
784 move |_, cx| {
785 telemetry::event!(
786 "Edit Prediction Setting Changed",
787 setting = "language",
788 language = language_name.to_string(),
789 enabled = !language_enabled,
790 );
791 toggle_show_edit_predictions_for_language(language.clone(), fs.clone(), cx)
792 },
793 );
794 }
795
796 let settings = AllLanguageSettings::get_global(cx);
797
798 let globally_enabled = settings.show_edit_predictions(None, cx);
799 let entry = ContextMenuEntry::new("All Files")
800 .toggleable(IconPosition::Start, globally_enabled)
801 .action(workspace::ToggleEditPrediction.boxed_clone())
802 .handler(|window, cx| {
803 window.dispatch_action(workspace::ToggleEditPrediction.boxed_clone(), cx)
804 });
805 menu = menu.item(entry);
806
807 let provider = settings.edit_predictions.provider;
808 let current_mode = settings.edit_predictions_mode();
809 let subtle_mode = matches!(current_mode, EditPredictionsMode::Subtle);
810 let eager_mode = matches!(current_mode, EditPredictionsMode::Eager);
811
812 menu = menu
813 .separator()
814 .header("Display Modes")
815 .item(
816 ContextMenuEntry::new("Eager")
817 .toggleable(IconPosition::Start, eager_mode)
818 .documentation_aside(DocumentationSide::Left, move |_| {
819 Label::new("Display predictions inline when there are no language server completions available.").into_any_element()
820 })
821 .handler({
822 let fs = fs.clone();
823 move |_, cx| {
824 telemetry::event!(
825 "Edit Prediction Setting Changed",
826 setting = "mode",
827 value = "eager",
828 );
829 toggle_edit_prediction_mode(fs.clone(), EditPredictionsMode::Eager, cx)
830 }
831 }),
832 )
833 .item(
834 ContextMenuEntry::new("Subtle")
835 .toggleable(IconPosition::Start, subtle_mode)
836 .documentation_aside(DocumentationSide::Left, move |_| {
837 Label::new("Display predictions inline only when holding a modifier key (alt by default).").into_any_element()
838 })
839 .handler({
840 let fs = fs.clone();
841 move |_, cx| {
842 telemetry::event!(
843 "Edit Prediction Setting Changed",
844 setting = "mode",
845 value = "subtle",
846 );
847 toggle_edit_prediction_mode(fs.clone(), EditPredictionsMode::Subtle, cx)
848 }
849 }),
850 );
851
852 menu = menu.separator().header("Privacy");
853
854 if matches!(
855 provider,
856 EditPredictionProvider::Zed
857 | EditPredictionProvider::Experimental(
858 EXPERIMENTAL_ZETA2_EDIT_PREDICTION_PROVIDER_NAME,
859 )
860 ) {
861 if let Some(provider) = &self.edit_prediction_provider {
862 let data_collection = provider.data_collection_state(cx);
863
864 if data_collection.is_supported() {
865 let provider = provider.clone();
866 let enabled = data_collection.is_enabled();
867 let is_open_source = data_collection.is_project_open_source();
868 let is_collecting = data_collection.is_enabled();
869 let (icon_name, icon_color) = if is_open_source && is_collecting {
870 (IconName::Check, Color::Success)
871 } else {
872 (IconName::Check, Color::Accent)
873 };
874
875 menu = menu.item(
876 ContextMenuEntry::new("Training Data Collection")
877 .toggleable(IconPosition::Start, data_collection.is_enabled())
878 .icon(icon_name)
879 .icon_color(icon_color)
880 .disabled(cx.is_staff())
881 .documentation_aside(DocumentationSide::Left, move |cx| {
882 let (msg, label_color, icon_name, icon_color) = match (is_open_source, is_collecting) {
883 (true, true) => (
884 "Project identified as open source, and you're sharing data.",
885 Color::Default,
886 IconName::Check,
887 Color::Success,
888 ),
889 (true, false) => (
890 "Project identified as open source, but you're not sharing data.",
891 Color::Muted,
892 IconName::Close,
893 Color::Muted,
894 ),
895 (false, true) => (
896 "Project not identified as open source. No data captured.",
897 Color::Muted,
898 IconName::Close,
899 Color::Muted,
900 ),
901 (false, false) => (
902 "Project not identified as open source, and setting turned off.",
903 Color::Muted,
904 IconName::Close,
905 Color::Muted,
906 ),
907 };
908 v_flex()
909 .gap_2()
910 .child(
911 Label::new(indoc!{
912 "Help us improve our open dataset model by sharing data from open source repositories. \
913 Zed must detect a license file in your repo for this setting to take effect. \
914 Files with sensitive data and secrets are excluded by default."
915 })
916 )
917 .child(
918 h_flex()
919 .items_start()
920 .pt_2()
921 .pr_1()
922 .flex_1()
923 .gap_1p5()
924 .border_t_1()
925 .border_color(cx.theme().colors().border_variant)
926 .child(h_flex().flex_shrink_0().h(line_height).child(Icon::new(icon_name).size(IconSize::XSmall).color(icon_color)))
927 .child(div().child(msg).w_full().text_sm().text_color(label_color.color(cx)))
928 )
929 .into_any_element()
930 })
931 .handler(move |_, cx| {
932 provider.toggle_data_collection(cx);
933
934 if !enabled {
935 telemetry::event!(
936 "Data Collection Enabled",
937 source = "Edit Prediction Status Menu"
938 );
939 } else {
940 telemetry::event!(
941 "Data Collection Disabled",
942 source = "Edit Prediction Status Menu"
943 );
944 }
945 })
946 );
947
948 if is_collecting && !is_open_source {
949 menu = menu.item(
950 ContextMenuEntry::new("No data captured.")
951 .disabled(true)
952 .icon(IconName::Close)
953 .icon_color(Color::Error)
954 .icon_size(IconSize::Small),
955 );
956 }
957 }
958 }
959 }
960
961 menu = menu.item(
962 ContextMenuEntry::new("Configure Excluded Files")
963 .icon(IconName::LockOutlined)
964 .icon_color(Color::Muted)
965 .documentation_aside(DocumentationSide::Left, |_| {
966 Label::new(indoc!{"
967 Open your settings to add sensitive paths for which Zed will never predict edits."}).into_any_element()
968 })
969 .handler(move |window, cx| {
970 telemetry::event!(
971 "Edit Prediction Menu Action",
972 action = "configure_excluded_files",
973 );
974 if let Some(workspace) = window.root().flatten() {
975 let workspace = workspace.downgrade();
976 window
977 .spawn(cx, async |cx| {
978 open_disabled_globs_setting_in_editor(
979 workspace,
980 cx,
981 ).await
982 })
983 .detach_and_log_err(cx);
984 }
985 }),
986 ).item(
987 ContextMenuEntry::new("View Docs")
988 .icon(IconName::FileGeneric)
989 .icon_color(Color::Muted)
990 .handler(move |_, cx| {
991 telemetry::event!(
992 "Edit Prediction Menu Action",
993 action = "view_docs",
994 );
995 cx.open_url(PRIVACY_DOCS);
996 })
997 );
998
999 if !self.editor_enabled.unwrap_or(true) {
1000 let icons = self
1001 .edit_prediction_provider
1002 .as_ref()
1003 .map(|p| p.icons(cx))
1004 .unwrap_or_else(|| {
1005 edit_prediction_types::EditPredictionIconSet::new(IconName::ZedPredict)
1006 });
1007 menu = menu.item(
1008 ContextMenuEntry::new("This file is excluded.")
1009 .disabled(true)
1010 .icon(icons.disabled)
1011 .icon_size(IconSize::Small),
1012 );
1013 }
1014
1015 if let Some(editor_focus_handle) = self.editor_focus_handle.clone() {
1016 menu = menu
1017 .separator()
1018 .header("Actions")
1019 .entry(
1020 "Predict Edit at Cursor",
1021 Some(Box::new(ShowEditPrediction)),
1022 {
1023 let editor_focus_handle = editor_focus_handle.clone();
1024 move |window, cx| {
1025 telemetry::event!(
1026 "Edit Prediction Menu Action",
1027 action = "predict_at_cursor",
1028 );
1029 editor_focus_handle.dispatch_action(&ShowEditPrediction, window, cx);
1030 }
1031 },
1032 )
1033 .context(editor_focus_handle)
1034 .when(
1035 cx.has_flag::<PredictEditsRatePredictionsFeatureFlag>(),
1036 |this| {
1037 this.action("Capture Prediction Example", CaptureExample.boxed_clone())
1038 .action("Rate Predictions", RatePredictions.boxed_clone())
1039 },
1040 );
1041 }
1042
1043 menu
1044 }
1045
1046 fn build_copilot_context_menu(
1047 &self,
1048 window: &mut Window,
1049 cx: &mut Context<Self>,
1050 ) -> Entity<ContextMenu> {
1051 let all_language_settings = all_language_settings(None, cx);
1052 let next_edit_suggestions = all_language_settings
1053 .edit_predictions
1054 .copilot
1055 .enable_next_edit_suggestions
1056 .unwrap_or(true);
1057 let copilot_config = copilot_chat::CopilotChatConfiguration {
1058 enterprise_uri: all_language_settings
1059 .edit_predictions
1060 .copilot
1061 .enterprise_uri
1062 .clone(),
1063 };
1064 let settings_url = copilot_settings_url(copilot_config.enterprise_uri.as_deref());
1065
1066 ContextMenu::build(window, cx, |menu, window, cx| {
1067 let menu = self.build_language_settings_menu(menu, window, cx);
1068 let menu =
1069 self.add_provider_switching_section(menu, EditPredictionProvider::Copilot, cx);
1070
1071 menu.separator()
1072 .item(
1073 ContextMenuEntry::new("Copilot: Next Edit Suggestions")
1074 .toggleable(IconPosition::Start, next_edit_suggestions)
1075 .handler({
1076 let fs = self.fs.clone();
1077 move |_, cx| {
1078 update_settings_file(fs.clone(), cx, move |settings, _| {
1079 settings
1080 .project
1081 .all_languages
1082 .edit_predictions
1083 .get_or_insert_default()
1084 .copilot
1085 .get_or_insert_default()
1086 .enable_next_edit_suggestions =
1087 Some(!next_edit_suggestions);
1088 });
1089 }
1090 }),
1091 )
1092 .separator()
1093 .link(
1094 "Go to Copilot Settings",
1095 OpenBrowser { url: settings_url }.boxed_clone(),
1096 )
1097 .action("Sign Out", copilot::SignOut.boxed_clone())
1098 })
1099 }
1100
1101 fn build_supermaven_context_menu(
1102 &self,
1103 window: &mut Window,
1104 cx: &mut Context<Self>,
1105 ) -> Entity<ContextMenu> {
1106 ContextMenu::build(window, cx, |menu, window, cx| {
1107 let menu = self.build_language_settings_menu(menu, window, cx);
1108 let menu =
1109 self.add_provider_switching_section(menu, EditPredictionProvider::Supermaven, cx);
1110
1111 menu.separator()
1112 .action("Sign Out", supermaven::SignOut.boxed_clone())
1113 })
1114 }
1115
1116 fn build_codestral_context_menu(
1117 &self,
1118 window: &mut Window,
1119 cx: &mut Context<Self>,
1120 ) -> Entity<ContextMenu> {
1121 ContextMenu::build(window, cx, |menu, window, cx| {
1122 let menu = self.build_language_settings_menu(menu, window, cx);
1123 let menu =
1124 self.add_provider_switching_section(menu, EditPredictionProvider::Codestral, cx);
1125
1126 menu
1127 })
1128 }
1129
1130 fn build_edit_prediction_context_menu(
1131 &self,
1132 provider: EditPredictionProvider,
1133 window: &mut Window,
1134 cx: &mut Context<Self>,
1135 ) -> Entity<ContextMenu> {
1136 ContextMenu::build(window, cx, |mut menu, window, cx| {
1137 let user = self.user_store.read(cx).current_user();
1138
1139 let needs_sign_in = user.is_none()
1140 && matches!(
1141 provider,
1142 EditPredictionProvider::None | EditPredictionProvider::Zed
1143 );
1144
1145 if needs_sign_in {
1146 menu = menu
1147 .custom_row(move |_window, cx| {
1148 let description = indoc! {
1149 "You get 2,000 accepted suggestions at every keystroke for free, \
1150 powered by Zeta, our open-source, open-data model"
1151 };
1152
1153 v_flex()
1154 .max_w_64()
1155 .h(rems_from_px(148.))
1156 .child(render_zeta_tab_animation(cx))
1157 .child(Label::new("Edit Prediction"))
1158 .child(
1159 Label::new(description)
1160 .color(Color::Muted)
1161 .size(LabelSize::Small),
1162 )
1163 .into_any_element()
1164 })
1165 .separator()
1166 .entry("Sign In & Start Using", None, |window, cx| {
1167 telemetry::event!(
1168 "Edit Prediction Menu Action",
1169 action = "sign_in",
1170 provider = "zed",
1171 );
1172 let client = Client::global(cx);
1173 window
1174 .spawn(cx, async move |cx| {
1175 client
1176 .sign_in_with_optional_connect(true, &cx)
1177 .await
1178 .log_err();
1179 })
1180 .detach();
1181 })
1182 .link_with_handler(
1183 "Learn More",
1184 OpenBrowser {
1185 url: zed_urls::edit_prediction_docs(cx),
1186 }
1187 .boxed_clone(),
1188 |_window, _cx| {
1189 telemetry::event!(
1190 "Edit Prediction Menu Action",
1191 action = "view_docs",
1192 source = "upsell",
1193 );
1194 },
1195 )
1196 .separator();
1197 } else if let Some(usage) = self
1198 .edit_prediction_provider
1199 .as_ref()
1200 .and_then(|provider| provider.usage(cx))
1201 {
1202 menu = menu.header("Usage");
1203 menu = menu
1204 .custom_entry(
1205 move |_window, cx| {
1206 let used_percentage = match usage.limit {
1207 UsageLimit::Limited(limit) => {
1208 Some((usage.amount as f32 / limit as f32) * 100.)
1209 }
1210 UsageLimit::Unlimited => None,
1211 };
1212
1213 h_flex()
1214 .flex_1()
1215 .gap_1p5()
1216 .children(
1217 used_percentage.map(|percent| {
1218 ProgressBar::new("usage", percent, 100., cx)
1219 }),
1220 )
1221 .child(
1222 Label::new(match usage.limit {
1223 UsageLimit::Limited(limit) => {
1224 format!("{} / {limit}", usage.amount)
1225 }
1226 UsageLimit::Unlimited => format!("{} / ∞", usage.amount),
1227 })
1228 .size(LabelSize::Small)
1229 .color(Color::Muted),
1230 )
1231 .into_any_element()
1232 },
1233 move |_, cx| cx.open_url(&zed_urls::account_url(cx)),
1234 )
1235 .when(usage.over_limit(), |menu| -> ContextMenu {
1236 menu.entry("Subscribe to increase your limit", None, |_window, cx| {
1237 telemetry::event!(
1238 "Edit Prediction Menu Action",
1239 action = "upsell_clicked",
1240 reason = "usage_limit",
1241 );
1242 cx.open_url(&zed_urls::account_url(cx))
1243 })
1244 })
1245 .separator();
1246 } else if self.user_store.read(cx).account_too_young() {
1247 menu = menu
1248 .custom_entry(
1249 |_window, _cx| {
1250 Label::new("Your GitHub account is less than 30 days old.")
1251 .size(LabelSize::Small)
1252 .color(Color::Warning)
1253 .into_any_element()
1254 },
1255 |_window, cx| cx.open_url(&zed_urls::account_url(cx)),
1256 )
1257 .entry("Upgrade to Zed Pro or contact us.", None, |_window, cx| {
1258 telemetry::event!(
1259 "Edit Prediction Menu Action",
1260 action = "upsell_clicked",
1261 reason = "account_age",
1262 );
1263 cx.open_url(&zed_urls::account_url(cx))
1264 })
1265 .separator();
1266 } else if self.user_store.read(cx).has_overdue_invoices() {
1267 menu = menu
1268 .custom_entry(
1269 |_window, _cx| {
1270 Label::new("You have an outstanding invoice")
1271 .size(LabelSize::Small)
1272 .color(Color::Warning)
1273 .into_any_element()
1274 },
1275 |_window, cx| {
1276 cx.open_url(&zed_urls::account_url(cx))
1277 },
1278 )
1279 .entry(
1280 "Check your payment status or contact us at billing-support@zed.dev to continue using this feature.",
1281 None,
1282 |_window, cx| {
1283 cx.open_url(&zed_urls::account_url(cx))
1284 },
1285 )
1286 .separator();
1287 }
1288
1289 if !needs_sign_in {
1290 menu = self.build_language_settings_menu(menu, window, cx);
1291 }
1292 menu = self.add_provider_switching_section(menu, provider, cx);
1293 menu = menu.separator().item(
1294 ContextMenuEntry::new("Configure Providers")
1295 .icon(IconName::Settings)
1296 .icon_position(IconPosition::Start)
1297 .icon_color(Color::Muted)
1298 .handler(move |window, cx| {
1299 telemetry::event!(
1300 "Edit Prediction Menu Action",
1301 action = "configure_providers",
1302 );
1303 window.dispatch_action(
1304 OpenSettingsAt {
1305 path: "edit_predictions.providers".to_string(),
1306 }
1307 .boxed_clone(),
1308 cx,
1309 );
1310 }),
1311 );
1312
1313 menu
1314 })
1315 }
1316
1317 pub fn update_enabled(&mut self, editor: Entity<Editor>, cx: &mut Context<Self>) {
1318 let editor = editor.read(cx);
1319 let snapshot = editor.buffer().read(cx).snapshot(cx);
1320 let suggestion_anchor = editor.selections.newest_anchor().start;
1321 let language = snapshot.language_at(suggestion_anchor);
1322 let file = snapshot.file_at(suggestion_anchor).cloned();
1323 self.editor_enabled = {
1324 let file = file.as_ref();
1325 Some(
1326 file.map(|file| {
1327 all_language_settings(Some(file), cx)
1328 .edit_predictions_enabled_for_file(file, cx)
1329 })
1330 .unwrap_or(true),
1331 )
1332 };
1333 self.editor_show_predictions = editor.edit_predictions_enabled();
1334 self.edit_prediction_provider = editor.edit_prediction_provider();
1335 self.language = language.cloned();
1336 self.file = file;
1337 self.editor_focus_handle = Some(editor.focus_handle(cx));
1338
1339 cx.notify();
1340 }
1341}
1342
1343impl StatusItemView for EditPredictionButton {
1344 fn set_active_pane_item(
1345 &mut self,
1346 item: Option<&dyn ItemHandle>,
1347 _: &mut Window,
1348 cx: &mut Context<Self>,
1349 ) {
1350 if let Some(editor) = item.and_then(|item| item.act_as::<Editor>(cx)) {
1351 self.editor_subscription = Some((
1352 cx.observe(&editor, Self::update_enabled),
1353 editor.entity_id().as_u64() as usize,
1354 ));
1355 self.update_enabled(editor, cx);
1356 } else {
1357 self.language = None;
1358 self.editor_subscription = None;
1359 self.editor_enabled = None;
1360 }
1361 cx.notify();
1362 }
1363}
1364
1365impl SupermavenButtonStatus {
1366 fn to_icon(&self) -> IconName {
1367 match self {
1368 SupermavenButtonStatus::Ready => IconName::Supermaven,
1369 SupermavenButtonStatus::Errored(_) => IconName::SupermavenError,
1370 SupermavenButtonStatus::NeedsActivation(_) => IconName::SupermavenInit,
1371 SupermavenButtonStatus::Initializing => IconName::SupermavenInit,
1372 }
1373 }
1374
1375 fn to_tooltip(&self) -> String {
1376 match self {
1377 SupermavenButtonStatus::Ready => "Supermaven is ready".to_string(),
1378 SupermavenButtonStatus::Errored(error) => format!("Supermaven error: {}", error),
1379 SupermavenButtonStatus::NeedsActivation(_) => "Supermaven needs activation".to_string(),
1380 SupermavenButtonStatus::Initializing => "Supermaven initializing".to_string(),
1381 }
1382 }
1383
1384 fn has_menu(&self) -> bool {
1385 match self {
1386 SupermavenButtonStatus::Ready | SupermavenButtonStatus::NeedsActivation(_) => true,
1387 SupermavenButtonStatus::Errored(_) | SupermavenButtonStatus::Initializing => false,
1388 }
1389 }
1390}
1391
1392async fn open_disabled_globs_setting_in_editor(
1393 workspace: WeakEntity<Workspace>,
1394 cx: &mut AsyncWindowContext,
1395) -> Result<()> {
1396 let settings_editor = workspace
1397 .update_in(cx, |_, window, cx| {
1398 create_and_open_local_file(paths::settings_file(), window, cx, || {
1399 settings::initial_user_settings_content().as_ref().into()
1400 })
1401 })?
1402 .await?
1403 .downcast::<Editor>()
1404 .unwrap();
1405
1406 settings_editor
1407 .downgrade()
1408 .update_in(cx, |item, window, cx| {
1409 let text = item.buffer().read(cx).snapshot(cx).text();
1410
1411 let settings = cx.global::<SettingsStore>();
1412
1413 // Ensure that we always have "edit_predictions { "disabled_globs": [] }"
1414 let edits = settings.edits_for_update(&text, |file| {
1415 file.project
1416 .all_languages
1417 .edit_predictions
1418 .get_or_insert_with(Default::default)
1419 .disabled_globs
1420 .get_or_insert_with(Vec::new);
1421 });
1422
1423 if !edits.is_empty() {
1424 item.edit(
1425 edits
1426 .into_iter()
1427 .map(|(r, s)| (MultiBufferOffset(r.start)..MultiBufferOffset(r.end), s)),
1428 cx,
1429 );
1430 }
1431
1432 let text = item.buffer().read(cx).snapshot(cx).text();
1433
1434 static DISABLED_GLOBS_REGEX: LazyLock<Regex> = LazyLock::new(|| {
1435 Regex::new(r#""disabled_globs":\s*\[\s*(?P<content>(?:.|\n)*?)\s*\]"#).unwrap()
1436 });
1437 // Only capture [...]
1438 let range = DISABLED_GLOBS_REGEX.captures(&text).and_then(|captures| {
1439 captures
1440 .name("content")
1441 .map(|inner_match| inner_match.start()..inner_match.end())
1442 });
1443 if let Some(range) = range {
1444 let range = MultiBufferOffset(range.start)..MultiBufferOffset(range.end);
1445 item.change_selections(
1446 SelectionEffects::scroll(Autoscroll::newest()),
1447 window,
1448 cx,
1449 |selections| {
1450 selections.select_ranges(vec![range]);
1451 },
1452 );
1453 }
1454 })?;
1455
1456 anyhow::Ok(())
1457}
1458
1459pub fn set_completion_provider(fs: Arc<dyn Fs>, cx: &mut App, provider: EditPredictionProvider) {
1460 update_settings_file(fs, cx, move |settings, _| {
1461 settings
1462 .project
1463 .all_languages
1464 .edit_predictions
1465 .get_or_insert_default()
1466 .provider = Some(provider);
1467 });
1468}
1469
1470pub fn get_available_providers(cx: &mut App) -> Vec<EditPredictionProvider> {
1471 let mut providers = Vec::new();
1472
1473 providers.push(EditPredictionProvider::Zed);
1474
1475 if cx.has_flag::<Zeta2FeatureFlag>() {
1476 providers.push(EditPredictionProvider::Experimental(
1477 EXPERIMENTAL_ZETA2_EDIT_PREDICTION_PROVIDER_NAME,
1478 ));
1479 }
1480
1481 if let Some(app_state) = workspace::AppState::global(cx).upgrade()
1482 && copilot::GlobalCopilotAuth::try_get_or_init(app_state, cx)
1483 .is_some_and(|copilot| copilot.0.read(cx).is_authenticated())
1484 {
1485 providers.push(EditPredictionProvider::Copilot);
1486 };
1487
1488 if let Some(supermaven) = Supermaven::global(cx) {
1489 if let Supermaven::Spawned(agent) = supermaven.read(cx) {
1490 if matches!(agent.account_status, AccountStatus::Ready) {
1491 providers.push(EditPredictionProvider::Supermaven);
1492 }
1493 }
1494 }
1495
1496 if CodestralEditPredictionDelegate::has_api_key(cx) {
1497 providers.push(EditPredictionProvider::Codestral);
1498 }
1499
1500 if edit_prediction::ollama::is_available(cx) {
1501 providers.push(EditPredictionProvider::Ollama);
1502 }
1503
1504 if edit_prediction::sweep_ai::sweep_api_token(cx)
1505 .read(cx)
1506 .has_key()
1507 {
1508 providers.push(EditPredictionProvider::Sweep);
1509 }
1510
1511 if edit_prediction::mercury::mercury_api_token(cx)
1512 .read(cx)
1513 .has_key()
1514 {
1515 providers.push(EditPredictionProvider::Mercury);
1516 }
1517
1518 providers
1519}
1520
1521fn toggle_show_edit_predictions_for_language(
1522 language: Arc<Language>,
1523 fs: Arc<dyn Fs>,
1524 cx: &mut App,
1525) {
1526 let show_edit_predictions =
1527 all_language_settings(None, cx).show_edit_predictions(Some(&language), cx);
1528 update_settings_file(fs, cx, move |settings, _| {
1529 settings
1530 .project
1531 .all_languages
1532 .languages
1533 .0
1534 .entry(language.name().0.to_string())
1535 .or_default()
1536 .show_edit_predictions = Some(!show_edit_predictions);
1537 });
1538}
1539
1540fn hide_copilot(fs: Arc<dyn Fs>, cx: &mut App) {
1541 update_settings_file(fs, cx, move |settings, _| {
1542 settings
1543 .project
1544 .all_languages
1545 .edit_predictions
1546 .get_or_insert(Default::default())
1547 .provider = Some(EditPredictionProvider::None);
1548 });
1549}
1550
1551fn toggle_edit_prediction_mode(fs: Arc<dyn Fs>, mode: EditPredictionsMode, cx: &mut App) {
1552 let settings = AllLanguageSettings::get_global(cx);
1553 let current_mode = settings.edit_predictions_mode();
1554
1555 if current_mode != mode {
1556 update_settings_file(fs, cx, move |settings, _cx| {
1557 if let Some(edit_predictions) = settings.project.all_languages.edit_predictions.as_mut()
1558 {
1559 edit_predictions.mode = Some(mode);
1560 } else {
1561 settings.project.all_languages.edit_predictions =
1562 Some(settings::EditPredictionSettingsContent {
1563 mode: Some(mode),
1564 ..Default::default()
1565 });
1566 }
1567 });
1568 }
1569}
1570
1571fn render_zeta_tab_animation(cx: &App) -> impl IntoElement {
1572 let tab = |n: u64, inverted: bool| {
1573 let text_color = cx.theme().colors().text;
1574
1575 h_flex().child(
1576 h_flex()
1577 .text_size(TextSize::XSmall.rems(cx))
1578 .text_color(text_color)
1579 .child("tab")
1580 .with_animation(
1581 ElementId::Integer(n),
1582 Animation::new(Duration::from_secs(3)).repeat(),
1583 move |tab, delta| {
1584 let n_f32 = n as f32;
1585
1586 let offset = if inverted {
1587 0.2 * (4.0 - n_f32)
1588 } else {
1589 0.2 * n_f32
1590 };
1591
1592 let phase = (delta - offset + 1.0) % 1.0;
1593 let pulse = if phase < 0.6 {
1594 let t = phase / 0.6;
1595 1.0 - (0.5 - t).abs() * 2.0
1596 } else {
1597 0.0
1598 };
1599
1600 let eased = ease_in_out(pulse);
1601 let opacity = 0.1 + 0.5 * eased;
1602
1603 tab.text_color(text_color.opacity(opacity))
1604 },
1605 ),
1606 )
1607 };
1608
1609 let tab_sequence = |inverted: bool| {
1610 h_flex()
1611 .gap_1()
1612 .child(tab(0, inverted))
1613 .child(tab(1, inverted))
1614 .child(tab(2, inverted))
1615 .child(tab(3, inverted))
1616 .child(tab(4, inverted))
1617 };
1618
1619 h_flex()
1620 .my_1p5()
1621 .p_4()
1622 .justify_center()
1623 .gap_2()
1624 .rounded_xs()
1625 .border_1()
1626 .border_dashed()
1627 .border_color(cx.theme().colors().border)
1628 .bg(gpui::pattern_slash(
1629 cx.theme().colors().border.opacity(0.5),
1630 1.,
1631 8.,
1632 ))
1633 .child(tab_sequence(true))
1634 .child(Icon::new(IconName::ZedPredict))
1635 .child(tab_sequence(false))
1636}
1637
1638fn emit_edit_prediction_menu_opened(
1639 provider: &str,
1640 file: &Option<Arc<dyn File>>,
1641 language: &Option<Arc<Language>>,
1642 project: &WeakEntity<Project>,
1643 cx: &App,
1644) {
1645 let language_name = language.as_ref().map(|l| l.name());
1646 let edit_predictions_enabled_for_language =
1647 language_settings::language_settings(language_name, file.as_ref(), cx)
1648 .show_edit_predictions;
1649 let file_extension = file
1650 .as_ref()
1651 .and_then(|f| {
1652 std::path::Path::new(f.file_name(cx))
1653 .extension()
1654 .and_then(|e| e.to_str())
1655 })
1656 .map(|s| s.to_string());
1657 let is_via_ssh = project
1658 .upgrade()
1659 .map(|p| p.read(cx).is_via_remote_server())
1660 .unwrap_or(false);
1661 telemetry::event!(
1662 "Toolbar Menu Opened",
1663 name = "Edit Predictions",
1664 provider,
1665 file_extension,
1666 edit_predictions_enabled_for_language,
1667 is_via_ssh,
1668 );
1669}
1670
1671fn copilot_settings_url(enterprise_uri: Option<&str>) -> String {
1672 match enterprise_uri {
1673 Some(uri) => {
1674 format!("{}{}", uri.trim_end_matches('/'), COPILOT_SETTINGS_PATH)
1675 }
1676 None => COPILOT_SETTINGS_URL.to_string(),
1677 }
1678}
1679
1680#[cfg(test)]
1681mod tests {
1682 use super::*;
1683 use gpui::TestAppContext;
1684
1685 #[gpui::test]
1686 async fn test_copilot_settings_url_with_enterprise_uri(cx: &mut TestAppContext) {
1687 cx.update(|cx| {
1688 let settings_store = SettingsStore::test(cx);
1689 cx.set_global(settings_store);
1690 });
1691
1692 cx.update_global(|settings_store: &mut SettingsStore, cx| {
1693 settings_store
1694 .set_user_settings(
1695 r#"{"edit_predictions":{"copilot":{"enterprise_uri":"https://my-company.ghe.com"}}}"#,
1696 cx,
1697 )
1698 .unwrap();
1699 });
1700
1701 let url = cx.update(|cx| {
1702 let all_language_settings = all_language_settings(None, cx);
1703 copilot_settings_url(
1704 all_language_settings
1705 .edit_predictions
1706 .copilot
1707 .enterprise_uri
1708 .as_deref(),
1709 )
1710 });
1711
1712 assert_eq!(url, "https://my-company.ghe.com/settings/copilot");
1713 }
1714
1715 #[gpui::test]
1716 async fn test_copilot_settings_url_with_enterprise_uri_trailing_slash(cx: &mut TestAppContext) {
1717 cx.update(|cx| {
1718 let settings_store = SettingsStore::test(cx);
1719 cx.set_global(settings_store);
1720 });
1721
1722 cx.update_global(|settings_store: &mut SettingsStore, cx| {
1723 settings_store
1724 .set_user_settings(
1725 r#"{"edit_predictions":{"copilot":{"enterprise_uri":"https://my-company.ghe.com/"}}}"#,
1726 cx,
1727 )
1728 .unwrap();
1729 });
1730
1731 let url = cx.update(|cx| {
1732 let all_language_settings = all_language_settings(None, cx);
1733 copilot_settings_url(
1734 all_language_settings
1735 .edit_predictions
1736 .copilot
1737 .enterprise_uri
1738 .as_deref(),
1739 )
1740 });
1741
1742 assert_eq!(url, "https://my-company.ghe.com/settings/copilot");
1743 }
1744
1745 #[gpui::test]
1746 async fn test_copilot_settings_url_without_enterprise_uri(cx: &mut TestAppContext) {
1747 cx.update(|cx| {
1748 let settings_store = SettingsStore::test(cx);
1749 cx.set_global(settings_store);
1750 });
1751
1752 let url = cx.update(|cx| {
1753 let all_language_settings = all_language_settings(None, cx);
1754 copilot_settings_url(
1755 all_language_settings
1756 .edit_predictions
1757 .copilot
1758 .enterprise_uri
1759 .as_deref(),
1760 )
1761 });
1762
1763 assert_eq!(url, "https://github.com/settings/copilot");
1764 }
1765}