edit_prediction_button.rs

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