edit_prediction_button.rs

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