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::{
   7    Editor, MultiBufferOffset, SelectionEffects, actions::ShowEditPrediction, scroll::Autoscroll,
   8};
   9use feature_flags::{FeatureFlagAppExt, PredictEditsRateCompletionsFeatureFlag};
  10use fs::Fs;
  11use gpui::{
  12    Action, Animation, AnimationExt, App, AsyncWindowContext, Corner, Entity, FocusHandle,
  13    Focusable, IntoElement, ParentElement, Render, Subscription, WeakEntity, actions, div,
  14    pulsating_between,
  15};
  16use indoc::indoc;
  17use language::{
  18    EditPredictionsMode, File, Language,
  19    language_settings::{self, AllLanguageSettings, EditPredictionProvider, all_language_settings},
  20};
  21use project::DisableAiSettings;
  22use regex::Regex;
  23use settings::{
  24    EXPERIMENTAL_SWEEP_EDIT_PREDICTION_PROVIDER_NAME, Settings, SettingsStore, update_settings_file,
  25};
  26use std::{
  27    sync::{Arc, LazyLock},
  28    time::Duration,
  29};
  30use supermaven::{AccountStatus, Supermaven};
  31use ui::{
  32    Clickable, ContextMenu, ContextMenuEntry, DocumentationEdge, DocumentationSide, IconButton,
  33    IconButtonShape, Indicator, PopoverMenu, PopoverMenuHandle, ProgressBar, Tooltip, prelude::*,
  34};
  35use workspace::{
  36    StatusItemView, Toast, Workspace, create_and_open_local_file, item::ItemHandle,
  37    notifications::NotificationId,
  38};
  39use zed_actions::OpenBrowser;
  40use zeta::RateCompletions;
  41use zeta2::SweepFeatureFlag;
  42
  43actions!(
  44    edit_prediction,
  45    [
  46        /// Toggles the edit prediction menu.
  47        ToggleMenu
  48    ]
  49);
  50
  51const COPILOT_SETTINGS_PATH: &str = "/settings/copilot";
  52const COPILOT_SETTINGS_URL: &str = concat!("https://github.com", "/settings/copilot");
  53const PRIVACY_DOCS: &str = "https://zed.dev/docs/ai/privacy-and-security";
  54
  55struct CopilotErrorToast;
  56
  57pub struct EditPredictionButton {
  58    editor_subscription: Option<(Subscription, usize)>,
  59    editor_enabled: Option<bool>,
  60    editor_show_predictions: bool,
  61    editor_focus_handle: Option<FocusHandle>,
  62    language: Option<Arc<Language>>,
  63    file: Option<Arc<dyn File>>,
  64    edit_prediction_provider: Option<Arc<dyn edit_prediction::EditPredictionProviderHandle>>,
  65    fs: Arc<dyn Fs>,
  66    user_store: Entity<UserStore>,
  67    popover_menu_handle: PopoverMenuHandle<ContextMenu>,
  68}
  69
  70enum SupermavenButtonStatus {
  71    Ready,
  72    Errored(String),
  73    NeedsActivation(String),
  74    Initializing,
  75}
  76
  77impl Render for EditPredictionButton {
  78    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
  79        // Return empty div if AI is disabled
  80        if DisableAiSettings::get_global(cx).disable_ai {
  81            return div().hidden();
  82        }
  83
  84        let all_language_settings = all_language_settings(None, cx);
  85
  86        match all_language_settings.edit_predictions.provider {
  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            provider @ (EditPredictionProvider::Experimental(
 304                EXPERIMENTAL_SWEEP_EDIT_PREDICTION_PROVIDER_NAME,
 305            )
 306            | EditPredictionProvider::Zed) => {
 307                let enabled = self.editor_enabled.unwrap_or(true);
 308
 309                let is_sweep = matches!(
 310                    provider,
 311                    EditPredictionProvider::Experimental(
 312                        EXPERIMENTAL_SWEEP_EDIT_PREDICTION_PROVIDER_NAME
 313                    )
 314                );
 315
 316                let zeta_icon = match (is_sweep, enabled) {
 317                    (true, _) => IconName::SweepAi,
 318                    (false, true) => IconName::ZedPredict,
 319                    (false, false) => 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| {
 404                            this.build_zeta_context_menu(provider, window, cx)
 405                        })
 406                        .ok()
 407                    })
 408                    .anchor(Corner::BottomRight)
 409                    .with_handle(self.popover_menu_handle.clone());
 410
 411                let is_refreshing = self
 412                    .edit_prediction_provider
 413                    .as_ref()
 414                    .is_some_and(|provider| provider.is_refreshing(cx));
 415
 416                if is_refreshing {
 417                    popover_menu = popover_menu.trigger(
 418                        icon_button.with_animation(
 419                            "pulsating-label",
 420                            Animation::new(Duration::from_secs(2))
 421                                .repeat()
 422                                .with_easing(pulsating_between(0.2, 1.0)),
 423                            |icon_button, delta| icon_button.alpha(delta),
 424                        ),
 425                    );
 426                } else {
 427                    popover_menu = popover_menu.trigger(icon_button);
 428                }
 429
 430                div().child(popover_menu.into_any_element())
 431            }
 432
 433            EditPredictionProvider::None | EditPredictionProvider::Experimental(_) => {
 434                div().hidden()
 435            }
 436        }
 437    }
 438}
 439
 440impl EditPredictionButton {
 441    pub fn new(
 442        fs: Arc<dyn Fs>,
 443        user_store: Entity<UserStore>,
 444        popover_menu_handle: PopoverMenuHandle<ContextMenu>,
 445        client: Arc<Client>,
 446        cx: &mut Context<Self>,
 447    ) -> Self {
 448        if let Some(copilot) = Copilot::global(cx) {
 449            cx.observe(&copilot, |_, _, cx| cx.notify()).detach()
 450        }
 451
 452        cx.observe_global::<SettingsStore>(move |_, cx| cx.notify())
 453            .detach();
 454
 455        CodestralCompletionProvider::ensure_api_key_loaded(client.http_client(), cx);
 456
 457        Self {
 458            editor_subscription: None,
 459            editor_enabled: None,
 460            editor_show_predictions: true,
 461            editor_focus_handle: None,
 462            language: None,
 463            file: None,
 464            edit_prediction_provider: None,
 465            user_store,
 466            popover_menu_handle,
 467            fs,
 468        }
 469    }
 470
 471    fn get_available_providers(&self, cx: &App) -> Vec<EditPredictionProvider> {
 472        let mut providers = Vec::new();
 473
 474        providers.push(EditPredictionProvider::Zed);
 475
 476        if let Some(copilot) = Copilot::global(cx) {
 477            if matches!(copilot.read(cx).status(), Status::Authorized) {
 478                providers.push(EditPredictionProvider::Copilot);
 479            }
 480        }
 481
 482        if let Some(supermaven) = Supermaven::global(cx) {
 483            if let Supermaven::Spawned(agent) = supermaven.read(cx) {
 484                if matches!(agent.account_status, AccountStatus::Ready) {
 485                    providers.push(EditPredictionProvider::Supermaven);
 486                }
 487            }
 488        }
 489
 490        if CodestralCompletionProvider::has_api_key(cx) {
 491            providers.push(EditPredictionProvider::Codestral);
 492        }
 493
 494        if cx.has_flag::<SweepFeatureFlag>() {
 495            providers.push(EditPredictionProvider::Experimental(
 496                EXPERIMENTAL_SWEEP_EDIT_PREDICTION_PROVIDER_NAME,
 497            ));
 498        }
 499
 500        providers
 501    }
 502
 503    fn add_provider_switching_section(
 504        &self,
 505        mut menu: ContextMenu,
 506        current_provider: EditPredictionProvider,
 507        cx: &App,
 508    ) -> ContextMenu {
 509        let available_providers = self.get_available_providers(cx);
 510
 511        const ZED_AI_CALLOUT: &str =
 512            "Zed's edit prediction is powered by Zeta, an open-source, dataset mode.";
 513        const USE_SWEEP_API_TOKEN_CALLOUT: &str =
 514            "Set the SWEEP_API_TOKEN environment variable to use Sweep";
 515
 516        let other_providers: Vec<_> = available_providers
 517            .into_iter()
 518            .filter(|p| *p != current_provider && *p != EditPredictionProvider::None)
 519            .collect();
 520
 521        if !other_providers.is_empty() {
 522            menu = menu.separator().header("Switch Providers");
 523
 524            for provider in other_providers {
 525                let fs = self.fs.clone();
 526
 527                menu = match provider {
 528                    EditPredictionProvider::Zed => menu.item(
 529                        ContextMenuEntry::new("Zed AI")
 530                            .documentation_aside(
 531                                DocumentationSide::Left,
 532                                DocumentationEdge::Bottom,
 533                                |_| Label::new(ZED_AI_CALLOUT).into_any_element(),
 534                            )
 535                            .handler(move |_, cx| {
 536                                set_completion_provider(fs.clone(), cx, provider);
 537                            }),
 538                    ),
 539                    EditPredictionProvider::Copilot => {
 540                        menu.entry("GitHub Copilot", None, move |_, cx| {
 541                            set_completion_provider(fs.clone(), cx, provider);
 542                        })
 543                    }
 544                    EditPredictionProvider::Supermaven => {
 545                        menu.entry("Supermaven", None, move |_, cx| {
 546                            set_completion_provider(fs.clone(), cx, provider);
 547                        })
 548                    }
 549                    EditPredictionProvider::Codestral => {
 550                        menu.entry("Codestral", None, move |_, cx| {
 551                            set_completion_provider(fs.clone(), cx, provider);
 552                        })
 553                    }
 554                    EditPredictionProvider::Experimental(
 555                        EXPERIMENTAL_SWEEP_EDIT_PREDICTION_PROVIDER_NAME,
 556                    ) => {
 557                        let has_api_token = zeta2::Zeta::try_global(cx)
 558                            .map_or(false, |zeta| zeta.read(cx).has_sweep_api_token());
 559
 560                        let entry = ContextMenuEntry::new("Sweep")
 561                            .when(!has_api_token, |this| {
 562                                this.disabled(true).documentation_aside(
 563                                    DocumentationSide::Left,
 564                                    DocumentationEdge::Bottom,
 565                                    |_| Label::new(USE_SWEEP_API_TOKEN_CALLOUT).into_any_element(),
 566                                )
 567                            })
 568                            .handler(move |_, cx| {
 569                                set_completion_provider(fs.clone(), cx, provider);
 570                            });
 571
 572                        menu.item(entry)
 573                    }
 574                    EditPredictionProvider::None | EditPredictionProvider::Experimental(_) => {
 575                        continue;
 576                    }
 577                };
 578            }
 579        }
 580
 581        menu
 582    }
 583
 584    pub fn build_copilot_start_menu(
 585        &mut self,
 586        window: &mut Window,
 587        cx: &mut Context<Self>,
 588    ) -> Entity<ContextMenu> {
 589        let fs = self.fs.clone();
 590        ContextMenu::build(window, cx, |menu, _, _| {
 591            menu.entry("Sign In to Copilot", None, copilot::initiate_sign_in)
 592                .entry("Disable Copilot", None, {
 593                    let fs = fs.clone();
 594                    move |_window, cx| hide_copilot(fs.clone(), cx)
 595                })
 596                .separator()
 597                .entry("Use Zed AI", None, {
 598                    let fs = fs.clone();
 599                    move |_window, cx| {
 600                        set_completion_provider(fs.clone(), cx, EditPredictionProvider::Zed)
 601                    }
 602                })
 603        })
 604    }
 605
 606    pub fn build_language_settings_menu(
 607        &self,
 608        mut menu: ContextMenu,
 609        window: &Window,
 610        cx: &mut App,
 611    ) -> ContextMenu {
 612        let fs = self.fs.clone();
 613        let line_height = window.line_height();
 614
 615        menu = menu.header("Show Edit Predictions For");
 616
 617        let language_state = self.language.as_ref().map(|language| {
 618            (
 619                language.clone(),
 620                language_settings::language_settings(Some(language.name()), None, cx)
 621                    .show_edit_predictions,
 622            )
 623        });
 624
 625        if let Some(editor_focus_handle) = self.editor_focus_handle.clone() {
 626            let entry = ContextMenuEntry::new("This Buffer")
 627                .toggleable(IconPosition::Start, self.editor_show_predictions)
 628                .action(Box::new(editor::actions::ToggleEditPrediction))
 629                .handler(move |window, cx| {
 630                    editor_focus_handle.dispatch_action(
 631                        &editor::actions::ToggleEditPrediction,
 632                        window,
 633                        cx,
 634                    );
 635                });
 636
 637            match language_state.clone() {
 638                Some((language, false)) => {
 639                    menu = menu.item(
 640                        entry
 641                            .disabled(true)
 642                            .documentation_aside(DocumentationSide::Left, DocumentationEdge::Top, move |_cx| {
 643                                Label::new(format!("Edit predictions cannot be toggled for this buffer because they are disabled for {}", language.name()))
 644                                    .into_any_element()
 645                            })
 646                    );
 647                }
 648                Some(_) | None => menu = menu.item(entry),
 649            }
 650        }
 651
 652        if let Some((language, language_enabled)) = language_state {
 653            let fs = fs.clone();
 654
 655            menu = menu.toggleable_entry(
 656                language.name(),
 657                language_enabled,
 658                IconPosition::Start,
 659                None,
 660                move |_, cx| {
 661                    toggle_show_edit_predictions_for_language(language.clone(), fs.clone(), cx)
 662                },
 663            );
 664        }
 665
 666        let settings = AllLanguageSettings::get_global(cx);
 667
 668        let globally_enabled = settings.show_edit_predictions(None, cx);
 669        let entry = ContextMenuEntry::new("All Files")
 670            .toggleable(IconPosition::Start, globally_enabled)
 671            .action(workspace::ToggleEditPrediction.boxed_clone())
 672            .handler(|window, cx| {
 673                window.dispatch_action(workspace::ToggleEditPrediction.boxed_clone(), cx)
 674            });
 675        menu = menu.item(entry);
 676
 677        let provider = settings.edit_predictions.provider;
 678        let current_mode = settings.edit_predictions_mode();
 679        let subtle_mode = matches!(current_mode, EditPredictionsMode::Subtle);
 680        let eager_mode = matches!(current_mode, EditPredictionsMode::Eager);
 681
 682        if matches!(
 683            provider,
 684            EditPredictionProvider::Zed
 685                | EditPredictionProvider::Copilot
 686                | EditPredictionProvider::Supermaven
 687                | EditPredictionProvider::Codestral
 688        ) {
 689            menu = menu
 690                .separator()
 691                .header("Display Modes")
 692                .item(
 693                    ContextMenuEntry::new("Eager")
 694                        .toggleable(IconPosition::Start, eager_mode)
 695                        .documentation_aside(DocumentationSide::Left, DocumentationEdge::Top, move |_| {
 696                            Label::new("Display predictions inline when there are no language server completions available.").into_any_element()
 697                        })
 698                        .handler({
 699                            let fs = fs.clone();
 700                            move |_, cx| {
 701                                toggle_edit_prediction_mode(fs.clone(), EditPredictionsMode::Eager, cx)
 702                            }
 703                        }),
 704                )
 705                .item(
 706                    ContextMenuEntry::new("Subtle")
 707                        .toggleable(IconPosition::Start, subtle_mode)
 708                        .documentation_aside(DocumentationSide::Left, DocumentationEdge::Top, move |_| {
 709                            Label::new("Display predictions inline only when holding a modifier key (alt by default).").into_any_element()
 710                        })
 711                        .handler({
 712                            let fs = fs.clone();
 713                            move |_, cx| {
 714                                toggle_edit_prediction_mode(fs.clone(), EditPredictionsMode::Subtle, cx)
 715                            }
 716                        }),
 717                );
 718        }
 719
 720        menu = menu.separator().header("Privacy");
 721
 722        if let Some(provider) = &self.edit_prediction_provider {
 723            let data_collection = provider.data_collection_state(cx);
 724
 725            if data_collection.is_supported() {
 726                let provider = provider.clone();
 727                let enabled = data_collection.is_enabled();
 728                let is_open_source = data_collection.is_project_open_source();
 729                let is_collecting = data_collection.is_enabled();
 730                let (icon_name, icon_color) = if is_open_source && is_collecting {
 731                    (IconName::Check, Color::Success)
 732                } else {
 733                    (IconName::Check, Color::Accent)
 734                };
 735
 736                menu = menu.item(
 737                    ContextMenuEntry::new("Training Data Collection")
 738                        .toggleable(IconPosition::Start, data_collection.is_enabled())
 739                        .icon(icon_name)
 740                        .icon_color(icon_color)
 741                        .documentation_aside(DocumentationSide::Left, DocumentationEdge::Top, move |cx| {
 742                            let (msg, label_color, icon_name, icon_color) = match (is_open_source, is_collecting) {
 743                                (true, true) => (
 744                                    "Project identified as open source, and you're sharing data.",
 745                                    Color::Default,
 746                                    IconName::Check,
 747                                    Color::Success,
 748                                ),
 749                                (true, false) => (
 750                                    "Project identified as open source, but you're not sharing data.",
 751                                    Color::Muted,
 752                                    IconName::Close,
 753                                    Color::Muted,
 754                                ),
 755                                (false, true) => (
 756                                    "Project not identified as open source. No data captured.",
 757                                    Color::Muted,
 758                                    IconName::Close,
 759                                    Color::Muted,
 760                                ),
 761                                (false, false) => (
 762                                    "Project not identified as open source, and setting turned off.",
 763                                    Color::Muted,
 764                                    IconName::Close,
 765                                    Color::Muted,
 766                                ),
 767                            };
 768                            v_flex()
 769                                .gap_2()
 770                                .child(
 771                                    Label::new(indoc!{
 772                                        "Help us improve our open dataset model by sharing data from open source repositories. \
 773                                        Zed must detect a license file in your repo for this setting to take effect. \
 774                                        Files with sensitive data and secrets are excluded by default."
 775                                    })
 776                                )
 777                                .child(
 778                                    h_flex()
 779                                        .items_start()
 780                                        .pt_2()
 781                                        .pr_1()
 782                                        .flex_1()
 783                                        .gap_1p5()
 784                                        .border_t_1()
 785                                        .border_color(cx.theme().colors().border_variant)
 786                                        .child(h_flex().flex_shrink_0().h(line_height).child(Icon::new(icon_name).size(IconSize::XSmall).color(icon_color)))
 787                                        .child(div().child(msg).w_full().text_sm().text_color(label_color.color(cx)))
 788                                )
 789                                .into_any_element()
 790                        })
 791                        .handler(move |_, cx| {
 792                            provider.toggle_data_collection(cx);
 793
 794                            if !enabled {
 795                                telemetry::event!(
 796                                    "Data Collection Enabled",
 797                                    source = "Edit Prediction Status Menu"
 798                                );
 799                            } else {
 800                                telemetry::event!(
 801                                    "Data Collection Disabled",
 802                                    source = "Edit Prediction Status Menu"
 803                                );
 804                            }
 805                        })
 806                );
 807
 808                if is_collecting && !is_open_source {
 809                    menu = menu.item(
 810                        ContextMenuEntry::new("No data captured.")
 811                            .disabled(true)
 812                            .icon(IconName::Close)
 813                            .icon_color(Color::Error)
 814                            .icon_size(IconSize::Small),
 815                    );
 816                }
 817            }
 818        }
 819
 820        menu = menu.item(
 821            ContextMenuEntry::new("Configure Excluded Files")
 822                .icon(IconName::LockOutlined)
 823                .icon_color(Color::Muted)
 824                .documentation_aside(DocumentationSide::Left, DocumentationEdge::Top, |_| {
 825                    Label::new(indoc!{"
 826                        Open your settings to add sensitive paths for which Zed will never predict edits."}).into_any_element()
 827                })
 828                .handler(move |window, cx| {
 829                    if let Some(workspace) = window.root().flatten() {
 830                        let workspace = workspace.downgrade();
 831                        window
 832                            .spawn(cx, async |cx| {
 833                                open_disabled_globs_setting_in_editor(
 834                                    workspace,
 835                                    cx,
 836                                ).await
 837                            })
 838                            .detach_and_log_err(cx);
 839                    }
 840                }),
 841        ).item(
 842            ContextMenuEntry::new("View Docs")
 843                .icon(IconName::FileGeneric)
 844                .icon_color(Color::Muted)
 845                .handler(move |_, cx| {
 846                    cx.open_url(PRIVACY_DOCS);
 847                })
 848        );
 849
 850        if !self.editor_enabled.unwrap_or(true) {
 851            menu = menu.item(
 852                ContextMenuEntry::new("This file is excluded.")
 853                    .disabled(true)
 854                    .icon(IconName::ZedPredictDisabled)
 855                    .icon_size(IconSize::Small),
 856            );
 857        }
 858
 859        if let Some(editor_focus_handle) = self.editor_focus_handle.clone() {
 860            menu = menu
 861                .separator()
 862                .header("Actions")
 863                .entry(
 864                    "Predict Edit at Cursor",
 865                    Some(Box::new(ShowEditPrediction)),
 866                    {
 867                        let editor_focus_handle = editor_focus_handle.clone();
 868                        move |window, cx| {
 869                            editor_focus_handle.dispatch_action(&ShowEditPrediction, window, cx);
 870                        }
 871                    },
 872                )
 873                .context(editor_focus_handle)
 874                .when(
 875                    cx.has_flag::<PredictEditsRateCompletionsFeatureFlag>(),
 876                    |this| this.action("Rate Completions", RateCompletions.boxed_clone()),
 877                );
 878        }
 879
 880        menu
 881    }
 882
 883    fn build_copilot_context_menu(
 884        &self,
 885        window: &mut Window,
 886        cx: &mut Context<Self>,
 887    ) -> Entity<ContextMenu> {
 888        let all_language_settings = all_language_settings(None, cx);
 889        let copilot_config = copilot::copilot_chat::CopilotChatConfiguration {
 890            enterprise_uri: all_language_settings
 891                .edit_predictions
 892                .copilot
 893                .enterprise_uri
 894                .clone(),
 895        };
 896        let settings_url = copilot_settings_url(copilot_config.enterprise_uri.as_deref());
 897
 898        ContextMenu::build(window, cx, |menu, window, cx| {
 899            let menu = self.build_language_settings_menu(menu, window, cx);
 900            let menu =
 901                self.add_provider_switching_section(menu, EditPredictionProvider::Copilot, cx);
 902
 903            menu.separator()
 904                .link(
 905                    "Go to Copilot Settings",
 906                    OpenBrowser { url: settings_url }.boxed_clone(),
 907                )
 908                .action("Sign Out", copilot::SignOut.boxed_clone())
 909        })
 910    }
 911
 912    fn build_supermaven_context_menu(
 913        &self,
 914        window: &mut Window,
 915        cx: &mut Context<Self>,
 916    ) -> Entity<ContextMenu> {
 917        ContextMenu::build(window, cx, |menu, window, cx| {
 918            let menu = self.build_language_settings_menu(menu, window, cx);
 919            let menu =
 920                self.add_provider_switching_section(menu, EditPredictionProvider::Supermaven, cx);
 921
 922            menu.separator()
 923                .action("Sign Out", supermaven::SignOut.boxed_clone())
 924        })
 925    }
 926
 927    fn build_codestral_context_menu(
 928        &self,
 929        window: &mut Window,
 930        cx: &mut Context<Self>,
 931    ) -> Entity<ContextMenu> {
 932        ContextMenu::build(window, cx, |menu, window, cx| {
 933            let menu = self.build_language_settings_menu(menu, window, cx);
 934            let menu =
 935                self.add_provider_switching_section(menu, EditPredictionProvider::Codestral, cx);
 936
 937            menu.separator()
 938                .entry("Configure Codestral API Key", None, move |window, cx| {
 939                    window.dispatch_action(zed_actions::agent::OpenSettings.boxed_clone(), cx);
 940                })
 941        })
 942    }
 943
 944    fn build_zeta_context_menu(
 945        &self,
 946        provider: EditPredictionProvider,
 947        window: &mut Window,
 948        cx: &mut Context<Self>,
 949    ) -> Entity<ContextMenu> {
 950        ContextMenu::build(window, cx, |mut menu, window, cx| {
 951            if let Some(usage) = self
 952                .edit_prediction_provider
 953                .as_ref()
 954                .and_then(|provider| provider.usage(cx))
 955            {
 956                menu = menu.header("Usage");
 957                menu = menu
 958                    .custom_entry(
 959                        move |_window, cx| {
 960                            let used_percentage = match usage.limit {
 961                                UsageLimit::Limited(limit) => {
 962                                    Some((usage.amount as f32 / limit as f32) * 100.)
 963                                }
 964                                UsageLimit::Unlimited => None,
 965                            };
 966
 967                            h_flex()
 968                                .flex_1()
 969                                .gap_1p5()
 970                                .children(
 971                                    used_percentage.map(|percent| {
 972                                        ProgressBar::new("usage", percent, 100., cx)
 973                                    }),
 974                                )
 975                                .child(
 976                                    Label::new(match usage.limit {
 977                                        UsageLimit::Limited(limit) => {
 978                                            format!("{} / {limit}", usage.amount)
 979                                        }
 980                                        UsageLimit::Unlimited => format!("{} / ∞", usage.amount),
 981                                    })
 982                                    .size(LabelSize::Small)
 983                                    .color(Color::Muted),
 984                                )
 985                                .into_any_element()
 986                        },
 987                        move |_, cx| cx.open_url(&zed_urls::account_url(cx)),
 988                    )
 989                    .when(usage.over_limit(), |menu| -> ContextMenu {
 990                        menu.entry("Subscribe to increase your limit", None, |_window, cx| {
 991                            cx.open_url(&zed_urls::account_url(cx))
 992                        })
 993                    })
 994                    .separator();
 995            } else if self.user_store.read(cx).account_too_young() {
 996                menu = menu
 997                    .custom_entry(
 998                        |_window, _cx| {
 999                            Label::new("Your GitHub account is less than 30 days old.")
1000                                .size(LabelSize::Small)
1001                                .color(Color::Warning)
1002                                .into_any_element()
1003                        },
1004                        |_window, cx| cx.open_url(&zed_urls::account_url(cx)),
1005                    )
1006                    .entry("Upgrade to Zed Pro or contact us.", None, |_window, cx| {
1007                        cx.open_url(&zed_urls::account_url(cx))
1008                    })
1009                    .separator();
1010            } else if self.user_store.read(cx).has_overdue_invoices() {
1011                menu = menu
1012                    .custom_entry(
1013                        |_window, _cx| {
1014                            Label::new("You have an outstanding invoice")
1015                                .size(LabelSize::Small)
1016                                .color(Color::Warning)
1017                                .into_any_element()
1018                        },
1019                        |_window, cx| {
1020                            cx.open_url(&zed_urls::account_url(cx))
1021                        },
1022                    )
1023                    .entry(
1024                        "Check your payment status or contact us at billing-support@zed.dev to continue using this feature.",
1025                        None,
1026                        |_window, cx| {
1027                            cx.open_url(&zed_urls::account_url(cx))
1028                        },
1029                    )
1030                    .separator();
1031            }
1032
1033            let menu = self.build_language_settings_menu(menu, window, cx);
1034            let menu = self.add_provider_switching_section(menu, provider, cx);
1035
1036            menu
1037        })
1038    }
1039
1040    pub fn update_enabled(&mut self, editor: Entity<Editor>, cx: &mut Context<Self>) {
1041        let editor = editor.read(cx);
1042        let snapshot = editor.buffer().read(cx).snapshot(cx);
1043        let suggestion_anchor = editor.selections.newest_anchor().start;
1044        let language = snapshot.language_at(suggestion_anchor);
1045        let file = snapshot.file_at(suggestion_anchor).cloned();
1046        self.editor_enabled = {
1047            let file = file.as_ref();
1048            Some(
1049                file.map(|file| {
1050                    all_language_settings(Some(file), cx)
1051                        .edit_predictions_enabled_for_file(file, cx)
1052                })
1053                .unwrap_or(true),
1054            )
1055        };
1056        self.editor_show_predictions = editor.edit_predictions_enabled();
1057        self.edit_prediction_provider = editor.edit_prediction_provider();
1058        self.language = language.cloned();
1059        self.file = file;
1060        self.editor_focus_handle = Some(editor.focus_handle(cx));
1061
1062        cx.notify();
1063    }
1064}
1065
1066impl StatusItemView for EditPredictionButton {
1067    fn set_active_pane_item(
1068        &mut self,
1069        item: Option<&dyn ItemHandle>,
1070        _: &mut Window,
1071        cx: &mut Context<Self>,
1072    ) {
1073        if let Some(editor) = item.and_then(|item| item.act_as::<Editor>(cx)) {
1074            self.editor_subscription = Some((
1075                cx.observe(&editor, Self::update_enabled),
1076                editor.entity_id().as_u64() as usize,
1077            ));
1078            self.update_enabled(editor, cx);
1079        } else {
1080            self.language = None;
1081            self.editor_subscription = None;
1082            self.editor_enabled = None;
1083        }
1084        cx.notify();
1085    }
1086}
1087
1088impl SupermavenButtonStatus {
1089    fn to_icon(&self) -> IconName {
1090        match self {
1091            SupermavenButtonStatus::Ready => IconName::Supermaven,
1092            SupermavenButtonStatus::Errored(_) => IconName::SupermavenError,
1093            SupermavenButtonStatus::NeedsActivation(_) => IconName::SupermavenInit,
1094            SupermavenButtonStatus::Initializing => IconName::SupermavenInit,
1095        }
1096    }
1097
1098    fn to_tooltip(&self) -> String {
1099        match self {
1100            SupermavenButtonStatus::Ready => "Supermaven is ready".to_string(),
1101            SupermavenButtonStatus::Errored(error) => format!("Supermaven error: {}", error),
1102            SupermavenButtonStatus::NeedsActivation(_) => "Supermaven needs activation".to_string(),
1103            SupermavenButtonStatus::Initializing => "Supermaven initializing".to_string(),
1104        }
1105    }
1106
1107    fn has_menu(&self) -> bool {
1108        match self {
1109            SupermavenButtonStatus::Ready | SupermavenButtonStatus::NeedsActivation(_) => true,
1110            SupermavenButtonStatus::Errored(_) | SupermavenButtonStatus::Initializing => false,
1111        }
1112    }
1113}
1114
1115async fn open_disabled_globs_setting_in_editor(
1116    workspace: WeakEntity<Workspace>,
1117    cx: &mut AsyncWindowContext,
1118) -> Result<()> {
1119    let settings_editor = workspace
1120        .update_in(cx, |_, window, cx| {
1121            create_and_open_local_file(paths::settings_file(), window, cx, || {
1122                settings::initial_user_settings_content().as_ref().into()
1123            })
1124        })?
1125        .await?
1126        .downcast::<Editor>()
1127        .unwrap();
1128
1129    settings_editor
1130        .downgrade()
1131        .update_in(cx, |item, window, cx| {
1132            let text = item.buffer().read(cx).snapshot(cx).text();
1133
1134            let settings = cx.global::<SettingsStore>();
1135
1136            // Ensure that we always have "edit_predictions { "disabled_globs": [] }"
1137            let edits = settings.edits_for_update(&text, |file| {
1138                file.project
1139                    .all_languages
1140                    .edit_predictions
1141                    .get_or_insert_with(Default::default)
1142                    .disabled_globs
1143                    .get_or_insert_with(Vec::new);
1144            });
1145
1146            if !edits.is_empty() {
1147                item.edit(
1148                    edits
1149                        .into_iter()
1150                        .map(|(r, s)| (MultiBufferOffset(r.start)..MultiBufferOffset(r.end), s)),
1151                    cx,
1152                );
1153            }
1154
1155            let text = item.buffer().read(cx).snapshot(cx).text();
1156
1157            static DISABLED_GLOBS_REGEX: LazyLock<Regex> = LazyLock::new(|| {
1158                Regex::new(r#""disabled_globs":\s*\[\s*(?P<content>(?:.|\n)*?)\s*\]"#).unwrap()
1159            });
1160            // Only capture [...]
1161            let range = DISABLED_GLOBS_REGEX.captures(&text).and_then(|captures| {
1162                captures
1163                    .name("content")
1164                    .map(|inner_match| inner_match.start()..inner_match.end())
1165            });
1166            if let Some(range) = range {
1167                let range = MultiBufferOffset(range.start)..MultiBufferOffset(range.end);
1168                item.change_selections(
1169                    SelectionEffects::scroll(Autoscroll::newest()),
1170                    window,
1171                    cx,
1172                    |selections| {
1173                        selections.select_ranges(vec![range]);
1174                    },
1175                );
1176            }
1177        })?;
1178
1179    anyhow::Ok(())
1180}
1181
1182fn set_completion_provider(fs: Arc<dyn Fs>, cx: &mut App, provider: EditPredictionProvider) {
1183    update_settings_file(fs, cx, move |settings, _| {
1184        settings
1185            .project
1186            .all_languages
1187            .features
1188            .get_or_insert_default()
1189            .edit_prediction_provider = Some(provider);
1190    });
1191}
1192
1193fn toggle_show_edit_predictions_for_language(
1194    language: Arc<Language>,
1195    fs: Arc<dyn Fs>,
1196    cx: &mut App,
1197) {
1198    let show_edit_predictions =
1199        all_language_settings(None, cx).show_edit_predictions(Some(&language), cx);
1200    update_settings_file(fs, cx, move |settings, _| {
1201        settings
1202            .project
1203            .all_languages
1204            .languages
1205            .0
1206            .entry(language.name().0)
1207            .or_default()
1208            .show_edit_predictions = Some(!show_edit_predictions);
1209    });
1210}
1211
1212fn hide_copilot(fs: Arc<dyn Fs>, cx: &mut App) {
1213    update_settings_file(fs, cx, move |settings, _| {
1214        settings
1215            .project
1216            .all_languages
1217            .features
1218            .get_or_insert(Default::default())
1219            .edit_prediction_provider = Some(EditPredictionProvider::None);
1220    });
1221}
1222
1223fn toggle_edit_prediction_mode(fs: Arc<dyn Fs>, mode: EditPredictionsMode, cx: &mut App) {
1224    let settings = AllLanguageSettings::get_global(cx);
1225    let current_mode = settings.edit_predictions_mode();
1226
1227    if current_mode != mode {
1228        update_settings_file(fs, cx, move |settings, _cx| {
1229            if let Some(edit_predictions) = settings.project.all_languages.edit_predictions.as_mut()
1230            {
1231                edit_predictions.mode = Some(mode);
1232            } else {
1233                settings.project.all_languages.edit_predictions =
1234                    Some(settings::EditPredictionSettingsContent {
1235                        mode: Some(mode),
1236                        ..Default::default()
1237                    });
1238            }
1239        });
1240    }
1241}
1242
1243fn copilot_settings_url(enterprise_uri: Option<&str>) -> String {
1244    match enterprise_uri {
1245        Some(uri) => {
1246            format!("{}{}", uri.trim_end_matches('/'), COPILOT_SETTINGS_PATH)
1247        }
1248        None => COPILOT_SETTINGS_URL.to_string(),
1249    }
1250}
1251
1252#[cfg(test)]
1253mod tests {
1254    use super::*;
1255    use gpui::TestAppContext;
1256
1257    #[gpui::test]
1258    async fn test_copilot_settings_url_with_enterprise_uri(cx: &mut TestAppContext) {
1259        cx.update(|cx| {
1260            let settings_store = SettingsStore::test(cx);
1261            cx.set_global(settings_store);
1262        });
1263
1264        cx.update_global(|settings_store: &mut SettingsStore, cx| {
1265            settings_store
1266                .set_user_settings(
1267                    r#"{"edit_predictions":{"copilot":{"enterprise_uri":"https://my-company.ghe.com"}}}"#,
1268                    cx,
1269                )
1270                .unwrap();
1271        });
1272
1273        let url = cx.update(|cx| {
1274            let all_language_settings = all_language_settings(None, cx);
1275            copilot_settings_url(
1276                all_language_settings
1277                    .edit_predictions
1278                    .copilot
1279                    .enterprise_uri
1280                    .as_deref(),
1281            )
1282        });
1283
1284        assert_eq!(url, "https://my-company.ghe.com/settings/copilot");
1285    }
1286
1287    #[gpui::test]
1288    async fn test_copilot_settings_url_with_enterprise_uri_trailing_slash(cx: &mut TestAppContext) {
1289        cx.update(|cx| {
1290            let settings_store = SettingsStore::test(cx);
1291            cx.set_global(settings_store);
1292        });
1293
1294        cx.update_global(|settings_store: &mut SettingsStore, cx| {
1295            settings_store
1296                .set_user_settings(
1297                    r#"{"edit_predictions":{"copilot":{"enterprise_uri":"https://my-company.ghe.com/"}}}"#,
1298                    cx,
1299                )
1300                .unwrap();
1301        });
1302
1303        let url = cx.update(|cx| {
1304            let all_language_settings = all_language_settings(None, cx);
1305            copilot_settings_url(
1306                all_language_settings
1307                    .edit_predictions
1308                    .copilot
1309                    .enterprise_uri
1310                    .as_deref(),
1311            )
1312        });
1313
1314        assert_eq!(url, "https://my-company.ghe.com/settings/copilot");
1315    }
1316
1317    #[gpui::test]
1318    async fn test_copilot_settings_url_without_enterprise_uri(cx: &mut TestAppContext) {
1319        cx.update(|cx| {
1320            let settings_store = SettingsStore::test(cx);
1321            cx.set_global(settings_store);
1322        });
1323
1324        let url = cx.update(|cx| {
1325            let all_language_settings = all_language_settings(None, cx);
1326            copilot_settings_url(
1327                all_language_settings
1328                    .edit_predictions
1329                    .copilot
1330                    .enterprise_uri
1331                    .as_deref(),
1332            )
1333        });
1334
1335        assert_eq!(url, "https://github.com/settings/copilot");
1336    }
1337}