edit_prediction_button.rs

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