Enable configuring edit prediction providers through the settings UI (#44505)

Danilo Leal and Ben Kunkle created

- Edit prediction providers can now be configured through the settings
UI
- Cleaned up the status bar menu to only show _configured_ providers
- Added to the status bar icon button tooltip the name of the active
provider
- Only display the data collection functionality under "Privacy" for the
Zed models
- Moved the Codestral edit prediction provider out of the Mistral
section in the agent panel into the settings UI
- Refined and improved UI and states for configuring GitHub Copilot as
both an agent and edit prediction provider

#### Todos before merge:

- [x] UI: Unify with settings UI style and tidy it all up
- [x] Unify Copilot modal `impl`s to use separate window
- [x] Remove stop light icons from GitHub modal
- [x] Make dismiss events work on GitHub modal
- [ ] Investigate workarounds to tell if Copilot authenticated even when
LSP not running


Release Notes:

- settings_ui: Added a section for configuring edit prediction providers
under AI > Edit Predictions, including Codestral and GitHub Copilot.
Once you've updated you can use the following link to open it:
zed://settings/edit_predictions.providers

---------

Co-authored-by: Ben Kunkle <ben@zed.dev>

Change summary

Cargo.lock                                                         |   8 
assets/settings/default.json                                       |   5 
crates/agent_ui/src/agent_configuration.rs                         |   6 
crates/copilot/src/copilot.rs                                      |  57 
crates/copilot/src/sign_in.rs                                      | 660 
crates/edit_prediction/Cargo.toml                                  |   1 
crates/edit_prediction/src/edit_prediction.rs                      |  19 
crates/edit_prediction/src/mercury.rs                              |  82 
crates/edit_prediction/src/sweep_ai.rs                             |  73 
crates/edit_prediction/src/zed_edit_prediction_delegate.rs         |   2 
crates/edit_prediction_ui/Cargo.toml                               |   3 
crates/edit_prediction_ui/src/edit_prediction_button.rs            | 520 
crates/edit_prediction_ui/src/edit_prediction_ui.rs                |   2 
crates/edit_prediction_ui/src/external_provider_api_token_modal.rs |  86 
crates/language_model/Cargo.toml                                   |   2 
crates/language_model/src/api_key.rs                               |  21 
crates/language_model/src/language_model.rs                        |   3 
crates/language_models/Cargo.toml                                  |   1 
crates/language_models/src/language_models.rs                      |   2 
crates/language_models/src/provider/anthropic.rs                   |  49 
crates/language_models/src/provider/bedrock.rs                     |  51 
crates/language_models/src/provider/copilot_chat.rs                | 109 
crates/language_models/src/provider/deepseek.rs                    |  49 
crates/language_models/src/provider/google.rs                      |  43 
crates/language_models/src/provider/lmstudio.rs                    |  13 
crates/language_models/src/provider/mistral.rs                     | 236 
crates/language_models/src/provider/ollama.rs                      |  49 
crates/language_models/src/provider/open_ai.rs                     |  56 
crates/language_models/src/provider/open_ai_compatible.rs          |  28 
crates/language_models/src/provider/open_router.rs                 |  48 
crates/language_models/src/provider/vercel.rs                      |  50 
crates/language_models/src/provider/x_ai.rs                        |  51 
crates/language_models/src/ui.rs                                   |   4 
crates/language_models/src/ui/instruction_list_item.rs             |  69 
crates/settings/src/settings_content/language.rs                   |   4 
crates/settings_ui/Cargo.toml                                      |   5 
crates/settings_ui/src/components.rs                               |   2 
crates/settings_ui/src/components/input_field.rs                   |   1 
crates/settings_ui/src/components/section_items.rs                 |  56 
crates/settings_ui/src/page_data.rs                                |  60 
crates/settings_ui/src/pages.rs                                    |   2 
crates/settings_ui/src/pages/edit_prediction_provider_setup.rs     | 365 
crates/settings_ui/src/settings_ui.rs                              | 222 
crates/ui/src/components.rs                                        |   4 
crates/ui/src/components/ai.rs                                     |   3 
crates/ui/src/components/ai/configured_api_card.rs                 |  17 
crates/ui/src/components/ai/copilot_configuration_callout.rs       |   0 
crates/ui/src/components/button.rs                                 |   2 
crates/ui/src/components/button/button_link.rs                     | 102 
crates/ui/src/components/divider.rs                                |  18 
crates/ui/src/components/inline_code.rs                            |  64 
crates/ui/src/components/label/label_like.rs                       |   2 
crates/ui/src/components/list/list_bullet_item.rs                  |  88 
crates/workspace/src/notifications.rs                              |   2 
crates/zed_env_vars/src/zed_env_vars.rs                            |   5 
55 files changed, 1,907 insertions(+), 1,575 deletions(-)

Detailed changes

Cargo.lock ๐Ÿ”—

@@ -5111,7 +5111,6 @@ dependencies = [
  "cloud_llm_client",
  "collections",
  "copilot",
- "credentials_provider",
  "ctor",
  "db",
  "edit_prediction_context",
@@ -5275,7 +5274,6 @@ dependencies = [
  "text",
  "theme",
  "ui",
- "ui_input",
  "util",
  "workspace",
  "zed_actions",
@@ -8802,6 +8800,7 @@ dependencies = [
  "cloud_api_types",
  "cloud_llm_client",
  "collections",
+ "credentials_provider",
  "futures 0.3.31",
  "gpui",
  "http_client",
@@ -8820,6 +8819,7 @@ dependencies = [
  "telemetry_events",
  "thiserror 2.0.17",
  "util",
+ "zed_env_vars",
 ]
 
 [[package]]
@@ -8876,7 +8876,6 @@ dependencies = [
  "util",
  "vercel",
  "x_ai",
- "zed_env_vars",
 ]
 
 [[package]]
@@ -14778,6 +14777,8 @@ dependencies = [
  "assets",
  "bm25",
  "client",
+ "copilot",
+ "edit_prediction",
  "editor",
  "feature_flags",
  "fs",
@@ -14786,6 +14787,7 @@ dependencies = [
  "gpui",
  "heck 0.5.0",
  "language",
+ "language_models",
  "log",
  "menu",
  "node_runtime",

assets/settings/default.json ๐Ÿ”—

@@ -1410,8 +1410,9 @@
       "proxy_no_verify": null,
     },
     "codestral": {
-      "model": null,
-      "max_tokens": null,
+      "api_url": "https://codestral.mistral.ai",
+      "model": "codestral-latest",
+      "max_tokens": 150,
     },
     // Whether edit predictions are enabled when editing text threads in the agent panel.
     // This setting has no effect if globally disabled.

crates/agent_ui/src/agent_configuration.rs ๐Ÿ”—

@@ -34,9 +34,9 @@ use project::{
 };
 use settings::{Settings, SettingsStore, update_settings_file};
 use ui::{
-    Button, ButtonStyle, Chip, CommonAnimationExt, ContextMenu, ContextMenuEntry, Disclosure,
-    Divider, DividerColor, ElevationIndex, IconName, IconPosition, IconSize, Indicator, LabelSize,
-    PopoverMenu, Switch, Tooltip, WithScrollbar, prelude::*,
+    ButtonStyle, Chip, CommonAnimationExt, ContextMenu, ContextMenuEntry, Disclosure, Divider,
+    DividerColor, ElevationIndex, Indicator, LabelSize, PopoverMenu, Switch, Tooltip,
+    WithScrollbar, prelude::*,
 };
 use util::ResultExt as _;
 use workspace::{Workspace, create_and_open_local_file};

crates/copilot/src/copilot.rs ๐Ÿ”—

@@ -4,7 +4,7 @@ pub mod copilot_responses;
 pub mod request;
 mod sign_in;
 
-use crate::sign_in::initiate_sign_in_within_workspace;
+use crate::sign_in::initiate_sign_out;
 use ::fs::Fs;
 use anyhow::{Context as _, Result, anyhow};
 use collections::{HashMap, HashSet};
@@ -28,12 +28,10 @@ use project::DisableAiSettings;
 use request::StatusNotification;
 use semver::Version;
 use serde_json::json;
-use settings::Settings;
-use settings::SettingsStore;
-use sign_in::{reinstall_and_sign_in_within_workspace, sign_out_within_workspace};
-use std::collections::hash_map::Entry;
+use settings::{Settings, SettingsStore};
 use std::{
     any::TypeId,
+    collections::hash_map::Entry,
     env,
     ffi::OsString,
     mem,
@@ -42,12 +40,14 @@ use std::{
     sync::Arc,
 };
 use sum_tree::Dimensions;
-use util::rel_path::RelPath;
-use util::{ResultExt, fs::remove_matching};
+use util::{ResultExt, fs::remove_matching, rel_path::RelPath};
 use workspace::Workspace;
 
 pub use crate::copilot_edit_prediction_delegate::CopilotEditPredictionDelegate;
-pub use crate::sign_in::{CopilotCodeVerification, initiate_sign_in, reinstall_and_sign_in};
+pub use crate::sign_in::{
+    ConfigurationMode, ConfigurationView, CopilotCodeVerification, initiate_sign_in,
+    reinstall_and_sign_in,
+};
 
 actions!(
     copilot,
@@ -98,21 +98,14 @@ pub fn init(
     .detach();
 
     cx.observe_new(|workspace: &mut Workspace, _window, _cx| {
-        workspace.register_action(|workspace, _: &SignIn, window, cx| {
-            if let Some(copilot) = Copilot::global(cx) {
-                let is_reinstall = false;
-                initiate_sign_in_within_workspace(workspace, copilot, is_reinstall, window, cx);
-            }
+        workspace.register_action(|_, _: &SignIn, window, cx| {
+            initiate_sign_in(window, cx);
         });
-        workspace.register_action(|workspace, _: &Reinstall, window, cx| {
-            if let Some(copilot) = Copilot::global(cx) {
-                reinstall_and_sign_in_within_workspace(workspace, copilot, window, cx);
-            }
+        workspace.register_action(|_, _: &Reinstall, window, cx| {
+            reinstall_and_sign_in(window, cx);
         });
-        workspace.register_action(|workspace, _: &SignOut, _window, cx| {
-            if let Some(copilot) = Copilot::global(cx) {
-                sign_out_within_workspace(workspace, copilot, cx);
-            }
+        workspace.register_action(|_, _: &SignOut, window, cx| {
+            initiate_sign_out(window, cx);
         });
     })
     .detach();
@@ -375,7 +368,7 @@ impl Copilot {
         }
     }
 
-    fn start_copilot(
+    pub fn start_copilot(
         &mut self,
         check_edit_prediction_provider: bool,
         awaiting_sign_in_after_start: bool,
@@ -563,6 +556,14 @@ impl Copilot {
         let server = start_language_server.await;
         this.update(cx, |this, cx| {
             cx.notify();
+
+            if env::var("ZED_FORCE_COPILOT_ERROR").is_ok() {
+                this.server = CopilotServer::Error(
+                    "Forced error for testing (ZED_FORCE_COPILOT_ERROR)".into(),
+                );
+                return;
+            }
+
             match server {
                 Ok((server, status)) => {
                     this.server = CopilotServer::Running(RunningCopilotServer {
@@ -584,7 +585,17 @@ impl Copilot {
         .ok();
     }
 
-    pub(crate) fn sign_in(&mut self, cx: &mut Context<Self>) -> Task<Result<()>> {
+    pub fn is_authenticated(&self) -> bool {
+        return matches!(
+            self.server,
+            CopilotServer::Running(RunningCopilotServer {
+                sign_in_status: SignInStatus::Authorized,
+                ..
+            })
+        );
+    }
+
+    pub fn sign_in(&mut self, cx: &mut Context<Self>) -> Task<Result<()>> {
         if let CopilotServer::Running(server) = &mut self.server {
             let task = match &server.sign_in_status {
                 SignInStatus::Authorized => Task::ready(Ok(())).shared(),

crates/copilot/src/sign_in.rs ๐Ÿ”—

@@ -1,160 +1,151 @@
 use crate::{Copilot, Status, request::PromptUserDeviceFlow};
+use anyhow::Context as _;
 use gpui::{
-    Animation, AnimationExt, App, ClipboardItem, Context, DismissEvent, Element, Entity,
-    EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, MouseDownEvent,
-    ParentElement, Render, Styled, Subscription, Transformation, Window, div, percentage, svg,
+    App, ClipboardItem, Context, DismissEvent, Element, Entity, EventEmitter, FocusHandle,
+    Focusable, InteractiveElement, IntoElement, MouseDownEvent, ParentElement, Render, Styled,
+    Subscription, Window, WindowBounds, WindowOptions, div, point,
 };
-use std::time::Duration;
-use ui::{Button, Label, Vector, VectorName, prelude::*};
+use ui::{ButtonLike, CommonAnimationExt, ConfiguredApiCard, Vector, VectorName, prelude::*};
 use util::ResultExt as _;
-use workspace::notifications::NotificationId;
-use workspace::{ModalView, Toast, Workspace};
+use workspace::{Toast, Workspace, notifications::NotificationId};
 
 const COPILOT_SIGN_UP_URL: &str = "https://github.com/features/copilot";
+const ERROR_LABEL: &str =
+    "Copilot had issues starting. You can try reinstalling it and signing in again.";
 
 struct CopilotStatusToast;
 
 pub fn initiate_sign_in(window: &mut Window, cx: &mut App) {
+    let is_reinstall = false;
+    initiate_sign_in_impl(is_reinstall, window, cx)
+}
+
+pub fn initiate_sign_out(window: &mut Window, cx: &mut App) {
     let Some(copilot) = Copilot::global(cx) else {
         return;
     };
-    let Some(workspace) = window.root::<Workspace>().flatten() else {
-        return;
-    };
-    workspace.update(cx, |workspace, cx| {
-        let is_reinstall = false;
-        initiate_sign_in_within_workspace(workspace, copilot, is_reinstall, window, cx)
-    });
+
+    copilot_toast(Some("Signing out of Copilotโ€ฆ"), window, cx);
+
+    let sign_out_task = copilot.update(cx, |copilot, cx| copilot.sign_out(cx));
+    window
+        .spawn(cx, async move |cx| match sign_out_task.await {
+            Ok(()) => {
+                cx.update(|window, cx| copilot_toast(Some("Signed out of Copilot"), window, cx))
+            }
+            Err(err) => cx.update(|window, cx| {
+                if let Some(workspace) = window.root::<Workspace>().flatten() {
+                    workspace.update(cx, |workspace, cx| {
+                        workspace.show_error(&err, cx);
+                    })
+                } else {
+                    log::error!("{:?}", err);
+                }
+            }),
+        })
+        .detach();
 }
 
 pub fn reinstall_and_sign_in(window: &mut Window, cx: &mut App) {
     let Some(copilot) = Copilot::global(cx) else {
         return;
     };
+    let _ = copilot.update(cx, |copilot, cx| copilot.reinstall(cx));
+    let is_reinstall = true;
+    initiate_sign_in_impl(is_reinstall, window, cx);
+}
+
+fn open_copilot_code_verification_window(copilot: &Entity<Copilot>, window: &Window, cx: &mut App) {
+    let current_window_center = window.bounds().center();
+    let height = px(450.);
+    let width = px(350.);
+    let window_bounds = WindowBounds::Windowed(gpui::bounds(
+        current_window_center - point(height / 2.0, width / 2.0),
+        gpui::size(height, width),
+    ));
+    cx.open_window(
+        WindowOptions {
+            kind: gpui::WindowKind::PopUp,
+            window_bounds: Some(window_bounds),
+            is_resizable: false,
+            is_movable: true,
+            titlebar: Some(gpui::TitlebarOptions {
+                appears_transparent: true,
+                ..Default::default()
+            }),
+            ..Default::default()
+        },
+        |window, cx| cx.new(|cx| CopilotCodeVerification::new(&copilot, window, cx)),
+    )
+    .context("Failed to open Copilot code verification window")
+    .log_err();
+}
+
+fn copilot_toast(message: Option<&'static str>, window: &Window, cx: &mut App) {
+    const NOTIFICATION_ID: NotificationId = NotificationId::unique::<CopilotStatusToast>();
+
     let Some(workspace) = window.root::<Workspace>().flatten() else {
         return;
     };
-    workspace.update(cx, |workspace, cx| {
-        reinstall_and_sign_in_within_workspace(workspace, copilot, window, cx);
-    });
-}
 
-pub fn reinstall_and_sign_in_within_workspace(
-    workspace: &mut Workspace,
-    copilot: Entity<Copilot>,
-    window: &mut Window,
-    cx: &mut Context<Workspace>,
-) {
-    let _ = copilot.update(cx, |copilot, cx| copilot.reinstall(cx));
-    let is_reinstall = true;
-    initiate_sign_in_within_workspace(workspace, copilot, is_reinstall, window, cx);
+    workspace.update(cx, |workspace, cx| match message {
+        Some(message) => workspace.show_toast(Toast::new(NOTIFICATION_ID, message), cx),
+        None => workspace.dismiss_toast(&NOTIFICATION_ID, cx),
+    });
 }
 
-pub fn initiate_sign_in_within_workspace(
-    workspace: &mut Workspace,
-    copilot: Entity<Copilot>,
-    is_reinstall: bool,
-    window: &mut Window,
-    cx: &mut Context<Workspace>,
-) {
+pub fn initiate_sign_in_impl(is_reinstall: bool, window: &mut Window, cx: &mut App) {
+    let Some(copilot) = Copilot::global(cx) else {
+        return;
+    };
     if matches!(copilot.read(cx).status(), Status::Disabled) {
         copilot.update(cx, |copilot, cx| copilot.start_copilot(false, true, cx));
     }
     match copilot.read(cx).status() {
         Status::Starting { task } => {
-            workspace.show_toast(
-                Toast::new(
-                    NotificationId::unique::<CopilotStatusToast>(),
-                    if is_reinstall {
-                        "Copilot is reinstalling..."
-                    } else {
-                        "Copilot is starting..."
-                    },
-                ),
+            copilot_toast(
+                Some(if is_reinstall {
+                    "Copilot is reinstallingโ€ฆ"
+                } else {
+                    "Copilot is startingโ€ฆ"
+                }),
+                window,
                 cx,
             );
 
-            cx.spawn_in(window, async move |workspace, cx| {
-                task.await;
-                if let Some(copilot) = cx.update(|_window, cx| Copilot::global(cx)).ok().flatten() {
-                    workspace
-                        .update_in(cx, |workspace, window, cx| {
-                            match copilot.read(cx).status() {
-                                Status::Authorized => workspace.show_toast(
-                                    Toast::new(
-                                        NotificationId::unique::<CopilotStatusToast>(),
-                                        "Copilot has started.",
-                                    ),
-                                    cx,
-                                ),
-                                _ => {
-                                    workspace.dismiss_toast(
-                                        &NotificationId::unique::<CopilotStatusToast>(),
-                                        cx,
-                                    );
-                                    copilot
-                                        .update(cx, |copilot, cx| copilot.sign_in(cx))
-                                        .detach_and_log_err(cx);
-                                    workspace.toggle_modal(window, cx, |_, cx| {
-                                        CopilotCodeVerification::new(&copilot, cx)
-                                    });
-                                }
+            window
+                .spawn(cx, async move |cx| {
+                    task.await;
+                    cx.update(|window, cx| {
+                        let Some(copilot) = Copilot::global(cx) else {
+                            return;
+                        };
+                        match copilot.read(cx).status() {
+                            Status::Authorized => {
+                                copilot_toast(Some("Copilot has started."), window, cx)
                             }
-                        })
-                        .log_err();
-                }
-            })
-            .detach();
+                            _ => {
+                                copilot_toast(None, window, cx);
+                                copilot
+                                    .update(cx, |copilot, cx| copilot.sign_in(cx))
+                                    .detach_and_log_err(cx);
+                                open_copilot_code_verification_window(&copilot, window, cx);
+                            }
+                        }
+                    })
+                    .log_err();
+                })
+                .detach();
         }
         _ => {
             copilot
                 .update(cx, |copilot, cx| copilot.sign_in(cx))
                 .detach();
-            workspace.toggle_modal(window, cx, |_, cx| {
-                CopilotCodeVerification::new(&copilot, cx)
-            });
+            open_copilot_code_verification_window(&copilot, window, cx);
         }
     }
 }
 
-pub fn sign_out_within_workspace(
-    workspace: &mut Workspace,
-    copilot: Entity<Copilot>,
-    cx: &mut Context<Workspace>,
-) {
-    workspace.show_toast(
-        Toast::new(
-            NotificationId::unique::<CopilotStatusToast>(),
-            "Signing out of Copilot...",
-        ),
-        cx,
-    );
-    let sign_out_task = copilot.update(cx, |copilot, cx| copilot.sign_out(cx));
-    cx.spawn(async move |workspace, cx| match sign_out_task.await {
-        Ok(()) => {
-            workspace
-                .update(cx, |workspace, cx| {
-                    workspace.show_toast(
-                        Toast::new(
-                            NotificationId::unique::<CopilotStatusToast>(),
-                            "Signed out of Copilot.",
-                        ),
-                        cx,
-                    )
-                })
-                .ok();
-        }
-        Err(err) => {
-            workspace
-                .update(cx, |workspace, cx| {
-                    workspace.show_error(&err, cx);
-                })
-                .ok();
-        }
-    })
-    .detach();
-}
-
 pub struct CopilotCodeVerification {
     status: Status,
     connect_clicked: bool,
@@ -170,23 +161,27 @@ impl Focusable for CopilotCodeVerification {
 }
 
 impl EventEmitter<DismissEvent> for CopilotCodeVerification {}
-impl ModalView for CopilotCodeVerification {
-    fn on_before_dismiss(
-        &mut self,
-        _: &mut Window,
-        cx: &mut Context<Self>,
-    ) -> workspace::DismissDecision {
-        self.copilot.update(cx, |copilot, cx| {
-            if matches!(copilot.status(), Status::SigningIn { .. }) {
-                copilot.sign_out(cx).detach_and_log_err(cx);
+
+impl CopilotCodeVerification {
+    pub fn new(copilot: &Entity<Copilot>, window: &mut Window, cx: &mut Context<Self>) -> Self {
+        window.on_window_should_close(cx, |window, cx| {
+            if let Some(this) = window.root::<CopilotCodeVerification>().flatten() {
+                this.update(cx, |this, cx| {
+                    this.before_dismiss(cx);
+                });
             }
+            true
         });
-        workspace::DismissDecision::Dismiss(true)
-    }
-}
+        cx.subscribe_in(
+            &cx.entity(),
+            window,
+            |this, _, _: &DismissEvent, window, cx| {
+                window.remove_window();
+                this.before_dismiss(cx);
+            },
+        )
+        .detach();
 
-impl CopilotCodeVerification {
-    pub fn new(copilot: &Entity<Copilot>, cx: &mut Context<Self>) -> Self {
         let status = copilot.read(cx).status();
         Self {
             status,
@@ -215,45 +210,45 @@ impl CopilotCodeVerification {
             .read_from_clipboard()
             .map(|item| item.text().as_ref() == Some(&data.user_code))
             .unwrap_or(false);
-        h_flex()
-            .w_full()
-            .p_1()
-            .border_1()
-            .border_muted(cx)
-            .rounded_sm()
-            .cursor_pointer()
-            .justify_between()
-            .on_mouse_down(gpui::MouseButton::Left, {
+
+        ButtonLike::new("copy-button")
+            .full_width()
+            .style(ButtonStyle::Tinted(ui::TintColor::Accent))
+            .size(ButtonSize::Medium)
+            .child(
+                h_flex()
+                    .w_full()
+                    .p_1()
+                    .justify_between()
+                    .child(Label::new(data.user_code.clone()))
+                    .child(Label::new(if copied { "Copied!" } else { "Copy" })),
+            )
+            .on_click({
                 let user_code = data.user_code.clone();
                 move |_, window, cx| {
                     cx.write_to_clipboard(ClipboardItem::new_string(user_code.clone()));
                     window.refresh();
                 }
             })
-            .child(div().flex_1().child(Label::new(data.user_code.clone())))
-            .child(div().flex_none().px_1().child(Label::new(if copied {
-                "Copied!"
-            } else {
-                "Copy"
-            })))
     }
 
     fn render_prompting_modal(
         connect_clicked: bool,
         data: &PromptUserDeviceFlow,
-
         cx: &mut Context<Self>,
     ) -> impl Element {
         let connect_button_label = if connect_clicked {
-            "Waiting for connection..."
+            "Waiting for connectionโ€ฆ"
         } else {
             "Connect to GitHub"
         };
+
         v_flex()
             .flex_1()
-            .gap_2()
+            .gap_2p5()
             .items_center()
-            .child(Headline::new("Use GitHub Copilot in Zed.").size(HeadlineSize::Large))
+            .text_center()
+            .child(Headline::new("Use GitHub Copilot in Zed").size(HeadlineSize::Large))
             .child(
                 Label::new("Using Copilot requires an active subscription on GitHub.")
                     .color(Color::Muted),
@@ -261,83 +256,119 @@ impl CopilotCodeVerification {
             .child(Self::render_device_code(data, cx))
             .child(
                 Label::new("Paste this code into GitHub after clicking the button below.")
-                    .size(ui::LabelSize::Small),
-            )
-            .child(
-                Button::new("connect-button", connect_button_label)
-                    .on_click({
-                        let verification_uri = data.verification_uri.clone();
-                        cx.listener(move |this, _, _window, cx| {
-                            cx.open_url(&verification_uri);
-                            this.connect_clicked = true;
-                        })
-                    })
-                    .full_width()
-                    .style(ButtonStyle::Filled),
+                    .color(Color::Muted),
             )
             .child(
-                Button::new("copilot-enable-cancel-button", "Cancel")
-                    .full_width()
-                    .on_click(cx.listener(|_, _, _, cx| {
-                        cx.emit(DismissEvent);
-                    })),
+                v_flex()
+                    .w_full()
+                    .gap_1()
+                    .child(
+                        Button::new("connect-button", connect_button_label)
+                            .full_width()
+                            .style(ButtonStyle::Outlined)
+                            .size(ButtonSize::Medium)
+                            .on_click({
+                                let verification_uri = data.verification_uri.clone();
+                                cx.listener(move |this, _, _window, cx| {
+                                    cx.open_url(&verification_uri);
+                                    this.connect_clicked = true;
+                                })
+                            }),
+                    )
+                    .child(
+                        Button::new("copilot-enable-cancel-button", "Cancel")
+                            .full_width()
+                            .size(ButtonSize::Medium)
+                            .on_click(cx.listener(|_, _, _, cx| {
+                                cx.emit(DismissEvent);
+                            })),
+                    ),
             )
     }
 
     fn render_enabled_modal(cx: &mut Context<Self>) -> impl Element {
         v_flex()
             .gap_2()
+            .text_center()
+            .justify_center()
             .child(Headline::new("Copilot Enabled!").size(HeadlineSize::Large))
-            .child(Label::new(
-                "You can update your settings or sign out from the Copilot menu in the status bar.",
-            ))
+            .child(Label::new("You're all set to use GitHub Copilot.").color(Color::Muted))
             .child(
                 Button::new("copilot-enabled-done-button", "Done")
                     .full_width()
+                    .style(ButtonStyle::Outlined)
+                    .size(ButtonSize::Medium)
                     .on_click(cx.listener(|_, _, _, cx| cx.emit(DismissEvent))),
             )
     }
 
     fn render_unauthorized_modal(cx: &mut Context<Self>) -> impl Element {
-        v_flex()
-            .child(Headline::new("You must have an active GitHub Copilot subscription.").size(HeadlineSize::Large))
+        let description = "Enable Copilot by connecting your existing license once you have subscribed or renewed your subscription.";
 
-            .child(Label::new(
-                "You can enable Copilot by connecting your existing license once you have subscribed or renewed your subscription.",
-            ).color(Color::Warning))
+        v_flex()
+            .gap_2()
+            .text_center()
+            .justify_center()
+            .child(
+                Headline::new("You must have an active GitHub Copilot subscription.")
+                    .size(HeadlineSize::Large),
+            )
+            .child(Label::new(description).color(Color::Warning))
             .child(
                 Button::new("copilot-subscribe-button", "Subscribe on GitHub")
                     .full_width()
+                    .style(ButtonStyle::Outlined)
+                    .size(ButtonSize::Medium)
                     .on_click(|_, _, cx| cx.open_url(COPILOT_SIGN_UP_URL)),
             )
             .child(
                 Button::new("copilot-subscribe-cancel-button", "Cancel")
                     .full_width()
+                    .size(ButtonSize::Medium)
                     .on_click(cx.listener(|_, _, _, cx| cx.emit(DismissEvent))),
             )
     }
 
-    fn render_loading(window: &mut Window, _: &mut Context<Self>) -> impl Element {
-        let loading_icon = svg()
-            .size_8()
-            .path(IconName::ArrowCircle.path())
-            .text_color(window.text_style().color)
-            .with_animation(
-                "icon_circle_arrow",
-                Animation::new(Duration::from_secs(2)).repeat(),
-                |svg, delta| svg.with_transformation(Transformation::rotate(percentage(delta))),
-            );
+    fn render_error_modal(_cx: &mut Context<Self>) -> impl Element {
+        v_flex()
+            .gap_2()
+            .text_center()
+            .justify_center()
+            .child(Headline::new("An Error Happened").size(HeadlineSize::Large))
+            .child(Label::new(ERROR_LABEL).color(Color::Muted))
+            .child(
+                Button::new("copilot-subscribe-button", "Reinstall Copilot and Sign In")
+                    .full_width()
+                    .style(ButtonStyle::Outlined)
+                    .size(ButtonSize::Medium)
+                    .icon(IconName::Download)
+                    .icon_color(Color::Muted)
+                    .icon_position(IconPosition::Start)
+                    .icon_size(IconSize::Small)
+                    .on_click(|_, window, cx| reinstall_and_sign_in(window, cx)),
+            )
+    }
 
-        h_flex().justify_center().child(loading_icon)
+    fn before_dismiss(
+        &mut self,
+        cx: &mut Context<'_, CopilotCodeVerification>,
+    ) -> workspace::DismissDecision {
+        self.copilot.update(cx, |copilot, cx| {
+            if matches!(copilot.status(), Status::SigningIn { .. }) {
+                copilot.sign_out(cx).detach_and_log_err(cx);
+            }
+        });
+        workspace::DismissDecision::Dismiss(true)
     }
 }
 
 impl Render for CopilotCodeVerification {
-    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
         let prompt = match &self.status {
-            Status::SigningIn { prompt: None } => {
-                Self::render_loading(window, cx).into_any_element()
-            }
+            Status::SigningIn { prompt: None } => Icon::new(IconName::ArrowCircle)
+                .color(Color::Muted)
+                .with_rotate_animation(2)
+                .into_any_element(),
             Status::SigningIn {
                 prompt: Some(prompt),
             } => Self::render_prompting_modal(self.connect_clicked, prompt, cx).into_any_element(),
@@ -349,17 +380,20 @@ impl Render for CopilotCodeVerification {
                 self.connect_clicked = false;
                 Self::render_enabled_modal(cx).into_any_element()
             }
+            Status::Error(..) => Self::render_error_modal(cx).into_any_element(),
             _ => div().into_any_element(),
         };
 
         v_flex()
-            .id("copilot code verification")
+            .id("copilot_code_verification")
             .track_focus(&self.focus_handle(cx))
-            .elevation_3(cx)
-            .w_96()
-            .items_center()
-            .p_4()
+            .size_full()
+            .px_4()
+            .py_8()
             .gap_2()
+            .items_center()
+            .justify_center()
+            .elevation_3(cx)
             .on_action(cx.listener(|_, _: &menu::Cancel, _, cx| {
                 cx.emit(DismissEvent);
             }))
@@ -373,3 +407,243 @@ impl Render for CopilotCodeVerification {
             .child(prompt)
     }
 }
+
+pub struct ConfigurationView {
+    copilot_status: Option<Status>,
+    is_authenticated: fn(cx: &App) -> bool,
+    edit_prediction: bool,
+    _subscription: Option<Subscription>,
+}
+
+pub enum ConfigurationMode {
+    Chat,
+    EditPrediction,
+}
+
+impl ConfigurationView {
+    pub fn new(
+        is_authenticated: fn(cx: &App) -> bool,
+        mode: ConfigurationMode,
+        cx: &mut Context<Self>,
+    ) -> Self {
+        let copilot = Copilot::global(cx);
+
+        Self {
+            copilot_status: copilot.as_ref().map(|copilot| copilot.read(cx).status()),
+            is_authenticated,
+            edit_prediction: matches!(mode, ConfigurationMode::EditPrediction),
+            _subscription: copilot.as_ref().map(|copilot| {
+                cx.observe(copilot, |this, model, cx| {
+                    this.copilot_status = Some(model.read(cx).status());
+                    cx.notify();
+                })
+            }),
+        }
+    }
+}
+
+impl ConfigurationView {
+    fn is_starting(&self) -> bool {
+        matches!(&self.copilot_status, Some(Status::Starting { .. }))
+    }
+
+    fn is_signing_in(&self) -> bool {
+        matches!(
+            &self.copilot_status,
+            Some(Status::SigningIn { .. })
+                | Some(Status::SignedOut {
+                    awaiting_signing_in: true
+                })
+        )
+    }
+
+    fn is_error(&self) -> bool {
+        matches!(&self.copilot_status, Some(Status::Error(_)))
+    }
+
+    fn has_no_status(&self) -> bool {
+        self.copilot_status.is_none()
+    }
+
+    fn loading_message(&self) -> Option<SharedString> {
+        if self.is_starting() {
+            Some("Starting Copilotโ€ฆ".into())
+        } else if self.is_signing_in() {
+            Some("Signing into Copilotโ€ฆ".into())
+        } else {
+            None
+        }
+    }
+
+    fn render_loading_button(
+        &self,
+        label: impl Into<SharedString>,
+        edit_prediction: bool,
+    ) -> impl IntoElement {
+        ButtonLike::new("loading_button")
+            .disabled(true)
+            .style(ButtonStyle::Outlined)
+            .when(edit_prediction, |this| this.size(ButtonSize::Medium))
+            .child(
+                h_flex()
+                    .w_full()
+                    .gap_1()
+                    .justify_center()
+                    .child(
+                        Icon::new(IconName::ArrowCircle)
+                            .size(IconSize::Small)
+                            .color(Color::Muted)
+                            .with_rotate_animation(4),
+                    )
+                    .child(Label::new(label)),
+            )
+    }
+
+    fn render_sign_in_button(&self, edit_prediction: bool) -> impl IntoElement {
+        let label = if edit_prediction {
+            "Sign in to GitHub"
+        } else {
+            "Sign in to use GitHub Copilot"
+        };
+
+        Button::new("sign_in", label)
+            .map(|this| {
+                if edit_prediction {
+                    this.size(ButtonSize::Medium)
+                } else {
+                    this.full_width()
+                }
+            })
+            .style(ButtonStyle::Outlined)
+            .icon(IconName::Github)
+            .icon_color(Color::Muted)
+            .icon_position(IconPosition::Start)
+            .icon_size(IconSize::Small)
+            .on_click(|_, window, cx| initiate_sign_in(window, cx))
+    }
+
+    fn render_reinstall_button(&self, edit_prediction: bool) -> impl IntoElement {
+        let label = if edit_prediction {
+            "Reinstall and Sign in"
+        } else {
+            "Reinstall Copilot and Sign in"
+        };
+
+        Button::new("reinstall_and_sign_in", label)
+            .map(|this| {
+                if edit_prediction {
+                    this.size(ButtonSize::Medium)
+                } else {
+                    this.full_width()
+                }
+            })
+            .style(ButtonStyle::Outlined)
+            .icon(IconName::Download)
+            .icon_color(Color::Muted)
+            .icon_position(IconPosition::Start)
+            .icon_size(IconSize::Small)
+            .on_click(|_, window, cx| reinstall_and_sign_in(window, cx))
+    }
+
+    fn render_for_edit_prediction(&self) -> impl IntoElement {
+        let container = |description: SharedString, action: AnyElement| {
+            h_flex()
+                .pt_2p5()
+                .w_full()
+                .justify_between()
+                .child(
+                    v_flex()
+                        .w_full()
+                        .max_w_1_2()
+                        .child(Label::new("Authenticate To Use"))
+                        .child(
+                            Label::new(description)
+                                .color(Color::Muted)
+                                .size(LabelSize::Small),
+                        ),
+                )
+                .child(action)
+        };
+
+        let start_label = "To use Copilot for edit predictions, you need to be logged in to GitHub. Note that your GitHub account must have an active Copilot subscription.".into();
+        let no_status_label = "Copilot requires an active GitHub Copilot subscription. Please ensure Copilot is configured and try again, or use a different edit predictions provider.".into();
+
+        if let Some(msg) = self.loading_message() {
+            container(
+                start_label,
+                self.render_loading_button(msg, true).into_any_element(),
+            )
+            .into_any_element()
+        } else if self.is_error() {
+            container(
+                ERROR_LABEL.into(),
+                self.render_reinstall_button(true).into_any_element(),
+            )
+            .into_any_element()
+        } else if self.has_no_status() {
+            container(
+                no_status_label,
+                self.render_sign_in_button(true).into_any_element(),
+            )
+            .into_any_element()
+        } else {
+            container(
+                start_label,
+                self.render_sign_in_button(true).into_any_element(),
+            )
+            .into_any_element()
+        }
+    }
+
+    fn render_for_chat(&self) -> impl IntoElement {
+        let start_label = "To use Zed's agent with GitHub Copilot, you need to be logged in to GitHub. Note that your GitHub account must have an active Copilot Chat subscription.";
+        let no_status_label = "Copilot Chat requires an active GitHub Copilot subscription. Please ensure Copilot is configured and try again, or use a different LLM provider.";
+
+        if let Some(msg) = self.loading_message() {
+            v_flex()
+                .gap_2()
+                .child(Label::new(start_label))
+                .child(self.render_loading_button(msg, false))
+                .into_any_element()
+        } else if self.is_error() {
+            v_flex()
+                .gap_2()
+                .child(Label::new(ERROR_LABEL))
+                .child(self.render_reinstall_button(false))
+                .into_any_element()
+        } else if self.has_no_status() {
+            v_flex()
+                .gap_2()
+                .child(Label::new(no_status_label))
+                .child(self.render_sign_in_button(false))
+                .into_any_element()
+        } else {
+            v_flex()
+                .gap_2()
+                .child(Label::new(start_label))
+                .child(self.render_sign_in_button(false))
+                .into_any_element()
+        }
+    }
+}
+
+impl Render for ConfigurationView {
+    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        let is_authenticated = self.is_authenticated;
+
+        if is_authenticated(cx) {
+            return ConfiguredApiCard::new("Authorized")
+                .button_label("Sign Out")
+                .on_click(|_, window, cx| {
+                    initiate_sign_out(window, cx);
+                })
+                .into_any_element();
+        }
+
+        if self.edit_prediction {
+            self.render_for_edit_prediction().into_any_element()
+        } else {
+            self.render_for_chat().into_any_element()
+        }
+    }
+}

crates/edit_prediction/Cargo.toml ๐Ÿ”—

@@ -23,7 +23,6 @@ client.workspace = true
 cloud_llm_client.workspace = true
 collections.workspace = true
 copilot.workspace = true
-credentials_provider.workspace = true
 db.workspace = true
 edit_prediction_types.workspace = true
 edit_prediction_context.workspace = true

crates/edit_prediction/src/edit_prediction.rs ๐Ÿ”—

@@ -72,6 +72,7 @@ pub use crate::prediction::EditPrediction;
 pub use crate::prediction::EditPredictionId;
 use crate::prediction::EditPredictionResult;
 pub use crate::sweep_ai::SweepAi;
+pub use language_model::ApiKeyState;
 pub use telemetry_events::EditPredictionRating;
 pub use zed_edit_prediction_delegate::ZedEditPredictionDelegate;
 
@@ -536,22 +537,12 @@ impl EditPredictionStore {
         self.edit_prediction_model = model;
     }
 
-    pub fn has_sweep_api_token(&self) -> bool {
-        self.sweep_ai
-            .api_token
-            .clone()
-            .now_or_never()
-            .flatten()
-            .is_some()
+    pub fn has_sweep_api_token(&self, cx: &App) -> bool {
+        self.sweep_ai.api_token.read(cx).has_key()
     }
 
-    pub fn has_mercury_api_token(&self) -> bool {
-        self.mercury
-            .api_token
-            .clone()
-            .now_or_never()
-            .flatten()
-            .is_some()
+    pub fn has_mercury_api_token(&self, cx: &App) -> bool {
+        self.mercury.api_token.read(cx).has_key()
     }
 
     #[cfg(feature = "cli-support")]

crates/edit_prediction/src/mercury.rs ๐Ÿ”—

@@ -1,40 +1,34 @@
+use crate::{
+    DebugEvent, EditPredictionFinishedDebugEvent, EditPredictionId, EditPredictionModelInput,
+    EditPredictionStartedDebugEvent, open_ai_response::text_from_response,
+    prediction::EditPredictionResult,
+};
 use anyhow::{Context as _, Result};
-use credentials_provider::CredentialsProvider;
-use futures::{AsyncReadExt as _, FutureExt, future::Shared};
+use futures::AsyncReadExt as _;
 use gpui::{
-    App, AppContext as _, Task,
+    App, AppContext as _, Entity, SharedString, Task,
     http_client::{self, AsyncBody, Method},
 };
 use language::{OffsetRangeExt as _, ToOffset, ToPoint as _};
+use language_model::{ApiKeyState, EnvVar, env_var};
 use std::{mem, ops::Range, path::Path, sync::Arc, time::Instant};
 use zeta_prompt::ZetaPromptInput;
 
-use crate::{
-    DebugEvent, EditPredictionFinishedDebugEvent, EditPredictionId, EditPredictionModelInput,
-    EditPredictionStartedDebugEvent, open_ai_response::text_from_response,
-    prediction::EditPredictionResult,
-};
-
 const MERCURY_API_URL: &str = "https://api.inceptionlabs.ai/v1/edit/completions";
 const MAX_CONTEXT_TOKENS: usize = 150;
 const MAX_REWRITE_TOKENS: usize = 350;
 
 pub struct Mercury {
-    pub api_token: Shared<Task<Option<String>>>,
+    pub api_token: Entity<ApiKeyState>,
 }
 
 impl Mercury {
-    pub fn new(cx: &App) -> Self {
+    pub fn new(cx: &mut App) -> Self {
         Mercury {
-            api_token: load_api_token(cx).shared(),
+            api_token: mercury_api_token(cx),
         }
     }
 
-    pub fn set_api_token(&mut self, api_token: Option<String>, cx: &mut App) -> Task<Result<()>> {
-        self.api_token = Task::ready(api_token.clone()).shared();
-        store_api_token_in_keychain(api_token, cx)
-    }
-
     pub(crate) fn request_prediction(
         &self,
         EditPredictionModelInput {
@@ -48,7 +42,10 @@ impl Mercury {
         }: EditPredictionModelInput,
         cx: &mut App,
     ) -> Task<Result<Option<EditPredictionResult>>> {
-        let Some(api_token) = self.api_token.clone().now_or_never().flatten() else {
+        self.api_token.update(cx, |key_state, cx| {
+            _ = key_state.load_if_needed(MERCURY_CREDENTIALS_URL, |s| s, cx);
+        });
+        let Some(api_token) = self.api_token.read(cx).key(&MERCURY_CREDENTIALS_URL) else {
             return Task::ready(Ok(None));
         };
         let full_path: Arc<Path> = snapshot
@@ -299,45 +296,16 @@ fn push_delimited(prompt: &mut String, delimiters: Range<&str>, cb: impl FnOnce(
     prompt.push_str(delimiters.end);
 }
 
-pub const MERCURY_CREDENTIALS_URL: &str = "https://api.inceptionlabs.ai/v1/edit/completions";
+pub const MERCURY_CREDENTIALS_URL: SharedString =
+    SharedString::new_static("https://api.inceptionlabs.ai/v1/edit/completions");
 pub const MERCURY_CREDENTIALS_USERNAME: &str = "mercury-api-token";
+pub static MERCURY_TOKEN_ENV_VAR: std::sync::LazyLock<EnvVar> = env_var!("MERCURY_AI_TOKEN");
+pub static MERCURY_API_KEY: std::sync::OnceLock<Entity<ApiKeyState>> = std::sync::OnceLock::new();
 
-pub fn load_api_token(cx: &App) -> Task<Option<String>> {
-    if let Some(api_token) = std::env::var("MERCURY_AI_TOKEN")
-        .ok()
-        .filter(|value| !value.is_empty())
-    {
-        return Task::ready(Some(api_token));
-    }
-    let credentials_provider = <dyn CredentialsProvider>::global(cx);
-    cx.spawn(async move |cx| {
-        let (_, credentials) = credentials_provider
-            .read_credentials(MERCURY_CREDENTIALS_URL, &cx)
-            .await
-            .ok()??;
-        String::from_utf8(credentials).ok()
-    })
-}
-
-fn store_api_token_in_keychain(api_token: Option<String>, cx: &App) -> Task<Result<()>> {
-    let credentials_provider = <dyn CredentialsProvider>::global(cx);
-
-    cx.spawn(async move |cx| {
-        if let Some(api_token) = api_token {
-            credentials_provider
-                .write_credentials(
-                    MERCURY_CREDENTIALS_URL,
-                    MERCURY_CREDENTIALS_USERNAME,
-                    api_token.as_bytes(),
-                    cx,
-                )
-                .await
-                .context("Failed to save Mercury API token to system keychain")
-        } else {
-            credentials_provider
-                .delete_credentials(MERCURY_CREDENTIALS_URL, cx)
-                .await
-                .context("Failed to delete Mercury API token from system keychain")
-        }
-    })
+pub fn mercury_api_token(cx: &mut App) -> Entity<ApiKeyState> {
+    MERCURY_API_KEY
+        .get_or_init(|| {
+            cx.new(|_| ApiKeyState::new(MERCURY_CREDENTIALS_URL, MERCURY_TOKEN_ENV_VAR.clone()))
+        })
+        .clone()
 }

crates/edit_prediction/src/sweep_ai.rs ๐Ÿ”—

@@ -1,11 +1,11 @@
-use anyhow::{Context as _, Result};
-use credentials_provider::CredentialsProvider;
-use futures::{AsyncReadExt as _, FutureExt, future::Shared};
+use anyhow::Result;
+use futures::AsyncReadExt as _;
 use gpui::{
-    App, AppContext as _, Task,
+    App, AppContext as _, Entity, SharedString, Task,
     http_client::{self, AsyncBody, Method},
 };
 use language::{Point, ToOffset as _};
+use language_model::{ApiKeyState, EnvVar, env_var};
 use lsp::DiagnosticSeverity;
 use serde::{Deserialize, Serialize};
 use std::{
@@ -20,30 +20,28 @@ use crate::{EditPredictionId, EditPredictionModelInput, prediction::EditPredicti
 const SWEEP_API_URL: &str = "https://autocomplete.sweep.dev/backend/next_edit_autocomplete";
 
 pub struct SweepAi {
-    pub api_token: Shared<Task<Option<String>>>,
+    pub api_token: Entity<ApiKeyState>,
     pub debug_info: Arc<str>,
 }
 
 impl SweepAi {
-    pub fn new(cx: &App) -> Self {
+    pub fn new(cx: &mut App) -> Self {
         SweepAi {
-            api_token: load_api_token(cx).shared(),
+            api_token: sweep_api_token(cx),
             debug_info: debug_info(cx),
         }
     }
 
-    pub fn set_api_token(&mut self, api_token: Option<String>, cx: &mut App) -> Task<Result<()>> {
-        self.api_token = Task::ready(api_token.clone()).shared();
-        store_api_token_in_keychain(api_token, cx)
-    }
-
     pub fn request_prediction_with_sweep(
         &self,
         inputs: EditPredictionModelInput,
         cx: &mut App,
     ) -> Task<Result<Option<EditPredictionResult>>> {
         let debug_info = self.debug_info.clone();
-        let Some(api_token) = self.api_token.clone().now_or_never().flatten() else {
+        self.api_token.update(cx, |key_state, cx| {
+            _ = key_state.load_if_needed(SWEEP_CREDENTIALS_URL, |s| s, cx);
+        });
+        let Some(api_token) = self.api_token.read(cx).key(&SWEEP_CREDENTIALS_URL) else {
             return Task::ready(Ok(None));
         };
         let full_path: Arc<Path> = inputs
@@ -270,47 +268,18 @@ impl SweepAi {
     }
 }
 
-pub const SWEEP_CREDENTIALS_URL: &str = "https://autocomplete.sweep.dev";
+pub const SWEEP_CREDENTIALS_URL: SharedString =
+    SharedString::new_static("https://autocomplete.sweep.dev");
 pub const SWEEP_CREDENTIALS_USERNAME: &str = "sweep-api-token";
+pub static SWEEP_AI_TOKEN_ENV_VAR: std::sync::LazyLock<EnvVar> = env_var!("SWEEP_AI_TOKEN");
+pub static SWEEP_API_KEY: std::sync::OnceLock<Entity<ApiKeyState>> = std::sync::OnceLock::new();
 
-pub fn load_api_token(cx: &App) -> Task<Option<String>> {
-    if let Some(api_token) = std::env::var("SWEEP_AI_TOKEN")
-        .ok()
-        .filter(|value| !value.is_empty())
-    {
-        return Task::ready(Some(api_token));
-    }
-    let credentials_provider = <dyn CredentialsProvider>::global(cx);
-    cx.spawn(async move |cx| {
-        let (_, credentials) = credentials_provider
-            .read_credentials(SWEEP_CREDENTIALS_URL, &cx)
-            .await
-            .ok()??;
-        String::from_utf8(credentials).ok()
-    })
-}
-
-fn store_api_token_in_keychain(api_token: Option<String>, cx: &App) -> Task<Result<()>> {
-    let credentials_provider = <dyn CredentialsProvider>::global(cx);
-
-    cx.spawn(async move |cx| {
-        if let Some(api_token) = api_token {
-            credentials_provider
-                .write_credentials(
-                    SWEEP_CREDENTIALS_URL,
-                    SWEEP_CREDENTIALS_USERNAME,
-                    api_token.as_bytes(),
-                    cx,
-                )
-                .await
-                .context("Failed to save Sweep API token to system keychain")
-        } else {
-            credentials_provider
-                .delete_credentials(SWEEP_CREDENTIALS_URL, cx)
-                .await
-                .context("Failed to delete Sweep API token from system keychain")
-        }
-    })
+pub fn sweep_api_token(cx: &mut App) -> Entity<ApiKeyState> {
+    SWEEP_API_KEY
+        .get_or_init(|| {
+            cx.new(|_| ApiKeyState::new(SWEEP_CREDENTIALS_URL, SWEEP_AI_TOKEN_ENV_VAR.clone()))
+        })
+        .clone()
 }
 
 #[derive(Debug, Clone, Serialize)]

crates/edit_prediction/src/zed_edit_prediction_delegate.rs ๐Ÿ”—

@@ -100,7 +100,7 @@ impl EditPredictionDelegate for ZedEditPredictionDelegate {
     ) -> bool {
         let store = self.store.read(cx);
         if store.edit_prediction_model == EditPredictionModel::Sweep {
-            store.has_sweep_api_token()
+            store.has_sweep_api_token(cx)
         } else {
             true
         }

crates/edit_prediction_ui/Cargo.toml ๐Ÿ”—

@@ -20,8 +20,8 @@ cloud_llm_client.workspace = true
 codestral.workspace = true
 command_palette_hooks.workspace = true
 copilot.workspace = true
-edit_prediction.workspace = true
 edit_prediction_types.workspace = true
+edit_prediction.workspace = true
 editor.workspace = true
 feature_flags.workspace = true
 fs.workspace = true
@@ -41,7 +41,6 @@ telemetry.workspace = true
 text.workspace = true
 theme.workspace = true
 ui.workspace = true
-ui_input.workspace = true
 util.workspace = true
 workspace.workspace = true
 zed_actions.workspace = true

crates/edit_prediction_ui/src/edit_prediction_button.rs ๐Ÿ”—

@@ -3,7 +3,9 @@ use client::{Client, UserStore, zed_urls};
 use cloud_llm_client::UsageLimit;
 use codestral::CodestralEditPredictionDelegate;
 use copilot::{Copilot, Status};
-use edit_prediction::{MercuryFeatureFlag, SweepFeatureFlag, Zeta2FeatureFlag};
+use edit_prediction::{
+    EditPredictionStore, MercuryFeatureFlag, SweepFeatureFlag, Zeta2FeatureFlag,
+};
 use edit_prediction_types::EditPredictionDelegateHandle;
 use editor::{
     Editor, MultiBufferOffset, SelectionEffects, actions::ShowEditPrediction, scroll::Autoscroll,
@@ -42,12 +44,9 @@ use workspace::{
     StatusItemView, Toast, Workspace, create_and_open_local_file, item::ItemHandle,
     notifications::NotificationId,
 };
-use zed_actions::OpenBrowser;
+use zed_actions::{OpenBrowser, OpenSettingsAt};
 
-use crate::{
-    ExternalProviderApiKeyModal, RatePredictions,
-    rate_prediction_modal::PredictEditsRatePredictionsFeatureFlag,
-};
+use crate::{RatePredictions, rate_prediction_modal::PredictEditsRatePredictionsFeatureFlag};
 
 actions!(
     edit_prediction,
@@ -248,45 +247,21 @@ impl Render for EditPredictionButton {
             EditPredictionProvider::Codestral => {
                 let enabled = self.editor_enabled.unwrap_or(true);
                 let has_api_key = CodestralEditPredictionDelegate::has_api_key(cx);
-                let fs = self.fs.clone();
                 let this = cx.weak_entity();
 
+                let tooltip_meta = if has_api_key {
+                    "Powered by Codestral"
+                } else {
+                    "Missing API key for Codestral"
+                };
+
                 div().child(
                     PopoverMenu::new("codestral")
                         .menu(move |window, cx| {
-                            if has_api_key {
-                                this.update(cx, |this, cx| {
-                                    this.build_codestral_context_menu(window, cx)
-                                })
-                                .ok()
-                            } else {
-                                Some(ContextMenu::build(window, cx, |menu, _, _| {
-                                    let fs = fs.clone();
-
-                                    menu.entry(
-                                        "Configure Codestral API Key",
-                                        None,
-                                        move |window, cx| {
-                                            window.dispatch_action(
-                                                zed_actions::agent::OpenSettings.boxed_clone(),
-                                                cx,
-                                            );
-                                        },
-                                    )
-                                    .separator()
-                                    .entry(
-                                        "Use Zed AI instead",
-                                        None,
-                                        move |_, cx| {
-                                            set_completion_provider(
-                                                fs.clone(),
-                                                cx,
-                                                EditPredictionProvider::Zed,
-                                            )
-                                        },
-                                    )
-                                }))
-                            }
+                            this.update(cx, |this, cx| {
+                                this.build_codestral_context_menu(window, cx)
+                            })
+                            .ok()
                         })
                         .anchor(Corner::BottomRight)
                         .trigger_with_tooltip(
@@ -304,7 +279,14 @@ impl Render for EditPredictionButton {
                                             cx.theme().colors().status_bar_background,
                                         ))
                                 }),
-                            move |_window, cx| Tooltip::for_action("Codestral", &ToggleMenu, cx),
+                            move |_window, cx| {
+                                Tooltip::with_meta(
+                                    "Edit Prediction",
+                                    Some(&ToggleMenu),
+                                    tooltip_meta,
+                                    cx,
+                                )
+                            },
                         )
                         .with_handle(self.popover_menu_handle.clone()),
                 )
@@ -313,6 +295,7 @@ impl Render for EditPredictionButton {
                 let enabled = self.editor_enabled.unwrap_or(true);
 
                 let ep_icon;
+                let tooltip_meta;
                 let mut missing_token = false;
 
                 match provider {
@@ -320,15 +303,25 @@ impl Render for EditPredictionButton {
                         EXPERIMENTAL_SWEEP_EDIT_PREDICTION_PROVIDER_NAME,
                     ) => {
                         ep_icon = IconName::SweepAi;
+                        tooltip_meta = if missing_token {
+                            "Missing API key for Sweep"
+                        } else {
+                            "Powered by Sweep"
+                        };
                         missing_token = edit_prediction::EditPredictionStore::try_global(cx)
-                            .is_some_and(|ep_store| !ep_store.read(cx).has_sweep_api_token());
+                            .is_some_and(|ep_store| !ep_store.read(cx).has_sweep_api_token(cx));
                     }
                     EditPredictionProvider::Experimental(
                         EXPERIMENTAL_MERCURY_EDIT_PREDICTION_PROVIDER_NAME,
                     ) => {
                         ep_icon = IconName::Inception;
                         missing_token = edit_prediction::EditPredictionStore::try_global(cx)
-                            .is_some_and(|ep_store| !ep_store.read(cx).has_mercury_api_token());
+                            .is_some_and(|ep_store| !ep_store.read(cx).has_mercury_api_token(cx));
+                        tooltip_meta = if missing_token {
+                            "Missing API key for Mercury"
+                        } else {
+                            "Powered by Mercury"
+                        };
                     }
                     _ => {
                         ep_icon = if enabled {
@@ -336,6 +329,7 @@ impl Render for EditPredictionButton {
                         } else {
                             IconName::ZedPredictDisabled
                         };
+                        tooltip_meta = "Powered by Zeta"
                     }
                 };
 
@@ -400,33 +394,26 @@ impl Render for EditPredictionButton {
                     })
                     .when(!self.popover_menu_handle.is_deployed(), |element| {
                         let user = user.clone();
+
                         element.tooltip(move |_window, cx| {
-                            if enabled {
+                            let description = if enabled {
                                 if show_editor_predictions {
-                                    Tooltip::for_action("Edit Prediction", &ToggleMenu, cx)
+                                    tooltip_meta
                                 } else if user.is_none() {
-                                    Tooltip::with_meta(
-                                        "Edit Prediction",
-                                        Some(&ToggleMenu),
-                                        "Sign In To Use",
-                                        cx,
-                                    )
+                                    "Sign In To Use"
                                 } else {
-                                    Tooltip::with_meta(
-                                        "Edit Prediction",
-                                        Some(&ToggleMenu),
-                                        "Hidden For This File",
-                                        cx,
-                                    )
+                                    "Hidden For This File"
                                 }
                             } else {
-                                Tooltip::with_meta(
-                                    "Edit Prediction",
-                                    Some(&ToggleMenu),
-                                    "Disabled For This File",
-                                    cx,
-                                )
-                            }
+                                "Disabled For This File"
+                            };
+
+                            Tooltip::with_meta(
+                                "Edit Prediction",
+                                Some(&ToggleMenu),
+                                description,
+                                cx,
+                            )
                         })
                     });
 
@@ -519,6 +506,12 @@ impl EditPredictionButton {
 
         providers.push(EditPredictionProvider::Zed);
 
+        if cx.has_flag::<Zeta2FeatureFlag>() {
+            providers.push(EditPredictionProvider::Experimental(
+                EXPERIMENTAL_ZETA2_EDIT_PREDICTION_PROVIDER_NAME,
+            ));
+        }
+
         if let Some(copilot) = Copilot::global(cx) {
             if matches!(copilot.read(cx).status(), Status::Authorized) {
                 providers.push(EditPredictionProvider::Copilot);
@@ -537,24 +530,28 @@ impl EditPredictionButton {
             providers.push(EditPredictionProvider::Codestral);
         }
 
-        if cx.has_flag::<SweepFeatureFlag>() {
+        let ep_store = EditPredictionStore::try_global(cx);
+
+        if cx.has_flag::<SweepFeatureFlag>()
+            && ep_store
+                .as_ref()
+                .is_some_and(|ep_store| ep_store.read(cx).has_sweep_api_token(cx))
+        {
             providers.push(EditPredictionProvider::Experimental(
                 EXPERIMENTAL_SWEEP_EDIT_PREDICTION_PROVIDER_NAME,
             ));
         }
 
-        if cx.has_flag::<MercuryFeatureFlag>() {
+        if cx.has_flag::<MercuryFeatureFlag>()
+            && ep_store
+                .as_ref()
+                .is_some_and(|ep_store| ep_store.read(cx).has_mercury_api_token(cx))
+        {
             providers.push(EditPredictionProvider::Experimental(
                 EXPERIMENTAL_MERCURY_EDIT_PREDICTION_PROVIDER_NAME,
             ));
         }
 
-        if cx.has_flag::<Zeta2FeatureFlag>() {
-            providers.push(EditPredictionProvider::Experimental(
-                EXPERIMENTAL_ZETA2_EDIT_PREDICTION_PROVIDER_NAME,
-            ));
-        }
-
         providers
     }
 
@@ -562,13 +559,10 @@ impl EditPredictionButton {
         &self,
         mut menu: ContextMenu,
         current_provider: EditPredictionProvider,
-        cx: &App,
+        cx: &mut App,
     ) -> ContextMenu {
         let available_providers = self.get_available_providers(cx);
 
-        const ZED_AI_CALLOUT: &str =
-            "Zed's edit prediction is powered by Zeta, an open-source, dataset mode.";
-
         let providers: Vec<_> = available_providers
             .into_iter()
             .filter(|p| *p != EditPredictionProvider::None)
@@ -581,153 +575,32 @@ impl EditPredictionButton {
                 let is_current = provider == current_provider;
                 let fs = self.fs.clone();
 
-                menu = match provider {
-                    EditPredictionProvider::Zed => menu.item(
-                        ContextMenuEntry::new("Zed AI")
-                            .toggleable(IconPosition::Start, is_current)
-                            .documentation_aside(
-                                DocumentationSide::Left,
-                                DocumentationEdge::Bottom,
-                                |_| Label::new(ZED_AI_CALLOUT).into_any_element(),
-                            )
-                            .handler(move |_, cx| {
-                                set_completion_provider(fs.clone(), cx, provider);
-                            }),
-                    ),
-                    EditPredictionProvider::Copilot => menu.item(
-                        ContextMenuEntry::new("GitHub Copilot")
-                            .toggleable(IconPosition::Start, is_current)
-                            .handler(move |_, cx| {
-                                set_completion_provider(fs.clone(), cx, provider);
-                            }),
-                    ),
-                    EditPredictionProvider::Supermaven => menu.item(
-                        ContextMenuEntry::new("Supermaven")
-                            .toggleable(IconPosition::Start, is_current)
-                            .handler(move |_, cx| {
-                                set_completion_provider(fs.clone(), cx, provider);
-                            }),
-                    ),
-                    EditPredictionProvider::Codestral => menu.item(
-                        ContextMenuEntry::new("Codestral")
-                            .toggleable(IconPosition::Start, is_current)
-                            .handler(move |_, cx| {
-                                set_completion_provider(fs.clone(), cx, provider);
-                            }),
-                    ),
+                let name = match provider {
+                    EditPredictionProvider::Zed => "Zed AI",
+                    EditPredictionProvider::Copilot => "GitHub Copilot",
+                    EditPredictionProvider::Supermaven => "Supermaven",
+                    EditPredictionProvider::Codestral => "Codestral",
                     EditPredictionProvider::Experimental(
                         EXPERIMENTAL_SWEEP_EDIT_PREDICTION_PROVIDER_NAME,
-                    ) => {
-                        let has_api_token = edit_prediction::EditPredictionStore::try_global(cx)
-                            .map_or(false, |ep_store| ep_store.read(cx).has_sweep_api_token());
-
-                        let should_open_modal = !has_api_token || is_current;
-
-                        let entry = if has_api_token {
-                            ContextMenuEntry::new("Sweep")
-                                .toggleable(IconPosition::Start, is_current)
-                        } else {
-                            ContextMenuEntry::new("Sweep")
-                                .icon(IconName::XCircle)
-                                .icon_color(Color::Error)
-                                .documentation_aside(
-                                    DocumentationSide::Left,
-                                    DocumentationEdge::Bottom,
-                                    |_| {
-                                        Label::new("Click to configure your Sweep API token")
-                                            .into_any_element()
-                                    },
-                                )
-                        };
-
-                        let entry = entry.handler(move |window, cx| {
-                            if should_open_modal {
-                                if let Some(workspace) = window.root::<Workspace>().flatten() {
-                                    workspace.update(cx, |workspace, cx| {
-                                        workspace.toggle_modal(window, cx, |window, cx| {
-                                            ExternalProviderApiKeyModal::new(
-                                                window,
-                                                cx,
-                                                |api_key, store, cx| {
-                                                    store
-                                                        .sweep_ai
-                                                        .set_api_token(api_key, cx)
-                                                        .detach_and_log_err(cx);
-                                                },
-                                            )
-                                        });
-                                    });
-                                };
-                            } else {
-                                set_completion_provider(fs.clone(), cx, provider);
-                            }
-                        });
-
-                        menu.item(entry)
-                    }
+                    ) => "Sweep",
                     EditPredictionProvider::Experimental(
                         EXPERIMENTAL_MERCURY_EDIT_PREDICTION_PROVIDER_NAME,
-                    ) => {
-                        let has_api_token = edit_prediction::EditPredictionStore::try_global(cx)
-                            .map_or(false, |ep_store| ep_store.read(cx).has_mercury_api_token());
-
-                        let should_open_modal = !has_api_token || is_current;
-
-                        let entry = if has_api_token {
-                            ContextMenuEntry::new("Mercury")
-                                .toggleable(IconPosition::Start, is_current)
-                        } else {
-                            ContextMenuEntry::new("Mercury")
-                                .icon(IconName::XCircle)
-                                .icon_color(Color::Error)
-                                .documentation_aside(
-                                    DocumentationSide::Left,
-                                    DocumentationEdge::Bottom,
-                                    |_| {
-                                        Label::new("Click to configure your Mercury API token")
-                                            .into_any_element()
-                                    },
-                                )
-                        };
-
-                        let entry = entry.handler(move |window, cx| {
-                            if should_open_modal {
-                                if let Some(workspace) = window.root::<Workspace>().flatten() {
-                                    workspace.update(cx, |workspace, cx| {
-                                        workspace.toggle_modal(window, cx, |window, cx| {
-                                            ExternalProviderApiKeyModal::new(
-                                                window,
-                                                cx,
-                                                |api_key, store, cx| {
-                                                    store
-                                                        .mercury
-                                                        .set_api_token(api_key, cx)
-                                                        .detach_and_log_err(cx);
-                                                },
-                                            )
-                                        });
-                                    });
-                                };
-                            } else {
-                                set_completion_provider(fs.clone(), cx, provider);
-                            }
-                        });
-
-                        menu.item(entry)
-                    }
+                    ) => "Mercury",
                     EditPredictionProvider::Experimental(
                         EXPERIMENTAL_ZETA2_EDIT_PREDICTION_PROVIDER_NAME,
-                    ) => menu.item(
-                        ContextMenuEntry::new("Zeta2")
-                            .toggleable(IconPosition::Start, is_current)
-                            .handler(move |_, cx| {
-                                set_completion_provider(fs.clone(), cx, provider);
-                            }),
-                    ),
+                    ) => "Zeta2",
                     EditPredictionProvider::None | EditPredictionProvider::Experimental(_) => {
                         continue;
                     }
                 };
+
+                menu = menu.item(
+                    ContextMenuEntry::new(name)
+                        .toggleable(IconPosition::Start, is_current)
+                        .handler(move |_, cx| {
+                            set_completion_provider(fs.clone(), cx, provider);
+                        }),
+                )
             }
         }
 
@@ -832,14 +705,7 @@ impl EditPredictionButton {
         let subtle_mode = matches!(current_mode, EditPredictionsMode::Subtle);
         let eager_mode = matches!(current_mode, EditPredictionsMode::Eager);
 
-        if matches!(
-            provider,
-            EditPredictionProvider::Zed
-                | EditPredictionProvider::Copilot
-                | EditPredictionProvider::Supermaven
-                | EditPredictionProvider::Codestral
-        ) {
-            menu = menu
+        menu = menu
                 .separator()
                 .header("Display Modes")
                 .item(
@@ -868,104 +734,111 @@ impl EditPredictionButton {
                             }
                         }),
                 );
-        }
 
         menu = menu.separator().header("Privacy");
 
-        if let Some(provider) = &self.edit_prediction_provider {
-            let data_collection = provider.data_collection_state(cx);
-
-            if data_collection.is_supported() {
-                let provider = provider.clone();
-                let enabled = data_collection.is_enabled();
-                let is_open_source = data_collection.is_project_open_source();
-                let is_collecting = data_collection.is_enabled();
-                let (icon_name, icon_color) = if is_open_source && is_collecting {
-                    (IconName::Check, Color::Success)
-                } else {
-                    (IconName::Check, Color::Accent)
-                };
-
-                menu = menu.item(
-                    ContextMenuEntry::new("Training Data Collection")
-                        .toggleable(IconPosition::Start, data_collection.is_enabled())
-                        .icon(icon_name)
-                        .icon_color(icon_color)
-                        .documentation_aside(DocumentationSide::Left, DocumentationEdge::Top, move |cx| {
-                            let (msg, label_color, icon_name, icon_color) = match (is_open_source, is_collecting) {
-                                (true, true) => (
-                                    "Project identified as open source, and you're sharing data.",
-                                    Color::Default,
-                                    IconName::Check,
-                                    Color::Success,
-                                ),
-                                (true, false) => (
-                                    "Project identified as open source, but you're not sharing data.",
-                                    Color::Muted,
-                                    IconName::Close,
-                                    Color::Muted,
-                                ),
-                                (false, true) => (
-                                    "Project not identified as open source. No data captured.",
-                                    Color::Muted,
-                                    IconName::Close,
-                                    Color::Muted,
-                                ),
-                                (false, false) => (
-                                    "Project not identified as open source, and setting turned off.",
-                                    Color::Muted,
-                                    IconName::Close,
-                                    Color::Muted,
-                                ),
-                            };
-                            v_flex()
-                                .gap_2()
-                                .child(
-                                    Label::new(indoc!{
-                                        "Help us improve our open dataset model by sharing data from open source repositories. \
-                                        Zed must detect a license file in your repo for this setting to take effect. \
-                                        Files with sensitive data and secrets are excluded by default."
-                                    })
-                                )
-                                .child(
-                                    h_flex()
-                                        .items_start()
-                                        .pt_2()
-                                        .pr_1()
-                                        .flex_1()
-                                        .gap_1p5()
-                                        .border_t_1()
-                                        .border_color(cx.theme().colors().border_variant)
-                                        .child(h_flex().flex_shrink_0().h(line_height).child(Icon::new(icon_name).size(IconSize::XSmall).color(icon_color)))
-                                        .child(div().child(msg).w_full().text_sm().text_color(label_color.color(cx)))
-                                )
-                                .into_any_element()
-                        })
-                        .handler(move |_, cx| {
-                            provider.toggle_data_collection(cx);
-
-                            if !enabled {
-                                telemetry::event!(
-                                    "Data Collection Enabled",
-                                    source = "Edit Prediction Status Menu"
-                                );
-                            } else {
-                                telemetry::event!(
-                                    "Data Collection Disabled",
-                                    source = "Edit Prediction Status Menu"
-                                );
-                            }
-                        })
-                );
+        if matches!(
+            provider,
+            EditPredictionProvider::Zed
+                | EditPredictionProvider::Experimental(
+                    EXPERIMENTAL_ZETA2_EDIT_PREDICTION_PROVIDER_NAME,
+                )
+        ) {
+            if let Some(provider) = &self.edit_prediction_provider {
+                let data_collection = provider.data_collection_state(cx);
+
+                if data_collection.is_supported() {
+                    let provider = provider.clone();
+                    let enabled = data_collection.is_enabled();
+                    let is_open_source = data_collection.is_project_open_source();
+                    let is_collecting = data_collection.is_enabled();
+                    let (icon_name, icon_color) = if is_open_source && is_collecting {
+                        (IconName::Check, Color::Success)
+                    } else {
+                        (IconName::Check, Color::Accent)
+                    };
 
-                if is_collecting && !is_open_source {
                     menu = menu.item(
-                        ContextMenuEntry::new("No data captured.")
-                            .disabled(true)
-                            .icon(IconName::Close)
-                            .icon_color(Color::Error)
-                            .icon_size(IconSize::Small),
+                        ContextMenuEntry::new("Training Data Collection")
+                            .toggleable(IconPosition::Start, data_collection.is_enabled())
+                            .icon(icon_name)
+                            .icon_color(icon_color)
+                            .documentation_aside(DocumentationSide::Left, DocumentationEdge::Top, move |cx| {
+                                let (msg, label_color, icon_name, icon_color) = match (is_open_source, is_collecting) {
+                                    (true, true) => (
+                                        "Project identified as open source, and you're sharing data.",
+                                        Color::Default,
+                                        IconName::Check,
+                                        Color::Success,
+                                    ),
+                                    (true, false) => (
+                                        "Project identified as open source, but you're not sharing data.",
+                                        Color::Muted,
+                                        IconName::Close,
+                                        Color::Muted,
+                                    ),
+                                    (false, true) => (
+                                        "Project not identified as open source. No data captured.",
+                                        Color::Muted,
+                                        IconName::Close,
+                                        Color::Muted,
+                                    ),
+                                    (false, false) => (
+                                        "Project not identified as open source, and setting turned off.",
+                                        Color::Muted,
+                                        IconName::Close,
+                                        Color::Muted,
+                                    ),
+                                };
+                                v_flex()
+                                    .gap_2()
+                                    .child(
+                                        Label::new(indoc!{
+                                            "Help us improve our open dataset model by sharing data from open source repositories. \
+                                            Zed must detect a license file in your repo for this setting to take effect. \
+                                            Files with sensitive data and secrets are excluded by default."
+                                        })
+                                    )
+                                    .child(
+                                        h_flex()
+                                            .items_start()
+                                            .pt_2()
+                                            .pr_1()
+                                            .flex_1()
+                                            .gap_1p5()
+                                            .border_t_1()
+                                            .border_color(cx.theme().colors().border_variant)
+                                            .child(h_flex().flex_shrink_0().h(line_height).child(Icon::new(icon_name).size(IconSize::XSmall).color(icon_color)))
+                                            .child(div().child(msg).w_full().text_sm().text_color(label_color.color(cx)))
+                                    )
+                                    .into_any_element()
+                            })
+                            .handler(move |_, cx| {
+                                provider.toggle_data_collection(cx);
+
+                                if !enabled {
+                                    telemetry::event!(
+                                        "Data Collection Enabled",
+                                        source = "Edit Prediction Status Menu"
+                                    );
+                                } else {
+                                    telemetry::event!(
+                                        "Data Collection Disabled",
+                                        source = "Edit Prediction Status Menu"
+                                    );
+                                }
+                            })
                     );
+
+                    if is_collecting && !is_open_source {
+                        menu = menu.item(
+                            ContextMenuEntry::new("No data captured.")
+                                .disabled(true)
+                                .icon(IconName::Close)
+                                .icon_color(Color::Error)
+                                .icon_size(IconSize::Small),
+                        );
+                    }
                 }
             }
         }
@@ -1087,10 +960,7 @@ impl EditPredictionButton {
             let menu =
                 self.add_provider_switching_section(menu, EditPredictionProvider::Codestral, cx);
 
-            menu.separator()
-                .entry("Configure Codestral API Key", None, move |window, cx| {
-                    window.dispatch_action(zed_actions::agent::OpenSettings.boxed_clone(), cx);
-                })
+            menu
         })
     }
 
@@ -1210,6 +1080,22 @@ impl EditPredictionButton {
             }
 
             menu = self.add_provider_switching_section(menu, provider, cx);
+            menu = menu.separator().item(
+                ContextMenuEntry::new("Configure Providers")
+                    .icon(IconName::Settings)
+                    .icon_position(IconPosition::Start)
+                    .icon_color(Color::Muted)
+                    .handler(move |window, cx| {
+                        window.dispatch_action(
+                            OpenSettingsAt {
+                                path: "edit_predictions.providers".to_string(),
+                            }
+                            .boxed_clone(),
+                            cx,
+                        );
+                    }),
+            );
+
             menu
         })
     }

crates/edit_prediction_ui/src/edit_prediction_ui.rs ๐Ÿ”—

@@ -1,6 +1,5 @@
 mod edit_prediction_button;
 mod edit_prediction_context_view;
-mod external_provider_api_token_modal;
 mod rate_prediction_modal;
 
 use std::any::{Any as _, TypeId};
@@ -17,7 +16,6 @@ use ui::{App, prelude::*};
 use workspace::{SplitDirection, Workspace};
 
 pub use edit_prediction_button::{EditPredictionButton, ToggleMenu};
-pub use external_provider_api_token_modal::ExternalProviderApiKeyModal;
 
 use crate::rate_prediction_modal::PredictEditsRatePredictionsFeatureFlag;
 

crates/edit_prediction_ui/src/external_provider_api_token_modal.rs ๐Ÿ”—

@@ -1,86 +0,0 @@
-use edit_prediction::EditPredictionStore;
-use gpui::{
-    DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, IntoElement, ParentElement, Render,
-};
-use ui::{Button, ButtonStyle, Clickable, Headline, HeadlineSize, prelude::*};
-use ui_input::InputField;
-use workspace::ModalView;
-
-pub struct ExternalProviderApiKeyModal {
-    api_key_input: Entity<InputField>,
-    focus_handle: FocusHandle,
-    on_confirm: Box<dyn Fn(Option<String>, &mut EditPredictionStore, &mut App)>,
-}
-
-impl ExternalProviderApiKeyModal {
-    pub fn new(
-        window: &mut Window,
-        cx: &mut Context<Self>,
-        on_confirm: impl Fn(Option<String>, &mut EditPredictionStore, &mut App) + 'static,
-    ) -> Self {
-        let api_key_input = cx.new(|cx| InputField::new(window, cx, "Enter your API key"));
-
-        Self {
-            api_key_input,
-            focus_handle: cx.focus_handle(),
-            on_confirm: Box::new(on_confirm),
-        }
-    }
-
-    fn cancel(&mut self, _: &menu::Cancel, _window: &mut Window, cx: &mut Context<Self>) {
-        cx.emit(DismissEvent);
-    }
-
-    fn confirm(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context<Self>) {
-        let api_key = self.api_key_input.read(cx).text(cx);
-        let api_key = (!api_key.trim().is_empty()).then_some(api_key);
-
-        if let Some(ep_store) = EditPredictionStore::try_global(cx) {
-            ep_store.update(cx, |ep_store, cx| (self.on_confirm)(api_key, ep_store, cx))
-        }
-
-        cx.emit(DismissEvent);
-    }
-}
-
-impl EventEmitter<DismissEvent> for ExternalProviderApiKeyModal {}
-
-impl ModalView for ExternalProviderApiKeyModal {}
-
-impl Focusable for ExternalProviderApiKeyModal {
-    fn focus_handle(&self, _cx: &App) -> FocusHandle {
-        self.focus_handle.clone()
-    }
-}
-
-impl Render for ExternalProviderApiKeyModal {
-    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
-        v_flex()
-            .key_context("ExternalApiKeyModal")
-            .on_action(cx.listener(Self::cancel))
-            .on_action(cx.listener(Self::confirm))
-            .elevation_2(cx)
-            .w(px(400.))
-            .p_4()
-            .gap_3()
-            .child(Headline::new("API Token").size(HeadlineSize::Small))
-            .child(self.api_key_input.clone())
-            .child(
-                h_flex()
-                    .justify_end()
-                    .gap_2()
-                    .child(Button::new("cancel", "Cancel").on_click(cx.listener(
-                        |_, _, _window, cx| {
-                            cx.emit(DismissEvent);
-                        },
-                    )))
-                    .child(
-                        Button::new("save", "Save")
-                            .style(ButtonStyle::Filled)
-                            .on_click(cx.listener(|this, _, window, cx| {
-                                this.confirm(&menu::Confirm, window, cx);
-                            })),
-                    ),
-            )
-    }
-}

crates/language_model/Cargo.toml ๐Ÿ”—

@@ -18,6 +18,7 @@ test-support = []
 [dependencies]
 anthropic = { workspace = true, features = ["schemars"] }
 anyhow.workspace = true
+credentials_provider.workspace = true
 base64.workspace = true
 client.workspace = true
 cloud_api_types.workspace = true
@@ -41,6 +42,7 @@ smol.workspace = true
 telemetry_events.workspace = true
 thiserror.workspace = true
 util.workspace = true
+zed_env_vars.workspace = true
 
 [dev-dependencies]
 gpui = { workspace = true, features = ["test-support"] }

crates/language_models/src/api_key.rs โ†’ crates/language_model/src/api_key.rs ๐Ÿ”—

@@ -2,7 +2,6 @@ use anyhow::{Result, anyhow};
 use credentials_provider::CredentialsProvider;
 use futures::{FutureExt, future};
 use gpui::{AsyncApp, Context, SharedString, Task};
-use language_model::AuthenticateError;
 use std::{
     fmt::{Display, Formatter},
     sync::Arc,
@@ -10,13 +9,16 @@ use std::{
 use util::ResultExt as _;
 use zed_env_vars::EnvVar;
 
+use crate::AuthenticateError;
+
 /// Manages a single API key for a language model provider. API keys either come from environment
 /// variables or the system keychain.
 ///
 /// Keys from the system keychain are associated with a provider URL, and this ensures that they are
 /// only used with that URL.
 pub struct ApiKeyState {
-    url: SharedString,
+    pub url: SharedString,
+    env_var: EnvVar,
     load_status: LoadStatus,
     load_task: Option<future::Shared<Task<()>>>,
 }
@@ -35,9 +37,10 @@ pub struct ApiKey {
 }
 
 impl ApiKeyState {
-    pub fn new(url: SharedString) -> Self {
+    pub fn new(url: SharedString, env_var: EnvVar) -> Self {
         Self {
             url,
+            env_var,
             load_status: LoadStatus::NotPresent,
             load_task: None,
         }
@@ -47,6 +50,10 @@ impl ApiKeyState {
         matches!(self.load_status, LoadStatus::Loaded { .. })
     }
 
+    pub fn env_var_name(&self) -> &SharedString {
+        &self.env_var.name
+    }
+
     pub fn is_from_env_var(&self) -> bool {
         match &self.load_status {
             LoadStatus::Loaded(ApiKey {
@@ -136,14 +143,13 @@ impl ApiKeyState {
     pub fn handle_url_change<Ent: 'static>(
         &mut self,
         url: SharedString,
-        env_var: &EnvVar,
         get_this: impl Fn(&mut Ent) -> &mut Self + Clone + 'static,
         cx: &mut Context<Ent>,
     ) {
         if url != self.url {
             if !self.is_from_env_var() {
                 // loading will continue even though this result task is dropped
-                let _task = self.load_if_needed(url, env_var, get_this, cx);
+                let _task = self.load_if_needed(url, get_this, cx);
             }
         }
     }
@@ -156,7 +162,6 @@ impl ApiKeyState {
     pub fn load_if_needed<Ent: 'static>(
         &mut self,
         url: SharedString,
-        env_var: &EnvVar,
         get_this: impl Fn(&mut Ent) -> &mut Self + Clone + 'static,
         cx: &mut Context<Ent>,
     ) -> Task<Result<(), AuthenticateError>> {
@@ -166,10 +171,10 @@ impl ApiKeyState {
             return Task::ready(Ok(()));
         }
 
-        if let Some(key) = &env_var.value
+        if let Some(key) = &self.env_var.value
             && !key.is_empty()
         {
-            let api_key = ApiKey::from_env(env_var.name.clone(), key);
+            let api_key = ApiKey::from_env(self.env_var.name.clone(), key);
             self.url = url;
             self.load_status = LoadStatus::Loaded(api_key);
             self.load_task = None;

crates/language_model/src/language_model.rs ๐Ÿ”—

@@ -1,3 +1,4 @@
+mod api_key;
 mod model;
 mod rate_limiter;
 mod registry;
@@ -30,6 +31,7 @@ use std::{fmt, io};
 use thiserror::Error;
 use util::serde::is_default;
 
+pub use crate::api_key::{ApiKey, ApiKeyState};
 pub use crate::model::*;
 pub use crate::rate_limiter::*;
 pub use crate::registry::*;
@@ -37,6 +39,7 @@ pub use crate::request::*;
 pub use crate::role::*;
 pub use crate::telemetry::*;
 pub use crate::tool_schema::LanguageModelToolSchemaFormat;
+pub use zed_env_vars::{EnvVar, env_var};
 
 pub const ANTHROPIC_PROVIDER_ID: LanguageModelProviderId =
     LanguageModelProviderId::new("anthropic");

crates/language_models/Cargo.toml ๐Ÿ”—

@@ -60,7 +60,6 @@ ui_input.workspace = true
 util.workspace = true
 vercel = { workspace = true, features = ["schemars"] }
 x_ai = { workspace = true, features = ["schemars"] }
-zed_env_vars.workspace = true
 
 [dev-dependencies]
 editor = { workspace = true, features = ["test-support"] }

crates/language_models/src/language_models.rs ๐Ÿ”—

@@ -7,10 +7,8 @@ use gpui::{App, Context, Entity};
 use language_model::{LanguageModelProviderId, LanguageModelRegistry};
 use provider::deepseek::DeepSeekLanguageModelProvider;
 
-mod api_key;
 pub mod provider;
 mod settings;
-pub mod ui;
 
 use crate::provider::anthropic::AnthropicLanguageModelProvider;
 use crate::provider::bedrock::BedrockLanguageModelProvider;

crates/language_models/src/provider/anthropic.rs ๐Ÿ”—

@@ -8,25 +8,21 @@ use futures::{FutureExt, Stream, StreamExt, future, future::BoxFuture, stream::B
 use gpui::{AnyView, App, AsyncApp, Context, Entity, Task};
 use http_client::HttpClient;
 use language_model::{
-    AuthenticateError, ConfigurationViewTargetAgent, LanguageModel,
-    LanguageModelCacheConfiguration, LanguageModelCompletionError, LanguageModelId,
-    LanguageModelName, LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName,
-    LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice,
-    LanguageModelToolResultContent, MessageContent, RateLimiter, Role,
+    ApiKeyState, AuthenticateError, ConfigurationViewTargetAgent, EnvVar, LanguageModel,
+    LanguageModelCacheConfiguration, LanguageModelCompletionError, LanguageModelCompletionEvent,
+    LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId,
+    LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest,
+    LanguageModelToolChoice, LanguageModelToolResultContent, LanguageModelToolUse, MessageContent,
+    RateLimiter, Role, StopReason, env_var,
 };
-use language_model::{LanguageModelCompletionEvent, LanguageModelToolUse, StopReason};
 use settings::{Settings, SettingsStore};
 use std::pin::Pin;
 use std::str::FromStr;
 use std::sync::{Arc, LazyLock};
 use strum::IntoEnumIterator;
-use ui::{List, prelude::*};
+use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*};
 use ui_input::InputField;
 use util::ResultExt;
-use zed_env_vars::{EnvVar, env_var};
-
-use crate::api_key::ApiKeyState;
-use crate::ui::{ConfiguredApiCard, InstructionListItem};
 
 pub use settings::AnthropicAvailableModel as AvailableModel;
 
@@ -65,12 +61,8 @@ impl State {
 
     fn authenticate(&mut self, cx: &mut Context<Self>) -> Task<Result<(), AuthenticateError>> {
         let api_url = AnthropicLanguageModelProvider::api_url(cx);
-        self.api_key_state.load_if_needed(
-            api_url,
-            &API_KEY_ENV_VAR,
-            |this| &mut this.api_key_state,
-            cx,
-        )
+        self.api_key_state
+            .load_if_needed(api_url, |this| &mut this.api_key_state, cx)
     }
 }
 
@@ -79,17 +71,13 @@ impl AnthropicLanguageModelProvider {
         let state = cx.new(|cx| {
             cx.observe_global::<SettingsStore>(|this: &mut State, cx| {
                 let api_url = Self::api_url(cx);
-                this.api_key_state.handle_url_change(
-                    api_url,
-                    &API_KEY_ENV_VAR,
-                    |this| &mut this.api_key_state,
-                    cx,
-                );
+                this.api_key_state
+                    .handle_url_change(api_url, |this| &mut this.api_key_state, cx);
                 cx.notify();
             })
             .detach();
             State {
-                api_key_state: ApiKeyState::new(Self::api_url(cx)),
+                api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()),
             }
         });
 
@@ -937,14 +925,12 @@ impl Render for ConfigurationView {
                 .child(
                     List::new()
                         .child(
-                            InstructionListItem::new(
-                                "Create one by visiting",
-                                Some("Anthropic's settings"),
-                                Some("https://console.anthropic.com/settings/keys")
-                            )
+                            ListBulletItem::new("")
+                                .child(Label::new("Create one by visiting"))
+                                .child(ButtonLink::new("Anthropic's settings", "https://console.anthropic.com/settings/keys"))
                         )
                         .child(
-                            InstructionListItem::text_only("Paste your API key below and hit enter to start using the agent")
+                            ListBulletItem::new("Paste your API key below and hit enter to start using the agent")
                         )
                 )
                 .child(self.api_key_editor.clone())
@@ -953,7 +939,8 @@ impl Render for ConfigurationView {
                         format!("You can also assign the {API_KEY_ENV_VAR_NAME} environment variable and restart Zed."),
                     )
                     .size(LabelSize::Small)
-                    .color(Color::Muted),
+                    .color(Color::Muted)
+                    .mt_0p5(),
                 )
                 .into_any_element()
         } else {

crates/language_models/src/provider/bedrock.rs ๐Ÿ”—

@@ -2,7 +2,6 @@ use std::pin::Pin;
 use std::str::FromStr;
 use std::sync::Arc;
 
-use crate::ui::{ConfiguredApiCard, InstructionListItem};
 use anyhow::{Context as _, Result, anyhow};
 use aws_config::stalled_stream_protection::StalledStreamProtectionConfig;
 use aws_config::{BehaviorVersion, Region};
@@ -44,7 +43,7 @@ use serde_json::Value;
 use settings::{BedrockAvailableModel as AvailableModel, Settings, SettingsStore};
 use smol::lock::OnceCell;
 use strum::{EnumIter, IntoEnumIterator, IntoStaticStr};
-use ui::{List, prelude::*};
+use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*};
 use ui_input::InputField;
 use util::ResultExt;
 
@@ -1250,18 +1249,14 @@ impl Render for ConfigurationView {
             .child(
                 List::new()
                     .child(
-                        InstructionListItem::new(
-                            "Grant permissions to the strategy you'll use according to the:",
-                            Some("Prerequisites"),
-                            Some("https://docs.aws.amazon.com/bedrock/latest/userguide/inference-prereq.html"),
-                        )
+                        ListBulletItem::new("")
+                            .child(Label::new("Grant permissions to the strategy you'll use according to the:"))
+                            .child(ButtonLink::new("Prerequisites", "https://docs.aws.amazon.com/bedrock/latest/userguide/inference-prereq.html"))
                     )
                     .child(
-                        InstructionListItem::new(
-                            "Select the models you would like access to:",
-                            Some("Bedrock Model Catalog"),
-                            Some("https://us-east-1.console.aws.amazon.com/bedrock/home?region=us-east-1#/modelaccess"),
-                        )
+                        ListBulletItem::new("")
+                            .child(Label::new("Select the models you would like access to:"))
+                            .child(ButtonLink::new("Bedrock Model Catalog", "https://us-east-1.console.aws.amazon.com/bedrock/home?region=us-east-1#/modelaccess"))
                     )
             )
             .child(self.render_static_credentials_ui())
@@ -1302,22 +1297,22 @@ impl ConfigurationView {
             )
             .child(
                 List::new()
-                    .child(InstructionListItem::new(
-                        "Create an IAM user in the AWS console with programmatic access",
-                        Some("IAM Console"),
-                        Some("https://us-east-1.console.aws.amazon.com/iam/home?region=us-east-1#/users"),
-                    ))
-                    .child(InstructionListItem::new(
-                        "Attach the necessary Bedrock permissions to this ",
-                        Some("user"),
-                        Some("https://docs.aws.amazon.com/bedrock/latest/userguide/inference-prereq.html"),
-                    ))
-                    .child(InstructionListItem::text_only(
-                        "Copy the access key ID and secret access key when provided",
-                    ))
-                    .child(InstructionListItem::text_only(
-                        "Enter these credentials below",
-                    )),
+                    .child(
+                        ListBulletItem::new("")
+                            .child(Label::new("Create an IAM user in the AWS console with programmatic access"))
+                            .child(ButtonLink::new("IAM Console", "https://us-east-1.console.aws.amazon.com/iam/home?region=us-east-1#/users"))
+                    )
+                    .child(
+                        ListBulletItem::new("")
+                            .child(Label::new("Attach the necessary Bedrock permissions to this"))
+                            .child(ButtonLink::new("user", "https://docs.aws.amazon.com/bedrock/latest/userguide/inference-prereq.html"))
+                    )
+                    .child(
+                        ListBulletItem::new("Copy the access key ID and secret access key when provided")
+                    )
+                    .child(
+                        ListBulletItem::new("Enter these credentials below")
+                    )
             )
             .child(self.access_key_id_editor.clone())
             .child(self.secret_access_key_editor.clone())

crates/language_models/src/provider/copilot_chat.rs ๐Ÿ”—

@@ -14,7 +14,7 @@ use copilot::{Copilot, Status};
 use futures::future::BoxFuture;
 use futures::stream::BoxStream;
 use futures::{FutureExt, Stream, StreamExt};
-use gpui::{Action, AnyView, App, AsyncApp, Entity, Render, Subscription, Task, svg};
+use gpui::{AnyView, App, AsyncApp, Entity, Subscription, Task};
 use http_client::StatusCode;
 use language::language_settings::all_language_settings;
 use language_model::{
@@ -26,11 +26,9 @@ use language_model::{
     StopReason, TokenUsage,
 };
 use settings::SettingsStore;
-use ui::{CommonAnimationExt, prelude::*};
+use ui::prelude::*;
 use util::debug_panic;
 
-use crate::ui::ConfiguredApiCard;
-
 const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("copilot_chat");
 const PROVIDER_NAME: LanguageModelProviderName =
     LanguageModelProviderName::new("GitHub Copilot Chat");
@@ -179,8 +177,18 @@ impl LanguageModelProvider for CopilotChatLanguageModelProvider {
         _: &mut Window,
         cx: &mut App,
     ) -> AnyView {
-        let state = self.state.clone();
-        cx.new(|cx| ConfigurationView::new(state, cx)).into()
+        cx.new(|cx| {
+            copilot::ConfigurationView::new(
+                |cx| {
+                    CopilotChat::global(cx)
+                        .map(|m| m.read(cx).is_authenticated())
+                        .unwrap_or(false)
+                },
+                copilot::ConfigurationMode::Chat,
+                cx,
+            )
+        })
+        .into()
     }
 
     fn reset_credentials(&self, _cx: &mut App) -> Task<Result<()>> {
@@ -1474,92 +1482,3 @@ mod tests {
         );
     }
 }
-struct ConfigurationView {
-    copilot_status: Option<copilot::Status>,
-    state: Entity<State>,
-    _subscription: Option<Subscription>,
-}
-
-impl ConfigurationView {
-    pub fn new(state: Entity<State>, cx: &mut Context<Self>) -> Self {
-        let copilot = Copilot::global(cx);
-
-        Self {
-            copilot_status: copilot.as_ref().map(|copilot| copilot.read(cx).status()),
-            state,
-            _subscription: copilot.as_ref().map(|copilot| {
-                cx.observe(copilot, |this, model, cx| {
-                    this.copilot_status = Some(model.read(cx).status());
-                    cx.notify();
-                })
-            }),
-        }
-    }
-}
-
-impl Render for ConfigurationView {
-    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
-        if self.state.read(cx).is_authenticated(cx) {
-            ConfiguredApiCard::new("Authorized")
-                .button_label("Sign Out")
-                .on_click(|_, window, cx| {
-                    window.dispatch_action(copilot::SignOut.boxed_clone(), cx);
-                })
-                .into_any_element()
-        } else {
-            let loading_icon = Icon::new(IconName::ArrowCircle).with_rotate_animation(4);
-
-            const ERROR_LABEL: &str = "Copilot Chat requires an active GitHub Copilot subscription. Please ensure Copilot is configured and try again, or use a different Assistant provider.";
-
-            match &self.copilot_status {
-                Some(status) => match status {
-                    Status::Starting { task: _ } => h_flex()
-                        .gap_2()
-                        .child(loading_icon)
-                        .child(Label::new("Starting Copilotโ€ฆ"))
-                        .into_any_element(),
-                    Status::SigningIn { prompt: _ }
-                    | Status::SignedOut {
-                        awaiting_signing_in: true,
-                    } => h_flex()
-                        .gap_2()
-                        .child(loading_icon)
-                        .child(Label::new("Signing into Copilotโ€ฆ"))
-                        .into_any_element(),
-                    Status::Error(_) => {
-                        const LABEL: &str = "Copilot had issues starting. Please try restarting it. If the issue persists, try reinstalling Copilot.";
-                        v_flex()
-                            .gap_6()
-                            .child(Label::new(LABEL))
-                            .child(svg().size_8().path(IconName::CopilotError.path()))
-                            .into_any_element()
-                    }
-                    _ => {
-                        const LABEL: &str = "To use Zed's agent with GitHub Copilot, you need to be logged in to GitHub. Note that your GitHub account must have an active Copilot Chat subscription.";
-
-                        v_flex()
-                            .gap_2()
-                            .child(Label::new(LABEL))
-                            .child(
-                                Button::new("sign_in", "Sign in to use GitHub Copilot")
-                                    .full_width()
-                                    .style(ButtonStyle::Outlined)
-                                    .icon_color(Color::Muted)
-                                    .icon(IconName::Github)
-                                    .icon_position(IconPosition::Start)
-                                    .icon_size(IconSize::Small)
-                                    .on_click(|_, window, cx| {
-                                        copilot::initiate_sign_in(window, cx)
-                                    }),
-                            )
-                            .into_any_element()
-                    }
-                },
-                None => v_flex()
-                    .gap_6()
-                    .child(Label::new(ERROR_LABEL))
-                    .into_any_element(),
-            }
-        }
-    }
-}

crates/language_models/src/provider/deepseek.rs ๐Ÿ”—

@@ -7,11 +7,11 @@ use futures::{FutureExt, StreamExt, future, future::BoxFuture, stream::BoxStream
 use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, Window};
 use http_client::HttpClient;
 use language_model::{
-    AuthenticateError, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent,
-    LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId,
-    LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest,
-    LanguageModelToolChoice, LanguageModelToolResultContent, LanguageModelToolUse, MessageContent,
-    RateLimiter, Role, StopReason, TokenUsage,
+    ApiKeyState, AuthenticateError, EnvVar, LanguageModel, LanguageModelCompletionError,
+    LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider,
+    LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState,
+    LanguageModelRequest, LanguageModelToolChoice, LanguageModelToolResultContent,
+    LanguageModelToolUse, MessageContent, RateLimiter, Role, StopReason, TokenUsage, env_var,
 };
 pub use settings::DeepseekAvailableModel as AvailableModel;
 use settings::{Settings, SettingsStore};
@@ -19,13 +19,9 @@ use std::pin::Pin;
 use std::str::FromStr;
 use std::sync::{Arc, LazyLock};
 
-use ui::{List, prelude::*};
+use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*};
 use ui_input::InputField;
 use util::ResultExt;
-use zed_env_vars::{EnvVar, env_var};
-
-use crate::ui::ConfiguredApiCard;
-use crate::{api_key::ApiKeyState, ui::InstructionListItem};
 
 const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("deepseek");
 const PROVIDER_NAME: LanguageModelProviderName = LanguageModelProviderName::new("DeepSeek");
@@ -67,12 +63,8 @@ impl State {
 
     fn authenticate(&mut self, cx: &mut Context<Self>) -> Task<Result<(), AuthenticateError>> {
         let api_url = DeepSeekLanguageModelProvider::api_url(cx);
-        self.api_key_state.load_if_needed(
-            api_url,
-            &API_KEY_ENV_VAR,
-            |this| &mut this.api_key_state,
-            cx,
-        )
+        self.api_key_state
+            .load_if_needed(api_url, |this| &mut this.api_key_state, cx)
     }
 }
 
@@ -81,17 +73,13 @@ impl DeepSeekLanguageModelProvider {
         let state = cx.new(|cx| {
             cx.observe_global::<SettingsStore>(|this: &mut State, cx| {
                 let api_url = Self::api_url(cx);
-                this.api_key_state.handle_url_change(
-                    api_url,
-                    &API_KEY_ENV_VAR,
-                    |this| &mut this.api_key_state,
-                    cx,
-                );
+                this.api_key_state
+                    .handle_url_change(api_url, |this| &mut this.api_key_state, cx);
                 cx.notify();
             })
             .detach();
             State {
-                api_key_state: ApiKeyState::new(Self::api_url(cx)),
+                api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()),
             }
         });
 
@@ -632,12 +620,15 @@ impl Render for ConfigurationView {
                 .child(Label::new("To use DeepSeek in Zed, you need an API key:"))
                 .child(
                     List::new()
-                        .child(InstructionListItem::new(
-                            "Get your API key from the",
-                            Some("DeepSeek console"),
-                            Some("https://platform.deepseek.com/api_keys"),
-                        ))
-                        .child(InstructionListItem::text_only(
+                        .child(
+                            ListBulletItem::new("")
+                                .child(Label::new("Get your API key from the"))
+                                .child(ButtonLink::new(
+                                    "DeepSeek console",
+                                    "https://platform.deepseek.com/api_keys",
+                                )),
+                        )
+                        .child(ListBulletItem::new(
                             "Paste your API key below and hit enter to start using the assistant",
                         )),
                 )

crates/language_models/src/provider/google.rs ๐Ÿ”—

@@ -9,7 +9,7 @@ use google_ai::{
 use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, Window};
 use http_client::HttpClient;
 use language_model::{
-    AuthenticateError, ConfigurationViewTargetAgent, LanguageModelCompletionError,
+    AuthenticateError, ConfigurationViewTargetAgent, EnvVar, LanguageModelCompletionError,
     LanguageModelCompletionEvent, LanguageModelToolChoice, LanguageModelToolSchemaFormat,
     LanguageModelToolUse, LanguageModelToolUseId, MessageContent, StopReason,
 };
@@ -28,14 +28,11 @@ use std::sync::{
     atomic::{self, AtomicU64},
 };
 use strum::IntoEnumIterator;
-use ui::{List, prelude::*};
+use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*};
 use ui_input::InputField;
 use util::ResultExt;
-use zed_env_vars::EnvVar;
 
-use crate::api_key::ApiKey;
-use crate::api_key::ApiKeyState;
-use crate::ui::{ConfiguredApiCard, InstructionListItem};
+use language_model::{ApiKey, ApiKeyState};
 
 const PROVIDER_ID: LanguageModelProviderId = language_model::GOOGLE_PROVIDER_ID;
 const PROVIDER_NAME: LanguageModelProviderName = language_model::GOOGLE_PROVIDER_NAME;
@@ -87,12 +84,8 @@ impl State {
 
     fn authenticate(&mut self, cx: &mut Context<Self>) -> Task<Result<(), AuthenticateError>> {
         let api_url = GoogleLanguageModelProvider::api_url(cx);
-        self.api_key_state.load_if_needed(
-            api_url,
-            &API_KEY_ENV_VAR,
-            |this| &mut this.api_key_state,
-            cx,
-        )
+        self.api_key_state
+            .load_if_needed(api_url, |this| &mut this.api_key_state, cx)
     }
 }
 
@@ -101,17 +94,13 @@ impl GoogleLanguageModelProvider {
         let state = cx.new(|cx| {
             cx.observe_global::<SettingsStore>(|this: &mut State, cx| {
                 let api_url = Self::api_url(cx);
-                this.api_key_state.handle_url_change(
-                    api_url,
-                    &API_KEY_ENV_VAR,
-                    |this| &mut this.api_key_state,
-                    cx,
-                );
+                this.api_key_state
+                    .handle_url_change(api_url, |this| &mut this.api_key_state, cx);
                 cx.notify();
             })
             .detach();
             State {
-                api_key_state: ApiKeyState::new(Self::api_url(cx)),
+                api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()),
             }
         });
 
@@ -873,14 +862,14 @@ impl Render for ConfigurationView {
                 })))
                 .child(
                     List::new()
-                        .child(InstructionListItem::new(
-                            "Create one by visiting",
-                            Some("Google AI's console"),
-                            Some("https://aistudio.google.com/app/apikey"),
-                        ))
-                        .child(InstructionListItem::text_only(
-                            "Paste your API key below and hit enter to start using the assistant",
-                        )),
+                        .child(
+                            ListBulletItem::new("")
+                                .child(Label::new("Create one by visiting"))
+                                .child(ButtonLink::new("Google AI's console", "https://aistudio.google.com/app/apikey"))
+                        )
+                        .child(
+                            ListBulletItem::new("Paste your API key below and hit enter to start using the agent")
+                        )
                 )
                 .child(self.api_key_editor.clone())
                 .child(

crates/language_models/src/provider/lmstudio.rs ๐Ÿ”—

@@ -20,11 +20,10 @@ use settings::{Settings, SettingsStore};
 use std::pin::Pin;
 use std::str::FromStr;
 use std::{collections::BTreeMap, sync::Arc};
-use ui::{ButtonLike, Indicator, List, prelude::*};
+use ui::{ButtonLike, Indicator, InlineCode, List, ListBulletItem, prelude::*};
 use util::ResultExt;
 
 use crate::AllLanguageModelSettings;
-use crate::ui::InstructionListItem;
 
 const LMSTUDIO_DOWNLOAD_URL: &str = "https://lmstudio.ai/download";
 const LMSTUDIO_CATALOG_URL: &str = "https://lmstudio.ai/models";
@@ -686,12 +685,14 @@ impl Render for ConfigurationView {
                 .child(
                     v_flex().gap_1().child(Label::new(lmstudio_intro)).child(
                         List::new()
-                            .child(InstructionListItem::text_only(
+                            .child(ListBulletItem::new(
                                 "LM Studio needs to be running with at least one model downloaded.",
                             ))
-                            .child(InstructionListItem::text_only(
-                                "To get your first model, try running `lms get qwen2.5-coder-7b`",
-                            )),
+                            .child(
+                                ListBulletItem::new("")
+                                    .child(Label::new("To get your first model, try running"))
+                                    .child(InlineCode::new("lms get qwen2.5-coder-7b")),
+                            ),
                     ),
                 )
                 .child(

crates/language_models/src/provider/mistral.rs ๐Ÿ”—

@@ -1,31 +1,27 @@
 use anyhow::{Result, anyhow};
 use collections::BTreeMap;
-use fs::Fs;
+
 use futures::{FutureExt, Stream, StreamExt, future, future::BoxFuture, stream::BoxStream};
 use gpui::{AnyView, App, AsyncApp, Context, Entity, Global, SharedString, Task, Window};
 use http_client::HttpClient;
 use language_model::{
-    AuthenticateError, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent,
-    LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId,
-    LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest,
-    LanguageModelToolChoice, LanguageModelToolResultContent, LanguageModelToolUse, MessageContent,
-    RateLimiter, Role, StopReason, TokenUsage,
+    ApiKeyState, AuthenticateError, EnvVar, LanguageModel, LanguageModelCompletionError,
+    LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider,
+    LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState,
+    LanguageModelRequest, LanguageModelToolChoice, LanguageModelToolResultContent,
+    LanguageModelToolUse, MessageContent, RateLimiter, Role, StopReason, TokenUsage, env_var,
 };
-use mistral::{CODESTRAL_API_URL, MISTRAL_API_URL, StreamResponse};
+pub use mistral::{CODESTRAL_API_URL, MISTRAL_API_URL, StreamResponse};
 pub use settings::MistralAvailableModel as AvailableModel;
-use settings::{EditPredictionProvider, Settings, SettingsStore, update_settings_file};
+use settings::{Settings, SettingsStore};
 use std::collections::HashMap;
 use std::pin::Pin;
 use std::str::FromStr;
-use std::sync::{Arc, LazyLock};
+use std::sync::{Arc, LazyLock, OnceLock};
 use strum::IntoEnumIterator;
-use ui::{List, prelude::*};
+use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*};
 use ui_input::InputField;
 use util::ResultExt;
-use zed_env_vars::{EnvVar, env_var};
-
-use crate::ui::ConfiguredApiCard;
-use crate::{api_key::ApiKeyState, ui::InstructionListItem};
 
 const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("mistral");
 const PROVIDER_NAME: LanguageModelProviderName = LanguageModelProviderName::new("Mistral");
@@ -35,6 +31,7 @@ static API_KEY_ENV_VAR: LazyLock<EnvVar> = env_var!(API_KEY_ENV_VAR_NAME);
 
 const CODESTRAL_API_KEY_ENV_VAR_NAME: &str = "CODESTRAL_API_KEY";
 static CODESTRAL_API_KEY_ENV_VAR: LazyLock<EnvVar> = env_var!(CODESTRAL_API_KEY_ENV_VAR_NAME);
+static CODESTRAL_API_KEY: OnceLock<Entity<ApiKeyState>> = OnceLock::new();
 
 #[derive(Default, Clone, Debug, PartialEq)]
 pub struct MistralSettings {
@@ -44,12 +41,22 @@ pub struct MistralSettings {
 
 pub struct MistralLanguageModelProvider {
     http_client: Arc<dyn HttpClient>,
-    state: Entity<State>,
+    pub state: Entity<State>,
 }
 
 pub struct State {
     api_key_state: ApiKeyState,
-    codestral_api_key_state: ApiKeyState,
+    codestral_api_key_state: Entity<ApiKeyState>,
+}
+
+pub fn codestral_api_key(cx: &mut App) -> Entity<ApiKeyState> {
+    return CODESTRAL_API_KEY
+        .get_or_init(|| {
+            cx.new(|_| {
+                ApiKeyState::new(CODESTRAL_API_URL.into(), CODESTRAL_API_KEY_ENV_VAR.clone())
+            })
+        })
+        .clone();
 }
 
 impl State {
@@ -63,39 +70,19 @@ impl State {
             .store(api_url, api_key, |this| &mut this.api_key_state, cx)
     }
 
-    fn set_codestral_api_key(
-        &mut self,
-        api_key: Option<String>,
-        cx: &mut Context<Self>,
-    ) -> Task<Result<()>> {
-        self.codestral_api_key_state.store(
-            CODESTRAL_API_URL.into(),
-            api_key,
-            |this| &mut this.codestral_api_key_state,
-            cx,
-        )
-    }
-
     fn authenticate(&mut self, cx: &mut Context<Self>) -> Task<Result<(), AuthenticateError>> {
         let api_url = MistralLanguageModelProvider::api_url(cx);
-        self.api_key_state.load_if_needed(
-            api_url,
-            &API_KEY_ENV_VAR,
-            |this| &mut this.api_key_state,
-            cx,
-        )
+        self.api_key_state
+            .load_if_needed(api_url, |this| &mut this.api_key_state, cx)
     }
 
     fn authenticate_codestral(
         &mut self,
         cx: &mut Context<Self>,
     ) -> Task<Result<(), AuthenticateError>> {
-        self.codestral_api_key_state.load_if_needed(
-            CODESTRAL_API_URL.into(),
-            &CODESTRAL_API_KEY_ENV_VAR,
-            |this| &mut this.codestral_api_key_state,
-            cx,
-        )
+        self.codestral_api_key_state.update(cx, |state, cx| {
+            state.load_if_needed(CODESTRAL_API_URL.into(), |state| state, cx)
+        })
     }
 }
 
@@ -116,18 +103,14 @@ impl MistralLanguageModelProvider {
         let state = cx.new(|cx| {
             cx.observe_global::<SettingsStore>(|this: &mut State, cx| {
                 let api_url = Self::api_url(cx);
-                this.api_key_state.handle_url_change(
-                    api_url,
-                    &API_KEY_ENV_VAR,
-                    |this| &mut this.api_key_state,
-                    cx,
-                );
+                this.api_key_state
+                    .handle_url_change(api_url, |this| &mut this.api_key_state, cx);
                 cx.notify();
             })
             .detach();
             State {
-                api_key_state: ApiKeyState::new(Self::api_url(cx)),
-                codestral_api_key_state: ApiKeyState::new(CODESTRAL_API_URL.into()),
+                api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()),
+                codestral_api_key_state: codestral_api_key(cx),
             }
         });
 
@@ -142,7 +125,11 @@ impl MistralLanguageModelProvider {
     }
 
     pub fn codestral_api_key(&self, url: &str, cx: &App) -> Option<Arc<str>> {
-        self.state.read(cx).codestral_api_key_state.key(url)
+        self.state
+            .read(cx)
+            .codestral_api_key_state
+            .read(cx)
+            .key(url)
     }
 
     fn create_language_model(&self, model: mistral::Model) -> Arc<dyn LanguageModel> {
@@ -159,7 +146,7 @@ impl MistralLanguageModelProvider {
         &crate::AllLanguageModelSettings::get_global(cx).mistral
     }
 
-    fn api_url(cx: &App) -> SharedString {
+    pub fn api_url(cx: &App) -> SharedString {
         let api_url = &Self::settings(cx).api_url;
         if api_url.is_empty() {
             mistral::MISTRAL_API_URL.into()
@@ -747,7 +734,6 @@ struct RawToolCall {
 
 struct ConfigurationView {
     api_key_editor: Entity<InputField>,
-    codestral_api_key_editor: Entity<InputField>,
     state: Entity<State>,
     load_credentials_task: Option<Task<()>>,
 }
@@ -756,8 +742,6 @@ impl ConfigurationView {
     fn new(state: Entity<State>, window: &mut Window, cx: &mut Context<Self>) -> Self {
         let api_key_editor =
             cx.new(|cx| InputField::new(window, cx, "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"));
-        let codestral_api_key_editor =
-            cx.new(|cx| InputField::new(window, cx, "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"));
 
         cx.observe(&state, |_, _, cx| {
             cx.notify();
@@ -774,12 +758,6 @@ impl ConfigurationView {
                     // We don't log an error, because "not signed in" is also an error.
                     let _ = task.await;
                 }
-                if let Some(task) = state
-                    .update(cx, |state, cx| state.authenticate_codestral(cx))
-                    .log_err()
-                {
-                    let _ = task.await;
-                }
 
                 this.update(cx, |this, cx| {
                     this.load_credentials_task = None;
@@ -791,7 +769,6 @@ impl ConfigurationView {
 
         Self {
             api_key_editor,
-            codestral_api_key_editor,
             state,
             load_credentials_task,
         }
@@ -829,110 +806,9 @@ impl ConfigurationView {
         .detach_and_log_err(cx);
     }
 
-    fn save_codestral_api_key(
-        &mut self,
-        _: &menu::Confirm,
-        window: &mut Window,
-        cx: &mut Context<Self>,
-    ) {
-        let api_key = self
-            .codestral_api_key_editor
-            .read(cx)
-            .text(cx)
-            .trim()
-            .to_string();
-        if api_key.is_empty() {
-            return;
-        }
-
-        // url changes can cause the editor to be displayed again
-        self.codestral_api_key_editor
-            .update(cx, |editor, cx| editor.set_text("", window, cx));
-
-        let state = self.state.clone();
-        cx.spawn_in(window, async move |_, cx| {
-            state
-                .update(cx, |state, cx| {
-                    state.set_codestral_api_key(Some(api_key), cx)
-                })?
-                .await?;
-            cx.update(|_window, cx| {
-                set_edit_prediction_provider(EditPredictionProvider::Codestral, cx)
-            })
-        })
-        .detach_and_log_err(cx);
-    }
-
-    fn reset_codestral_api_key(&mut self, window: &mut Window, cx: &mut Context<Self>) {
-        self.codestral_api_key_editor
-            .update(cx, |editor, cx| editor.set_text("", window, cx));
-
-        let state = self.state.clone();
-        cx.spawn_in(window, async move |_, cx| {
-            state
-                .update(cx, |state, cx| state.set_codestral_api_key(None, cx))?
-                .await?;
-            cx.update(|_window, cx| set_edit_prediction_provider(EditPredictionProvider::Zed, cx))
-        })
-        .detach_and_log_err(cx);
-    }
-
     fn should_render_api_key_editor(&self, cx: &mut Context<Self>) -> bool {
         !self.state.read(cx).is_authenticated()
     }
-
-    fn render_codestral_api_key_editor(&mut self, cx: &mut Context<Self>) -> AnyElement {
-        let key_state = &self.state.read(cx).codestral_api_key_state;
-        let should_show_editor = !key_state.has_key();
-        let env_var_set = key_state.is_from_env_var();
-        let configured_card_label = if env_var_set {
-            format!("API key set in {CODESTRAL_API_KEY_ENV_VAR_NAME} environment variable")
-        } else {
-            "Codestral API key configured".to_string()
-        };
-
-        if should_show_editor {
-            v_flex()
-                .id("codestral")
-                .size_full()
-                .mt_2()
-                .on_action(cx.listener(Self::save_codestral_api_key))
-                .child(Label::new(
-                    "To use Codestral as an edit prediction provider, \
-                    you need to add a Codestral-specific API key. Follow these steps:",
-                ))
-                .child(
-                    List::new()
-                        .child(InstructionListItem::new(
-                            "Create one by visiting",
-                            Some("the Codestral section of Mistral's console"),
-                            Some("https://console.mistral.ai/codestral"),
-                        ))
-                        .child(InstructionListItem::text_only("Paste your API key below and hit enter")),
-                )
-                .child(self.codestral_api_key_editor.clone())
-                .child(
-                    Label::new(
-                        format!("You can also assign the {CODESTRAL_API_KEY_ENV_VAR_NAME} environment variable and restart Zed."),
-                    )
-                    .size(LabelSize::Small).color(Color::Muted),
-                ).into_any()
-        } else {
-            ConfiguredApiCard::new(configured_card_label)
-                .disabled(env_var_set)
-                .on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx)))
-                .when(env_var_set, |this| {
-                    this.tooltip_label(format!(
-                        "To reset your API key, \
-                            unset the {CODESTRAL_API_KEY_ENV_VAR_NAME} environment variable."
-                    ))
-                })
-                .on_click(
-                    cx.listener(|this, _, window, cx| this.reset_codestral_api_key(window, cx)),
-                )
-                .into_any_element()
-        }
-    }
 }
 
 impl Render for ConfigurationView {
@@ -958,17 +834,17 @@ impl Render for ConfigurationView {
                 .child(Label::new("To use Zed's agent with Mistral, you need to add an API key. Follow these steps:"))
                 .child(
                     List::new()
-                        .child(InstructionListItem::new(
-                            "Create one by visiting",
-                            Some("Mistral's console"),
-                            Some("https://console.mistral.ai/api-keys"),
-                        ))
-                        .child(InstructionListItem::text_only(
-                            "Ensure your Mistral account has credits",
-                        ))
-                        .child(InstructionListItem::text_only(
-                            "Paste your API key below and hit enter to start using the assistant",
-                        )),
+                        .child(
+                            ListBulletItem::new("")
+                                .child(Label::new("Create one by visiting"))
+                                .child(ButtonLink::new("Mistral's console", "https://console.mistral.ai/api-keys"))
+                        )
+                        .child(
+                            ListBulletItem::new("Ensure your Mistral account has credits")
+                        )
+                        .child(
+                            ListBulletItem::new("Paste your API key below and hit enter to start using the assistant")
+                        ),
                 )
                 .child(self.api_key_editor.clone())
                 .child(
@@ -977,7 +853,6 @@ impl Render for ConfigurationView {
                     )
                     .size(LabelSize::Small).color(Color::Muted),
                 )
-                .child(self.render_codestral_api_key_editor(cx))
                 .into_any()
         } else {
             v_flex()
@@ -994,24 +869,11 @@ impl Render for ConfigurationView {
                             ))
                         }),
                 )
-                .child(self.render_codestral_api_key_editor(cx))
                 .into_any()
         }
     }
 }
 
-fn set_edit_prediction_provider(provider: EditPredictionProvider, cx: &mut App) {
-    let fs = <dyn Fs>::global(cx);
-    update_settings_file(fs, cx, move |settings, _| {
-        settings
-            .project
-            .all_languages
-            .features
-            .get_or_insert_default()
-            .edit_prediction_provider = Some(provider);
-    });
-}
-
 #[cfg(test)]
 mod tests {
     use super::*;

crates/language_models/src/provider/ollama.rs ๐Ÿ”—

@@ -5,11 +5,11 @@ use futures::{Stream, TryFutureExt, stream};
 use gpui::{AnyView, App, AsyncApp, Context, CursorStyle, Entity, Task};
 use http_client::HttpClient;
 use language_model::{
-    AuthenticateError, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent,
-    LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId,
-    LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest,
-    LanguageModelRequestTool, LanguageModelToolChoice, LanguageModelToolUse,
-    LanguageModelToolUseId, MessageContent, RateLimiter, Role, StopReason, TokenUsage,
+    ApiKeyState, AuthenticateError, EnvVar, LanguageModel, LanguageModelCompletionError,
+    LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider,
+    LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState,
+    LanguageModelRequest, LanguageModelRequestTool, LanguageModelToolChoice, LanguageModelToolUse,
+    LanguageModelToolUseId, MessageContent, RateLimiter, Role, StopReason, TokenUsage, env_var,
 };
 use menu;
 use ollama::{
@@ -22,13 +22,13 @@ use std::pin::Pin;
 use std::sync::LazyLock;
 use std::sync::atomic::{AtomicU64, Ordering};
 use std::{collections::HashMap, sync::Arc};
-use ui::{ButtonLike, ElevationIndex, List, Tooltip, prelude::*};
+use ui::{
+    ButtonLike, ButtonLink, ConfiguredApiCard, ElevationIndex, InlineCode, List, ListBulletItem,
+    Tooltip, prelude::*,
+};
 use ui_input::InputField;
-use zed_env_vars::{EnvVar, env_var};
 
 use crate::AllLanguageModelSettings;
-use crate::api_key::ApiKeyState;
-use crate::ui::{ConfiguredApiCard, InstructionListItem};
 
 const OLLAMA_DOWNLOAD_URL: &str = "https://ollama.com/download";
 const OLLAMA_LIBRARY_URL: &str = "https://ollama.com/library";
@@ -80,12 +80,9 @@ impl State {
 
     fn authenticate(&mut self, cx: &mut Context<Self>) -> Task<Result<(), AuthenticateError>> {
         let api_url = OllamaLanguageModelProvider::api_url(cx);
-        let task = self.api_key_state.load_if_needed(
-            api_url,
-            &API_KEY_ENV_VAR,
-            |this| &mut this.api_key_state,
-            cx,
-        );
+        let task = self
+            .api_key_state
+            .load_if_needed(api_url, |this| &mut this.api_key_state, cx);
 
         // Always try to fetch models - if no API key is needed (local Ollama), it will work
         // If API key is needed and provided, it will work
@@ -185,7 +182,7 @@ impl OllamaLanguageModelProvider {
                     http_client,
                     fetched_models: Default::default(),
                     fetch_model_task: None,
-                    api_key_state: ApiKeyState::new(Self::api_url(cx)),
+                    api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()),
                 }
             }),
         };
@@ -733,15 +730,17 @@ impl ConfigurationView {
             .child(Label::new("To use local Ollama:"))
             .child(
                 List::new()
-                    .child(InstructionListItem::new(
-                        "Download and install Ollama from",
-                        Some("ollama.com"),
-                        Some("https://ollama.com/download"),
-                    ))
-                    .child(InstructionListItem::text_only(
-                        "Start Ollama and download a model: `ollama run gpt-oss:20b`",
-                    ))
-                    .child(InstructionListItem::text_only(
+                    .child(
+                        ListBulletItem::new("")
+                            .child(Label::new("Download and install Ollama from"))
+                            .child(ButtonLink::new("ollama.com", "https://ollama.com/download")),
+                    )
+                    .child(
+                        ListBulletItem::new("")
+                            .child(Label::new("Start Ollama and download a model:"))
+                            .child(InlineCode::new("ollama run gpt-oss:20b")),
+                    )
+                    .child(ListBulletItem::new(
                         "Click 'Connect' below to start using Ollama in Zed",
                     )),
             )

crates/language_models/src/provider/open_ai.rs ๐Ÿ”—

@@ -5,11 +5,11 @@ use futures::{FutureExt, StreamExt, future, future::BoxFuture};
 use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, Window};
 use http_client::HttpClient;
 use language_model::{
-    AuthenticateError, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent,
-    LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId,
-    LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest,
-    LanguageModelToolChoice, LanguageModelToolResultContent, LanguageModelToolUse, MessageContent,
-    RateLimiter, Role, StopReason, TokenUsage,
+    ApiKeyState, AuthenticateError, EnvVar, LanguageModel, LanguageModelCompletionError,
+    LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider,
+    LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState,
+    LanguageModelRequest, LanguageModelToolChoice, LanguageModelToolResultContent,
+    LanguageModelToolUse, MessageContent, RateLimiter, Role, StopReason, TokenUsage, env_var,
 };
 use menu;
 use open_ai::{
@@ -20,13 +20,9 @@ use std::pin::Pin;
 use std::str::FromStr as _;
 use std::sync::{Arc, LazyLock};
 use strum::IntoEnumIterator;
-use ui::{List, prelude::*};
+use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*};
 use ui_input::InputField;
 use util::ResultExt;
-use zed_env_vars::{EnvVar, env_var};
-
-use crate::ui::ConfiguredApiCard;
-use crate::{api_key::ApiKeyState, ui::InstructionListItem};
 
 const PROVIDER_ID: LanguageModelProviderId = language_model::OPEN_AI_PROVIDER_ID;
 const PROVIDER_NAME: LanguageModelProviderName = language_model::OPEN_AI_PROVIDER_NAME;
@@ -62,12 +58,8 @@ impl State {
 
     fn authenticate(&mut self, cx: &mut Context<Self>) -> Task<Result<(), AuthenticateError>> {
         let api_url = OpenAiLanguageModelProvider::api_url(cx);
-        self.api_key_state.load_if_needed(
-            api_url,
-            &API_KEY_ENV_VAR,
-            |this| &mut this.api_key_state,
-            cx,
-        )
+        self.api_key_state
+            .load_if_needed(api_url, |this| &mut this.api_key_state, cx)
     }
 }
 
@@ -76,17 +68,13 @@ impl OpenAiLanguageModelProvider {
         let state = cx.new(|cx| {
             cx.observe_global::<SettingsStore>(|this: &mut State, cx| {
                 let api_url = Self::api_url(cx);
-                this.api_key_state.handle_url_change(
-                    api_url,
-                    &API_KEY_ENV_VAR,
-                    |this| &mut this.api_key_state,
-                    cx,
-                );
+                this.api_key_state
+                    .handle_url_change(api_url, |this| &mut this.api_key_state, cx);
                 cx.notify();
             })
             .detach();
             State {
-                api_key_state: ApiKeyState::new(Self::api_url(cx)),
+                api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()),
             }
         });
 
@@ -790,17 +778,17 @@ impl Render for ConfigurationView {
                 .child(Label::new("To use Zed's agent with OpenAI, you need to add an API key. Follow these steps:"))
                 .child(
                     List::new()
-                        .child(InstructionListItem::new(
-                            "Create one by visiting",
-                            Some("OpenAI's console"),
-                            Some("https://platform.openai.com/api-keys"),
-                        ))
-                        .child(InstructionListItem::text_only(
-                            "Ensure your OpenAI account has credits",
-                        ))
-                        .child(InstructionListItem::text_only(
-                            "Paste your API key below and hit enter to start using the assistant",
-                        )),
+                        .child(
+                            ListBulletItem::new("")
+                                .child(Label::new("Create one by visiting"))
+                                .child(ButtonLink::new("OpenAI's console", "https://platform.openai.com/api-keys"))
+                        )
+                        .child(
+                            ListBulletItem::new("Ensure your OpenAI account has credits")
+                        )
+                        .child(
+                            ListBulletItem::new("Paste your API key below and hit enter to start using the agent")
+                        ),
                 )
                 .child(self.api_key_editor.clone())
                 .child(

crates/language_models/src/provider/open_ai_compatible.rs ๐Ÿ”—

@@ -4,10 +4,10 @@ use futures::{FutureExt, StreamExt, future, future::BoxFuture};
 use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, Window};
 use http_client::HttpClient;
 use language_model::{
-    AuthenticateError, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent,
-    LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId,
-    LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest,
-    LanguageModelToolChoice, LanguageModelToolSchemaFormat, RateLimiter,
+    ApiKeyState, AuthenticateError, EnvVar, LanguageModel, LanguageModelCompletionError,
+    LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider,
+    LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState,
+    LanguageModelRequest, LanguageModelToolChoice, LanguageModelToolSchemaFormat, RateLimiter,
 };
 use menu;
 use open_ai::{ResponseStreamEvent, stream_completion};
@@ -16,9 +16,7 @@ use std::sync::Arc;
 use ui::{ElevationIndex, Tooltip, prelude::*};
 use ui_input::InputField;
 use util::ResultExt;
-use zed_env_vars::EnvVar;
 
-use crate::api_key::ApiKeyState;
 use crate::provider::open_ai::{OpenAiEventMapper, into_open_ai};
 pub use settings::OpenAiCompatibleAvailableModel as AvailableModel;
 pub use settings::OpenAiCompatibleModelCapabilities as ModelCapabilities;
@@ -38,7 +36,6 @@ pub struct OpenAiCompatibleLanguageModelProvider {
 
 pub struct State {
     id: Arc<str>,
-    api_key_env_var: EnvVar,
     api_key_state: ApiKeyState,
     settings: OpenAiCompatibleSettings,
 }
@@ -56,12 +53,8 @@ impl State {
 
     fn authenticate(&mut self, cx: &mut Context<Self>) -> Task<Result<(), AuthenticateError>> {
         let api_url = SharedString::new(self.settings.api_url.clone());
-        self.api_key_state.load_if_needed(
-            api_url,
-            &self.api_key_env_var,
-            |this| &mut this.api_key_state,
-            cx,
-        )
+        self.api_key_state
+            .load_if_needed(api_url, |this| &mut this.api_key_state, cx)
     }
 }
 
@@ -83,7 +76,6 @@ impl OpenAiCompatibleLanguageModelProvider {
                     let api_url = SharedString::new(settings.api_url.as_str());
                     this.api_key_state.handle_url_change(
                         api_url,
-                        &this.api_key_env_var,
                         |this| &mut this.api_key_state,
                         cx,
                     );
@@ -95,8 +87,10 @@ impl OpenAiCompatibleLanguageModelProvider {
             let settings = resolve_settings(&id, cx).cloned().unwrap_or_default();
             State {
                 id: id.clone(),
-                api_key_env_var: EnvVar::new(api_key_env_var_name),
-                api_key_state: ApiKeyState::new(SharedString::new(settings.api_url.as_str())),
+                api_key_state: ApiKeyState::new(
+                    SharedString::new(settings.api_url.as_str()),
+                    EnvVar::new(api_key_env_var_name),
+                ),
                 settings,
             }
         });
@@ -437,7 +431,7 @@ impl Render for ConfigurationView {
     fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
         let state = self.state.read(cx);
         let env_var_set = state.api_key_state.is_from_env_var();
-        let env_var_name = &state.api_key_env_var.name;
+        let env_var_name = state.api_key_state.env_var_name();
 
         let api_key_section = if self.should_render_editor(cx) {
             v_flex()

crates/language_models/src/provider/open_router.rs ๐Ÿ”—

@@ -4,11 +4,12 @@ use futures::{FutureExt, Stream, StreamExt, future, future::BoxFuture};
 use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task};
 use http_client::HttpClient;
 use language_model::{
-    AuthenticateError, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent,
-    LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId,
-    LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest,
-    LanguageModelToolChoice, LanguageModelToolResultContent, LanguageModelToolSchemaFormat,
-    LanguageModelToolUse, MessageContent, RateLimiter, Role, StopReason, TokenUsage,
+    ApiKeyState, AuthenticateError, EnvVar, LanguageModel, LanguageModelCompletionError,
+    LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider,
+    LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState,
+    LanguageModelRequest, LanguageModelToolChoice, LanguageModelToolResultContent,
+    LanguageModelToolSchemaFormat, LanguageModelToolUse, MessageContent, RateLimiter, Role,
+    StopReason, TokenUsage, env_var,
 };
 use open_router::{
     Model, ModelMode as OpenRouterModelMode, OPEN_ROUTER_API_URL, ResponseStreamEvent, list_models,
@@ -17,13 +18,9 @@ use settings::{OpenRouterAvailableModel as AvailableModel, Settings, SettingsSto
 use std::pin::Pin;
 use std::str::FromStr as _;
 use std::sync::{Arc, LazyLock};
-use ui::{List, prelude::*};
+use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*};
 use ui_input::InputField;
 use util::ResultExt;
-use zed_env_vars::{EnvVar, env_var};
-
-use crate::ui::ConfiguredApiCard;
-use crate::{api_key::ApiKeyState, ui::InstructionListItem};
 
 const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("openrouter");
 const PROVIDER_NAME: LanguageModelProviderName = LanguageModelProviderName::new("OpenRouter");
@@ -62,12 +59,9 @@ impl State {
 
     fn authenticate(&mut self, cx: &mut Context<Self>) -> Task<Result<(), AuthenticateError>> {
         let api_url = OpenRouterLanguageModelProvider::api_url(cx);
-        let task = self.api_key_state.load_if_needed(
-            api_url,
-            &API_KEY_ENV_VAR,
-            |this| &mut this.api_key_state,
-            cx,
-        );
+        let task = self
+            .api_key_state
+            .load_if_needed(api_url, |this| &mut this.api_key_state, cx);
 
         cx.spawn(async move |this, cx| {
             let result = task.await;
@@ -135,7 +129,7 @@ impl OpenRouterLanguageModelProvider {
             })
             .detach();
             State {
-                api_key_state: ApiKeyState::new(Self::api_url(cx)),
+                api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()),
                 http_client: http_client.clone(),
                 available_models: Vec::new(),
                 fetch_models_task: None,
@@ -830,17 +824,15 @@ impl Render for ConfigurationView {
                 .child(Label::new("To use Zed's agent with OpenRouter, you need to add an API key. Follow these steps:"))
                 .child(
                     List::new()
-                        .child(InstructionListItem::new(
-                            "Create an API key by visiting",
-                            Some("OpenRouter's console"),
-                            Some("https://openrouter.ai/keys"),
-                        ))
-                        .child(InstructionListItem::text_only(
-                            "Ensure your OpenRouter account has credits",
-                        ))
-                        .child(InstructionListItem::text_only(
-                            "Paste your API key below and hit enter to start using the assistant",
-                        )),
+                        .child(
+                            ListBulletItem::new("")
+                                .child(Label::new("Create an API key by visiting"))
+                                .child(ButtonLink::new("OpenRouter's console", "https://openrouter.ai/keys"))
+                        )
+                        .child(ListBulletItem::new("Ensure your OpenRouter account has credits")
+                        )
+                        .child(ListBulletItem::new("Paste your API key below and hit enter to start using the assistant")
+                        ),
                 )
                 .child(self.api_key_editor.clone())
                 .child(

crates/language_models/src/provider/vercel.rs ๐Ÿ”—

@@ -4,26 +4,20 @@ use futures::{FutureExt, StreamExt, future, future::BoxFuture};
 use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, Window};
 use http_client::HttpClient;
 use language_model::{
-    AuthenticateError, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent,
-    LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId,
-    LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest,
-    LanguageModelToolChoice, RateLimiter, Role,
+    ApiKeyState, AuthenticateError, EnvVar, LanguageModel, LanguageModelCompletionError,
+    LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider,
+    LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState,
+    LanguageModelRequest, LanguageModelToolChoice, RateLimiter, Role, env_var,
 };
 use open_ai::ResponseStreamEvent;
 pub use settings::VercelAvailableModel as AvailableModel;
 use settings::{Settings, SettingsStore};
 use std::sync::{Arc, LazyLock};
 use strum::IntoEnumIterator;
-use ui::{List, prelude::*};
+use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*};
 use ui_input::InputField;
 use util::ResultExt;
 use vercel::{Model, VERCEL_API_URL};
-use zed_env_vars::{EnvVar, env_var};
-
-use crate::{
-    api_key::ApiKeyState,
-    ui::{ConfiguredApiCard, InstructionListItem},
-};
 
 const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("vercel");
 const PROVIDER_NAME: LanguageModelProviderName = LanguageModelProviderName::new("Vercel");
@@ -59,12 +53,8 @@ impl State {
 
     fn authenticate(&mut self, cx: &mut Context<Self>) -> Task<Result<(), AuthenticateError>> {
         let api_url = VercelLanguageModelProvider::api_url(cx);
-        self.api_key_state.load_if_needed(
-            api_url,
-            &API_KEY_ENV_VAR,
-            |this| &mut this.api_key_state,
-            cx,
-        )
+        self.api_key_state
+            .load_if_needed(api_url, |this| &mut this.api_key_state, cx)
     }
 }
 
@@ -73,17 +63,13 @@ impl VercelLanguageModelProvider {
         let state = cx.new(|cx| {
             cx.observe_global::<SettingsStore>(|this: &mut State, cx| {
                 let api_url = Self::api_url(cx);
-                this.api_key_state.handle_url_change(
-                    api_url,
-                    &API_KEY_ENV_VAR,
-                    |this| &mut this.api_key_state,
-                    cx,
-                );
+                this.api_key_state
+                    .handle_url_change(api_url, |this| &mut this.api_key_state, cx);
                 cx.notify();
             })
             .detach();
             State {
-                api_key_state: ApiKeyState::new(Self::api_url(cx)),
+                api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()),
             }
         });
 
@@ -472,14 +458,14 @@ impl Render for ConfigurationView {
                 .child(Label::new("To use Zed's agent with Vercel v0, you need to add an API key. Follow these steps:"))
                 .child(
                     List::new()
-                        .child(InstructionListItem::new(
-                            "Create one by visiting",
-                            Some("Vercel v0's console"),
-                            Some("https://v0.dev/chat/settings/keys"),
-                        ))
-                        .child(InstructionListItem::text_only(
-                            "Paste your API key below and hit enter to start using the agent",
-                        )),
+                        .child(
+                            ListBulletItem::new("")
+                                .child(Label::new("Create one by visiting"))
+                                .child(ButtonLink::new("Vercel v0's console", "https://v0.dev/chat/settings/keys"))
+                        )
+                        .child(
+                            ListBulletItem::new("Paste your API key below and hit enter to start using the agent")
+                        ),
                 )
                 .child(self.api_key_editor.clone())
                 .child(

crates/language_models/src/provider/x_ai.rs ๐Ÿ”—

@@ -4,26 +4,21 @@ use futures::{FutureExt, StreamExt, future, future::BoxFuture};
 use gpui::{AnyView, App, AsyncApp, Context, Entity, Task, Window};
 use http_client::HttpClient;
 use language_model::{
-    AuthenticateError, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent,
-    LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId,
-    LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest,
-    LanguageModelToolChoice, LanguageModelToolSchemaFormat, RateLimiter, Role,
+    ApiKeyState, AuthenticateError, EnvVar, LanguageModel, LanguageModelCompletionError,
+    LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider,
+    LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState,
+    LanguageModelRequest, LanguageModelToolChoice, LanguageModelToolSchemaFormat, RateLimiter,
+    Role, env_var,
 };
 use open_ai::ResponseStreamEvent;
 pub use settings::XaiAvailableModel as AvailableModel;
 use settings::{Settings, SettingsStore};
 use std::sync::{Arc, LazyLock};
 use strum::IntoEnumIterator;
-use ui::{List, prelude::*};
+use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*};
 use ui_input::InputField;
 use util::ResultExt;
 use x_ai::{Model, XAI_API_URL};
-use zed_env_vars::{EnvVar, env_var};
-
-use crate::{
-    api_key::ApiKeyState,
-    ui::{ConfiguredApiCard, InstructionListItem},
-};
 
 const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("x_ai");
 const PROVIDER_NAME: LanguageModelProviderName = LanguageModelProviderName::new("xAI");
@@ -59,12 +54,8 @@ impl State {
 
     fn authenticate(&mut self, cx: &mut Context<Self>) -> Task<Result<(), AuthenticateError>> {
         let api_url = XAiLanguageModelProvider::api_url(cx);
-        self.api_key_state.load_if_needed(
-            api_url,
-            &API_KEY_ENV_VAR,
-            |this| &mut this.api_key_state,
-            cx,
-        )
+        self.api_key_state
+            .load_if_needed(api_url, |this| &mut this.api_key_state, cx)
     }
 }
 
@@ -73,17 +64,13 @@ impl XAiLanguageModelProvider {
         let state = cx.new(|cx| {
             cx.observe_global::<SettingsStore>(|this: &mut State, cx| {
                 let api_url = Self::api_url(cx);
-                this.api_key_state.handle_url_change(
-                    api_url,
-                    &API_KEY_ENV_VAR,
-                    |this| &mut this.api_key_state,
-                    cx,
-                );
+                this.api_key_state
+                    .handle_url_change(api_url, |this| &mut this.api_key_state, cx);
                 cx.notify();
             })
             .detach();
             State {
-                api_key_state: ApiKeyState::new(Self::api_url(cx)),
+                api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()),
             }
         });
 
@@ -474,14 +461,14 @@ impl Render for ConfigurationView {
                 .child(Label::new("To use Zed's agent with xAI, you need to add an API key. Follow these steps:"))
                 .child(
                     List::new()
-                        .child(InstructionListItem::new(
-                            "Create one by visiting",
-                            Some("xAI console"),
-                            Some("https://console.x.ai/team/default/api-keys"),
-                        ))
-                        .child(InstructionListItem::text_only(
-                            "Paste your API key below and hit enter to start using the agent",
-                        )),
+                        .child(
+                            ListBulletItem::new("")
+                                .child(Label::new("Create one by visiting"))
+                                .child(ButtonLink::new("xAI console", "https://console.x.ai/team/default/api-keys"))
+                        )
+                        .child(
+                            ListBulletItem::new("Paste your API key below and hit enter to start using the agent")
+                        ),
                 )
                 .child(self.api_key_editor.clone())
                 .child(

crates/language_models/src/ui.rs ๐Ÿ”—

@@ -1,4 +0,0 @@
-pub mod configured_api_card;
-pub mod instruction_list_item;
-pub use configured_api_card::ConfiguredApiCard;
-pub use instruction_list_item::InstructionListItem;

crates/language_models/src/ui/instruction_list_item.rs ๐Ÿ”—

@@ -1,69 +0,0 @@
-use gpui::{AnyElement, IntoElement, ParentElement, SharedString};
-use ui::{ListItem, prelude::*};
-
-/// A reusable list item component for adding LLM provider configuration instructions
-pub struct InstructionListItem {
-    label: SharedString,
-    button_label: Option<SharedString>,
-    button_link: Option<String>,
-}
-
-impl InstructionListItem {
-    pub fn new(
-        label: impl Into<SharedString>,
-        button_label: Option<impl Into<SharedString>>,
-        button_link: Option<impl Into<String>>,
-    ) -> Self {
-        Self {
-            label: label.into(),
-            button_label: button_label.map(|l| l.into()),
-            button_link: button_link.map(|l| l.into()),
-        }
-    }
-
-    pub fn text_only(label: impl Into<SharedString>) -> Self {
-        Self {
-            label: label.into(),
-            button_label: None,
-            button_link: None,
-        }
-    }
-}
-
-impl IntoElement for InstructionListItem {
-    type Element = AnyElement;
-
-    fn into_element(self) -> Self::Element {
-        let item_content = if let (Some(button_label), Some(button_link)) =
-            (self.button_label, self.button_link)
-        {
-            let link = button_link;
-            let unique_id = SharedString::from(format!("{}-button", self.label));
-
-            h_flex()
-                .flex_wrap()
-                .child(Label::new(self.label))
-                .child(
-                    Button::new(unique_id, button_label)
-                        .style(ButtonStyle::Subtle)
-                        .icon(IconName::ArrowUpRight)
-                        .icon_size(IconSize::Small)
-                        .icon_color(Color::Muted)
-                        .on_click(move |_, _window, cx| cx.open_url(&link)),
-                )
-                .into_any_element()
-        } else {
-            Label::new(self.label).into_any_element()
-        };
-
-        ListItem::new("list-item")
-            .selectable(false)
-            .start_slot(
-                Icon::new(IconName::Dash)
-                    .size(IconSize::XSmall)
-                    .color(Color::Hidden),
-            )
-            .child(div().w_full().child(item_content))
-            .into_any_element()
-    }
-}

crates/settings/src/settings_content/language.rs ๐Ÿ”—

@@ -186,22 +186,20 @@ pub struct CopilotSettingsContent {
     pub enterprise_uri: Option<String>,
 }
 
+#[with_fallible_options]
 #[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq)]
 pub struct CodestralSettingsContent {
     /// Model to use for completions.
     ///
     /// Default: "codestral-latest"
-    #[serde(default)]
     pub model: Option<String>,
     /// Maximum tokens to generate.
     ///
     /// Default: 150
-    #[serde(default)]
     pub max_tokens: Option<u32>,
     /// Api URL to use for completions.
     ///
     /// Default: "https://codestral.mistral.ai"
-    #[serde(default)]
     pub api_url: Option<String>,
 }
 

crates/settings_ui/Cargo.toml ๐Ÿ”—

@@ -18,6 +18,9 @@ test-support = []
 [dependencies]
 anyhow.workspace = true
 bm25 = "2.3.2"
+copilot.workspace = true
+edit_prediction.workspace = true
+language_models.workspace = true
 editor.workspace = true
 feature_flags.workspace = true
 fs.workspace = true
@@ -38,8 +41,8 @@ strum.workspace = true
 telemetry.workspace = true
 theme.workspace = true
 title_bar.workspace = true
-ui.workspace = true
 ui_input.workspace = true
+ui.workspace = true
 util.workspace = true
 workspace.workspace = true
 zed_actions.workspace = true

crates/settings_ui/src/components.rs ๐Ÿ”—

@@ -2,10 +2,12 @@ mod dropdown;
 mod font_picker;
 mod icon_theme_picker;
 mod input_field;
+mod section_items;
 mod theme_picker;
 
 pub use dropdown::*;
 pub use font_picker::font_picker;
 pub use icon_theme_picker::icon_theme_picker;
 pub use input_field::*;
+pub use section_items::*;
 pub use theme_picker::theme_picker;

crates/settings_ui/src/components/input_field.rs ๐Ÿ”—

@@ -13,6 +13,7 @@ pub struct SettingsInputField {
     tab_index: Option<isize>,
 }
 
+// TODO: Update the `ui_input::InputField` to use `window.use_state` and `RenceOnce` and remove this component
 impl SettingsInputField {
     pub fn new() -> Self {
         Self {

crates/settings_ui/src/components/section_items.rs ๐Ÿ”—

@@ -0,0 +1,56 @@
+use gpui::{IntoElement, ParentElement, Styled};
+use ui::{Divider, DividerColor, prelude::*};
+
+#[derive(IntoElement)]
+pub struct SettingsSectionHeader {
+    icon: Option<IconName>,
+    label: SharedString,
+    no_padding: bool,
+}
+
+impl SettingsSectionHeader {
+    pub fn new(label: impl Into<SharedString>) -> Self {
+        Self {
+            label: label.into(),
+            icon: None,
+            no_padding: false,
+        }
+    }
+
+    pub fn icon(mut self, icon: IconName) -> Self {
+        self.icon = Some(icon);
+        self
+    }
+
+    pub fn no_padding(mut self, no_padding: bool) -> Self {
+        self.no_padding = no_padding;
+        self
+    }
+}
+
+impl RenderOnce for SettingsSectionHeader {
+    fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
+        let label = Label::new(self.label)
+            .size(LabelSize::Small)
+            .color(Color::Muted)
+            .buffer_font(cx);
+
+        v_flex()
+            .w_full()
+            .when(!self.no_padding, |this| this.px_8())
+            .gap_1p5()
+            .map(|this| {
+                if self.icon.is_some() {
+                    this.child(
+                        h_flex()
+                            .gap_1p5()
+                            .child(Icon::new(self.icon.unwrap()).color(Color::Muted))
+                            .child(label),
+                    )
+                } else {
+                    this.child(label)
+                }
+            })
+            .child(Divider::horizontal().color(DividerColor::BorderFaded))
+    }
+}

crates/settings_ui/src/page_data.rs ๐Ÿ”—

@@ -2330,8 +2330,12 @@ pub(crate) fn settings_data(cx: &App) -> Vec<SettingsPage> {
                 // Note that `crates/json_schema_store` solves the same problem, there is probably a way to unify the two
                 items.push(SettingsPageItem::SectionHeader(LANGUAGES_SECTION_HEADER));
                 items.extend(all_language_names(cx).into_iter().map(|language_name| {
+                    let link = format!("languages.{language_name}");
                     SettingsPageItem::SubPageLink(SubPageLink {
                         title: language_name,
+                        description: None,
+                        json_path: Some(link.leak()),
+                        in_json: true,
                         files: USER | PROJECT,
                         render: Arc::new(|this, window, cx| {
                             this.render_sub_page_items(
@@ -6013,7 +6017,7 @@ pub(crate) fn settings_data(cx: &App) -> Vec<SettingsPage> {
                             files: USER,
                         }),
                         SettingsPageItem::SettingItem(SettingItem {
-                            title: "In Text Threads",
+                            title: "Display In Text Threads",
                             description: "Whether edit predictions are enabled when editing text threads in the agent panel.",
                             field: Box::new(SettingField {
                                 json_path: Some("edit_prediction.in_text_threads"),
@@ -6027,42 +6031,6 @@ pub(crate) fn settings_data(cx: &App) -> Vec<SettingsPage> {
                             metadata: None,
                             files: USER,
                         }),
-                        SettingsPageItem::SettingItem(SettingItem {
-                            title: "Copilot Provider",
-                            description: "Use GitHub Copilot as your edit prediction provider.",
-                            field: Box::new(
-                                SettingField {
-                                    json_path: Some("edit_prediction.copilot_provider"),
-                                    pick: |settings_content| {
-                                        settings_content.project.all_languages.edit_predictions.as_ref()?.copilot.as_ref()
-                                    },
-                                    write: |settings_content, value| {
-                                        settings_content.project.all_languages.edit_predictions.get_or_insert_default().copilot = value;
-                                    },
-                                }
-                                .unimplemented(),
-                            ),
-                            metadata: None,
-                            files: USER | PROJECT,
-                        }),
-                        SettingsPageItem::SettingItem(SettingItem {
-                            title: "Codestral Provider",
-                            description: "Use Mistral's Codestral as your edit prediction provider.",
-                            field: Box::new(
-                                SettingField {
-                                    json_path: Some("edit_prediction.codestral_provider"),
-                                    pick: |settings_content| {
-                                        settings_content.project.all_languages.edit_predictions.as_ref()?.codestral.as_ref()
-                                    },
-                                    write: |settings_content, value| {
-                                        settings_content.project.all_languages.edit_predictions.get_or_insert_default().codestral = value;
-                                    },
-                                }
-                                .unimplemented(),
-                            ),
-                            metadata: None,
-                            files: USER | PROJECT,
-                        }),
                     ]
                 );
                 items
@@ -7485,9 +7453,23 @@ fn non_editor_language_settings_data() -> Vec<SettingsPageItem> {
 fn edit_prediction_language_settings_section() -> Vec<SettingsPageItem> {
     vec![
         SettingsPageItem::SectionHeader("Edit Predictions"),
+        SettingsPageItem::SubPageLink(SubPageLink {
+            title: "Configure Providers".into(),
+            json_path: Some("edit_predictions.providers"),
+            description: Some("Set up different edit prediction providers in complement to Zed's built-in Zeta model.".into()),
+            in_json: false,
+            files: USER,
+            render: Arc::new(|_, window, cx| {
+                let settings_window = cx.entity();
+                let page = window.use_state(cx, |_, _| {
+                    crate::pages::EditPredictionSetupPage::new(settings_window)
+                });
+                page.into_any_element()
+            }),
+        }),
         SettingsPageItem::SettingItem(SettingItem {
             title: "Show Edit Predictions",
-            description: "Controls whether edit predictions are shown immediately or manually by triggering `editor::showeditprediction` (false).",
+            description: "Controls whether edit predictions are shown immediately or manually.",
             field: Box::new(SettingField {
                 json_path: Some("languages.$(language).show_edit_predictions"),
                 pick: |settings_content| {
@@ -7505,7 +7487,7 @@ fn edit_prediction_language_settings_section() -> Vec<SettingsPageItem> {
             files: USER | PROJECT,
         }),
         SettingsPageItem::SettingItem(SettingItem {
-            title: "Edit Predictions Disabled In",
+            title: "Disable in Language Scopes",
             description: "Controls whether edit predictions are shown in the given language scopes.",
             field: Box::new(
                 SettingField {

crates/settings_ui/src/pages/edit_prediction_provider_setup.rs ๐Ÿ”—

@@ -0,0 +1,365 @@
+use edit_prediction::{
+    ApiKeyState, Zeta2FeatureFlag,
+    mercury::{MERCURY_CREDENTIALS_URL, mercury_api_token},
+    sweep_ai::{SWEEP_CREDENTIALS_URL, sweep_api_token},
+};
+use feature_flags::FeatureFlagAppExt as _;
+use gpui::{Entity, ScrollHandle, prelude::*};
+use language_models::provider::mistral::{CODESTRAL_API_URL, codestral_api_key};
+use ui::{ButtonLink, ConfiguredApiCard, WithScrollbar, prelude::*};
+
+use crate::{
+    SettingField, SettingItem, SettingsFieldMetadata, SettingsPageItem, SettingsWindow, USER,
+    components::{SettingsInputField, SettingsSectionHeader},
+};
+
+pub struct EditPredictionSetupPage {
+    settings_window: Entity<SettingsWindow>,
+    scroll_handle: ScrollHandle,
+}
+
+impl EditPredictionSetupPage {
+    pub fn new(settings_window: Entity<SettingsWindow>) -> Self {
+        Self {
+            settings_window,
+            scroll_handle: ScrollHandle::new(),
+        }
+    }
+}
+
+impl Render for EditPredictionSetupPage {
+    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        let settings_window = self.settings_window.clone();
+
+        let providers = [
+            Some(render_github_copilot_provider(window, cx).into_any_element()),
+            cx.has_flag::<Zeta2FeatureFlag>().then(|| {
+                render_api_key_provider(
+                    IconName::Inception,
+                    "Mercury",
+                    "https://platform.inceptionlabs.ai/dashboard/api-keys".into(),
+                    mercury_api_token(cx),
+                    |_cx| MERCURY_CREDENTIALS_URL,
+                    None,
+                    window,
+                    cx,
+                )
+                .into_any_element()
+            }),
+            cx.has_flag::<Zeta2FeatureFlag>().then(|| {
+                render_api_key_provider(
+                    IconName::SweepAi,
+                    "Sweep",
+                    "https://app.sweep.dev/".into(),
+                    sweep_api_token(cx),
+                    |_cx| SWEEP_CREDENTIALS_URL,
+                    None,
+                    window,
+                    cx,
+                )
+                .into_any_element()
+            }),
+            Some(
+                render_api_key_provider(
+                    IconName::AiMistral,
+                    "Codestral",
+                    "https://console.mistral.ai/codestral".into(),
+                    codestral_api_key(cx),
+                    |cx| language_models::MistralLanguageModelProvider::api_url(cx),
+                    Some(settings_window.update(cx, |settings_window, cx| {
+                        let codestral_settings = codestral_settings();
+                        settings_window
+                            .render_sub_page_items_section(
+                                codestral_settings.iter().enumerate(),
+                                None,
+                                window,
+                                cx,
+                            )
+                            .into_any_element()
+                    })),
+                    window,
+                    cx,
+                )
+                .into_any_element(),
+            ),
+        ];
+
+        div()
+            .size_full()
+            .vertical_scrollbar_for(&self.scroll_handle, window, cx)
+            .child(
+                v_flex()
+                    .id("ep-setup-page")
+                    .min_w_0()
+                    .size_full()
+                    .px_8()
+                    .pb_16()
+                    .overflow_y_scroll()
+                    .track_scroll(&self.scroll_handle)
+                    .children(providers.into_iter().flatten()),
+            )
+    }
+}
+
+fn render_api_key_provider(
+    icon: IconName,
+    title: &'static str,
+    link: SharedString,
+    api_key_state: Entity<ApiKeyState>,
+    current_url: fn(&mut App) -> SharedString,
+    additional_fields: Option<AnyElement>,
+    window: &mut Window,
+    cx: &mut Context<EditPredictionSetupPage>,
+) -> impl IntoElement {
+    let weak_page = cx.weak_entity();
+    _ = window.use_keyed_state(title, cx, |_, cx| {
+        let task = api_key_state.update(cx, |key_state, cx| {
+            key_state.load_if_needed(current_url(cx), |state| state, cx)
+        });
+        cx.spawn(async move |_, cx| {
+            task.await.ok();
+            weak_page
+                .update(cx, |_, cx| {
+                    cx.notify();
+                })
+                .ok();
+        })
+    });
+
+    let (has_key, env_var_name, is_from_env_var) = api_key_state.read_with(cx, |state, _| {
+        (
+            state.has_key(),
+            Some(state.env_var_name().clone()),
+            state.is_from_env_var(),
+        )
+    });
+
+    let write_key = move |api_key: Option<String>, cx: &mut App| {
+        api_key_state
+            .update(cx, |key_state, cx| {
+                let url = current_url(cx);
+                key_state.store(url, api_key, |key_state| key_state, cx)
+            })
+            .detach_and_log_err(cx);
+    };
+
+    let base_container = v_flex().id(title).min_w_0().pt_8().gap_1p5();
+    let header = SettingsSectionHeader::new(title)
+        .icon(icon)
+        .no_padding(true);
+    let button_link_label = format!("{} dashboard", title);
+    let description = h_flex()
+        .min_w_0()
+        .gap_0p5()
+        .child(
+            Label::new("Visit the")
+                .size(LabelSize::Small)
+                .color(Color::Muted),
+        )
+        .child(
+            ButtonLink::new(button_link_label, link)
+                .no_icon(true)
+                .label_size(LabelSize::Small)
+                .label_color(Color::Muted),
+        )
+        .child(
+            Label::new("to generate an API key.")
+                .size(LabelSize::Small)
+                .color(Color::Muted),
+        );
+    let configured_card_label = if is_from_env_var {
+        "API Key Set in Environment Variable"
+    } else {
+        "API Key Configured"
+    };
+
+    let container = if has_key {
+        base_container.child(header).child(
+            ConfiguredApiCard::new(configured_card_label)
+                .button_label("Reset Key")
+                .button_tab_index(0)
+                .disabled(is_from_env_var)
+                .when_some(env_var_name, |this, env_var_name| {
+                    this.when(is_from_env_var, |this| {
+                        this.tooltip_label(format!(
+                            "To reset your API key, unset the {} environment variable.",
+                            env_var_name
+                        ))
+                    })
+                })
+                .on_click(move |_, _, cx| {
+                    write_key(None, cx);
+                }),
+        )
+    } else {
+        base_container.child(header).child(
+            h_flex()
+                .pt_2p5()
+                .w_full()
+                .justify_between()
+                .child(
+                    v_flex()
+                        .w_full()
+                        .max_w_1_2()
+                        .child(Label::new("API Key"))
+                        .child(description)
+                        .when_some(env_var_name, |this, env_var_name| {
+                            this.child({
+                                let label = format!(
+                                    "Or set the {} env var and restart Zed.",
+                                    env_var_name.as_ref()
+                                );
+                                Label::new(label).size(LabelSize::Small).color(Color::Muted)
+                            })
+                        }),
+                )
+                .child(
+                    SettingsInputField::new()
+                        .tab_index(0)
+                        .with_placeholder("xxxxxxxxxxxxxxxxxxxx")
+                        .on_confirm(move |api_key, cx| {
+                            write_key(api_key.filter(|key| !key.is_empty()), cx);
+                        }),
+                ),
+        )
+    };
+
+    container.when_some(additional_fields, |this, additional_fields| {
+        this.child(
+            div()
+                .map(|this| if has_key { this.mt_1() } else { this.mt_4() })
+                .px_neg_8()
+                .border_t_1()
+                .border_color(cx.theme().colors().border_variant)
+                .child(additional_fields),
+        )
+    })
+}
+
+fn codestral_settings() -> Box<[SettingsPageItem]> {
+    Box::new([
+        SettingsPageItem::SettingItem(SettingItem {
+            title: "API URL",
+            description: "The API URL to use for Codestral.",
+            field: Box::new(SettingField {
+                pick: |settings| {
+                    settings
+                        .project
+                        .all_languages
+                        .edit_predictions
+                        .as_ref()?
+                        .codestral
+                        .as_ref()?
+                        .api_url
+                        .as_ref()
+                },
+                write: |settings, value| {
+                    settings
+                        .project
+                        .all_languages
+                        .edit_predictions
+                        .get_or_insert_default()
+                        .codestral
+                        .get_or_insert_default()
+                        .api_url = value;
+                },
+                json_path: Some("edit_predictions.codestral.api_url"),
+            }),
+            metadata: Some(Box::new(SettingsFieldMetadata {
+                placeholder: Some(CODESTRAL_API_URL),
+                ..Default::default()
+            })),
+            files: USER,
+        }),
+        SettingsPageItem::SettingItem(SettingItem {
+            title: "Max Tokens",
+            description: "The maximum number of tokens to generate.",
+            field: Box::new(SettingField {
+                pick: |settings| {
+                    settings
+                        .project
+                        .all_languages
+                        .edit_predictions
+                        .as_ref()?
+                        .codestral
+                        .as_ref()?
+                        .max_tokens
+                        .as_ref()
+                },
+                write: |settings, value| {
+                    settings
+                        .project
+                        .all_languages
+                        .edit_predictions
+                        .get_or_insert_default()
+                        .codestral
+                        .get_or_insert_default()
+                        .max_tokens = value;
+                },
+                json_path: Some("edit_predictions.codestral.max_tokens"),
+            }),
+            metadata: None,
+            files: USER,
+        }),
+        SettingsPageItem::SettingItem(SettingItem {
+            title: "Model",
+            description: "The Codestral model id to use.",
+            field: Box::new(SettingField {
+                pick: |settings| {
+                    settings
+                        .project
+                        .all_languages
+                        .edit_predictions
+                        .as_ref()?
+                        .codestral
+                        .as_ref()?
+                        .model
+                        .as_ref()
+                },
+                write: |settings, value| {
+                    settings
+                        .project
+                        .all_languages
+                        .edit_predictions
+                        .get_or_insert_default()
+                        .codestral
+                        .get_or_insert_default()
+                        .model = value;
+                },
+                json_path: Some("edit_predictions.codestral.model"),
+            }),
+            metadata: Some(Box::new(SettingsFieldMetadata {
+                placeholder: Some("codestral-latest"),
+                ..Default::default()
+            })),
+            files: USER,
+        }),
+    ])
+}
+
+pub(crate) fn render_github_copilot_provider(
+    window: &mut Window,
+    cx: &mut App,
+) -> impl IntoElement {
+    let configuration_view = window.use_state(cx, |_, cx| {
+        copilot::ConfigurationView::new(
+            |cx| {
+                copilot::Copilot::global(cx)
+                    .is_some_and(|copilot| copilot.read(cx).is_authenticated())
+            },
+            copilot::ConfigurationMode::EditPrediction,
+            cx,
+        )
+    });
+
+    v_flex()
+        .id("github-copilot")
+        .min_w_0()
+        .gap_1p5()
+        .child(
+            SettingsSectionHeader::new("GitHub Copilot")
+                .icon(IconName::Copilot)
+                .no_padding(true),
+        )
+        .child(configuration_view)
+}

crates/settings_ui/src/settings_ui.rs ๐Ÿ”—

@@ -1,5 +1,6 @@
 mod components;
 mod page_data;
+mod pages;
 
 use anyhow::Result;
 use editor::{Editor, EditorEvent};
@@ -28,9 +29,8 @@ use std::{
 };
 use title_bar::platform_title_bar::PlatformTitleBar;
 use ui::{
-    Banner, ContextMenu, Divider, DividerColor, DropdownMenu, DropdownStyle, IconButtonShape,
-    KeyBinding, KeybindingHint, PopoverMenu, Switch, Tooltip, TreeViewItem, WithScrollbar,
-    prelude::*,
+    Banner, ContextMenu, Divider, DropdownMenu, DropdownStyle, IconButtonShape, KeyBinding,
+    KeybindingHint, PopoverMenu, Switch, Tooltip, TreeViewItem, WithScrollbar, prelude::*,
 };
 use ui_input::{NumberField, NumberFieldType};
 use util::{ResultExt as _, paths::PathStyle, rel_path::RelPath};
@@ -38,7 +38,8 @@ use workspace::{AppState, OpenOptions, OpenVisible, Workspace, client_side_decor
 use zed_actions::{OpenProjectSettings, OpenSettings, OpenSettingsAt};
 
 use crate::components::{
-    EnumVariantDropdown, SettingsInputField, font_picker, icon_theme_picker, theme_picker,
+    EnumVariantDropdown, SettingsInputField, SettingsSectionHeader, font_picker, icon_theme_picker,
+    theme_picker,
 };
 
 const NAVBAR_CONTAINER_TAB_INDEX: isize = 0;
@@ -613,7 +614,10 @@ pub fn open_settings_editor(
                 app_id: Some(app_id.to_owned()),
                 window_decorations: Some(window_decorations),
                 window_min_size: Some(gpui::Size {
-                    width: px(360.0),
+                    // Don't make the settings window thinner than this,
+                    // otherwise, it gets unusable. Users with smaller res monitors
+                    // can customize the height, but not the width.
+                    width: px(900.0),
                     height: px(240.0),
                 }),
                 window_bounds: Some(WindowBounds::centered(scaled_bounds, cx)),
@@ -834,18 +838,9 @@ impl SettingsPageItem {
             };
 
         match self {
-            SettingsPageItem::SectionHeader(header) => v_flex()
-                .w_full()
-                .px_8()
-                .gap_1p5()
-                .child(
-                    Label::new(SharedString::new_static(header))
-                        .size(LabelSize::Small)
-                        .color(Color::Muted)
-                        .buffer_font(cx),
-                )
-                .child(Divider::horizontal().color(DividerColor::BorderFaded))
-                .into_any_element(),
+            SettingsPageItem::SectionHeader(header) => {
+                SettingsSectionHeader::new(SharedString::new_static(header)).into_any_element()
+            }
             SettingsPageItem::SettingItem(setting_item) => {
                 let (field_with_padding, _) =
                     render_setting_item_inner(setting_item, true, false, cx);
@@ -869,9 +864,20 @@ impl SettingsPageItem {
                         .map(apply_padding)
                         .child(
                             v_flex()
+                                .relative()
                                 .w_full()
                                 .max_w_1_2()
-                                .child(Label::new(sub_page_link.title.clone())),
+                                .child(Label::new(sub_page_link.title.clone()))
+                                .when_some(
+                                    sub_page_link.description.as_ref(),
+                                    |this, description| {
+                                        this.child(
+                                            Label::new(description.clone())
+                                                .size(LabelSize::Small)
+                                                .color(Color::Muted),
+                                        )
+                                    },
+                                ),
                         )
                         .child(
                             Button::new(
@@ -909,7 +915,13 @@ impl SettingsPageItem {
                                     this.push_sub_page(sub_page_link.clone(), header, cx)
                                 })
                             }),
-                        ),
+                        )
+                        .child(render_settings_item_link(
+                            sub_page_link.title.clone(),
+                            sub_page_link.json_path,
+                            false,
+                            cx,
+                        )),
                 )
                 .when(!is_last, |this| this.child(Divider::horizontal()))
                 .into_any_element(),
@@ -983,20 +995,6 @@ fn render_settings_item(
     let (found_in_file, _) = setting_item.field.file_set_in(file.clone(), cx);
     let file_set_in = SettingsUiFile::from_settings(found_in_file.clone());
 
-    let clipboard_has_link = cx
-        .read_from_clipboard()
-        .and_then(|entry| entry.text())
-        .map_or(false, |maybe_url| {
-            setting_item.field.json_path().is_some()
-                && maybe_url.strip_prefix("zed://settings/") == setting_item.field.json_path()
-        });
-
-    let (link_icon, link_icon_color) = if clipboard_has_link {
-        (IconName::Check, Color::Success)
-    } else {
-        (IconName::Link, Color::Muted)
-    };
-
     h_flex()
         .id(setting_item.title)
         .min_w_0()
@@ -1056,40 +1054,60 @@ fn render_settings_item(
         )
         .child(control)
         .when(sub_page_stack().is_empty(), |this| {
-            // Intentionally using the description to make the icon button
-            // unique because some items share the same title (e.g., "Font Size")
-            let icon_button_id =
-                SharedString::new(format!("copy-link-btn-{}", setting_item.description));
+            this.child(render_settings_item_link(
+                setting_item.description,
+                setting_item.field.json_path(),
+                sub_field,
+                cx,
+            ))
+        })
+}
 
-            this.child(
-                div()
-                    .absolute()
-                    .top(rems_from_px(18.))
-                    .map(|this| {
-                        if sub_field {
-                            this.visible_on_hover("setting-sub-item")
-                                .left(rems_from_px(-8.5))
-                        } else {
-                            this.visible_on_hover("setting-item")
-                                .left(rems_from_px(-22.))
-                        }
-                    })
-                    .child({
-                        IconButton::new(icon_button_id, link_icon)
-                            .icon_color(link_icon_color)
-                            .icon_size(IconSize::Small)
-                            .shape(IconButtonShape::Square)
-                            .tooltip(Tooltip::text("Copy Link"))
-                            .when_some(setting_item.field.json_path(), |this, path| {
-                                this.on_click(cx.listener(move |_, _, _, cx| {
-                                    let link = format!("zed://settings/{}", path);
-                                    cx.write_to_clipboard(ClipboardItem::new_string(link));
-                                    cx.notify();
-                                }))
-                            })
-                    }),
-            )
+fn render_settings_item_link(
+    id: impl Into<ElementId>,
+    json_path: Option<&'static str>,
+    sub_field: bool,
+    cx: &mut Context<'_, SettingsWindow>,
+) -> impl IntoElement {
+    let clipboard_has_link = cx
+        .read_from_clipboard()
+        .and_then(|entry| entry.text())
+        .map_or(false, |maybe_url| {
+            json_path.is_some() && maybe_url.strip_prefix("zed://settings/") == json_path
+        });
+
+    let (link_icon, link_icon_color) = if clipboard_has_link {
+        (IconName::Check, Color::Success)
+    } else {
+        (IconName::Link, Color::Muted)
+    };
+
+    div()
+        .absolute()
+        .top(rems_from_px(18.))
+        .map(|this| {
+            if sub_field {
+                this.visible_on_hover("setting-sub-item")
+                    .left(rems_from_px(-8.5))
+            } else {
+                this.visible_on_hover("setting-item")
+                    .left(rems_from_px(-22.))
+            }
         })
+        .child(
+            IconButton::new((id.into(), "copy-link-btn"), link_icon)
+                .icon_color(link_icon_color)
+                .icon_size(IconSize::Small)
+                .shape(IconButtonShape::Square)
+                .tooltip(Tooltip::text("Copy Link"))
+                .when_some(json_path, |this, path| {
+                    this.on_click(cx.listener(move |_, _, _, cx| {
+                        let link = format!("zed://settings/{}", path);
+                        cx.write_to_clipboard(ClipboardItem::new_string(link));
+                        cx.notify();
+                    }))
+                }),
+        )
 }
 
 struct SettingItem {
@@ -1175,6 +1193,12 @@ impl PartialEq for SettingItem {
 #[derive(Clone)]
 struct SubPageLink {
     title: SharedString,
+    description: Option<SharedString>,
+    /// See [`SettingField.json_path`]
+    json_path: Option<&'static str>,
+    /// Whether or not the settings in this sub page are configurable in settings.json
+    /// Removes the "Edit in settings.json" button from the page.
+    in_json: bool,
     files: FileMask,
     render: Arc<
         dyn Fn(&mut SettingsWindow, &mut Window, &mut Context<SettingsWindow>) -> AnyElement
@@ -1835,6 +1859,7 @@ impl SettingsWindow {
                         header_str = *header;
                     }
                     SettingsPageItem::SubPageLink(sub_page_link) => {
+                        json_path = sub_page_link.json_path;
                         documents.push(bm25::Document {
                             id: key_index,
                             contents: [page.title, header_str, sub_page_link.title.as_ref()]
@@ -2758,19 +2783,49 @@ impl SettingsWindow {
         page_content
     }
 
-    fn render_sub_page_items<'a, Items: Iterator<Item = (usize, &'a SettingsPageItem)>>(
+    fn render_sub_page_items<'a, Items>(
         &self,
         items: Items,
         page_index: Option<usize>,
         window: &mut Window,
         cx: &mut Context<SettingsWindow>,
-    ) -> impl IntoElement {
-        let mut page_content = v_flex()
+    ) -> impl IntoElement
+    where
+        Items: Iterator<Item = (usize, &'a SettingsPageItem)>,
+    {
+        let page_content = v_flex()
             .id("settings-ui-page")
             .size_full()
             .overflow_y_scroll()
             .track_scroll(&self.sub_page_scroll_handle);
+        self.render_sub_page_items_in(page_content, items, page_index, window, cx)
+    }
 
+    fn render_sub_page_items_section<'a, Items>(
+        &self,
+        items: Items,
+        page_index: Option<usize>,
+        window: &mut Window,
+        cx: &mut Context<SettingsWindow>,
+    ) -> impl IntoElement
+    where
+        Items: Iterator<Item = (usize, &'a SettingsPageItem)>,
+    {
+        let page_content = v_flex().id("settings-ui-sub-page-section").size_full();
+        self.render_sub_page_items_in(page_content, items, page_index, window, cx)
+    }
+
+    fn render_sub_page_items_in<'a, Items>(
+        &self,
+        mut page_content: Stateful<Div>,
+        items: Items,
+        page_index: Option<usize>,
+        window: &mut Window,
+        cx: &mut Context<SettingsWindow>,
+    ) -> impl IntoElement
+    where
+        Items: Iterator<Item = (usize, &'a SettingsPageItem)>,
+    {
         let items: Vec<_> = items.collect();
         let items_len = items.len();
         let mut section_header = None;
@@ -2871,18 +2926,25 @@ impl SettingsWindow {
                         )
                         .child(self.render_sub_page_breadcrumbs()),
                 )
-                .child(
-                    Button::new("open-in-settings-file", "Edit in settings.json")
-                        .tab_index(0_isize)
-                        .style(ButtonStyle::OutlinedGhost)
-                        .tooltip(Tooltip::for_action_title_in(
-                            "Edit in settings.json",
-                            &OpenCurrentFile,
-                            &self.focus_handle,
-                        ))
-                        .on_click(cx.listener(|this, _, window, cx| {
-                            this.open_current_settings_file(window, cx);
-                        })),
+                .when(
+                    sub_page_stack()
+                        .last()
+                        .is_none_or(|sub_page| sub_page.link.in_json),
+                    |this| {
+                        this.child(
+                            Button::new("open-in-settings-file", "Edit in settings.json")
+                                .tab_index(0_isize)
+                                .style(ButtonStyle::OutlinedGhost)
+                                .tooltip(Tooltip::for_action_title_in(
+                                    "Edit in settings.json",
+                                    &OpenCurrentFile,
+                                    &self.focus_handle,
+                                ))
+                                .on_click(cx.listener(|this, _, window, cx| {
+                                    this.open_current_settings_file(window, cx);
+                                })),
+                        )
+                    },
                 )
                 .into_any_element();
 

crates/ui/src/components.rs ๐Ÿ”—

@@ -1,3 +1,4 @@
+mod ai;
 mod avatar;
 mod banner;
 mod button;
@@ -16,6 +17,7 @@ mod icon;
 mod image;
 mod indent_guides;
 mod indicator;
+mod inline_code;
 mod keybinding;
 mod keybinding_hint;
 mod label;
@@ -43,6 +45,7 @@ mod tree_view_item;
 #[cfg(feature = "stories")]
 mod stories;
 
+pub use ai::*;
 pub use avatar::*;
 pub use banner::*;
 pub use button::*;
@@ -61,6 +64,7 @@ pub use icon::*;
 pub use image::*;
 pub use indent_guides::*;
 pub use indicator::*;
+pub use inline_code::*;
 pub use keybinding::*;
 pub use keybinding_hint::*;
 pub use label::*;

crates/language_models/src/ui/configured_api_card.rs โ†’ crates/ui/src/components/ai/configured_api_card.rs ๐Ÿ”—

@@ -1,10 +1,11 @@
+use crate::{Tooltip, prelude::*};
 use gpui::{ClickEvent, IntoElement, ParentElement, SharedString};
-use ui::{Tooltip, prelude::*};
 
 #[derive(IntoElement)]
 pub struct ConfiguredApiCard {
     label: SharedString,
     button_label: Option<SharedString>,
+    button_tab_index: Option<isize>,
     tooltip_label: Option<SharedString>,
     disabled: bool,
     on_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
@@ -15,6 +16,7 @@ impl ConfiguredApiCard {
         Self {
             label: label.into(),
             button_label: None,
+            button_tab_index: None,
             tooltip_label: None,
             disabled: false,
             on_click: None,
@@ -43,6 +45,11 @@ impl ConfiguredApiCard {
         self.disabled = disabled;
         self
     }
+
+    pub fn button_tab_index(mut self, tab_index: isize) -> Self {
+        self.button_tab_index = Some(tab_index);
+        self
+    }
 }
 
 impl RenderOnce for ConfiguredApiCard {
@@ -51,23 +58,27 @@ impl RenderOnce for ConfiguredApiCard {
         let button_id = SharedString::new(format!("id-{}", button_label));
 
         h_flex()
+            .min_w_0()
             .mt_0p5()
             .p_1()
             .justify_between()
             .rounded_md()
+            .flex_wrap()
             .border_1()
             .border_color(cx.theme().colors().border)
             .bg(cx.theme().colors().background)
             .child(
                 h_flex()
-                    .flex_1()
                     .min_w_0()
                     .gap_1()
                     .child(Icon::new(IconName::Check).color(Color::Success))
-                    .child(Label::new(self.label).truncate()),
+                    .child(Label::new(self.label)),
             )
             .child(
                 Button::new(button_id, button_label)
+                    .when_some(self.button_tab_index, |elem, tab_index| {
+                        elem.tab_index(tab_index)
+                    })
                     .label_size(LabelSize::Small)
                     .icon(IconName::Undo)
                     .icon_size(IconSize::Small)

crates/ui/src/components/button.rs ๐Ÿ”—

@@ -1,12 +1,14 @@
 mod button;
 mod button_icon;
 mod button_like;
+mod button_link;
 mod icon_button;
 mod split_button;
 mod toggle_button;
 
 pub use button::*;
 pub use button_like::*;
+pub use button_link::*;
 pub use icon_button::*;
 pub use split_button::*;
 pub use toggle_button::*;

crates/ui/src/components/button/button_link.rs ๐Ÿ”—

@@ -0,0 +1,102 @@
+use gpui::{IntoElement, Window, prelude::*};
+
+use crate::{ButtonLike, prelude::*};
+
+/// A button that takes an underline to look like a regular web link.
+/// It also contains an arrow icon to communicate the link takes you out of Zed.
+///
+/// # Usage Example
+///
+/// ```
+/// use ui::ButtonLink;
+///
+/// let button_link = ButtonLink::new("Click me", "https://example.com");
+/// ```
+#[derive(IntoElement, RegisterComponent)]
+pub struct ButtonLink {
+    label: SharedString,
+    label_size: LabelSize,
+    label_color: Color,
+    link: String,
+    no_icon: bool,
+}
+
+impl ButtonLink {
+    pub fn new(label: impl Into<SharedString>, link: impl Into<String>) -> Self {
+        Self {
+            link: link.into(),
+            label: label.into(),
+            label_size: LabelSize::Default,
+            label_color: Color::Default,
+            no_icon: false,
+        }
+    }
+
+    pub fn no_icon(mut self, no_icon: bool) -> Self {
+        self.no_icon = no_icon;
+        self
+    }
+
+    pub fn label_size(mut self, label_size: LabelSize) -> Self {
+        self.label_size = label_size;
+        self
+    }
+
+    pub fn label_color(mut self, label_color: Color) -> Self {
+        self.label_color = label_color;
+        self
+    }
+}
+
+impl RenderOnce for ButtonLink {
+    fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
+        let id = format!("{}-{}", self.label, self.link);
+
+        ButtonLike::new(id)
+            .size(ButtonSize::None)
+            .child(
+                h_flex()
+                    .gap_0p5()
+                    .child(
+                        Label::new(self.label)
+                            .size(self.label_size)
+                            .color(self.label_color)
+                            .underline(),
+                    )
+                    .when(!self.no_icon, |this| {
+                        this.child(
+                            Icon::new(IconName::ArrowUpRight)
+                                .size(IconSize::Small)
+                                .color(Color::Muted),
+                        )
+                    }),
+            )
+            .on_click(move |_, _, cx| cx.open_url(&self.link))
+            .into_any_element()
+    }
+}
+
+impl Component for ButtonLink {
+    fn scope() -> ComponentScope {
+        ComponentScope::Navigation
+    }
+
+    fn description() -> Option<&'static str> {
+        Some("A button that opens a URL.")
+    }
+
+    fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
+        Some(
+            v_flex()
+                .gap_6()
+                .child(
+                    example_group(vec![single_example(
+                        "Simple",
+                        ButtonLink::new("zed.dev", "https://zed.dev").into_any_element(),
+                    )])
+                    .vertical(),
+                )
+                .into_any_element(),
+        )
+    }
+}

crates/ui/src/components/divider.rs ๐Ÿ”—

@@ -144,12 +144,18 @@ impl Divider {
 impl RenderOnce for Divider {
     fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
         let base = match self.direction {
-            DividerDirection::Horizontal => {
-                div().h_px().w_full().when(self.inset, |this| this.mx_1p5())
-            }
-            DividerDirection::Vertical => {
-                div().w_px().h_full().when(self.inset, |this| this.my_1p5())
-            }
+            DividerDirection::Horizontal => div()
+                .min_w_0()
+                .flex_none()
+                .h_px()
+                .w_full()
+                .when(self.inset, |this| this.mx_1p5()),
+            DividerDirection::Vertical => div()
+                .min_w_0()
+                .flex_none()
+                .w_px()
+                .h_full()
+                .when(self.inset, |this| this.my_1p5()),
         };
 
         match self.style {

crates/ui/src/components/inline_code.rs ๐Ÿ”—

@@ -0,0 +1,64 @@
+use crate::prelude::*;
+use gpui::{AnyElement, IntoElement, ParentElement, Styled};
+
+/// InlineCode mimics the way inline code is rendered when wrapped in backticks in Markdown.
+///
+/// # Usage Example
+///
+/// ```
+/// use ui::InlineCode;
+///
+/// let InlineCode = InlineCode::new("<div>hey</div>");
+/// ```
+#[derive(IntoElement, RegisterComponent)]
+pub struct InlineCode {
+    label: SharedString,
+    label_size: LabelSize,
+}
+
+impl InlineCode {
+    pub fn new(label: impl Into<SharedString>) -> Self {
+        Self {
+            label: label.into(),
+            label_size: LabelSize::Default,
+        }
+    }
+
+    /// Sets the size of the label.
+    pub fn label_size(mut self, size: LabelSize) -> Self {
+        self.label_size = size;
+        self
+    }
+}
+
+impl RenderOnce for InlineCode {
+    fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
+        h_flex()
+            .min_w_0()
+            .px_0p5()
+            .overflow_hidden()
+            .bg(cx.theme().colors().text.opacity(0.05))
+            .child(Label::new(self.label).size(self.label_size).buffer_font(cx))
+    }
+}
+
+impl Component for InlineCode {
+    fn scope() -> ComponentScope {
+        ComponentScope::DataDisplay
+    }
+
+    fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
+        Some(
+            v_flex()
+                .gap_6()
+                .child(
+                    example_group(vec![single_example(
+                        "Simple",
+                        InlineCode::new("zed.dev").into_any_element(),
+                    )])
+                    .vertical(),
+                )
+                .into_any_element(),
+        )
+    }
+}

crates/ui/src/components/label/label_like.rs ๐Ÿ”—

@@ -227,7 +227,7 @@ impl RenderOnce for LabelLike {
                     .get_or_insert_with(Default::default)
                     .underline = Some(UnderlineStyle {
                     thickness: px(1.),
-                    color: None,
+                    color: Some(cx.theme().colors().text_muted.opacity(0.4)),
                     wavy: false,
                 });
                 this

crates/ui/src/components/list/list_bullet_item.rs ๐Ÿ”—

@@ -1,18 +1,33 @@
-use crate::{ListItem, prelude::*};
-use component::{Component, ComponentScope, example_group_with_title, single_example};
+use crate::{ButtonLink, ListItem, prelude::*};
+use component::{Component, ComponentScope, example_group, single_example};
 use gpui::{IntoElement, ParentElement, SharedString};
 
 #[derive(IntoElement, RegisterComponent)]
 pub struct ListBulletItem {
     label: SharedString,
+    label_color: Option<Color>,
+    children: Vec<AnyElement>,
 }
 
 impl ListBulletItem {
     pub fn new(label: impl Into<SharedString>) -> Self {
         Self {
             label: label.into(),
+            label_color: None,
+            children: Vec::new(),
         }
     }
+
+    pub fn label_color(mut self, color: Color) -> Self {
+        self.label_color = Some(color);
+        self
+    }
+}
+
+impl ParentElement for ListBulletItem {
+    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
+        self.children.extend(elements)
+    }
 }
 
 impl RenderOnce for ListBulletItem {
@@ -34,7 +49,18 @@ impl RenderOnce for ListBulletItem {
                                 .color(Color::Hidden),
                         ),
                     )
-                    .child(div().w_full().min_w_0().child(Label::new(self.label))),
+                    .map(|this| {
+                        if !self.children.is_empty() {
+                            this.child(h_flex().gap_0p5().flex_wrap().children(self.children))
+                        } else {
+                            this.child(
+                                div().w_full().min_w_0().child(
+                                    Label::new(self.label)
+                                        .color(self.label_color.unwrap_or(Color::Default)),
+                                ),
+                            )
+                        }
+                    }),
             )
             .into_any_element()
     }
@@ -46,37 +72,43 @@ impl Component for ListBulletItem {
     }
 
     fn description() -> Option<&'static str> {
-        Some("A list item with a bullet point indicator for unordered lists.")
+        Some("A list item with a dash indicator for unordered lists.")
     }
 
     fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
+        let basic_examples = vec![
+            single_example(
+                "Simple",
+                ListBulletItem::new("First bullet item").into_any_element(),
+            ),
+            single_example(
+                "Multiple Lines",
+                v_flex()
+                    .child(ListBulletItem::new("First item"))
+                    .child(ListBulletItem::new("Second item"))
+                    .child(ListBulletItem::new("Third item"))
+                    .into_any_element(),
+            ),
+            single_example(
+                "Long Text",
+                ListBulletItem::new(
+                    "A longer bullet item that demonstrates text wrapping behavior",
+                )
+                .into_any_element(),
+            ),
+            single_example(
+                "With Link",
+                ListBulletItem::new("")
+                    .child(Label::new("Create a Zed account by"))
+                    .child(ButtonLink::new("visiting the website", "https://zed.dev"))
+                    .into_any_element(),
+            ),
+        ];
+
         Some(
             v_flex()
                 .gap_6()
-                .child(example_group_with_title(
-                    "Bullet Items",
-                    vec![
-                        single_example(
-                            "Simple",
-                            ListBulletItem::new("First bullet item").into_any_element(),
-                        ),
-                        single_example(
-                            "Multiple Lines",
-                            v_flex()
-                                .child(ListBulletItem::new("First item"))
-                                .child(ListBulletItem::new("Second item"))
-                                .child(ListBulletItem::new("Third item"))
-                                .into_any_element(),
-                        ),
-                        single_example(
-                            "Long Text",
-                            ListBulletItem::new(
-                                "A longer bullet item that demonstrates text wrapping behavior",
-                            )
-                            .into_any_element(),
-                        ),
-                    ],
-                ))
+                .child(example_group(basic_examples).vertical())
                 .into_any_element(),
         )
     }

crates/workspace/src/notifications.rs ๐Ÿ”—

@@ -41,7 +41,7 @@ pub enum NotificationId {
 
 impl NotificationId {
     /// Returns a unique [`NotificationId`] for the given type.
-    pub fn unique<T: 'static>() -> Self {
+    pub const fn unique<T: 'static>() -> Self {
         Self::Unique(TypeId::of::<T>())
     }
 

crates/zed_env_vars/src/zed_env_vars.rs ๐Ÿ”—

@@ -5,6 +5,7 @@ use std::sync::LazyLock;
 /// When true, Zed will use in-memory databases instead of persistent storage.
 pub static ZED_STATELESS: LazyLock<bool> = bool_env_var!("ZED_STATELESS");
 
+#[derive(Clone)]
 pub struct EnvVar {
     pub name: SharedString,
     /// Value of the environment variable. Also `None` when set to an empty string.
@@ -30,7 +31,7 @@ impl EnvVar {
 #[macro_export]
 macro_rules! env_var {
     ($name:expr) => {
-        LazyLock::new(|| $crate::EnvVar::new(($name).into()))
+        ::std::sync::LazyLock::new(|| $crate::EnvVar::new(($name).into()))
     };
 }
 
@@ -39,6 +40,6 @@ macro_rules! env_var {
 #[macro_export]
 macro_rules! bool_env_var {
     ($name:expr) => {
-        LazyLock::new(|| $crate::EnvVar::new(($name).into()).value.is_some())
+        ::std::sync::LazyLock::new(|| $crate::EnvVar::new(($name).into()).value.is_some())
     };
 }