edit_prediction_button.rs

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