1use anyhow::Result;
2use client::{Client, UserStore, zed_urls};
3use cloud_llm_client::UsageLimit;
4use codestral::{self, 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 = codestral::codestral_api_key(cx).is_some();
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 project: Entity<Project>,
604 cx: &mut Context<Self>,
605 ) -> Self {
606 let copilot = EditPredictionStore::try_global(cx).and_then(|store| {
607 store.update(cx, |this, cx| this.start_copilot_for_project(&project, cx))
608 });
609 if let Some(copilot) = copilot {
610 cx.observe(&copilot, |_, _, cx| cx.notify()).detach()
611 }
612
613 cx.observe_global::<SettingsStore>(move |_, cx| cx.notify())
614 .detach();
615
616 cx.observe_global::<EditPredictionStore>(move |_, cx| cx.notify())
617 .detach();
618
619 edit_prediction::ollama::ensure_authenticated(cx);
620 let sweep_api_token_task = edit_prediction::sweep_ai::load_sweep_api_token(cx);
621 let mercury_api_token_task = edit_prediction::mercury::load_mercury_api_token(cx);
622
623 cx.spawn(async move |this, cx| {
624 _ = futures::join!(sweep_api_token_task, mercury_api_token_task);
625 this.update(cx, |_, cx| {
626 cx.notify();
627 })
628 .ok();
629 })
630 .detach();
631
632 CodestralEditPredictionDelegate::ensure_api_key_loaded(cx);
633
634 Self {
635 editor_subscription: None,
636 editor_enabled: None,
637 editor_show_predictions: true,
638 editor_focus_handle: None,
639 language: None,
640 file: None,
641 edit_prediction_provider: None,
642 user_store,
643 popover_menu_handle,
644 project: project.downgrade(),
645 fs,
646 }
647 }
648
649 fn add_provider_switching_section(
650 &self,
651 mut menu: ContextMenu,
652 current_provider: EditPredictionProvider,
653 cx: &mut App,
654 ) -> ContextMenu {
655 let available_providers = get_available_providers(cx);
656
657 let providers: Vec<_> = available_providers
658 .into_iter()
659 .filter(|p| *p != EditPredictionProvider::None)
660 .collect();
661
662 if !providers.is_empty() {
663 menu = menu.separator().header("Providers");
664
665 for provider in providers {
666 let Some(name) = provider.display_name() else {
667 continue;
668 };
669 let is_current = provider == current_provider;
670 let fs = self.fs.clone();
671
672 menu = menu.item(
673 ContextMenuEntry::new(name)
674 .toggleable(IconPosition::Start, is_current)
675 .handler(move |_, cx| {
676 set_completion_provider(fs.clone(), cx, provider);
677 }),
678 )
679 }
680 }
681
682 menu
683 }
684
685 pub fn build_copilot_start_menu(
686 &mut self,
687 window: &mut Window,
688 cx: &mut Context<Self>,
689 ) -> Entity<ContextMenu> {
690 let fs = self.fs.clone();
691 let project = self.project.clone();
692 ContextMenu::build(window, cx, |menu, _, _| {
693 menu.entry("Sign In to Copilot", None, move |window, cx| {
694 telemetry::event!(
695 "Edit Prediction Menu Action",
696 action = "sign_in",
697 provider = "copilot",
698 );
699 if let Some(copilot) = EditPredictionStore::try_global(cx).and_then(|store| {
700 store.update(cx, |this, cx| {
701 this.start_copilot_for_project(&project.upgrade()?, cx)
702 })
703 }) {
704 copilot_ui::initiate_sign_in(copilot, window, cx);
705 }
706 })
707 .entry("Disable Copilot", None, {
708 let fs = fs.clone();
709 move |_window, cx| {
710 telemetry::event!(
711 "Edit Prediction Menu Action",
712 action = "disable_provider",
713 provider = "copilot",
714 );
715 hide_copilot(fs.clone(), cx)
716 }
717 })
718 .separator()
719 .entry("Use Zed AI", None, {
720 let fs = fs.clone();
721 move |_window, cx| {
722 set_completion_provider(fs.clone(), cx, EditPredictionProvider::Zed)
723 }
724 })
725 })
726 }
727
728 pub fn build_language_settings_menu(
729 &self,
730 mut menu: ContextMenu,
731 window: &Window,
732 cx: &mut App,
733 ) -> ContextMenu {
734 let fs = self.fs.clone();
735 let line_height = window.line_height();
736
737 menu = menu.header("Show Edit Predictions For");
738
739 let language_state = self.language.as_ref().map(|language| {
740 (
741 language.clone(),
742 language_settings::language_settings(Some(language.name()), None, cx)
743 .show_edit_predictions,
744 )
745 });
746
747 if let Some(editor_focus_handle) = self.editor_focus_handle.clone() {
748 let entry = ContextMenuEntry::new("This Buffer")
749 .toggleable(IconPosition::Start, self.editor_show_predictions)
750 .action(Box::new(editor::actions::ToggleEditPrediction))
751 .handler(move |window, cx| {
752 editor_focus_handle.dispatch_action(
753 &editor::actions::ToggleEditPrediction,
754 window,
755 cx,
756 );
757 });
758
759 match language_state.clone() {
760 Some((language, false)) => {
761 menu = menu.item(
762 entry
763 .disabled(true)
764 .documentation_aside(DocumentationSide::Left, move |_cx| {
765 Label::new(format!("Edit predictions cannot be toggled for this buffer because they are disabled for {}", language.name()))
766 .into_any_element()
767 })
768 );
769 }
770 Some(_) | None => menu = menu.item(entry),
771 }
772 }
773
774 if let Some((language, language_enabled)) = language_state {
775 let fs = fs.clone();
776 let language_name = language.name();
777
778 menu = menu.toggleable_entry(
779 language_name.clone(),
780 language_enabled,
781 IconPosition::Start,
782 None,
783 move |_, cx| {
784 telemetry::event!(
785 "Edit Prediction Setting Changed",
786 setting = "language",
787 language = language_name.to_string(),
788 enabled = !language_enabled,
789 );
790 toggle_show_edit_predictions_for_language(language.clone(), fs.clone(), cx)
791 },
792 );
793 }
794
795 let settings = AllLanguageSettings::get_global(cx);
796
797 let globally_enabled = settings.show_edit_predictions(None, cx);
798 let entry = ContextMenuEntry::new("All Files")
799 .toggleable(IconPosition::Start, globally_enabled)
800 .action(workspace::ToggleEditPrediction.boxed_clone())
801 .handler(|window, cx| {
802 window.dispatch_action(workspace::ToggleEditPrediction.boxed_clone(), cx)
803 });
804 menu = menu.item(entry);
805
806 let provider = settings.edit_predictions.provider;
807 let current_mode = settings.edit_predictions_mode();
808 let subtle_mode = matches!(current_mode, EditPredictionsMode::Subtle);
809 let eager_mode = matches!(current_mode, EditPredictionsMode::Eager);
810
811 menu = menu
812 .separator()
813 .header("Display Modes")
814 .item(
815 ContextMenuEntry::new("Eager")
816 .toggleable(IconPosition::Start, eager_mode)
817 .documentation_aside(DocumentationSide::Left, move |_| {
818 Label::new("Display predictions inline when there are no language server completions available.").into_any_element()
819 })
820 .handler({
821 let fs = fs.clone();
822 move |_, cx| {
823 telemetry::event!(
824 "Edit Prediction Setting Changed",
825 setting = "mode",
826 value = "eager",
827 );
828 toggle_edit_prediction_mode(fs.clone(), EditPredictionsMode::Eager, cx)
829 }
830 }),
831 )
832 .item(
833 ContextMenuEntry::new("Subtle")
834 .toggleable(IconPosition::Start, subtle_mode)
835 .documentation_aside(DocumentationSide::Left, move |_| {
836 Label::new("Display predictions inline only when holding a modifier key (alt by default).").into_any_element()
837 })
838 .handler({
839 let fs = fs.clone();
840 move |_, cx| {
841 telemetry::event!(
842 "Edit Prediction Setting Changed",
843 setting = "mode",
844 value = "subtle",
845 );
846 toggle_edit_prediction_mode(fs.clone(), EditPredictionsMode::Subtle, cx)
847 }
848 }),
849 );
850
851 menu = menu.separator().header("Privacy");
852
853 if matches!(
854 provider,
855 EditPredictionProvider::Zed
856 | EditPredictionProvider::Experimental(
857 EXPERIMENTAL_ZETA2_EDIT_PREDICTION_PROVIDER_NAME,
858 )
859 ) {
860 if let Some(provider) = &self.edit_prediction_provider {
861 let data_collection = provider.data_collection_state(cx);
862
863 if data_collection.is_supported() {
864 let provider = provider.clone();
865 let enabled = data_collection.is_enabled();
866 let is_open_source = data_collection.is_project_open_source();
867 let is_collecting = data_collection.is_enabled();
868 let (icon_name, icon_color) = if is_open_source && is_collecting {
869 (IconName::Check, Color::Success)
870 } else {
871 (IconName::Check, Color::Accent)
872 };
873
874 menu = menu.item(
875 ContextMenuEntry::new("Training Data Collection")
876 .toggleable(IconPosition::Start, data_collection.is_enabled())
877 .icon(icon_name)
878 .icon_color(icon_color)
879 .disabled(cx.is_staff())
880 .documentation_aside(DocumentationSide::Left, move |cx| {
881 let (msg, label_color, icon_name, icon_color) = match (is_open_source, is_collecting) {
882 (true, true) => (
883 "Project identified as open source, and you're sharing data.",
884 Color::Default,
885 IconName::Check,
886 Color::Success,
887 ),
888 (true, false) => (
889 "Project identified as open source, but you're not sharing data.",
890 Color::Muted,
891 IconName::Close,
892 Color::Muted,
893 ),
894 (false, true) => (
895 "Project not identified as open source. No data captured.",
896 Color::Muted,
897 IconName::Close,
898 Color::Muted,
899 ),
900 (false, false) => (
901 "Project not identified as open source, and setting turned off.",
902 Color::Muted,
903 IconName::Close,
904 Color::Muted,
905 ),
906 };
907 v_flex()
908 .gap_2()
909 .child(
910 Label::new(indoc!{
911 "Help us improve our open dataset model by sharing data from open source repositories. \
912 Zed must detect a license file in your repo for this setting to take effect. \
913 Files with sensitive data and secrets are excluded by default."
914 })
915 )
916 .child(
917 h_flex()
918 .items_start()
919 .pt_2()
920 .pr_1()
921 .flex_1()
922 .gap_1p5()
923 .border_t_1()
924 .border_color(cx.theme().colors().border_variant)
925 .child(h_flex().flex_shrink_0().h(line_height).child(Icon::new(icon_name).size(IconSize::XSmall).color(icon_color)))
926 .child(div().child(msg).w_full().text_sm().text_color(label_color.color(cx)))
927 )
928 .into_any_element()
929 })
930 .handler(move |_, cx| {
931 provider.toggle_data_collection(cx);
932
933 if !enabled {
934 telemetry::event!(
935 "Data Collection Enabled",
936 source = "Edit Prediction Status Menu"
937 );
938 } else {
939 telemetry::event!(
940 "Data Collection Disabled",
941 source = "Edit Prediction Status Menu"
942 );
943 }
944 })
945 );
946
947 if is_collecting && !is_open_source {
948 menu = menu.item(
949 ContextMenuEntry::new("No data captured.")
950 .disabled(true)
951 .icon(IconName::Close)
952 .icon_color(Color::Error)
953 .icon_size(IconSize::Small),
954 );
955 }
956 }
957 }
958 }
959
960 menu = menu.item(
961 ContextMenuEntry::new("Configure Excluded Files")
962 .icon(IconName::LockOutlined)
963 .icon_color(Color::Muted)
964 .documentation_aside(DocumentationSide::Left, |_| {
965 Label::new(indoc!{"
966 Open your settings to add sensitive paths for which Zed will never predict edits."}).into_any_element()
967 })
968 .handler(move |window, cx| {
969 telemetry::event!(
970 "Edit Prediction Menu Action",
971 action = "configure_excluded_files",
972 );
973 if let Some(workspace) = window.root().flatten() {
974 let workspace = workspace.downgrade();
975 window
976 .spawn(cx, async |cx| {
977 open_disabled_globs_setting_in_editor(
978 workspace,
979 cx,
980 ).await
981 })
982 .detach_and_log_err(cx);
983 }
984 }),
985 ).item(
986 ContextMenuEntry::new("View Docs")
987 .icon(IconName::FileGeneric)
988 .icon_color(Color::Muted)
989 .handler(move |_, cx| {
990 telemetry::event!(
991 "Edit Prediction Menu Action",
992 action = "view_docs",
993 );
994 cx.open_url(PRIVACY_DOCS);
995 })
996 );
997
998 if !self.editor_enabled.unwrap_or(true) {
999 let icons = self
1000 .edit_prediction_provider
1001 .as_ref()
1002 .map(|p| p.icons(cx))
1003 .unwrap_or_else(|| {
1004 edit_prediction_types::EditPredictionIconSet::new(IconName::ZedPredict)
1005 });
1006 menu = menu.item(
1007 ContextMenuEntry::new("This file is excluded.")
1008 .disabled(true)
1009 .icon(icons.disabled)
1010 .icon_size(IconSize::Small),
1011 );
1012 }
1013
1014 if let Some(editor_focus_handle) = self.editor_focus_handle.clone() {
1015 menu = menu
1016 .separator()
1017 .header("Actions")
1018 .entry(
1019 "Predict Edit at Cursor",
1020 Some(Box::new(ShowEditPrediction)),
1021 {
1022 let editor_focus_handle = editor_focus_handle.clone();
1023 move |window, cx| {
1024 telemetry::event!(
1025 "Edit Prediction Menu Action",
1026 action = "predict_at_cursor",
1027 );
1028 editor_focus_handle.dispatch_action(&ShowEditPrediction, window, cx);
1029 }
1030 },
1031 )
1032 .context(editor_focus_handle)
1033 .when(
1034 cx.has_flag::<PredictEditsRatePredictionsFeatureFlag>(),
1035 |this| {
1036 this.action("Capture Prediction Example", CaptureExample.boxed_clone())
1037 .action("Rate Predictions", RatePredictions.boxed_clone())
1038 },
1039 );
1040 }
1041
1042 menu
1043 }
1044
1045 fn build_copilot_context_menu(
1046 &self,
1047 window: &mut Window,
1048 cx: &mut Context<Self>,
1049 ) -> Entity<ContextMenu> {
1050 let all_language_settings = all_language_settings(None, cx);
1051 let next_edit_suggestions = all_language_settings
1052 .edit_predictions
1053 .copilot
1054 .enable_next_edit_suggestions
1055 .unwrap_or(true);
1056 let copilot_config = copilot_chat::CopilotChatConfiguration {
1057 enterprise_uri: all_language_settings
1058 .edit_predictions
1059 .copilot
1060 .enterprise_uri
1061 .clone(),
1062 };
1063 let settings_url = copilot_settings_url(copilot_config.enterprise_uri.as_deref());
1064
1065 ContextMenu::build(window, cx, |menu, window, cx| {
1066 let menu = self.build_language_settings_menu(menu, window, cx);
1067 let menu =
1068 self.add_provider_switching_section(menu, EditPredictionProvider::Copilot, cx);
1069
1070 menu.separator()
1071 .item(
1072 ContextMenuEntry::new("Copilot: Next Edit Suggestions")
1073 .toggleable(IconPosition::Start, next_edit_suggestions)
1074 .handler({
1075 let fs = self.fs.clone();
1076 move |_, cx| {
1077 update_settings_file(fs.clone(), cx, move |settings, _| {
1078 settings
1079 .project
1080 .all_languages
1081 .edit_predictions
1082 .get_or_insert_default()
1083 .copilot
1084 .get_or_insert_default()
1085 .enable_next_edit_suggestions =
1086 Some(!next_edit_suggestions);
1087 });
1088 }
1089 }),
1090 )
1091 .separator()
1092 .link(
1093 "Go to Copilot Settings",
1094 OpenBrowser { url: settings_url }.boxed_clone(),
1095 )
1096 .action("Sign Out", copilot::SignOut.boxed_clone())
1097 })
1098 }
1099
1100 fn build_supermaven_context_menu(
1101 &self,
1102 window: &mut Window,
1103 cx: &mut Context<Self>,
1104 ) -> Entity<ContextMenu> {
1105 ContextMenu::build(window, cx, |menu, window, cx| {
1106 let menu = self.build_language_settings_menu(menu, window, cx);
1107 let menu =
1108 self.add_provider_switching_section(menu, EditPredictionProvider::Supermaven, cx);
1109
1110 menu.separator()
1111 .action("Sign Out", supermaven::SignOut.boxed_clone())
1112 })
1113 }
1114
1115 fn build_codestral_context_menu(
1116 &self,
1117 window: &mut Window,
1118 cx: &mut Context<Self>,
1119 ) -> Entity<ContextMenu> {
1120 ContextMenu::build(window, cx, |menu, window, cx| {
1121 let menu = self.build_language_settings_menu(menu, window, cx);
1122 let menu =
1123 self.add_provider_switching_section(menu, EditPredictionProvider::Codestral, cx);
1124
1125 menu
1126 })
1127 }
1128
1129 fn build_edit_prediction_context_menu(
1130 &self,
1131 provider: EditPredictionProvider,
1132 window: &mut Window,
1133 cx: &mut Context<Self>,
1134 ) -> Entity<ContextMenu> {
1135 ContextMenu::build(window, cx, |mut menu, window, cx| {
1136 let user = self.user_store.read(cx).current_user();
1137
1138 let needs_sign_in = user.is_none()
1139 && matches!(
1140 provider,
1141 EditPredictionProvider::None | EditPredictionProvider::Zed
1142 );
1143
1144 if needs_sign_in {
1145 menu = menu
1146 .custom_row(move |_window, cx| {
1147 let description = indoc! {
1148 "You get 2,000 accepted suggestions at every keystroke for free, \
1149 powered by Zeta, our open-source, open-data model"
1150 };
1151
1152 v_flex()
1153 .max_w_64()
1154 .h(rems_from_px(148.))
1155 .child(render_zeta_tab_animation(cx))
1156 .child(Label::new("Edit Prediction"))
1157 .child(
1158 Label::new(description)
1159 .color(Color::Muted)
1160 .size(LabelSize::Small),
1161 )
1162 .into_any_element()
1163 })
1164 .separator()
1165 .entry("Sign In & Start Using", None, |window, cx| {
1166 telemetry::event!(
1167 "Edit Prediction Menu Action",
1168 action = "sign_in",
1169 provider = "zed",
1170 );
1171 let client = Client::global(cx);
1172 window
1173 .spawn(cx, async move |cx| {
1174 client
1175 .sign_in_with_optional_connect(true, &cx)
1176 .await
1177 .log_err();
1178 })
1179 .detach();
1180 })
1181 .link_with_handler(
1182 "Learn More",
1183 OpenBrowser {
1184 url: zed_urls::edit_prediction_docs(cx),
1185 }
1186 .boxed_clone(),
1187 |_window, _cx| {
1188 telemetry::event!(
1189 "Edit Prediction Menu Action",
1190 action = "view_docs",
1191 source = "upsell",
1192 );
1193 },
1194 )
1195 .separator();
1196 } else if let Some(usage) = self
1197 .edit_prediction_provider
1198 .as_ref()
1199 .and_then(|provider| provider.usage(cx))
1200 {
1201 menu = menu.header("Usage");
1202 menu = menu
1203 .custom_entry(
1204 move |_window, cx| {
1205 let used_percentage = match usage.limit {
1206 UsageLimit::Limited(limit) => {
1207 Some((usage.amount as f32 / limit as f32) * 100.)
1208 }
1209 UsageLimit::Unlimited => None,
1210 };
1211
1212 h_flex()
1213 .flex_1()
1214 .gap_1p5()
1215 .children(
1216 used_percentage.map(|percent| {
1217 ProgressBar::new("usage", percent, 100., cx)
1218 }),
1219 )
1220 .child(
1221 Label::new(match usage.limit {
1222 UsageLimit::Limited(limit) => {
1223 format!("{} / {limit}", usage.amount)
1224 }
1225 UsageLimit::Unlimited => format!("{} / ∞", usage.amount),
1226 })
1227 .size(LabelSize::Small)
1228 .color(Color::Muted),
1229 )
1230 .into_any_element()
1231 },
1232 move |_, cx| cx.open_url(&zed_urls::account_url(cx)),
1233 )
1234 .when(usage.over_limit(), |menu| -> ContextMenu {
1235 menu.entry("Subscribe to increase your limit", None, |_window, cx| {
1236 telemetry::event!(
1237 "Edit Prediction Menu Action",
1238 action = "upsell_clicked",
1239 reason = "usage_limit",
1240 );
1241 cx.open_url(&zed_urls::account_url(cx))
1242 })
1243 })
1244 .separator();
1245 } else if self.user_store.read(cx).account_too_young() {
1246 menu = menu
1247 .custom_entry(
1248 |_window, _cx| {
1249 Label::new("Your GitHub account is less than 30 days old.")
1250 .size(LabelSize::Small)
1251 .color(Color::Warning)
1252 .into_any_element()
1253 },
1254 |_window, cx| cx.open_url(&zed_urls::account_url(cx)),
1255 )
1256 .entry("Upgrade to Zed Pro or contact us.", None, |_window, cx| {
1257 telemetry::event!(
1258 "Edit Prediction Menu Action",
1259 action = "upsell_clicked",
1260 reason = "account_age",
1261 );
1262 cx.open_url(&zed_urls::account_url(cx))
1263 })
1264 .separator();
1265 } else if self.user_store.read(cx).has_overdue_invoices() {
1266 menu = menu
1267 .custom_entry(
1268 |_window, _cx| {
1269 Label::new("You have an outstanding invoice")
1270 .size(LabelSize::Small)
1271 .color(Color::Warning)
1272 .into_any_element()
1273 },
1274 |_window, cx| {
1275 cx.open_url(&zed_urls::account_url(cx))
1276 },
1277 )
1278 .entry(
1279 "Check your payment status or contact us at billing-support@zed.dev to continue using this feature.",
1280 None,
1281 |_window, cx| {
1282 cx.open_url(&zed_urls::account_url(cx))
1283 },
1284 )
1285 .separator();
1286 }
1287
1288 if !needs_sign_in {
1289 menu = self.build_language_settings_menu(menu, window, cx);
1290 }
1291 menu = self.add_provider_switching_section(menu, provider, cx);
1292 menu = menu.separator().item(
1293 ContextMenuEntry::new("Configure Providers")
1294 .icon(IconName::Settings)
1295 .icon_position(IconPosition::Start)
1296 .icon_color(Color::Muted)
1297 .handler(move |window, cx| {
1298 telemetry::event!(
1299 "Edit Prediction Menu Action",
1300 action = "configure_providers",
1301 );
1302 window.dispatch_action(
1303 OpenSettingsAt {
1304 path: "edit_predictions.providers".to_string(),
1305 }
1306 .boxed_clone(),
1307 cx,
1308 );
1309 }),
1310 );
1311
1312 menu
1313 })
1314 }
1315
1316 pub fn update_enabled(&mut self, editor: Entity<Editor>, cx: &mut Context<Self>) {
1317 let editor = editor.read(cx);
1318 let snapshot = editor.buffer().read(cx).snapshot(cx);
1319 let suggestion_anchor = editor.selections.newest_anchor().start;
1320 let language = snapshot.language_at(suggestion_anchor);
1321 let file = snapshot.file_at(suggestion_anchor).cloned();
1322 self.editor_enabled = {
1323 let file = file.as_ref();
1324 Some(
1325 file.map(|file| {
1326 all_language_settings(Some(file), cx)
1327 .edit_predictions_enabled_for_file(file, cx)
1328 })
1329 .unwrap_or(true),
1330 )
1331 };
1332 self.editor_show_predictions = editor.edit_predictions_enabled();
1333 self.edit_prediction_provider = editor.edit_prediction_provider();
1334 self.language = language.cloned();
1335 self.file = file;
1336 self.editor_focus_handle = Some(editor.focus_handle(cx));
1337
1338 cx.notify();
1339 }
1340}
1341
1342impl StatusItemView for EditPredictionButton {
1343 fn set_active_pane_item(
1344 &mut self,
1345 item: Option<&dyn ItemHandle>,
1346 _: &mut Window,
1347 cx: &mut Context<Self>,
1348 ) {
1349 if let Some(editor) = item.and_then(|item| item.act_as::<Editor>(cx)) {
1350 self.editor_subscription = Some((
1351 cx.observe(&editor, Self::update_enabled),
1352 editor.entity_id().as_u64() as usize,
1353 ));
1354 self.update_enabled(editor, cx);
1355 } else {
1356 self.language = None;
1357 self.editor_subscription = None;
1358 self.editor_enabled = None;
1359 }
1360 cx.notify();
1361 }
1362}
1363
1364impl SupermavenButtonStatus {
1365 fn to_icon(&self) -> IconName {
1366 match self {
1367 SupermavenButtonStatus::Ready => IconName::Supermaven,
1368 SupermavenButtonStatus::Errored(_) => IconName::SupermavenError,
1369 SupermavenButtonStatus::NeedsActivation(_) => IconName::SupermavenInit,
1370 SupermavenButtonStatus::Initializing => IconName::SupermavenInit,
1371 }
1372 }
1373
1374 fn to_tooltip(&self) -> String {
1375 match self {
1376 SupermavenButtonStatus::Ready => "Supermaven is ready".to_string(),
1377 SupermavenButtonStatus::Errored(error) => format!("Supermaven error: {}", error),
1378 SupermavenButtonStatus::NeedsActivation(_) => "Supermaven needs activation".to_string(),
1379 SupermavenButtonStatus::Initializing => "Supermaven initializing".to_string(),
1380 }
1381 }
1382
1383 fn has_menu(&self) -> bool {
1384 match self {
1385 SupermavenButtonStatus::Ready | SupermavenButtonStatus::NeedsActivation(_) => true,
1386 SupermavenButtonStatus::Errored(_) | SupermavenButtonStatus::Initializing => false,
1387 }
1388 }
1389}
1390
1391async fn open_disabled_globs_setting_in_editor(
1392 workspace: WeakEntity<Workspace>,
1393 cx: &mut AsyncWindowContext,
1394) -> Result<()> {
1395 let settings_editor = workspace
1396 .update_in(cx, |_, window, cx| {
1397 create_and_open_local_file(paths::settings_file(), window, cx, || {
1398 settings::initial_user_settings_content().as_ref().into()
1399 })
1400 })?
1401 .await?
1402 .downcast::<Editor>()
1403 .unwrap();
1404
1405 settings_editor
1406 .downgrade()
1407 .update_in(cx, |item, window, cx| {
1408 let text = item.buffer().read(cx).snapshot(cx).text();
1409
1410 let settings = cx.global::<SettingsStore>();
1411
1412 // Ensure that we always have "edit_predictions { "disabled_globs": [] }"
1413 let edits = settings.edits_for_update(&text, |file| {
1414 file.project
1415 .all_languages
1416 .edit_predictions
1417 .get_or_insert_with(Default::default)
1418 .disabled_globs
1419 .get_or_insert_with(Vec::new);
1420 });
1421
1422 if !edits.is_empty() {
1423 item.edit(
1424 edits
1425 .into_iter()
1426 .map(|(r, s)| (MultiBufferOffset(r.start)..MultiBufferOffset(r.end), s)),
1427 cx,
1428 );
1429 }
1430
1431 let text = item.buffer().read(cx).snapshot(cx).text();
1432
1433 static DISABLED_GLOBS_REGEX: LazyLock<Regex> = LazyLock::new(|| {
1434 Regex::new(r#""disabled_globs":\s*\[\s*(?P<content>(?:.|\n)*?)\s*\]"#).unwrap()
1435 });
1436 // Only capture [...]
1437 let range = DISABLED_GLOBS_REGEX.captures(&text).and_then(|captures| {
1438 captures
1439 .name("content")
1440 .map(|inner_match| inner_match.start()..inner_match.end())
1441 });
1442 if let Some(range) = range {
1443 let range = MultiBufferOffset(range.start)..MultiBufferOffset(range.end);
1444 item.change_selections(
1445 SelectionEffects::scroll(Autoscroll::newest()),
1446 window,
1447 cx,
1448 |selections| {
1449 selections.select_ranges(vec![range]);
1450 },
1451 );
1452 }
1453 })?;
1454
1455 anyhow::Ok(())
1456}
1457
1458pub fn set_completion_provider(fs: Arc<dyn Fs>, cx: &mut App, provider: EditPredictionProvider) {
1459 update_settings_file(fs, cx, move |settings, _| {
1460 settings
1461 .project
1462 .all_languages
1463 .edit_predictions
1464 .get_or_insert_default()
1465 .provider = Some(provider);
1466 });
1467}
1468
1469pub fn get_available_providers(cx: &mut App) -> Vec<EditPredictionProvider> {
1470 let mut providers = Vec::new();
1471
1472 providers.push(EditPredictionProvider::Zed);
1473
1474 if cx.has_flag::<Zeta2FeatureFlag>() {
1475 providers.push(EditPredictionProvider::Experimental(
1476 EXPERIMENTAL_ZETA2_EDIT_PREDICTION_PROVIDER_NAME,
1477 ));
1478 }
1479
1480 if let Some(app_state) = workspace::AppState::global(cx).upgrade()
1481 && copilot::GlobalCopilotAuth::try_get_or_init(app_state, cx)
1482 .is_some_and(|copilot| copilot.0.read(cx).is_authenticated())
1483 {
1484 providers.push(EditPredictionProvider::Copilot);
1485 };
1486
1487 if let Some(supermaven) = Supermaven::global(cx) {
1488 if let Supermaven::Spawned(agent) = supermaven.read(cx) {
1489 if matches!(agent.account_status, AccountStatus::Ready) {
1490 providers.push(EditPredictionProvider::Supermaven);
1491 }
1492 }
1493 }
1494
1495 if codestral::codestral_api_key(cx).is_some() {
1496 providers.push(EditPredictionProvider::Codestral);
1497 }
1498
1499 if edit_prediction::ollama::is_available(cx) {
1500 providers.push(EditPredictionProvider::Ollama);
1501 }
1502
1503 if edit_prediction::sweep_ai::sweep_api_token(cx)
1504 .read(cx)
1505 .has_key()
1506 {
1507 providers.push(EditPredictionProvider::Sweep);
1508 }
1509
1510 if edit_prediction::mercury::mercury_api_token(cx)
1511 .read(cx)
1512 .has_key()
1513 {
1514 providers.push(EditPredictionProvider::Mercury);
1515 }
1516
1517 providers
1518}
1519
1520fn toggle_show_edit_predictions_for_language(
1521 language: Arc<Language>,
1522 fs: Arc<dyn Fs>,
1523 cx: &mut App,
1524) {
1525 let show_edit_predictions =
1526 all_language_settings(None, cx).show_edit_predictions(Some(&language), cx);
1527 update_settings_file(fs, cx, move |settings, _| {
1528 settings
1529 .project
1530 .all_languages
1531 .languages
1532 .0
1533 .entry(language.name().0.to_string())
1534 .or_default()
1535 .show_edit_predictions = Some(!show_edit_predictions);
1536 });
1537}
1538
1539fn hide_copilot(fs: Arc<dyn Fs>, cx: &mut App) {
1540 update_settings_file(fs, cx, move |settings, _| {
1541 settings
1542 .project
1543 .all_languages
1544 .edit_predictions
1545 .get_or_insert(Default::default())
1546 .provider = Some(EditPredictionProvider::None);
1547 });
1548}
1549
1550fn toggle_edit_prediction_mode(fs: Arc<dyn Fs>, mode: EditPredictionsMode, cx: &mut App) {
1551 let settings = AllLanguageSettings::get_global(cx);
1552 let current_mode = settings.edit_predictions_mode();
1553
1554 if current_mode != mode {
1555 update_settings_file(fs, cx, move |settings, _cx| {
1556 if let Some(edit_predictions) = settings.project.all_languages.edit_predictions.as_mut()
1557 {
1558 edit_predictions.mode = Some(mode);
1559 } else {
1560 settings.project.all_languages.edit_predictions =
1561 Some(settings::EditPredictionSettingsContent {
1562 mode: Some(mode),
1563 ..Default::default()
1564 });
1565 }
1566 });
1567 }
1568}
1569
1570fn render_zeta_tab_animation(cx: &App) -> impl IntoElement {
1571 let tab = |n: u64, inverted: bool| {
1572 let text_color = cx.theme().colors().text;
1573
1574 h_flex().child(
1575 h_flex()
1576 .text_size(TextSize::XSmall.rems(cx))
1577 .text_color(text_color)
1578 .child("tab")
1579 .with_animation(
1580 ElementId::Integer(n),
1581 Animation::new(Duration::from_secs(3)).repeat(),
1582 move |tab, delta| {
1583 let n_f32 = n as f32;
1584
1585 let offset = if inverted {
1586 0.2 * (4.0 - n_f32)
1587 } else {
1588 0.2 * n_f32
1589 };
1590
1591 let phase = (delta - offset + 1.0) % 1.0;
1592 let pulse = if phase < 0.6 {
1593 let t = phase / 0.6;
1594 1.0 - (0.5 - t).abs() * 2.0
1595 } else {
1596 0.0
1597 };
1598
1599 let eased = ease_in_out(pulse);
1600 let opacity = 0.1 + 0.5 * eased;
1601
1602 tab.text_color(text_color.opacity(opacity))
1603 },
1604 ),
1605 )
1606 };
1607
1608 let tab_sequence = |inverted: bool| {
1609 h_flex()
1610 .gap_1()
1611 .child(tab(0, inverted))
1612 .child(tab(1, inverted))
1613 .child(tab(2, inverted))
1614 .child(tab(3, inverted))
1615 .child(tab(4, inverted))
1616 };
1617
1618 h_flex()
1619 .my_1p5()
1620 .p_4()
1621 .justify_center()
1622 .gap_2()
1623 .rounded_xs()
1624 .border_1()
1625 .border_dashed()
1626 .border_color(cx.theme().colors().border)
1627 .bg(gpui::pattern_slash(
1628 cx.theme().colors().border.opacity(0.5),
1629 1.,
1630 8.,
1631 ))
1632 .child(tab_sequence(true))
1633 .child(Icon::new(IconName::ZedPredict))
1634 .child(tab_sequence(false))
1635}
1636
1637fn emit_edit_prediction_menu_opened(
1638 provider: &str,
1639 file: &Option<Arc<dyn File>>,
1640 language: &Option<Arc<Language>>,
1641 project: &WeakEntity<Project>,
1642 cx: &App,
1643) {
1644 let language_name = language.as_ref().map(|l| l.name());
1645 let edit_predictions_enabled_for_language =
1646 language_settings::language_settings(language_name, file.as_ref(), cx)
1647 .show_edit_predictions;
1648 let file_extension = file
1649 .as_ref()
1650 .and_then(|f| {
1651 std::path::Path::new(f.file_name(cx))
1652 .extension()
1653 .and_then(|e| e.to_str())
1654 })
1655 .map(|s| s.to_string());
1656 let is_via_ssh = project
1657 .upgrade()
1658 .map(|p| p.read(cx).is_via_remote_server())
1659 .unwrap_or(false);
1660 telemetry::event!(
1661 "Toolbar Menu Opened",
1662 name = "Edit Predictions",
1663 provider,
1664 file_extension,
1665 edit_predictions_enabled_for_language,
1666 is_via_ssh,
1667 );
1668}
1669
1670fn copilot_settings_url(enterprise_uri: Option<&str>) -> String {
1671 match enterprise_uri {
1672 Some(uri) => {
1673 format!("{}{}", uri.trim_end_matches('/'), COPILOT_SETTINGS_PATH)
1674 }
1675 None => COPILOT_SETTINGS_URL.to_string(),
1676 }
1677}
1678
1679#[cfg(test)]
1680mod tests {
1681 use super::*;
1682 use gpui::TestAppContext;
1683
1684 #[gpui::test]
1685 async fn test_copilot_settings_url_with_enterprise_uri(cx: &mut TestAppContext) {
1686 cx.update(|cx| {
1687 let settings_store = SettingsStore::test(cx);
1688 cx.set_global(settings_store);
1689 });
1690
1691 cx.update_global(|settings_store: &mut SettingsStore, cx| {
1692 settings_store
1693 .set_user_settings(
1694 r#"{"edit_predictions":{"copilot":{"enterprise_uri":"https://my-company.ghe.com"}}}"#,
1695 cx,
1696 )
1697 .unwrap();
1698 });
1699
1700 let url = cx.update(|cx| {
1701 let all_language_settings = all_language_settings(None, cx);
1702 copilot_settings_url(
1703 all_language_settings
1704 .edit_predictions
1705 .copilot
1706 .enterprise_uri
1707 .as_deref(),
1708 )
1709 });
1710
1711 assert_eq!(url, "https://my-company.ghe.com/settings/copilot");
1712 }
1713
1714 #[gpui::test]
1715 async fn test_copilot_settings_url_with_enterprise_uri_trailing_slash(cx: &mut TestAppContext) {
1716 cx.update(|cx| {
1717 let settings_store = SettingsStore::test(cx);
1718 cx.set_global(settings_store);
1719 });
1720
1721 cx.update_global(|settings_store: &mut SettingsStore, cx| {
1722 settings_store
1723 .set_user_settings(
1724 r#"{"edit_predictions":{"copilot":{"enterprise_uri":"https://my-company.ghe.com/"}}}"#,
1725 cx,
1726 )
1727 .unwrap();
1728 });
1729
1730 let url = cx.update(|cx| {
1731 let all_language_settings = all_language_settings(None, cx);
1732 copilot_settings_url(
1733 all_language_settings
1734 .edit_predictions
1735 .copilot
1736 .enterprise_uri
1737 .as_deref(),
1738 )
1739 });
1740
1741 assert_eq!(url, "https://my-company.ghe.com/settings/copilot");
1742 }
1743
1744 #[gpui::test]
1745 async fn test_copilot_settings_url_without_enterprise_uri(cx: &mut TestAppContext) {
1746 cx.update(|cx| {
1747 let settings_store = SettingsStore::test(cx);
1748 cx.set_global(settings_store);
1749 });
1750
1751 let url = cx.update(|cx| {
1752 let all_language_settings = all_language_settings(None, cx);
1753 copilot_settings_url(
1754 all_language_settings
1755 .edit_predictions
1756 .copilot
1757 .enterprise_uri
1758 .as_deref(),
1759 )
1760 });
1761
1762 assert_eq!(url, "https://github.com/settings/copilot");
1763 }
1764}