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