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, Animation, AnimationExt, App, AsyncWindowContext, Corner, Entity, FocusHandle,
  15    Focusable, IntoElement, ParentElement, Render, Subscription, WeakEntity, actions, div,
  16    ease_in_out, pulsating_between,
  17};
  18use indoc::indoc;
  19use language::{
  20    EditPredictionsMode, File, Language,
  21    language_settings::{
  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(Corner::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(Corner::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(Corner::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(Corner::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                        "Sign In To Use"
 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 To Use"
 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(Corner::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 available_providers = get_available_providers(cx);
 577
 578        let providers: Vec<_> = available_providers
 579            .into_iter()
 580            .filter(|p| *p != EditPredictionProvider::None)
 581            .collect();
 582
 583        if !providers.is_empty() {
 584            menu = menu.separator().header("Providers");
 585
 586            for provider in providers {
 587                let Some(name) = provider.display_name() else {
 588                    continue;
 589                };
 590                let is_current = provider == current_provider;
 591                let fs = self.fs.clone();
 592
 593                menu = menu.item(
 594                    ContextMenuEntry::new(name)
 595                        .toggleable(IconPosition::Start, is_current)
 596                        .handler(move |_, cx| {
 597                            set_completion_provider(fs.clone(), cx, provider);
 598                        }),
 599                )
 600            }
 601        }
 602
 603        menu
 604    }
 605
 606    pub fn build_copilot_start_menu(
 607        &mut self,
 608        window: &mut Window,
 609        cx: &mut Context<Self>,
 610    ) -> Entity<ContextMenu> {
 611        let fs = self.fs.clone();
 612        let project = self.project.clone();
 613        ContextMenu::build(window, cx, |menu, _, _| {
 614            menu.entry("Sign In to Copilot", None, move |window, cx| {
 615                telemetry::event!(
 616                    "Edit Prediction Menu Action",
 617                    action = "sign_in",
 618                    provider = "copilot",
 619                );
 620                if let Some(copilot) = EditPredictionStore::try_global(cx).and_then(|store| {
 621                    store.update(cx, |this, cx| {
 622                        this.start_copilot_for_project(&project.upgrade()?, cx)
 623                    })
 624                }) {
 625                    copilot_ui::initiate_sign_in(copilot, window, cx);
 626                }
 627            })
 628            .entry("Disable Copilot", None, {
 629                let fs = fs.clone();
 630                move |_window, cx| {
 631                    telemetry::event!(
 632                        "Edit Prediction Menu Action",
 633                        action = "disable_provider",
 634                        provider = "copilot",
 635                    );
 636                    hide_copilot(fs.clone(), cx)
 637                }
 638            })
 639            .separator()
 640            .entry("Use Zed AI", None, {
 641                let fs = fs.clone();
 642                move |_window, cx| {
 643                    set_completion_provider(fs.clone(), cx, EditPredictionProvider::Zed)
 644                }
 645            })
 646        })
 647    }
 648
 649    pub fn build_language_settings_menu(
 650        &self,
 651        mut menu: ContextMenu,
 652        window: &Window,
 653        cx: &mut App,
 654    ) -> ContextMenu {
 655        let fs = self.fs.clone();
 656        let line_height = window.line_height();
 657
 658        menu = menu.header("Show Edit Predictions For");
 659
 660        let language_state = self.language.as_ref().map(|language| {
 661            (
 662                language.clone(),
 663                LanguageSettings::resolve(None, Some(&language.name()), cx).show_edit_predictions,
 664            )
 665        });
 666
 667        if let Some(editor_focus_handle) = self.editor_focus_handle.clone() {
 668            let entry = ContextMenuEntry::new("This Buffer")
 669                .toggleable(IconPosition::Start, self.editor_show_predictions)
 670                .action(Box::new(editor::actions::ToggleEditPrediction))
 671                .handler(move |window, cx| {
 672                    editor_focus_handle.dispatch_action(
 673                        &editor::actions::ToggleEditPrediction,
 674                        window,
 675                        cx,
 676                    );
 677                });
 678
 679            match language_state.clone() {
 680                Some((language, false)) => {
 681                    menu = menu.item(
 682                        entry
 683                            .disabled(true)
 684                            .documentation_aside(DocumentationSide::Left, move |_cx| {
 685                                Label::new(format!("Edit predictions cannot be toggled for this buffer because they are disabled for {}", language.name()))
 686                                    .into_any_element()
 687                            })
 688                    );
 689                }
 690                Some(_) | None => menu = menu.item(entry),
 691            }
 692        }
 693
 694        if let Some((language, language_enabled)) = language_state {
 695            let fs = fs.clone();
 696            let language_name = language.name();
 697
 698            menu = menu.toggleable_entry(
 699                language_name.clone(),
 700                language_enabled,
 701                IconPosition::Start,
 702                None,
 703                move |_, cx| {
 704                    telemetry::event!(
 705                        "Edit Prediction Setting Changed",
 706                        setting = "language",
 707                        language = language_name.to_string(),
 708                        enabled = !language_enabled,
 709                    );
 710                    toggle_show_edit_predictions_for_language(language.clone(), fs.clone(), cx)
 711                },
 712            );
 713        }
 714
 715        let settings = AllLanguageSettings::get_global(cx);
 716
 717        let globally_enabled = settings.show_edit_predictions(None, cx);
 718        let entry = ContextMenuEntry::new("All Files")
 719            .toggleable(IconPosition::Start, globally_enabled)
 720            .action(workspace::ToggleEditPrediction.boxed_clone())
 721            .handler(|window, cx| {
 722                window.dispatch_action(workspace::ToggleEditPrediction.boxed_clone(), cx)
 723            });
 724        menu = menu.item(entry);
 725
 726        let provider = settings.edit_predictions.provider;
 727        let current_mode = settings.edit_predictions_mode();
 728        let subtle_mode = matches!(current_mode, EditPredictionsMode::Subtle);
 729        let eager_mode = matches!(current_mode, EditPredictionsMode::Eager);
 730
 731        menu = menu
 732                .separator()
 733                .header("Display Modes")
 734                .item(
 735                    ContextMenuEntry::new("Eager")
 736                        .toggleable(IconPosition::Start, eager_mode)
 737                        .documentation_aside(DocumentationSide::Left, move |_| {
 738                            Label::new("Display predictions inline when there are no language server completions available.").into_any_element()
 739                        })
 740                        .handler({
 741                            let fs = fs.clone();
 742                            move |_, cx| {
 743                                telemetry::event!(
 744                                    "Edit Prediction Setting Changed",
 745                                    setting = "mode",
 746                                    value = "eager",
 747                                );
 748                                toggle_edit_prediction_mode(fs.clone(), EditPredictionsMode::Eager, cx)
 749                            }
 750                        }),
 751                )
 752                .item(
 753                    ContextMenuEntry::new("Subtle")
 754                        .toggleable(IconPosition::Start, subtle_mode)
 755                        .documentation_aside(DocumentationSide::Left, move |_| {
 756                            Label::new("Display predictions inline only when holding a modifier key (alt by default).").into_any_element()
 757                        })
 758                        .handler({
 759                            let fs = fs.clone();
 760                            move |_, cx| {
 761                                telemetry::event!(
 762                                    "Edit Prediction Setting Changed",
 763                                    setting = "mode",
 764                                    value = "subtle",
 765                                );
 766                                toggle_edit_prediction_mode(fs.clone(), EditPredictionsMode::Subtle, cx)
 767                            }
 768                        }),
 769                );
 770
 771        menu = menu.separator().header("Privacy");
 772
 773        if matches!(provider, EditPredictionProvider::Zed) {
 774            if let Some(provider) = &self.edit_prediction_provider {
 775                let data_collection = provider.data_collection_state(cx);
 776
 777                if data_collection.is_supported() {
 778                    let provider = provider.clone();
 779                    let enabled = data_collection.is_enabled();
 780                    let is_open_source = data_collection.is_project_open_source();
 781                    let is_collecting = data_collection.is_enabled();
 782                    let (icon_name, icon_color) = if is_open_source && is_collecting {
 783                        (IconName::Check, Color::Success)
 784                    } else {
 785                        (IconName::Check, Color::Accent)
 786                    };
 787
 788                    menu = menu.item(
 789                        ContextMenuEntry::new("Training Data Collection")
 790                            .toggleable(IconPosition::Start, data_collection.is_enabled())
 791                            .icon(icon_name)
 792                            .icon_color(icon_color)
 793                            .disabled(cx.is_staff())
 794                            .documentation_aside(DocumentationSide::Left, move |cx| {
 795                                let (msg, label_color, icon_name, icon_color) = match (is_open_source, is_collecting) {
 796                                    (true, true) => (
 797                                        "Project identified as open source, and you're sharing data.",
 798                                        Color::Default,
 799                                        IconName::Check,
 800                                        Color::Success,
 801                                    ),
 802                                    (true, false) => (
 803                                        "Project identified as open source, but you're not sharing data.",
 804                                        Color::Muted,
 805                                        IconName::Close,
 806                                        Color::Muted,
 807                                    ),
 808                                    (false, true) => (
 809                                        "Project not identified as open source. No data captured.",
 810                                        Color::Muted,
 811                                        IconName::Close,
 812                                        Color::Muted,
 813                                    ),
 814                                    (false, false) => (
 815                                        "Project not identified as open source, and setting turned off.",
 816                                        Color::Muted,
 817                                        IconName::Close,
 818                                        Color::Muted,
 819                                    ),
 820                                };
 821                                v_flex()
 822                                    .gap_2()
 823                                    .child(
 824                                        Label::new(indoc!{
 825                                            "Help us improve our open dataset model by sharing data from open source repositories. \
 826                                            Zed must detect a license file in your repo for this setting to take effect. \
 827                                            Files with sensitive data and secrets are excluded by default."
 828                                        })
 829                                    )
 830                                    .child(
 831                                        h_flex()
 832                                            .items_start()
 833                                            .pt_2()
 834                                            .pr_1()
 835                                            .flex_1()
 836                                            .gap_1p5()
 837                                            .border_t_1()
 838                                            .border_color(cx.theme().colors().border_variant)
 839                                            .child(h_flex().flex_shrink_0().h(line_height).child(Icon::new(icon_name).size(IconSize::XSmall).color(icon_color)))
 840                                            .child(div().child(msg).w_full().text_sm().text_color(label_color.color(cx)))
 841                                    )
 842                                    .into_any_element()
 843                            })
 844                            .handler(move |_, cx| {
 845                                provider.toggle_data_collection(cx);
 846
 847                                if !enabled {
 848                                    telemetry::event!(
 849                                        "Data Collection Enabled",
 850                                        source = "Edit Prediction Status Menu"
 851                                    );
 852                                } else {
 853                                    telemetry::event!(
 854                                        "Data Collection Disabled",
 855                                        source = "Edit Prediction Status Menu"
 856                                    );
 857                                }
 858                            })
 859                    );
 860
 861                    if is_collecting && !is_open_source {
 862                        menu = menu.item(
 863                            ContextMenuEntry::new("No data captured.")
 864                                .disabled(true)
 865                                .icon(IconName::Close)
 866                                .icon_color(Color::Error)
 867                                .icon_size(IconSize::Small),
 868                        );
 869                    }
 870                }
 871            }
 872        }
 873
 874        menu = menu.item(
 875            ContextMenuEntry::new("Configure Excluded Files")
 876                .icon(IconName::LockOutlined)
 877                .icon_color(Color::Muted)
 878                .documentation_aside(DocumentationSide::Left, |_| {
 879                    Label::new(indoc!{"
 880                        Open your settings to add sensitive paths for which Zed will never predict edits."}).into_any_element()
 881                })
 882                .handler(move |window, cx| {
 883                    telemetry::event!(
 884                        "Edit Prediction Menu Action",
 885                        action = "configure_excluded_files",
 886                    );
 887                    if let Some(workspace) = Workspace::for_window(window, cx) {
 888                        let workspace = workspace.downgrade();
 889                        window
 890                            .spawn(cx, async |cx| {
 891                                open_disabled_globs_setting_in_editor(
 892                                    workspace,
 893                                    cx,
 894                                ).await
 895                            })
 896                            .detach_and_log_err(cx);
 897                    }
 898                }),
 899        ).item(
 900            ContextMenuEntry::new("View Docs")
 901                .icon(IconName::FileGeneric)
 902                .icon_color(Color::Muted)
 903                .handler(move |_, cx| {
 904                    telemetry::event!(
 905                        "Edit Prediction Menu Action",
 906                        action = "view_docs",
 907                    );
 908                    cx.open_url(PRIVACY_DOCS);
 909                })
 910        );
 911
 912        if !self.editor_enabled.unwrap_or(true) {
 913            let icons = self
 914                .edit_prediction_provider
 915                .as_ref()
 916                .map(|p| p.icons(cx))
 917                .unwrap_or_else(|| {
 918                    edit_prediction_types::EditPredictionIconSet::new(IconName::ZedPredict)
 919                });
 920            menu = menu.item(
 921                ContextMenuEntry::new("This file is excluded.")
 922                    .disabled(true)
 923                    .icon(icons.disabled)
 924                    .icon_size(IconSize::Small),
 925            );
 926        }
 927
 928        if let Some(editor_focus_handle) = self.editor_focus_handle.clone() {
 929            menu = menu
 930                .separator()
 931                .header("Actions")
 932                .entry(
 933                    "Predict Edit at Cursor",
 934                    Some(Box::new(ShowEditPrediction)),
 935                    {
 936                        let editor_focus_handle = editor_focus_handle.clone();
 937                        move |window, cx| {
 938                            telemetry::event!(
 939                                "Edit Prediction Menu Action",
 940                                action = "predict_at_cursor",
 941                            );
 942                            editor_focus_handle.dispatch_action(&ShowEditPrediction, window, cx);
 943                        }
 944                    },
 945                )
 946                .context(editor_focus_handle)
 947                .when(
 948                    cx.has_flag::<PredictEditsRatePredictionsFeatureFlag>(),
 949                    |this| {
 950                        this.action("Capture Prediction Example", CaptureExample.boxed_clone())
 951                            .action("Rate Predictions", RatePredictions.boxed_clone())
 952                    },
 953                );
 954        }
 955
 956        menu
 957    }
 958
 959    fn build_copilot_context_menu(
 960        &self,
 961        window: &mut Window,
 962        cx: &mut Context<Self>,
 963    ) -> Entity<ContextMenu> {
 964        let all_language_settings = all_language_settings(None, cx);
 965        let next_edit_suggestions = all_language_settings
 966            .edit_predictions
 967            .copilot
 968            .enable_next_edit_suggestions
 969            .unwrap_or(true);
 970        let copilot_config = copilot_chat::CopilotChatConfiguration {
 971            enterprise_uri: all_language_settings
 972                .edit_predictions
 973                .copilot
 974                .enterprise_uri
 975                .clone(),
 976        };
 977        let settings_url = copilot_settings_url(copilot_config.enterprise_uri.as_deref());
 978
 979        ContextMenu::build(window, cx, |menu, window, cx| {
 980            let menu = self.build_language_settings_menu(menu, window, cx);
 981            let menu =
 982                self.add_provider_switching_section(menu, EditPredictionProvider::Copilot, cx);
 983
 984            menu.separator()
 985                .item(
 986                    ContextMenuEntry::new("Copilot: Next Edit Suggestions")
 987                        .toggleable(IconPosition::Start, next_edit_suggestions)
 988                        .handler({
 989                            let fs = self.fs.clone();
 990                            move |_, cx| {
 991                                update_settings_file(fs.clone(), cx, move |settings, _| {
 992                                    settings
 993                                        .project
 994                                        .all_languages
 995                                        .edit_predictions
 996                                        .get_or_insert_default()
 997                                        .copilot
 998                                        .get_or_insert_default()
 999                                        .enable_next_edit_suggestions =
1000                                        Some(!next_edit_suggestions);
1001                                });
1002                            }
1003                        }),
1004                )
1005                .separator()
1006                .link(
1007                    "Go to Copilot Settings",
1008                    OpenBrowser { url: settings_url }.boxed_clone(),
1009                )
1010                .action("Sign Out", copilot::SignOut.boxed_clone())
1011        })
1012    }
1013
1014    fn build_codestral_context_menu(
1015        &self,
1016        window: &mut Window,
1017        cx: &mut Context<Self>,
1018    ) -> Entity<ContextMenu> {
1019        ContextMenu::build(window, cx, |menu, window, cx| {
1020            let menu = self.build_language_settings_menu(menu, window, cx);
1021            let menu =
1022                self.add_provider_switching_section(menu, EditPredictionProvider::Codestral, cx);
1023
1024            menu
1025        })
1026    }
1027
1028    fn build_edit_prediction_context_menu(
1029        &self,
1030        provider: EditPredictionProvider,
1031        window: &mut Window,
1032        cx: &mut Context<Self>,
1033    ) -> Entity<ContextMenu> {
1034        ContextMenu::build(window, cx, |mut menu, window, cx| {
1035            let user = self.user_store.read(cx).current_user();
1036
1037            let needs_sign_in = user.is_none()
1038                && matches!(
1039                    provider,
1040                    EditPredictionProvider::None | EditPredictionProvider::Zed
1041                );
1042
1043            if needs_sign_in {
1044                menu = menu
1045                    .custom_row(move |_window, cx| {
1046                        let description = indoc! {
1047                            "You get 2,000 accepted suggestions at every keystroke for free, \
1048                            powered by Zeta, our open-source, open-data model"
1049                        };
1050
1051                        v_flex()
1052                            .max_w_64()
1053                            .h(rems_from_px(148.))
1054                            .child(render_zeta_tab_animation(cx))
1055                            .child(Label::new("Edit Prediction"))
1056                            .child(
1057                                Label::new(description)
1058                                    .color(Color::Muted)
1059                                    .size(LabelSize::Small),
1060                            )
1061                            .into_any_element()
1062                    })
1063                    .separator()
1064                    .entry("Sign In & Start Using", None, |window, cx| {
1065                        telemetry::event!(
1066                            "Edit Prediction Menu Action",
1067                            action = "sign_in",
1068                            provider = "zed",
1069                        );
1070                        let client = Client::global(cx);
1071                        window
1072                            .spawn(cx, async move |cx| {
1073                                client
1074                                    .sign_in_with_optional_connect(true, &cx)
1075                                    .await
1076                                    .log_err();
1077                            })
1078                            .detach();
1079                    })
1080                    .link_with_handler(
1081                        "Learn More",
1082                        OpenBrowser {
1083                            url: zed_urls::edit_prediction_docs(cx),
1084                        }
1085                        .boxed_clone(),
1086                        |_window, _cx| {
1087                            telemetry::event!(
1088                                "Edit Prediction Menu Action",
1089                                action = "view_docs",
1090                                source = "upsell",
1091                            );
1092                        },
1093                    )
1094                    .separator();
1095            } else {
1096                let mercury_payment_required = matches!(provider, EditPredictionProvider::Mercury)
1097                    && edit_prediction::EditPredictionStore::try_global(cx).is_some_and(
1098                        |ep_store| ep_store.read(cx).mercury_has_payment_required_error(),
1099                    );
1100
1101                if mercury_payment_required {
1102                    menu = menu
1103                        .header("Mercury")
1104                        .item(ContextMenuEntry::new("Free tier limit reached").disabled(true))
1105                        .item(
1106                            ContextMenuEntry::new(
1107                                "Upgrade to a paid plan to continue using the service",
1108                            )
1109                            .disabled(true),
1110                        )
1111                        .separator();
1112                }
1113
1114                if let Some(usage) = self
1115                    .edit_prediction_provider
1116                    .as_ref()
1117                    .and_then(|provider| provider.usage(cx))
1118                {
1119                    menu = menu.header("Usage");
1120                    menu = menu
1121                        .custom_entry(
1122                            move |_window, cx| {
1123                                let used_percentage = match usage.limit {
1124                                    UsageLimit::Limited(limit) => {
1125                                        Some((usage.amount as f32 / limit as f32) * 100.)
1126                                    }
1127                                    UsageLimit::Unlimited => None,
1128                                };
1129
1130                                h_flex()
1131                                    .flex_1()
1132                                    .gap_1p5()
1133                                    .children(used_percentage.map(|percent| {
1134                                        ProgressBar::new("usage", percent, 100., cx)
1135                                    }))
1136                                    .child(
1137                                        Label::new(match usage.limit {
1138                                            UsageLimit::Limited(limit) => {
1139                                                format!("{} / {limit}", usage.amount)
1140                                            }
1141                                            UsageLimit::Unlimited => {
1142                                                format!("{} / ∞", usage.amount)
1143                                            }
1144                                        })
1145                                        .size(LabelSize::Small)
1146                                        .color(Color::Muted),
1147                                    )
1148                                    .into_any_element()
1149                            },
1150                            move |_, cx| cx.open_url(&zed_urls::account_url(cx)),
1151                        )
1152                        .when(usage.over_limit(), |menu| -> ContextMenu {
1153                            menu.entry("Subscribe to increase your limit", None, |_window, cx| {
1154                                telemetry::event!(
1155                                    "Edit Prediction Menu Action",
1156                                    action = "upsell_clicked",
1157                                    reason = "usage_limit",
1158                                );
1159                                cx.open_url(&zed_urls::account_url(cx))
1160                            })
1161                        })
1162                        .separator();
1163                } else if self.user_store.read(cx).account_too_young() {
1164                    menu = menu
1165                        .custom_entry(
1166                            |_window, _cx| {
1167                                Label::new("Your GitHub account is less than 30 days old.")
1168                                    .size(LabelSize::Small)
1169                                    .color(Color::Warning)
1170                                    .into_any_element()
1171                            },
1172                            |_window, cx| cx.open_url(&zed_urls::account_url(cx)),
1173                        )
1174                        .entry("Upgrade to Zed Pro or contact us.", None, |_window, cx| {
1175                            telemetry::event!(
1176                                "Edit Prediction Menu Action",
1177                                action = "upsell_clicked",
1178                                reason = "account_age",
1179                            );
1180                            cx.open_url(&zed_urls::account_url(cx))
1181                        })
1182                        .separator();
1183                } else if self.user_store.read(cx).has_overdue_invoices() {
1184                    menu = menu
1185                        .custom_entry(
1186                            |_window, _cx| {
1187                                Label::new("You have an outstanding invoice")
1188                                    .size(LabelSize::Small)
1189                                    .color(Color::Warning)
1190                                    .into_any_element()
1191                            },
1192                            |_window, cx| {
1193                                cx.open_url(&zed_urls::account_url(cx))
1194                            },
1195                        )
1196                        .entry(
1197                            "Check your payment status or contact us at billing-support@zed.dev to continue using this feature.",
1198                            None,
1199                            |_window, cx| {
1200                                cx.open_url(&zed_urls::account_url(cx))
1201                            },
1202                        )
1203                        .separator();
1204                }
1205            }
1206
1207            if !needs_sign_in {
1208                menu = self.build_language_settings_menu(menu, window, cx);
1209            }
1210            menu = self.add_provider_switching_section(menu, provider, cx);
1211
1212            if cx.is_staff() {
1213                if let Some(store) = EditPredictionStore::try_global(cx) {
1214                    store.update(cx, |store, cx| {
1215                        store.refresh_available_experiments(cx);
1216                    });
1217                    let store = store.read(cx);
1218                    let experiments = store.available_experiments().to_vec();
1219                    let preferred = store.preferred_experiment().map(|s| s.to_owned());
1220                    let active = store.active_experiment().map(|s| s.to_owned());
1221
1222                    let preferred_for_submenu = preferred.clone();
1223                    menu = menu
1224                        .separator()
1225                        .submenu("Experiment", move |menu, _window, _cx| {
1226                            let mut menu = menu.toggleable_entry(
1227                                "Default",
1228                                preferred_for_submenu.is_none(),
1229                                IconPosition::Start,
1230                                None,
1231                                {
1232                                    move |_window, cx| {
1233                                        if let Some(store) = EditPredictionStore::try_global(cx) {
1234                                            store.update(cx, |store, _cx| {
1235                                                store.set_preferred_experiment(None);
1236                                            });
1237                                        }
1238                                    }
1239                                },
1240                            );
1241                            for experiment in &experiments {
1242                                let is_selected = active.as_deref() == Some(experiment.as_str())
1243                                    || preferred.as_deref() == Some(experiment.as_str());
1244                                let experiment_name = experiment.clone();
1245                                menu = menu.toggleable_entry(
1246                                    experiment.clone(),
1247                                    is_selected,
1248                                    IconPosition::Start,
1249                                    None,
1250                                    move |_window, cx| {
1251                                        if let Some(store) = EditPredictionStore::try_global(cx) {
1252                                            store.update(cx, |store, _cx| {
1253                                                store.set_preferred_experiment(Some(
1254                                                    experiment_name.clone(),
1255                                                ));
1256                                            });
1257                                        }
1258                                    },
1259                                );
1260                            }
1261                            menu
1262                        });
1263                }
1264            }
1265
1266            menu = menu.separator().item(
1267                ContextMenuEntry::new("Configure Providers")
1268                    .icon(IconName::Settings)
1269                    .icon_position(IconPosition::Start)
1270                    .icon_color(Color::Muted)
1271                    .handler(move |window, cx| {
1272                        telemetry::event!(
1273                            "Edit Prediction Menu Action",
1274                            action = "configure_providers",
1275                        );
1276                        window.dispatch_action(
1277                            OpenSettingsAt {
1278                                path: "edit_predictions.providers".to_string(),
1279                            }
1280                            .boxed_clone(),
1281                            cx,
1282                        );
1283                    }),
1284            );
1285
1286            menu
1287        })
1288    }
1289
1290    pub fn update_enabled(&mut self, editor: Entity<Editor>, cx: &mut Context<Self>) {
1291        let editor = editor.read(cx);
1292        let snapshot = editor.buffer().read(cx).snapshot(cx);
1293        let suggestion_anchor = editor.selections.newest_anchor().start;
1294        let language = snapshot.language_at(suggestion_anchor);
1295        let file = snapshot.file_at(suggestion_anchor).cloned();
1296        self.editor_enabled = {
1297            let file = file.as_ref();
1298            Some(
1299                file.map(|file| {
1300                    all_language_settings(Some(file), cx)
1301                        .edit_predictions_enabled_for_file(file, cx)
1302                })
1303                .unwrap_or(true),
1304            )
1305        };
1306        self.editor_show_predictions = editor.edit_predictions_enabled();
1307        self.edit_prediction_provider = editor.edit_prediction_provider();
1308        self.language = language.cloned();
1309        self.file = file;
1310        self.editor_focus_handle = Some(editor.focus_handle(cx));
1311
1312        cx.notify();
1313    }
1314}
1315
1316impl StatusItemView for EditPredictionButton {
1317    fn set_active_pane_item(
1318        &mut self,
1319        item: Option<&dyn ItemHandle>,
1320        _: &mut Window,
1321        cx: &mut Context<Self>,
1322    ) {
1323        if let Some(editor) = item.and_then(|item| item.act_as::<Editor>(cx)) {
1324            self.editor_subscription = Some((
1325                cx.observe(&editor, Self::update_enabled),
1326                editor.entity_id().as_u64() as usize,
1327            ));
1328            self.update_enabled(editor, cx);
1329        } else {
1330            self.language = None;
1331            self.editor_subscription = None;
1332            self.editor_enabled = None;
1333        }
1334        cx.notify();
1335    }
1336}
1337
1338async fn open_disabled_globs_setting_in_editor(
1339    workspace: WeakEntity<Workspace>,
1340    cx: &mut AsyncWindowContext,
1341) -> Result<()> {
1342    let settings_editor = workspace
1343        .update_in(cx, |_, window, cx| {
1344            create_and_open_local_file(paths::settings_file(), window, cx, || {
1345                settings::initial_user_settings_content().as_ref().into()
1346            })
1347        })?
1348        .await?
1349        .downcast::<Editor>()
1350        .unwrap();
1351
1352    settings_editor
1353        .downgrade()
1354        .update_in(cx, |item, window, cx| {
1355            let text = item.buffer().read(cx).snapshot(cx).text();
1356
1357            let settings = cx.global::<SettingsStore>();
1358
1359            // Ensure that we always have "edit_predictions { "disabled_globs": [] }"
1360            let Some(edits) = settings
1361                .edits_for_update(&text, |file| {
1362                    file.project
1363                        .all_languages
1364                        .edit_predictions
1365                        .get_or_insert_with(Default::default)
1366                        .disabled_globs
1367                        .get_or_insert_with(Vec::new);
1368                })
1369                .log_err()
1370            else {
1371                return;
1372            };
1373
1374            if !edits.is_empty() {
1375                item.edit(
1376                    edits
1377                        .into_iter()
1378                        .map(|(r, s)| (MultiBufferOffset(r.start)..MultiBufferOffset(r.end), s)),
1379                    cx,
1380                );
1381            }
1382
1383            let text = item.buffer().read(cx).snapshot(cx).text();
1384
1385            static DISABLED_GLOBS_REGEX: LazyLock<Regex> = LazyLock::new(|| {
1386                Regex::new(r#""disabled_globs":\s*\[\s*(?P<content>(?:.|\n)*?)\s*\]"#).unwrap()
1387            });
1388            // Only capture [...]
1389            let range = DISABLED_GLOBS_REGEX.captures(&text).and_then(|captures| {
1390                captures
1391                    .name("content")
1392                    .map(|inner_match| inner_match.start()..inner_match.end())
1393            });
1394            if let Some(range) = range {
1395                let range = MultiBufferOffset(range.start)..MultiBufferOffset(range.end);
1396                item.change_selections(
1397                    SelectionEffects::scroll(Autoscroll::newest()),
1398                    window,
1399                    cx,
1400                    |selections| {
1401                        selections.select_ranges(vec![range]);
1402                    },
1403                );
1404            }
1405        })?;
1406
1407    anyhow::Ok(())
1408}
1409
1410pub fn set_completion_provider(fs: Arc<dyn Fs>, cx: &mut App, provider: EditPredictionProvider) {
1411    update_settings_file(fs, cx, move |settings, _| {
1412        settings
1413            .project
1414            .all_languages
1415            .edit_predictions
1416            .get_or_insert_default()
1417            .provider = Some(provider);
1418    });
1419}
1420
1421pub fn get_available_providers(cx: &mut App) -> Vec<EditPredictionProvider> {
1422    let mut providers = Vec::new();
1423
1424    providers.push(EditPredictionProvider::Zed);
1425
1426    let app_state = workspace::AppState::global(cx);
1427    if copilot::GlobalCopilotAuth::try_get_or_init(app_state, cx)
1428        .is_some_and(|copilot| copilot.0.read(cx).is_authenticated())
1429    {
1430        providers.push(EditPredictionProvider::Copilot);
1431    };
1432
1433    if codestral::codestral_api_key(cx).is_some() {
1434        providers.push(EditPredictionProvider::Codestral);
1435    }
1436
1437    if edit_prediction::ollama::is_available(cx) {
1438        providers.push(EditPredictionProvider::Ollama);
1439    }
1440
1441    if all_language_settings(None, cx)
1442        .edit_predictions
1443        .open_ai_compatible_api
1444        .is_some()
1445    {
1446        providers.push(EditPredictionProvider::OpenAiCompatibleApi);
1447    }
1448
1449    if edit_prediction::mercury::mercury_api_token(cx)
1450        .read(cx)
1451        .has_key()
1452    {
1453        providers.push(EditPredictionProvider::Mercury);
1454    }
1455
1456    providers
1457}
1458
1459fn toggle_show_edit_predictions_for_language(
1460    language: Arc<Language>,
1461    fs: Arc<dyn Fs>,
1462    cx: &mut App,
1463) {
1464    let show_edit_predictions =
1465        all_language_settings(None, cx).show_edit_predictions(Some(&language), cx);
1466    update_settings_file(fs, cx, move |settings, _| {
1467        settings
1468            .project
1469            .all_languages
1470            .languages
1471            .0
1472            .entry(language.name().0.to_string())
1473            .or_default()
1474            .show_edit_predictions = Some(!show_edit_predictions);
1475    });
1476}
1477
1478fn hide_copilot(fs: Arc<dyn Fs>, cx: &mut App) {
1479    update_settings_file(fs, cx, move |settings, _| {
1480        settings
1481            .project
1482            .all_languages
1483            .edit_predictions
1484            .get_or_insert(Default::default())
1485            .provider = Some(EditPredictionProvider::None);
1486    });
1487}
1488
1489fn toggle_edit_prediction_mode(fs: Arc<dyn Fs>, mode: EditPredictionsMode, cx: &mut App) {
1490    let settings = AllLanguageSettings::get_global(cx);
1491    let current_mode = settings.edit_predictions_mode();
1492
1493    if current_mode != mode {
1494        update_settings_file(fs, cx, move |settings, _cx| {
1495            if let Some(edit_predictions) = settings.project.all_languages.edit_predictions.as_mut()
1496            {
1497                edit_predictions.mode = Some(mode);
1498            } else {
1499                settings.project.all_languages.edit_predictions =
1500                    Some(settings::EditPredictionSettingsContent {
1501                        mode: Some(mode),
1502                        ..Default::default()
1503                    });
1504            }
1505        });
1506    }
1507}
1508
1509fn render_zeta_tab_animation(cx: &App) -> impl IntoElement {
1510    let tab = |n: u64, inverted: bool| {
1511        let text_color = cx.theme().colors().text;
1512
1513        h_flex().child(
1514            h_flex()
1515                .text_size(TextSize::XSmall.rems(cx))
1516                .text_color(text_color)
1517                .child("tab")
1518                .with_animation(
1519                    ElementId::Integer(n),
1520                    Animation::new(Duration::from_secs(3)).repeat(),
1521                    move |tab, delta| {
1522                        let n_f32 = n as f32;
1523
1524                        let offset = if inverted {
1525                            0.2 * (4.0 - n_f32)
1526                        } else {
1527                            0.2 * n_f32
1528                        };
1529
1530                        let phase = (delta - offset + 1.0) % 1.0;
1531                        let pulse = if phase < 0.6 {
1532                            let t = phase / 0.6;
1533                            1.0 - (0.5 - t).abs() * 2.0
1534                        } else {
1535                            0.0
1536                        };
1537
1538                        let eased = ease_in_out(pulse);
1539                        let opacity = 0.1 + 0.5 * eased;
1540
1541                        tab.text_color(text_color.opacity(opacity))
1542                    },
1543                ),
1544        )
1545    };
1546
1547    let tab_sequence = |inverted: bool| {
1548        h_flex()
1549            .gap_1()
1550            .child(tab(0, inverted))
1551            .child(tab(1, inverted))
1552            .child(tab(2, inverted))
1553            .child(tab(3, inverted))
1554            .child(tab(4, inverted))
1555    };
1556
1557    h_flex()
1558        .my_1p5()
1559        .p_4()
1560        .justify_center()
1561        .gap_2()
1562        .rounded_xs()
1563        .border_1()
1564        .border_dashed()
1565        .border_color(cx.theme().colors().border)
1566        .bg(gpui::pattern_slash(
1567            cx.theme().colors().border.opacity(0.5),
1568            1.,
1569            8.,
1570        ))
1571        .child(tab_sequence(true))
1572        .child(Icon::new(IconName::ZedPredict))
1573        .child(tab_sequence(false))
1574}
1575
1576fn emit_edit_prediction_menu_opened(
1577    provider: &str,
1578    file: &Option<Arc<dyn File>>,
1579    language: &Option<Arc<Language>>,
1580    project: &WeakEntity<Project>,
1581    cx: &App,
1582) {
1583    let language_name = language.as_ref().map(|l| l.name());
1584    let edit_predictions_enabled_for_language =
1585        LanguageSettings::resolve(None, language_name.as_ref(), cx).show_edit_predictions;
1586    let file_extension = file
1587        .as_ref()
1588        .and_then(|f| {
1589            std::path::Path::new(f.file_name(cx))
1590                .extension()
1591                .and_then(|e| e.to_str())
1592        })
1593        .map(|s| s.to_string());
1594    let is_via_ssh = project
1595        .upgrade()
1596        .map(|p| p.read(cx).is_via_remote_server())
1597        .unwrap_or(false);
1598    telemetry::event!(
1599        "Toolbar Menu Opened",
1600        name = "Edit Predictions",
1601        provider,
1602        file_extension,
1603        edit_predictions_enabled_for_language,
1604        is_via_ssh,
1605    );
1606}
1607
1608fn copilot_settings_url(enterprise_uri: Option<&str>) -> String {
1609    match enterprise_uri {
1610        Some(uri) => {
1611            format!("{}{}", uri.trim_end_matches('/'), COPILOT_SETTINGS_PATH)
1612        }
1613        None => COPILOT_SETTINGS_URL.to_string(),
1614    }
1615}
1616
1617#[cfg(test)]
1618mod tests {
1619    use super::*;
1620    use gpui::TestAppContext;
1621
1622    #[gpui::test]
1623    async fn test_copilot_settings_url_with_enterprise_uri(cx: &mut TestAppContext) {
1624        cx.update(|cx| {
1625            let settings_store = SettingsStore::test(cx);
1626            cx.set_global(settings_store);
1627        });
1628
1629        cx.update_global(|settings_store: &mut SettingsStore, cx| {
1630            settings_store
1631                .set_user_settings(
1632                    r#"{"edit_predictions":{"copilot":{"enterprise_uri":"https://my-company.ghe.com"}}}"#,
1633                    cx,
1634                )
1635                .unwrap();
1636        });
1637
1638        let url = cx.update(|cx| {
1639            let all_language_settings = all_language_settings(None, cx);
1640            copilot_settings_url(
1641                all_language_settings
1642                    .edit_predictions
1643                    .copilot
1644                    .enterprise_uri
1645                    .as_deref(),
1646            )
1647        });
1648
1649        assert_eq!(url, "https://my-company.ghe.com/settings/copilot");
1650    }
1651
1652    #[gpui::test]
1653    async fn test_copilot_settings_url_with_enterprise_uri_trailing_slash(cx: &mut TestAppContext) {
1654        cx.update(|cx| {
1655            let settings_store = SettingsStore::test(cx);
1656            cx.set_global(settings_store);
1657        });
1658
1659        cx.update_global(|settings_store: &mut SettingsStore, cx| {
1660            settings_store
1661                .set_user_settings(
1662                    r#"{"edit_predictions":{"copilot":{"enterprise_uri":"https://my-company.ghe.com/"}}}"#,
1663                    cx,
1664                )
1665                .unwrap();
1666        });
1667
1668        let url = cx.update(|cx| {
1669            let all_language_settings = all_language_settings(None, cx);
1670            copilot_settings_url(
1671                all_language_settings
1672                    .edit_predictions
1673                    .copilot
1674                    .enterprise_uri
1675                    .as_deref(),
1676            )
1677        });
1678
1679        assert_eq!(url, "https://my-company.ghe.com/settings/copilot");
1680    }
1681
1682    #[gpui::test]
1683    async fn test_copilot_settings_url_without_enterprise_uri(cx: &mut TestAppContext) {
1684        cx.update(|cx| {
1685            let settings_store = SettingsStore::test(cx);
1686            cx.set_global(settings_store);
1687        });
1688
1689        let url = cx.update(|cx| {
1690            let all_language_settings = all_language_settings(None, cx);
1691            copilot_settings_url(
1692                all_language_settings
1693                    .edit_predictions
1694                    .copilot
1695                    .enterprise_uri
1696                    .as_deref(),
1697            )
1698        });
1699
1700        assert_eq!(url, "https://github.com/settings/copilot");
1701    }
1702}