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::Sweep
 329            | EditPredictionProvider::Mercury) => {
 330                let enabled = self.editor_enabled.unwrap_or(true);
 331                let file = self.file.clone();
 332                let language = self.language.clone();
 333                let project = self.project.clone();
 334                let provider_name: &'static str = match provider {
 335                    EditPredictionProvider::Experimental(name) => name,
 336                    EditPredictionProvider::Zed => "zed",
 337                    _ => "unknown",
 338                };
 339                let icons = self
 340                    .edit_prediction_provider
 341                    .as_ref()
 342                    .map(|p| p.icons(cx))
 343                    .unwrap_or_else(|| {
 344                        edit_prediction_types::EditPredictionIconSet::new(IconName::ZedPredict)
 345                    });
 346
 347                let ep_icon;
 348                let tooltip_meta;
 349                let mut missing_token = false;
 350
 351                match provider {
 352                    EditPredictionProvider::Sweep => {
 353                        missing_token = edit_prediction::EditPredictionStore::try_global(cx)
 354                            .is_some_and(|ep_store| !ep_store.read(cx).has_sweep_api_token(cx));
 355                        ep_icon = if enabled { icons.base } else { icons.disabled };
 356                        tooltip_meta = if missing_token {
 357                            "Missing API key for Sweep"
 358                        } else {
 359                            "Powered by Sweep"
 360                        };
 361                    }
 362                    EditPredictionProvider::Mercury => {
 363                        ep_icon = if enabled { icons.base } else { icons.disabled };
 364                        let mercury_has_error =
 365                            edit_prediction::EditPredictionStore::try_global(cx).is_some_and(
 366                                |ep_store| ep_store.read(cx).mercury_has_payment_required_error(),
 367                            );
 368                        missing_token = edit_prediction::EditPredictionStore::try_global(cx)
 369                            .is_some_and(|ep_store| !ep_store.read(cx).has_mercury_api_token(cx));
 370                        tooltip_meta = if missing_token {
 371                            "Missing API key for Mercury"
 372                        } else if mercury_has_error {
 373                            "Mercury free tier limit reached"
 374                        } else {
 375                            "Powered by Mercury"
 376                        };
 377                    }
 378                    _ => {
 379                        ep_icon = if enabled { icons.base } else { icons.disabled };
 380                        tooltip_meta = "Powered by Zeta"
 381                    }
 382                };
 383
 384                if edit_prediction::should_show_upsell_modal(cx) {
 385                    let tooltip_meta = if self.user_store.read(cx).current_user().is_some() {
 386                        "Choose a Plan"
 387                    } else {
 388                        "Sign In To Use"
 389                    };
 390
 391                    return div().child(
 392                        IconButton::new("zed-predict-pending-button", ep_icon)
 393                            .shape(IconButtonShape::Square)
 394                            .indicator(Indicator::dot().color(Color::Muted))
 395                            .indicator_border_color(Some(cx.theme().colors().status_bar_background))
 396                            .tooltip(move |_window, cx| {
 397                                Tooltip::with_meta("Edit Predictions", None, tooltip_meta, cx)
 398                            })
 399                            .on_click(cx.listener(move |_, _, window, cx| {
 400                                telemetry::event!(
 401                                    "Pending ToS Clicked",
 402                                    source = "Edit Prediction Status Button"
 403                                );
 404                                window.dispatch_action(
 405                                    zed_actions::OpenZedPredictOnboarding.boxed_clone(),
 406                                    cx,
 407                                );
 408                            })),
 409                    );
 410                }
 411
 412                let mut over_limit = false;
 413
 414                if let Some(usage) = self
 415                    .edit_prediction_provider
 416                    .as_ref()
 417                    .and_then(|provider| provider.usage(cx))
 418                {
 419                    over_limit = usage.over_limit()
 420                }
 421
 422                let show_editor_predictions = self.editor_show_predictions;
 423                let user = self.user_store.read(cx).current_user();
 424
 425                let mercury_has_error = matches!(provider, EditPredictionProvider::Mercury)
 426                    && edit_prediction::EditPredictionStore::try_global(cx).is_some_and(
 427                        |ep_store| ep_store.read(cx).mercury_has_payment_required_error(),
 428                    );
 429
 430                let indicator_color = if missing_token || mercury_has_error {
 431                    Some(Color::Error)
 432                } else if enabled && (!show_editor_predictions || over_limit) {
 433                    Some(if over_limit {
 434                        Color::Error
 435                    } else {
 436                        Color::Muted
 437                    })
 438                } else {
 439                    None
 440                };
 441
 442                let icon_button = IconButton::new("zed-predict-pending-button", ep_icon)
 443                    .shape(IconButtonShape::Square)
 444                    .when_some(indicator_color, |this, color| {
 445                        this.indicator(Indicator::dot().color(color))
 446                            .indicator_border_color(Some(cx.theme().colors().status_bar_background))
 447                    })
 448                    .when(!self.popover_menu_handle.is_deployed(), |element| {
 449                        let user = user.clone();
 450
 451                        element.tooltip(move |_window, cx| {
 452                            let description = if enabled {
 453                                if show_editor_predictions {
 454                                    tooltip_meta
 455                                } else if user.is_none() {
 456                                    "Sign In To Use"
 457                                } else {
 458                                    "Hidden For This File"
 459                                }
 460                            } else {
 461                                "Disabled For This File"
 462                            };
 463
 464                            Tooltip::with_meta(
 465                                "Edit Prediction",
 466                                Some(&ToggleMenu),
 467                                description,
 468                                cx,
 469                            )
 470                        })
 471                    });
 472
 473                let this = cx.weak_entity();
 474
 475                let mut popover_menu = PopoverMenu::new("edit-prediction")
 476                    .on_open({
 477                        let file = file.clone();
 478                        let language = language;
 479                        let project = project;
 480                        Rc::new(move |_window, cx| {
 481                            emit_edit_prediction_menu_opened(
 482                                provider_name,
 483                                &file,
 484                                &language,
 485                                &project,
 486                                cx,
 487                            );
 488                        })
 489                    })
 490                    .map(|popover_menu| {
 491                        let this = this.clone();
 492                        popover_menu.menu(move |window, cx| {
 493                            this.update(cx, |this, cx| {
 494                                this.build_edit_prediction_context_menu(provider, window, cx)
 495                            })
 496                            .ok()
 497                        })
 498                    })
 499                    .anchor(Corner::BottomRight)
 500                    .with_handle(self.popover_menu_handle.clone());
 501
 502                let is_refreshing = self
 503                    .edit_prediction_provider
 504                    .as_ref()
 505                    .is_some_and(|provider| provider.is_refreshing(cx));
 506
 507                if is_refreshing {
 508                    popover_menu = popover_menu.trigger(
 509                        icon_button.with_animation(
 510                            "pulsating-label",
 511                            Animation::new(Duration::from_secs(2))
 512                                .repeat()
 513                                .with_easing(pulsating_between(0.2, 1.0)),
 514                            |icon_button, delta| icon_button.alpha(delta),
 515                        ),
 516                    );
 517                } else {
 518                    popover_menu = popover_menu.trigger(icon_button);
 519                }
 520
 521                div().child(popover_menu.into_any_element())
 522            }
 523
 524            EditPredictionProvider::None => div().hidden(),
 525        }
 526    }
 527}
 528
 529impl EditPredictionButton {
 530    pub fn new(
 531        fs: Arc<dyn Fs>,
 532        user_store: Entity<UserStore>,
 533        popover_menu_handle: PopoverMenuHandle<ContextMenu>,
 534        project: Entity<Project>,
 535        cx: &mut Context<Self>,
 536    ) -> Self {
 537        let copilot = EditPredictionStore::try_global(cx).and_then(|store| {
 538            store.update(cx, |this, cx| this.start_copilot_for_project(&project, cx))
 539        });
 540        if let Some(copilot) = copilot {
 541            cx.observe(&copilot, |_, _, cx| cx.notify()).detach()
 542        }
 543
 544        cx.observe_global::<SettingsStore>(move |_, cx| cx.notify())
 545            .detach();
 546
 547        cx.observe_global::<EditPredictionStore>(move |_, cx| cx.notify())
 548            .detach();
 549
 550        edit_prediction::ollama::ensure_authenticated(cx);
 551        let sweep_api_token_task = edit_prediction::sweep_ai::load_sweep_api_token(cx);
 552        let mercury_api_token_task = edit_prediction::mercury::load_mercury_api_token(cx);
 553        let open_ai_compatible_api_token_task =
 554            edit_prediction::open_ai_compatible::load_open_ai_compatible_api_token(cx);
 555
 556        cx.spawn(async move |this, cx| {
 557            _ = futures::join!(
 558                sweep_api_token_task,
 559                mercury_api_token_task,
 560                open_ai_compatible_api_token_task
 561            );
 562            this.update(cx, |_, cx| {
 563                cx.notify();
 564            })
 565            .ok();
 566        })
 567        .detach();
 568
 569        CodestralEditPredictionDelegate::ensure_api_key_loaded(cx);
 570
 571        Self {
 572            editor_subscription: None,
 573            editor_enabled: None,
 574            editor_show_predictions: true,
 575            editor_focus_handle: None,
 576            language: None,
 577            file: None,
 578            edit_prediction_provider: None,
 579            user_store,
 580            popover_menu_handle,
 581            project: project.downgrade(),
 582            fs,
 583        }
 584    }
 585
 586    fn add_provider_switching_section(
 587        &self,
 588        mut menu: ContextMenu,
 589        current_provider: EditPredictionProvider,
 590        cx: &mut App,
 591    ) -> ContextMenu {
 592        let available_providers = get_available_providers(cx);
 593
 594        let providers: Vec<_> = available_providers
 595            .into_iter()
 596            .filter(|p| *p != EditPredictionProvider::None)
 597            .collect();
 598
 599        if !providers.is_empty() {
 600            menu = menu.separator().header("Providers");
 601
 602            for provider in providers {
 603                let Some(name) = provider.display_name() else {
 604                    continue;
 605                };
 606                let is_current = provider == current_provider;
 607                let fs = self.fs.clone();
 608
 609                menu = menu.item(
 610                    ContextMenuEntry::new(name)
 611                        .toggleable(IconPosition::Start, is_current)
 612                        .handler(move |_, cx| {
 613                            set_completion_provider(fs.clone(), cx, provider);
 614                        }),
 615                )
 616            }
 617        }
 618
 619        menu
 620    }
 621
 622    pub fn build_copilot_start_menu(
 623        &mut self,
 624        window: &mut Window,
 625        cx: &mut Context<Self>,
 626    ) -> Entity<ContextMenu> {
 627        let fs = self.fs.clone();
 628        let project = self.project.clone();
 629        ContextMenu::build(window, cx, |menu, _, _| {
 630            menu.entry("Sign In to Copilot", None, move |window, cx| {
 631                telemetry::event!(
 632                    "Edit Prediction Menu Action",
 633                    action = "sign_in",
 634                    provider = "copilot",
 635                );
 636                if let Some(copilot) = EditPredictionStore::try_global(cx).and_then(|store| {
 637                    store.update(cx, |this, cx| {
 638                        this.start_copilot_for_project(&project.upgrade()?, cx)
 639                    })
 640                }) {
 641                    copilot_ui::initiate_sign_in(copilot, window, cx);
 642                }
 643            })
 644            .entry("Disable Copilot", None, {
 645                let fs = fs.clone();
 646                move |_window, cx| {
 647                    telemetry::event!(
 648                        "Edit Prediction Menu Action",
 649                        action = "disable_provider",
 650                        provider = "copilot",
 651                    );
 652                    hide_copilot(fs.clone(), cx)
 653                }
 654            })
 655            .separator()
 656            .entry("Use Zed AI", None, {
 657                let fs = fs.clone();
 658                move |_window, cx| {
 659                    set_completion_provider(fs.clone(), cx, EditPredictionProvider::Zed)
 660                }
 661            })
 662        })
 663    }
 664
 665    pub fn build_language_settings_menu(
 666        &self,
 667        mut menu: ContextMenu,
 668        window: &Window,
 669        cx: &mut App,
 670    ) -> ContextMenu {
 671        let fs = self.fs.clone();
 672        let line_height = window.line_height();
 673
 674        menu = menu.header("Show Edit Predictions For");
 675
 676        let language_state = self.language.as_ref().map(|language| {
 677            (
 678                language.clone(),
 679                LanguageSettings::resolve(None, Some(&language.name()), cx).show_edit_predictions,
 680            )
 681        });
 682
 683        if let Some(editor_focus_handle) = self.editor_focus_handle.clone() {
 684            let entry = ContextMenuEntry::new("This Buffer")
 685                .toggleable(IconPosition::Start, self.editor_show_predictions)
 686                .action(Box::new(editor::actions::ToggleEditPrediction))
 687                .handler(move |window, cx| {
 688                    editor_focus_handle.dispatch_action(
 689                        &editor::actions::ToggleEditPrediction,
 690                        window,
 691                        cx,
 692                    );
 693                });
 694
 695            match language_state.clone() {
 696                Some((language, false)) => {
 697                    menu = menu.item(
 698                        entry
 699                            .disabled(true)
 700                            .documentation_aside(DocumentationSide::Left, move |_cx| {
 701                                Label::new(format!("Edit predictions cannot be toggled for this buffer because they are disabled for {}", language.name()))
 702                                    .into_any_element()
 703                            })
 704                    );
 705                }
 706                Some(_) | None => menu = menu.item(entry),
 707            }
 708        }
 709
 710        if let Some((language, language_enabled)) = language_state {
 711            let fs = fs.clone();
 712            let language_name = language.name();
 713
 714            menu = menu.toggleable_entry(
 715                language_name.clone(),
 716                language_enabled,
 717                IconPosition::Start,
 718                None,
 719                move |_, cx| {
 720                    telemetry::event!(
 721                        "Edit Prediction Setting Changed",
 722                        setting = "language",
 723                        language = language_name.to_string(),
 724                        enabled = !language_enabled,
 725                    );
 726                    toggle_show_edit_predictions_for_language(language.clone(), fs.clone(), cx)
 727                },
 728            );
 729        }
 730
 731        let settings = AllLanguageSettings::get_global(cx);
 732
 733        let globally_enabled = settings.show_edit_predictions(None, cx);
 734        let entry = ContextMenuEntry::new("All Files")
 735            .toggleable(IconPosition::Start, globally_enabled)
 736            .action(workspace::ToggleEditPrediction.boxed_clone())
 737            .handler(|window, cx| {
 738                window.dispatch_action(workspace::ToggleEditPrediction.boxed_clone(), cx)
 739            });
 740        menu = menu.item(entry);
 741
 742        let provider = settings.edit_predictions.provider;
 743        let current_mode = settings.edit_predictions_mode();
 744        let subtle_mode = matches!(current_mode, EditPredictionsMode::Subtle);
 745        let eager_mode = matches!(current_mode, EditPredictionsMode::Eager);
 746
 747        menu = menu
 748                .separator()
 749                .header("Display Modes")
 750                .item(
 751                    ContextMenuEntry::new("Eager")
 752                        .toggleable(IconPosition::Start, eager_mode)
 753                        .documentation_aside(DocumentationSide::Left, move |_| {
 754                            Label::new("Display predictions inline when there are no language server completions available.").into_any_element()
 755                        })
 756                        .handler({
 757                            let fs = fs.clone();
 758                            move |_, cx| {
 759                                telemetry::event!(
 760                                    "Edit Prediction Setting Changed",
 761                                    setting = "mode",
 762                                    value = "eager",
 763                                );
 764                                toggle_edit_prediction_mode(fs.clone(), EditPredictionsMode::Eager, cx)
 765                            }
 766                        }),
 767                )
 768                .item(
 769                    ContextMenuEntry::new("Subtle")
 770                        .toggleable(IconPosition::Start, subtle_mode)
 771                        .documentation_aside(DocumentationSide::Left, move |_| {
 772                            Label::new("Display predictions inline only when holding a modifier key (alt by default).").into_any_element()
 773                        })
 774                        .handler({
 775                            let fs = fs.clone();
 776                            move |_, cx| {
 777                                telemetry::event!(
 778                                    "Edit Prediction Setting Changed",
 779                                    setting = "mode",
 780                                    value = "subtle",
 781                                );
 782                                toggle_edit_prediction_mode(fs.clone(), EditPredictionsMode::Subtle, cx)
 783                            }
 784                        }),
 785                );
 786
 787        menu = menu.separator().header("Privacy");
 788
 789        if matches!(provider, EditPredictionProvider::Zed) {
 790            if let Some(provider) = &self.edit_prediction_provider {
 791                let data_collection = provider.data_collection_state(cx);
 792
 793                if data_collection.is_supported() {
 794                    let provider = provider.clone();
 795                    let enabled = data_collection.is_enabled();
 796                    let is_open_source = data_collection.is_project_open_source();
 797                    let is_collecting = data_collection.is_enabled();
 798                    let (icon_name, icon_color) = if is_open_source && is_collecting {
 799                        (IconName::Check, Color::Success)
 800                    } else {
 801                        (IconName::Check, Color::Accent)
 802                    };
 803
 804                    menu = menu.item(
 805                        ContextMenuEntry::new("Training Data Collection")
 806                            .toggleable(IconPosition::Start, data_collection.is_enabled())
 807                            .icon(icon_name)
 808                            .icon_color(icon_color)
 809                            .disabled(cx.is_staff())
 810                            .documentation_aside(DocumentationSide::Left, move |cx| {
 811                                let (msg, label_color, icon_name, icon_color) = match (is_open_source, is_collecting) {
 812                                    (true, true) => (
 813                                        "Project identified as open source, and you're sharing data.",
 814                                        Color::Default,
 815                                        IconName::Check,
 816                                        Color::Success,
 817                                    ),
 818                                    (true, false) => (
 819                                        "Project identified as open source, but you're not sharing data.",
 820                                        Color::Muted,
 821                                        IconName::Close,
 822                                        Color::Muted,
 823                                    ),
 824                                    (false, true) => (
 825                                        "Project not identified as open source. No data captured.",
 826                                        Color::Muted,
 827                                        IconName::Close,
 828                                        Color::Muted,
 829                                    ),
 830                                    (false, false) => (
 831                                        "Project not identified as open source, and setting turned off.",
 832                                        Color::Muted,
 833                                        IconName::Close,
 834                                        Color::Muted,
 835                                    ),
 836                                };
 837                                v_flex()
 838                                    .gap_2()
 839                                    .child(
 840                                        Label::new(indoc!{
 841                                            "Help us improve our open dataset model by sharing data from open source repositories. \
 842                                            Zed must detect a license file in your repo for this setting to take effect. \
 843                                            Files with sensitive data and secrets are excluded by default."
 844                                        })
 845                                    )
 846                                    .child(
 847                                        h_flex()
 848                                            .items_start()
 849                                            .pt_2()
 850                                            .pr_1()
 851                                            .flex_1()
 852                                            .gap_1p5()
 853                                            .border_t_1()
 854                                            .border_color(cx.theme().colors().border_variant)
 855                                            .child(h_flex().flex_shrink_0().h(line_height).child(Icon::new(icon_name).size(IconSize::XSmall).color(icon_color)))
 856                                            .child(div().child(msg).w_full().text_sm().text_color(label_color.color(cx)))
 857                                    )
 858                                    .into_any_element()
 859                            })
 860                            .handler(move |_, cx| {
 861                                provider.toggle_data_collection(cx);
 862
 863                                if !enabled {
 864                                    telemetry::event!(
 865                                        "Data Collection Enabled",
 866                                        source = "Edit Prediction Status Menu"
 867                                    );
 868                                } else {
 869                                    telemetry::event!(
 870                                        "Data Collection Disabled",
 871                                        source = "Edit Prediction Status Menu"
 872                                    );
 873                                }
 874                            })
 875                    );
 876
 877                    if is_collecting && !is_open_source {
 878                        menu = menu.item(
 879                            ContextMenuEntry::new("No data captured.")
 880                                .disabled(true)
 881                                .icon(IconName::Close)
 882                                .icon_color(Color::Error)
 883                                .icon_size(IconSize::Small),
 884                        );
 885                    }
 886                }
 887            }
 888        }
 889
 890        menu = menu.item(
 891            ContextMenuEntry::new("Configure Excluded Files")
 892                .icon(IconName::LockOutlined)
 893                .icon_color(Color::Muted)
 894                .documentation_aside(DocumentationSide::Left, |_| {
 895                    Label::new(indoc!{"
 896                        Open your settings to add sensitive paths for which Zed will never predict edits."}).into_any_element()
 897                })
 898                .handler(move |window, cx| {
 899                    telemetry::event!(
 900                        "Edit Prediction Menu Action",
 901                        action = "configure_excluded_files",
 902                    );
 903                    if let Some(workspace) = Workspace::for_window(window, cx) {
 904                        let workspace = workspace.downgrade();
 905                        window
 906                            .spawn(cx, async |cx| {
 907                                open_disabled_globs_setting_in_editor(
 908                                    workspace,
 909                                    cx,
 910                                ).await
 911                            })
 912                            .detach_and_log_err(cx);
 913                    }
 914                }),
 915        ).item(
 916            ContextMenuEntry::new("View Docs")
 917                .icon(IconName::FileGeneric)
 918                .icon_color(Color::Muted)
 919                .handler(move |_, cx| {
 920                    telemetry::event!(
 921                        "Edit Prediction Menu Action",
 922                        action = "view_docs",
 923                    );
 924                    cx.open_url(PRIVACY_DOCS);
 925                })
 926        );
 927
 928        if !self.editor_enabled.unwrap_or(true) {
 929            let icons = self
 930                .edit_prediction_provider
 931                .as_ref()
 932                .map(|p| p.icons(cx))
 933                .unwrap_or_else(|| {
 934                    edit_prediction_types::EditPredictionIconSet::new(IconName::ZedPredict)
 935                });
 936            menu = menu.item(
 937                ContextMenuEntry::new("This file is excluded.")
 938                    .disabled(true)
 939                    .icon(icons.disabled)
 940                    .icon_size(IconSize::Small),
 941            );
 942        }
 943
 944        if let Some(editor_focus_handle) = self.editor_focus_handle.clone() {
 945            menu = menu
 946                .separator()
 947                .header("Actions")
 948                .entry(
 949                    "Predict Edit at Cursor",
 950                    Some(Box::new(ShowEditPrediction)),
 951                    {
 952                        let editor_focus_handle = editor_focus_handle.clone();
 953                        move |window, cx| {
 954                            telemetry::event!(
 955                                "Edit Prediction Menu Action",
 956                                action = "predict_at_cursor",
 957                            );
 958                            editor_focus_handle.dispatch_action(&ShowEditPrediction, window, cx);
 959                        }
 960                    },
 961                )
 962                .context(editor_focus_handle)
 963                .when(
 964                    cx.has_flag::<PredictEditsRatePredictionsFeatureFlag>(),
 965                    |this| {
 966                        this.action("Capture Prediction Example", CaptureExample.boxed_clone())
 967                            .action("Rate Predictions", RatePredictions.boxed_clone())
 968                    },
 969                );
 970        }
 971
 972        menu
 973    }
 974
 975    fn build_copilot_context_menu(
 976        &self,
 977        window: &mut Window,
 978        cx: &mut Context<Self>,
 979    ) -> Entity<ContextMenu> {
 980        let all_language_settings = all_language_settings(None, cx);
 981        let next_edit_suggestions = all_language_settings
 982            .edit_predictions
 983            .copilot
 984            .enable_next_edit_suggestions
 985            .unwrap_or(true);
 986        let copilot_config = copilot_chat::CopilotChatConfiguration {
 987            enterprise_uri: all_language_settings
 988                .edit_predictions
 989                .copilot
 990                .enterprise_uri
 991                .clone(),
 992        };
 993        let settings_url = copilot_settings_url(copilot_config.enterprise_uri.as_deref());
 994
 995        ContextMenu::build(window, cx, |menu, window, cx| {
 996            let menu = self.build_language_settings_menu(menu, window, cx);
 997            let menu =
 998                self.add_provider_switching_section(menu, EditPredictionProvider::Copilot, cx);
 999
1000            menu.separator()
1001                .item(
1002                    ContextMenuEntry::new("Copilot: Next Edit Suggestions")
1003                        .toggleable(IconPosition::Start, next_edit_suggestions)
1004                        .handler({
1005                            let fs = self.fs.clone();
1006                            move |_, cx| {
1007                                update_settings_file(fs.clone(), cx, move |settings, _| {
1008                                    settings
1009                                        .project
1010                                        .all_languages
1011                                        .edit_predictions
1012                                        .get_or_insert_default()
1013                                        .copilot
1014                                        .get_or_insert_default()
1015                                        .enable_next_edit_suggestions =
1016                                        Some(!next_edit_suggestions);
1017                                });
1018                            }
1019                        }),
1020                )
1021                .separator()
1022                .link(
1023                    "Go to Copilot Settings",
1024                    OpenBrowser { url: settings_url }.boxed_clone(),
1025                )
1026                .action("Sign Out", copilot::SignOut.boxed_clone())
1027        })
1028    }
1029
1030    fn build_codestral_context_menu(
1031        &self,
1032        window: &mut Window,
1033        cx: &mut Context<Self>,
1034    ) -> Entity<ContextMenu> {
1035        ContextMenu::build(window, cx, |menu, window, cx| {
1036            let menu = self.build_language_settings_menu(menu, window, cx);
1037            let menu =
1038                self.add_provider_switching_section(menu, EditPredictionProvider::Codestral, cx);
1039
1040            menu
1041        })
1042    }
1043
1044    fn build_edit_prediction_context_menu(
1045        &self,
1046        provider: EditPredictionProvider,
1047        window: &mut Window,
1048        cx: &mut Context<Self>,
1049    ) -> Entity<ContextMenu> {
1050        ContextMenu::build(window, cx, |mut menu, window, cx| {
1051            let user = self.user_store.read(cx).current_user();
1052
1053            let needs_sign_in = user.is_none()
1054                && matches!(
1055                    provider,
1056                    EditPredictionProvider::None | EditPredictionProvider::Zed
1057                );
1058
1059            if needs_sign_in {
1060                menu = menu
1061                    .custom_row(move |_window, cx| {
1062                        let description = indoc! {
1063                            "You get 2,000 accepted suggestions at every keystroke for free, \
1064                            powered by Zeta, our open-source, open-data model"
1065                        };
1066
1067                        v_flex()
1068                            .max_w_64()
1069                            .h(rems_from_px(148.))
1070                            .child(render_zeta_tab_animation(cx))
1071                            .child(Label::new("Edit Prediction"))
1072                            .child(
1073                                Label::new(description)
1074                                    .color(Color::Muted)
1075                                    .size(LabelSize::Small),
1076                            )
1077                            .into_any_element()
1078                    })
1079                    .separator()
1080                    .entry("Sign In & Start Using", None, |window, cx| {
1081                        telemetry::event!(
1082                            "Edit Prediction Menu Action",
1083                            action = "sign_in",
1084                            provider = "zed",
1085                        );
1086                        let client = Client::global(cx);
1087                        window
1088                            .spawn(cx, async move |cx| {
1089                                client
1090                                    .sign_in_with_optional_connect(true, &cx)
1091                                    .await
1092                                    .log_err();
1093                            })
1094                            .detach();
1095                    })
1096                    .link_with_handler(
1097                        "Learn More",
1098                        OpenBrowser {
1099                            url: zed_urls::edit_prediction_docs(cx),
1100                        }
1101                        .boxed_clone(),
1102                        |_window, _cx| {
1103                            telemetry::event!(
1104                                "Edit Prediction Menu Action",
1105                                action = "view_docs",
1106                                source = "upsell",
1107                            );
1108                        },
1109                    )
1110                    .separator();
1111            } else {
1112                let mercury_payment_required = matches!(provider, EditPredictionProvider::Mercury)
1113                    && edit_prediction::EditPredictionStore::try_global(cx).is_some_and(
1114                        |ep_store| ep_store.read(cx).mercury_has_payment_required_error(),
1115                    );
1116
1117                if mercury_payment_required {
1118                    menu = menu
1119                        .header("Mercury")
1120                        .item(ContextMenuEntry::new("Free tier limit reached").disabled(true))
1121                        .item(
1122                            ContextMenuEntry::new(
1123                                "Upgrade to a paid plan to continue using the service",
1124                            )
1125                            .disabled(true),
1126                        )
1127                        .separator();
1128                }
1129
1130                if let Some(usage) = self
1131                    .edit_prediction_provider
1132                    .as_ref()
1133                    .and_then(|provider| provider.usage(cx))
1134                {
1135                    menu = menu.header("Usage");
1136                    menu = menu
1137                        .custom_entry(
1138                            move |_window, cx| {
1139                                let used_percentage = match usage.limit {
1140                                    UsageLimit::Limited(limit) => {
1141                                        Some((usage.amount as f32 / limit as f32) * 100.)
1142                                    }
1143                                    UsageLimit::Unlimited => None,
1144                                };
1145
1146                                h_flex()
1147                                    .flex_1()
1148                                    .gap_1p5()
1149                                    .children(used_percentage.map(|percent| {
1150                                        ProgressBar::new("usage", percent, 100., cx)
1151                                    }))
1152                                    .child(
1153                                        Label::new(match usage.limit {
1154                                            UsageLimit::Limited(limit) => {
1155                                                format!("{} / {limit}", usage.amount)
1156                                            }
1157                                            UsageLimit::Unlimited => {
1158                                                format!("{} / ∞", usage.amount)
1159                                            }
1160                                        })
1161                                        .size(LabelSize::Small)
1162                                        .color(Color::Muted),
1163                                    )
1164                                    .into_any_element()
1165                            },
1166                            move |_, cx| cx.open_url(&zed_urls::account_url(cx)),
1167                        )
1168                        .when(usage.over_limit(), |menu| -> ContextMenu {
1169                            menu.entry("Subscribe to increase your limit", None, |_window, cx| {
1170                                telemetry::event!(
1171                                    "Edit Prediction Menu Action",
1172                                    action = "upsell_clicked",
1173                                    reason = "usage_limit",
1174                                );
1175                                cx.open_url(&zed_urls::account_url(cx))
1176                            })
1177                        })
1178                        .separator();
1179                } else if self.user_store.read(cx).account_too_young() {
1180                    menu = menu
1181                        .custom_entry(
1182                            |_window, _cx| {
1183                                Label::new("Your GitHub account is less than 30 days old.")
1184                                    .size(LabelSize::Small)
1185                                    .color(Color::Warning)
1186                                    .into_any_element()
1187                            },
1188                            |_window, cx| cx.open_url(&zed_urls::account_url(cx)),
1189                        )
1190                        .entry("Upgrade to Zed Pro or contact us.", None, |_window, cx| {
1191                            telemetry::event!(
1192                                "Edit Prediction Menu Action",
1193                                action = "upsell_clicked",
1194                                reason = "account_age",
1195                            );
1196                            cx.open_url(&zed_urls::account_url(cx))
1197                        })
1198                        .separator();
1199                } else if self.user_store.read(cx).has_overdue_invoices() {
1200                    menu = menu
1201                        .custom_entry(
1202                            |_window, _cx| {
1203                                Label::new("You have an outstanding invoice")
1204                                    .size(LabelSize::Small)
1205                                    .color(Color::Warning)
1206                                    .into_any_element()
1207                            },
1208                            |_window, cx| {
1209                                cx.open_url(&zed_urls::account_url(cx))
1210                            },
1211                        )
1212                        .entry(
1213                            "Check your payment status or contact us at billing-support@zed.dev to continue using this feature.",
1214                            None,
1215                            |_window, cx| {
1216                                cx.open_url(&zed_urls::account_url(cx))
1217                            },
1218                        )
1219                        .separator();
1220                }
1221            }
1222
1223            if !needs_sign_in {
1224                menu = self.build_language_settings_menu(menu, window, cx);
1225            }
1226            menu = self.add_provider_switching_section(menu, provider, cx);
1227
1228            if cx.is_staff() {
1229                if let Some(store) = EditPredictionStore::try_global(cx) {
1230                    store.update(cx, |store, cx| {
1231                        store.refresh_available_experiments(cx);
1232                    });
1233                    let store = store.read(cx);
1234                    let experiments = store.available_experiments().to_vec();
1235                    let preferred = store.preferred_experiment().map(|s| s.to_owned());
1236                    let active = store.active_experiment().map(|s| s.to_owned());
1237
1238                    let preferred_for_submenu = preferred.clone();
1239                    menu = menu
1240                        .separator()
1241                        .submenu("Experiment", move |menu, _window, _cx| {
1242                            let mut menu = menu.toggleable_entry(
1243                                "Default",
1244                                preferred_for_submenu.is_none(),
1245                                IconPosition::Start,
1246                                None,
1247                                {
1248                                    move |_window, cx| {
1249                                        if let Some(store) = EditPredictionStore::try_global(cx) {
1250                                            store.update(cx, |store, _cx| {
1251                                                store.set_preferred_experiment(None);
1252                                            });
1253                                        }
1254                                    }
1255                                },
1256                            );
1257                            for experiment in &experiments {
1258                                let is_selected = active.as_deref() == Some(experiment.as_str())
1259                                    || preferred.as_deref() == Some(experiment.as_str());
1260                                let experiment_name = experiment.clone();
1261                                menu = menu.toggleable_entry(
1262                                    experiment.clone(),
1263                                    is_selected,
1264                                    IconPosition::Start,
1265                                    None,
1266                                    move |_window, cx| {
1267                                        if let Some(store) = EditPredictionStore::try_global(cx) {
1268                                            store.update(cx, |store, _cx| {
1269                                                store.set_preferred_experiment(Some(
1270                                                    experiment_name.clone(),
1271                                                ));
1272                                            });
1273                                        }
1274                                    },
1275                                );
1276                            }
1277                            menu
1278                        });
1279                }
1280            }
1281
1282            menu = menu.separator().item(
1283                ContextMenuEntry::new("Configure Providers")
1284                    .icon(IconName::Settings)
1285                    .icon_position(IconPosition::Start)
1286                    .icon_color(Color::Muted)
1287                    .handler(move |window, cx| {
1288                        telemetry::event!(
1289                            "Edit Prediction Menu Action",
1290                            action = "configure_providers",
1291                        );
1292                        window.dispatch_action(
1293                            OpenSettingsAt {
1294                                path: "edit_predictions.providers".to_string(),
1295                            }
1296                            .boxed_clone(),
1297                            cx,
1298                        );
1299                    }),
1300            );
1301
1302            menu
1303        })
1304    }
1305
1306    pub fn update_enabled(&mut self, editor: Entity<Editor>, cx: &mut Context<Self>) {
1307        let editor = editor.read(cx);
1308        let snapshot = editor.buffer().read(cx).snapshot(cx);
1309        let suggestion_anchor = editor.selections.newest_anchor().start;
1310        let language = snapshot.language_at(suggestion_anchor);
1311        let file = snapshot.file_at(suggestion_anchor).cloned();
1312        self.editor_enabled = {
1313            let file = file.as_ref();
1314            Some(
1315                file.map(|file| {
1316                    all_language_settings(Some(file), cx)
1317                        .edit_predictions_enabled_for_file(file, cx)
1318                })
1319                .unwrap_or(true),
1320            )
1321        };
1322        self.editor_show_predictions = editor.edit_predictions_enabled();
1323        self.edit_prediction_provider = editor.edit_prediction_provider();
1324        self.language = language.cloned();
1325        self.file = file;
1326        self.editor_focus_handle = Some(editor.focus_handle(cx));
1327
1328        cx.notify();
1329    }
1330}
1331
1332impl StatusItemView for EditPredictionButton {
1333    fn set_active_pane_item(
1334        &mut self,
1335        item: Option<&dyn ItemHandle>,
1336        _: &mut Window,
1337        cx: &mut Context<Self>,
1338    ) {
1339        if let Some(editor) = item.and_then(|item| item.act_as::<Editor>(cx)) {
1340            self.editor_subscription = Some((
1341                cx.observe(&editor, Self::update_enabled),
1342                editor.entity_id().as_u64() as usize,
1343            ));
1344            self.update_enabled(editor, cx);
1345        } else {
1346            self.language = None;
1347            self.editor_subscription = None;
1348            self.editor_enabled = None;
1349        }
1350        cx.notify();
1351    }
1352}
1353
1354async fn open_disabled_globs_setting_in_editor(
1355    workspace: WeakEntity<Workspace>,
1356    cx: &mut AsyncWindowContext,
1357) -> Result<()> {
1358    let settings_editor = workspace
1359        .update_in(cx, |_, window, cx| {
1360            create_and_open_local_file(paths::settings_file(), window, cx, || {
1361                settings::initial_user_settings_content().as_ref().into()
1362            })
1363        })?
1364        .await?
1365        .downcast::<Editor>()
1366        .unwrap();
1367
1368    settings_editor
1369        .downgrade()
1370        .update_in(cx, |item, window, cx| {
1371            let text = item.buffer().read(cx).snapshot(cx).text();
1372
1373            let settings = cx.global::<SettingsStore>();
1374
1375            // Ensure that we always have "edit_predictions { "disabled_globs": [] }"
1376            let edits = settings.edits_for_update(&text, |file| {
1377                file.project
1378                    .all_languages
1379                    .edit_predictions
1380                    .get_or_insert_with(Default::default)
1381                    .disabled_globs
1382                    .get_or_insert_with(Vec::new);
1383            });
1384
1385            if !edits.is_empty() {
1386                item.edit(
1387                    edits
1388                        .into_iter()
1389                        .map(|(r, s)| (MultiBufferOffset(r.start)..MultiBufferOffset(r.end), s)),
1390                    cx,
1391                );
1392            }
1393
1394            let text = item.buffer().read(cx).snapshot(cx).text();
1395
1396            static DISABLED_GLOBS_REGEX: LazyLock<Regex> = LazyLock::new(|| {
1397                Regex::new(r#""disabled_globs":\s*\[\s*(?P<content>(?:.|\n)*?)\s*\]"#).unwrap()
1398            });
1399            // Only capture [...]
1400            let range = DISABLED_GLOBS_REGEX.captures(&text).and_then(|captures| {
1401                captures
1402                    .name("content")
1403                    .map(|inner_match| inner_match.start()..inner_match.end())
1404            });
1405            if let Some(range) = range {
1406                let range = MultiBufferOffset(range.start)..MultiBufferOffset(range.end);
1407                item.change_selections(
1408                    SelectionEffects::scroll(Autoscroll::newest()),
1409                    window,
1410                    cx,
1411                    |selections| {
1412                        selections.select_ranges(vec![range]);
1413                    },
1414                );
1415            }
1416        })?;
1417
1418    anyhow::Ok(())
1419}
1420
1421pub fn set_completion_provider(fs: Arc<dyn Fs>, cx: &mut App, provider: EditPredictionProvider) {
1422    update_settings_file(fs, cx, move |settings, _| {
1423        settings
1424            .project
1425            .all_languages
1426            .edit_predictions
1427            .get_or_insert_default()
1428            .provider = Some(provider);
1429    });
1430}
1431
1432pub fn get_available_providers(cx: &mut App) -> Vec<EditPredictionProvider> {
1433    let mut providers = Vec::new();
1434
1435    providers.push(EditPredictionProvider::Zed);
1436
1437    if let Some(app_state) = workspace::AppState::global(cx).upgrade()
1438        && copilot::GlobalCopilotAuth::try_get_or_init(app_state, cx)
1439            .is_some_and(|copilot| copilot.0.read(cx).is_authenticated())
1440    {
1441        providers.push(EditPredictionProvider::Copilot);
1442    };
1443
1444    if codestral::codestral_api_key(cx).is_some() {
1445        providers.push(EditPredictionProvider::Codestral);
1446    }
1447
1448    if edit_prediction::ollama::is_available(cx) {
1449        providers.push(EditPredictionProvider::Ollama);
1450    }
1451
1452    if all_language_settings(None, cx)
1453        .edit_predictions
1454        .open_ai_compatible_api
1455        .is_some()
1456    {
1457        providers.push(EditPredictionProvider::OpenAiCompatibleApi);
1458    }
1459
1460    if edit_prediction::sweep_ai::sweep_api_token(cx)
1461        .read(cx)
1462        .has_key()
1463    {
1464        providers.push(EditPredictionProvider::Sweep);
1465    }
1466
1467    if edit_prediction::mercury::mercury_api_token(cx)
1468        .read(cx)
1469        .has_key()
1470    {
1471        providers.push(EditPredictionProvider::Mercury);
1472    }
1473
1474    providers
1475}
1476
1477fn toggle_show_edit_predictions_for_language(
1478    language: Arc<Language>,
1479    fs: Arc<dyn Fs>,
1480    cx: &mut App,
1481) {
1482    let show_edit_predictions =
1483        all_language_settings(None, cx).show_edit_predictions(Some(&language), cx);
1484    update_settings_file(fs, cx, move |settings, _| {
1485        settings
1486            .project
1487            .all_languages
1488            .languages
1489            .0
1490            .entry(language.name().0.to_string())
1491            .or_default()
1492            .show_edit_predictions = Some(!show_edit_predictions);
1493    });
1494}
1495
1496fn hide_copilot(fs: Arc<dyn Fs>, cx: &mut App) {
1497    update_settings_file(fs, cx, move |settings, _| {
1498        settings
1499            .project
1500            .all_languages
1501            .edit_predictions
1502            .get_or_insert(Default::default())
1503            .provider = Some(EditPredictionProvider::None);
1504    });
1505}
1506
1507fn toggle_edit_prediction_mode(fs: Arc<dyn Fs>, mode: EditPredictionsMode, cx: &mut App) {
1508    let settings = AllLanguageSettings::get_global(cx);
1509    let current_mode = settings.edit_predictions_mode();
1510
1511    if current_mode != mode {
1512        update_settings_file(fs, cx, move |settings, _cx| {
1513            if let Some(edit_predictions) = settings.project.all_languages.edit_predictions.as_mut()
1514            {
1515                edit_predictions.mode = Some(mode);
1516            } else {
1517                settings.project.all_languages.edit_predictions =
1518                    Some(settings::EditPredictionSettingsContent {
1519                        mode: Some(mode),
1520                        ..Default::default()
1521                    });
1522            }
1523        });
1524    }
1525}
1526
1527fn render_zeta_tab_animation(cx: &App) -> impl IntoElement {
1528    let tab = |n: u64, inverted: bool| {
1529        let text_color = cx.theme().colors().text;
1530
1531        h_flex().child(
1532            h_flex()
1533                .text_size(TextSize::XSmall.rems(cx))
1534                .text_color(text_color)
1535                .child("tab")
1536                .with_animation(
1537                    ElementId::Integer(n),
1538                    Animation::new(Duration::from_secs(3)).repeat(),
1539                    move |tab, delta| {
1540                        let n_f32 = n as f32;
1541
1542                        let offset = if inverted {
1543                            0.2 * (4.0 - n_f32)
1544                        } else {
1545                            0.2 * n_f32
1546                        };
1547
1548                        let phase = (delta - offset + 1.0) % 1.0;
1549                        let pulse = if phase < 0.6 {
1550                            let t = phase / 0.6;
1551                            1.0 - (0.5 - t).abs() * 2.0
1552                        } else {
1553                            0.0
1554                        };
1555
1556                        let eased = ease_in_out(pulse);
1557                        let opacity = 0.1 + 0.5 * eased;
1558
1559                        tab.text_color(text_color.opacity(opacity))
1560                    },
1561                ),
1562        )
1563    };
1564
1565    let tab_sequence = |inverted: bool| {
1566        h_flex()
1567            .gap_1()
1568            .child(tab(0, inverted))
1569            .child(tab(1, inverted))
1570            .child(tab(2, inverted))
1571            .child(tab(3, inverted))
1572            .child(tab(4, inverted))
1573    };
1574
1575    h_flex()
1576        .my_1p5()
1577        .p_4()
1578        .justify_center()
1579        .gap_2()
1580        .rounded_xs()
1581        .border_1()
1582        .border_dashed()
1583        .border_color(cx.theme().colors().border)
1584        .bg(gpui::pattern_slash(
1585            cx.theme().colors().border.opacity(0.5),
1586            1.,
1587            8.,
1588        ))
1589        .child(tab_sequence(true))
1590        .child(Icon::new(IconName::ZedPredict))
1591        .child(tab_sequence(false))
1592}
1593
1594fn emit_edit_prediction_menu_opened(
1595    provider: &str,
1596    file: &Option<Arc<dyn File>>,
1597    language: &Option<Arc<Language>>,
1598    project: &WeakEntity<Project>,
1599    cx: &App,
1600) {
1601    let language_name = language.as_ref().map(|l| l.name());
1602    let edit_predictions_enabled_for_language =
1603        LanguageSettings::resolve(None, language_name.as_ref(), cx).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}