edit_prediction_button.rs

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