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