edit_prediction_button.rs

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