Better promote edit prediction when signed out (#43665)

Danilo Leal created

Introducing this little popover here that's aimed at better
communicating what Zed's built-in edit prediction feature is and how
much people can get of it for free by purely just signing in.

<img width="600" height="1914" alt="Screenshot 2025-11-27 at 9  50@2x"
src="https://github.com/user-attachments/assets/a7013292-f662-4cae-9a6f-0e69a4a4fa1d"
/>

Release Notes:

- N/A

Change summary

Cargo.lock                                                  |   1 
crates/client/src/zed_urls.rs                               |   8 
crates/edit_prediction_button/Cargo.toml                    |   1 
crates/edit_prediction_button/src/edit_prediction_button.rs | 145 ++++++
4 files changed, 149 insertions(+), 6 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -5308,6 +5308,7 @@ dependencies = [
  "telemetry",
  "theme",
  "ui",
+ "util",
  "workspace",
  "zed_actions",
  "zeta",

crates/client/src/zed_urls.rs 🔗

@@ -59,3 +59,11 @@ pub fn agent_server_docs(cx: &App) -> String {
         server_url = server_url(cx)
     )
 }
+
+/// Returns the URL to Zed's edit prediction documentation.
+pub fn edit_prediction_docs(cx: &App) -> String {
+    format!(
+        "{server_url}/docs/ai/edit-prediction",
+        server_url = server_url(cx)
+    )
+}

crates/edit_prediction_button/Cargo.toml 🔗

@@ -32,6 +32,7 @@ settings.workspace = true
 supermaven.workspace = true
 telemetry.workspace = true
 ui.workspace = true
+util.workspace = true
 workspace.workspace = true
 zed_actions.workspace = true
 zeta.workspace = true

crates/edit_prediction_button/src/edit_prediction_button.rs 🔗

@@ -11,7 +11,7 @@ use fs::Fs;
 use gpui::{
     Action, Animation, AnimationExt, App, AsyncWindowContext, Corner, Entity, FocusHandle,
     Focusable, IntoElement, ParentElement, Render, Subscription, WeakEntity, actions, div,
-    pulsating_between,
+    ease_in_out, pulsating_between,
 };
 use indoc::indoc;
 use language::{
@@ -34,6 +34,7 @@ use ui::{
     Clickable, ContextMenu, ContextMenuEntry, DocumentationEdge, DocumentationSide, IconButton,
     IconButtonShape, Indicator, PopoverMenu, PopoverMenuHandle, ProgressBar, Tooltip, prelude::*,
 };
+use util::ResultExt as _;
 use workspace::{
     StatusItemView, Toast, Workspace, create_and_open_local_file, item::ItemHandle,
     notifications::NotificationId,
@@ -322,7 +323,7 @@ impl Render for EditPredictionButton {
                     let tooltip_meta = if self.user_store.read(cx).current_user().is_some() {
                         "Choose a Plan"
                     } else {
-                        "Sign In"
+                        "Sign In To Use"
                     };
 
                     return div().child(
@@ -357,6 +358,7 @@ impl Render for EditPredictionButton {
                 }
 
                 let show_editor_predictions = self.editor_show_predictions;
+                let user = self.user_store.read(cx).current_user();
 
                 let icon_button = IconButton::new("zed-predict-pending-button", zeta_icon)
                     .shape(IconButtonShape::Square)
@@ -372,10 +374,18 @@ impl Render for EditPredictionButton {
                         },
                     )
                     .when(!self.popover_menu_handle.is_deployed(), |element| {
+                        let user = user.clone();
                         element.tooltip(move |_window, cx| {
                             if enabled {
                                 if show_editor_predictions {
                                     Tooltip::for_action("Edit Prediction", &ToggleMenu, cx)
+                                } else if user.is_none() {
+                                    Tooltip::with_meta(
+                                        "Edit Prediction",
+                                        Some(&ToggleMenu),
+                                        "Sign In To Use",
+                                        cx,
+                                    )
                                 } else {
                                     Tooltip::with_meta(
                                         "Edit Prediction",
@@ -398,11 +408,25 @@ impl Render for EditPredictionButton {
                 let this = cx.weak_entity();
 
                 let mut popover_menu = PopoverMenu::new("zeta")
-                    .menu(move |window, cx| {
-                        this.update(cx, |this, cx| {
-                            this.build_zeta_context_menu(provider, window, cx)
+                    .when(user.is_some(), |popover_menu| {
+                        let this = this.clone();
+
+                        popover_menu.menu(move |window, cx| {
+                            this.update(cx, |this, cx| {
+                                this.build_zeta_context_menu(provider, window, cx)
+                            })
+                            .ok()
+                        })
+                    })
+                    .when(user.is_none(), |popover_menu| {
+                        let this = this.clone();
+
+                        popover_menu.menu(move |window, cx| {
+                            this.update(cx, |this, cx| {
+                                this.build_zeta_upsell_context_menu(window, cx)
+                            })
+                            .ok()
                         })
-                        .ok()
                     })
                     .anchor(Corner::BottomRight)
                     .with_handle(self.popover_menu_handle.clone());
@@ -1045,6 +1069,55 @@ impl EditPredictionButton {
         })
     }
 
+    fn build_zeta_upsell_context_menu(
+        &self,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Entity<ContextMenu> {
+        ContextMenu::build(window, cx, |mut menu, _window, cx| {
+            menu = menu
+                .custom_row(move |_window, cx| {
+                    let description = indoc! {
+                        "Sign in for 2,000 worth of accepted suggestions at every keystroke, \
+                        powered by Zeta, our open-source, open-data model."
+                    };
+
+                    v_flex()
+                        .max_w_64()
+                        .h(rems_from_px(148.))
+                        .child(render_zeta_tab_animation(cx))
+                        .child(Label::new("Edit Prediction"))
+                        .child(
+                            Label::new(description)
+                                .color(Color::Muted)
+                                .size(LabelSize::Small),
+                        )
+                        .into_any_element()
+                })
+                .separator()
+                .entry("Sign In & Start Using", None, |window, cx| {
+                    let client = Client::global(cx);
+                    window
+                        .spawn(cx, async move |cx| {
+                            client
+                                .sign_in_with_optional_connect(true, &cx)
+                                .await
+                                .log_err();
+                        })
+                        .detach();
+                })
+                .link(
+                    "Learn More",
+                    OpenBrowser {
+                        url: zed_urls::edit_prediction_docs(cx),
+                    }
+                    .boxed_clone(),
+                );
+
+            menu
+        })
+    }
+
     pub fn update_enabled(&mut self, editor: Entity<Editor>, cx: &mut Context<Self>) {
         let editor = editor.read(cx);
         let snapshot = editor.buffer().read(cx).snapshot(cx);
@@ -1248,6 +1321,66 @@ fn toggle_edit_prediction_mode(fs: Arc<dyn Fs>, mode: EditPredictionsMode, cx: &
     }
 }
 
+fn render_zeta_tab_animation(cx: &App) -> impl IntoElement {
+    let tab = |n: u64, inverted: bool| {
+        let text_color = cx.theme().colors().text;
+
+        h_flex().child(
+            h_flex()
+                .text_size(TextSize::XSmall.rems(cx))
+                .text_color(text_color)
+                .child("tab")
+                .with_animation(
+                    ElementId::Integer(n),
+                    Animation::new(Duration::from_secs(4)).repeat(),
+                    move |tab, delta| {
+                        let n_f32 = n as f32;
+
+                        let delta = if inverted {
+                            (delta - 0.15 * (5.0 - n_f32)) / 0.7
+                        } else {
+                            (delta - 0.15 * n_f32) / 0.7
+                        };
+
+                        let delta = 1.0 - (0.5 - delta).abs() * 2.;
+                        let delta = ease_in_out(delta.clamp(0., 1.));
+                        let delta = 0.1 + 0.5 * delta;
+
+                        tab.text_color(text_color.opacity(delta))
+                    },
+                ),
+        )
+    };
+
+    let tab_sequence = |inverted: bool| {
+        h_flex()
+            .gap_1()
+            .child(tab(0, inverted))
+            .child(tab(1, inverted))
+            .child(tab(2, inverted))
+            .child(tab(3, inverted))
+            .child(tab(4, inverted))
+    };
+
+    h_flex()
+        .my_1p5()
+        .p_4()
+        .justify_center()
+        .gap_2()
+        .rounded_xs()
+        .border_1()
+        .border_dashed()
+        .border_color(cx.theme().colors().border)
+        .bg(gpui::pattern_slash(
+            cx.theme().colors().border.opacity(0.5),
+            1.,
+            8.,
+        ))
+        .child(tab_sequence(true))
+        .child(Icon::new(IconName::ZedPredict))
+        .child(tab_sequence(false))
+}
+
 fn copilot_settings_url(enterprise_uri: Option<&str>) -> String {
     match enterprise_uri {
         Some(uri) => {