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