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