Require accepting ToS when enabling zeta (#23255)

Agus Zubiaga , Richard , Danilo Leal , and Joao created

Note: Design hasn't been reviewed yet, but the logic is done

When the user switches the inline completion provider to `zed`, we'll
show a modal prompting them to accept terms if they haven't done so:


https://github.com/user-attachments/assets/3fc6d368-c00a-4dcb-9484-fbbbb5eb859e

If they dismiss the modal, they'll be able to get to it again from the
inline completion button:


https://github.com/user-attachments/assets/cf842778-5538-4e06-9ed8-21579981cc47

This also stops zeta sending requests that will fail immediately when
ToS are not accepted.

Release Notes:

- N/A

---------

Co-authored-by: Richard <richard@zed.dev>
Co-authored-by: Danilo Leal <daniloleal09@gmail.com>
Co-authored-by: Joao <joao@zed.dev>

Change summary

Cargo.lock                                                      |  15 
Cargo.toml                                                      |   2 
assets/keymaps/default-macos.json                               |   7 
crates/client/src/user.rs                                       |  30 
crates/editor/Cargo.toml                                        |   1 
crates/editor/src/code_context_menus.rs                         |  19 
crates/editor/src/editor.rs                                     |  21 
crates/inline_completion/src/inline_completion.rs               |   8 
crates/inline_completion_button/Cargo.toml                      |   2 
crates/inline_completion_button/src/inline_completion_button.rs |  49 
crates/ui/src/components/icon.rs                                |   4 
crates/zed/Cargo.toml                                           |   1 
crates/zed/src/main.rs                                          |   6 
crates/zed/src/zed.rs                                           |   1 
crates/zed/src/zed/inline_completion_registry.rs                |  73 +
crates/zed_predict_tos/Cargo.toml                               |  23 
crates/zed_predict_tos/LICENSE-GPL                              |   1 
crates/zed_predict_tos/src/zed_predict_tos.rs                   | 152 +++
crates/zeta/src/zeta.rs                                         |  35 
19 files changed, 427 insertions(+), 23 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -3959,6 +3959,7 @@ dependencies = [
  "util",
  "uuid",
  "workspace",
+ "zed_predict_tos",
 ]
 
 [[package]]
@@ -6304,6 +6305,7 @@ name = "inline_completion_button"
 version = "0.1.0"
 dependencies = [
  "anyhow",
+ "client",
  "copilot",
  "editor",
  "feature_flags",
@@ -6323,6 +6325,7 @@ dependencies = [
  "ui",
  "workspace",
  "zed_actions",
+ "zed_predict_tos",
  "zeta",
 ]
 
@@ -16337,6 +16340,7 @@ dependencies = [
  "winresource",
  "workspace",
  "zed_actions",
+ "zed_predict_tos",
  "zeta",
 ]
 
@@ -16450,6 +16454,17 @@ dependencies = [
  "zed_extension_api 0.1.0",
 ]
 
+[[package]]
+name = "zed_predict_tos"
+version = "0.1.0"
+dependencies = [
+ "client",
+ "gpui",
+ "menu",
+ "ui",
+ "workspace",
+]
+
 [[package]]
 name = "zed_prisma"
 version = "0.0.4"

Cargo.toml 🔗

@@ -2,6 +2,7 @@
 resolver = "2"
 members = [
     "crates/activity_indicator",
+    "crates/zed_predict_tos",
     "crates/anthropic",
     "crates/assets",
     "crates/assistant",
@@ -198,6 +199,7 @@ edition = "2021"
 
 activity_indicator = { path = "crates/activity_indicator" }
 ai = { path = "crates/ai" }
+zed_predict_tos = { path = "crates/zed_predict_tos" }
 anthropic = { path = "crates/anthropic" }
 assets = { path = "crates/assets" }
 assistant = { path = "crates/assistant" }

assets/keymaps/default-macos.json 🔗

@@ -875,5 +875,12 @@
       "cmd-shift-enter": "zeta::ThumbsUpActiveCompletion",
       "cmd-shift-backspace": "zeta::ThumbsDownActiveCompletion"
     }
+  },
+  {
+    "context": "ZedPredictTos",
+    "use_key_equivalents": true,
+    "bindings": {
+      "escape": "menu::Cancel"
+    }
   }
 ]

crates/client/src/user.rs 🔗

@@ -122,6 +122,9 @@ pub enum Event {
     },
     ShowContacts,
     ParticipantIndicesChanged,
+    TermsStatusUpdated {
+        accepted: bool,
+    },
 }
 
 #[derive(Clone, Copy)]
@@ -210,10 +213,24 @@ impl UserStore {
                                             staff,
                                         );
 
-                                        this.update(cx, |this, _| {
-                                            this.set_current_user_accepted_tos_at(
-                                                info.accepted_tos_at,
-                                            );
+                                        this.update(cx, |this, cx| {
+                                            let accepted_tos_at = {
+                                                #[cfg(debug_assertions)]
+                                                if std::env::var("ZED_IGNORE_ACCEPTED_TOS").is_ok()
+                                                {
+                                                    None
+                                                } else {
+                                                    info.accepted_tos_at
+                                                }
+
+                                                #[cfg(not(debug_assertions))]
+                                                info.accepted_tos_at
+                                            };
+
+                                            this.set_current_user_accepted_tos_at(accepted_tos_at);
+                                            cx.emit(Event::TermsStatusUpdated {
+                                                accepted: accepted_tos_at.is_some(),
+                                            });
                                         })
                                     } else {
                                         anyhow::Ok(())
@@ -704,8 +721,9 @@ impl UserStore {
                     .await
                     .context("error accepting tos")?;
 
-                this.update(&mut cx, |this, _| {
-                    this.set_current_user_accepted_tos_at(Some(response.accepted_tos_at))
+                this.update(&mut cx, |this, cx| {
+                    this.set_current_user_accepted_tos_at(Some(response.accepted_tos_at));
+                    cx.emit(Event::TermsStatusUpdated { accepted: true });
                 })
             } else {
                 Err(anyhow!("client not found"))

crates/editor/Cargo.toml 🔗

@@ -88,6 +88,7 @@ url.workspace = true
 util.workspace = true
 uuid.workspace = true
 workspace.workspace = true
+zed_predict_tos.workspace = true
 
 [dev-dependencies]
 ctor.workspace = true

crates/editor/src/code_context_menus.rs 🔗

@@ -616,6 +616,25 @@ impl CompletionsMenu {
                                             )
                                     })),
                             ),
+                            CompletionEntry::InlineCompletionHint(
+                                hint @ InlineCompletionMenuHint::PendingTermsAcceptance,
+                            ) => div().min_w(px(250.)).max_w(px(500.)).child(
+                                ListItem::new("inline-completion")
+                                    .inset(true)
+                                    .toggle_state(item_ix == selected_item)
+                                    .start_slot(Icon::new(IconName::ZedPredict))
+                                    .child(
+                                        base_label.child(
+                                            StyledText::new(hint.label())
+                                                .with_highlights(&style.text, None),
+                                        ),
+                                    )
+                                    .on_click(cx.listener(move |editor, _event, cx| {
+                                        cx.stop_propagation();
+                                        editor.toggle_zed_predict_tos(cx);
+                                    })),
+                            ),
+
                             CompletionEntry::InlineCompletionHint(
                                 hint @ InlineCompletionMenuHint::Loaded { .. },
                             ) => div().min_w(px(250.)).max_w(px(500.)).child(

crates/editor/src/editor.rs 🔗

@@ -70,6 +70,7 @@ pub use element::{
 };
 use futures::{future, FutureExt};
 use fuzzy::StringMatchCandidate;
+use zed_predict_tos::ZedPredictTos;
 
 use code_context_menus::{
     AvailableCodeAction, CodeActionContents, CodeActionsItem, CodeActionsMenu, CodeContextMenu,
@@ -459,6 +460,7 @@ type CompletionId = usize;
 enum InlineCompletionMenuHint {
     Loading,
     Loaded { text: InlineCompletionText },
+    PendingTermsAcceptance,
     None,
 }
 
@@ -468,6 +470,7 @@ impl InlineCompletionMenuHint {
             InlineCompletionMenuHint::Loading | InlineCompletionMenuHint::Loaded { .. } => {
                 "Edit Prediction"
             }
+            InlineCompletionMenuHint::PendingTermsAcceptance => "Accept Terms of Service",
             InlineCompletionMenuHint::None => "No Prediction",
         }
     }
@@ -3828,6 +3831,14 @@ impl Editor {
         self.do_completion(action.item_ix, CompletionIntent::Compose, cx)
     }
 
+    fn toggle_zed_predict_tos(&mut self, cx: &mut ViewContext<Self>) {
+        let (Some(workspace), Some(project)) = (self.workspace(), self.project.as_ref()) else {
+            return;
+        };
+
+        ZedPredictTos::toggle(workspace, project.read(cx).user_store().clone(), cx);
+    }
+
     fn do_completion(
         &mut self,
         item_ix: Option<usize>,
@@ -3851,6 +3862,14 @@ impl Editor {
                         self.context_menu_next(&Default::default(), cx);
                         return Some(Task::ready(Ok(())));
                     }
+                    Some(CompletionEntry::InlineCompletionHint(
+                        InlineCompletionMenuHint::PendingTermsAcceptance,
+                    )) => {
+                        drop(entries);
+                        drop(context_menu);
+                        self.toggle_zed_predict_tos(cx);
+                        return Some(Task::ready(Ok(())));
+                    }
                     _ => {}
                 }
             }
@@ -4974,6 +4993,8 @@ impl Editor {
             Some(InlineCompletionMenuHint::Loaded { text })
         } else if provider.is_refreshing(cx) {
             Some(InlineCompletionMenuHint::Loading)
+        } else if provider.needs_terms_acceptance(cx) {
+            Some(InlineCompletionMenuHint::PendingTermsAcceptance)
         } else {
             Some(InlineCompletionMenuHint::None)
         }

crates/inline_completion/src/inline_completion.rs 🔗

@@ -36,6 +36,9 @@ pub trait InlineCompletionProvider: 'static + Sized {
         debounce: bool,
         cx: &mut ModelContext<Self>,
     );
+    fn needs_terms_acceptance(&self, _cx: &AppContext) -> bool {
+        false
+    }
     fn cycle(
         &mut self,
         buffer: Model<Buffer>,
@@ -64,6 +67,7 @@ pub trait InlineCompletionProviderHandle {
     ) -> bool;
     fn show_completions_in_menu(&self) -> bool;
     fn show_completions_in_normal_mode(&self) -> bool;
+    fn needs_terms_acceptance(&self, cx: &AppContext) -> bool;
     fn is_refreshing(&self, cx: &AppContext) -> bool;
     fn refresh(
         &self,
@@ -118,6 +122,10 @@ where
         self.read(cx).is_enabled(buffer, cursor_position, cx)
     }
 
+    fn needs_terms_acceptance(&self, cx: &AppContext) -> bool {
+        self.read(cx).needs_terms_acceptance(cx)
+    }
+
     fn is_refreshing(&self, cx: &AppContext) -> bool {
         self.read(cx).is_refreshing()
     }

crates/inline_completion_button/Cargo.toml 🔗

@@ -28,6 +28,8 @@ ui.workspace = true
 workspace.workspace = true
 zed_actions.workspace = true
 zeta.workspace = true
+client.workspace = true
+zed_predict_tos.workspace = true
 
 [dev-dependencies]
 copilot = { workspace = true, features = ["test-support"] }

crates/inline_completion_button/src/inline_completion_button.rs 🔗

@@ -1,12 +1,13 @@
 use anyhow::Result;
+use client::UserStore;
 use copilot::{Copilot, Status};
 use editor::{scroll::Autoscroll, Editor};
 use feature_flags::{FeatureFlagAppExt, PredictEditsFeatureFlag};
 use fs::Fs;
 use gpui::{
     actions, div, pulsating_between, Action, Animation, AnimationExt, AppContext,
-    AsyncWindowContext, Corner, Entity, IntoElement, ParentElement, Render, Subscription, View,
-    ViewContext, WeakView, WindowContext,
+    AsyncWindowContext, Corner, Entity, IntoElement, Model, ParentElement, Render, Subscription,
+    View, ViewContext, WeakView, WindowContext,
 };
 use language::{
     language_settings::{
@@ -17,6 +18,7 @@ use language::{
 use settings::{update_settings_file, Settings, SettingsStore};
 use std::{path::Path, sync::Arc, time::Duration};
 use supermaven::{AccountStatus, Supermaven};
+use ui::{ActiveTheme as _, ButtonLike, Color, Icon, IconWithIndicator, Indicator};
 use workspace::{
     create_and_open_local_file,
     item::ItemHandle,
@@ -27,6 +29,7 @@ use workspace::{
     StatusItemView, Toast, Workspace,
 };
 use zed_actions::OpenBrowser;
+use zed_predict_tos::ZedPredictTos;
 use zeta::RateCompletionModal;
 
 actions!(zeta, [RateCompletions]);
@@ -43,6 +46,7 @@ pub struct InlineCompletionButton {
     inline_completion_provider: Option<Arc<dyn inline_completion::InlineCompletionProviderHandle>>,
     fs: Arc<dyn Fs>,
     workspace: WeakView<Workspace>,
+    user_store: Model<UserStore>,
 }
 
 enum SupermavenButtonStatus {
@@ -206,6 +210,45 @@ impl Render for InlineCompletionButton {
                     return div();
                 }
 
+                if !self
+                    .user_store
+                    .read(cx)
+                    .current_user_has_accepted_terms()
+                    .unwrap_or(false)
+                {
+                    let workspace = self.workspace.clone();
+                    let user_store = self.user_store.clone();
+
+                    return div().child(
+                        ButtonLike::new("zeta-pending-tos-icon")
+                            .child(
+                                IconWithIndicator::new(
+                                    Icon::new(IconName::ZedPredict),
+                                    Some(Indicator::dot().color(Color::Error)),
+                                )
+                                .indicator_border_color(Some(
+                                    cx.theme().colors().status_bar_background,
+                                ))
+                                .into_any_element(),
+                            )
+                            .tooltip(|cx| {
+                                Tooltip::with_meta(
+                                    "Edit Predictions",
+                                    None,
+                                    "Read Terms of Service",
+                                    cx,
+                                )
+                            })
+                            .on_click(cx.listener(move |_, _, cx| {
+                                let user_store = user_store.clone();
+
+                                if let Some(workspace) = workspace.upgrade() {
+                                    ZedPredictTos::toggle(workspace, user_store, cx);
+                                }
+                            })),
+                    );
+                }
+
                 let this = cx.view().clone();
                 let button = IconButton::new("zeta", IconName::ZedPredict)
                     .tooltip(|cx| Tooltip::text("Edit Prediction", cx));
@@ -244,6 +287,7 @@ impl InlineCompletionButton {
     pub fn new(
         workspace: WeakView<Workspace>,
         fs: Arc<dyn Fs>,
+        user_store: Model<UserStore>,
         cx: &mut ViewContext<Self>,
     ) -> Self {
         if let Some(copilot) = Copilot::global(cx) {
@@ -261,6 +305,7 @@ impl InlineCompletionButton {
             inline_completion_provider: None,
             workspace,
             fs,
+            user_store,
         }
     }
 

crates/ui/src/components/icon.rs 🔗

@@ -68,6 +68,8 @@ pub enum IconSize {
     #[default]
     /// 16px
     Medium,
+    /// 48px
+    XLarge,
 }
 
 impl IconSize {
@@ -77,6 +79,7 @@ impl IconSize {
             IconSize::XSmall => rems_from_px(12.),
             IconSize::Small => rems_from_px(14.),
             IconSize::Medium => rems_from_px(16.),
+            IconSize::XLarge => rems_from_px(48.),
         }
     }
 
@@ -92,6 +95,7 @@ impl IconSize {
             IconSize::XSmall => DynamicSpacing::Base02.px(cx),
             IconSize::Small => DynamicSpacing::Base02.px(cx),
             IconSize::Medium => DynamicSpacing::Base02.px(cx),
+            IconSize::XLarge => DynamicSpacing::Base02.px(cx),
         };
 
         (icon_size, padding)

crates/zed/Cargo.toml 🔗

@@ -16,6 +16,7 @@ path = "src/main.rs"
 
 [dependencies]
 activity_indicator.workspace = true
+zed_predict_tos.workspace = true
 anyhow.workspace = true
 assets.workspace = true
 assistant.workspace = true

crates/zed/src/main.rs 🔗

@@ -438,7 +438,11 @@ fn main() {
             cx,
         );
         snippet_provider::init(cx);
-        inline_completion_registry::init(app_state.client.clone(), cx);
+        inline_completion_registry::init(
+            app_state.client.clone(),
+            app_state.user_store.clone(),
+            cx,
+        );
         let prompt_builder = assistant::init(
             app_state.fs.clone(),
             app_state.client.clone(),

crates/zed/src/zed.rs 🔗

@@ -168,6 +168,7 @@ pub fn initialize_workspace(
             inline_completion_button::InlineCompletionButton::new(
                 workspace.weak_handle(),
                 app_state.fs.clone(),
+                app_state.user_store.clone(),
                 cx,
             )
         });

crates/zed/src/zed/inline_completion_registry.rs 🔗

@@ -1,20 +1,23 @@
 use std::{cell::RefCell, rc::Rc, sync::Arc};
 
-use client::Client;
+use client::{Client, UserStore};
 use collections::HashMap;
 use copilot::{Copilot, CopilotCompletionProvider};
 use editor::{Editor, EditorMode};
 use feature_flags::{FeatureFlagAppExt, PredictEditsFeatureFlag};
-use gpui::{AnyWindowHandle, AppContext, Context, ViewContext, WeakView};
+use gpui::{AnyWindowHandle, AppContext, Context, Model, ViewContext, WeakView};
 use language::language_settings::{all_language_settings, InlineCompletionProvider};
 use settings::SettingsStore;
 use supermaven::{Supermaven, SupermavenCompletionProvider};
+use workspace::Workspace;
+use zed_predict_tos::ZedPredictTos;
 
-pub fn init(client: Arc<Client>, cx: &mut AppContext) {
+pub fn init(client: Arc<Client>, user_store: Model<UserStore>, cx: &mut AppContext) {
     let editors: Rc<RefCell<HashMap<WeakView<Editor>, AnyWindowHandle>>> = Rc::default();
     cx.observe_new_views({
         let editors = editors.clone();
         let client = client.clone();
+        let user_store = user_store.clone();
         move |editor: &mut Editor, cx: &mut ViewContext<Editor>| {
             if editor.mode() != EditorMode::Full {
                 return;
@@ -35,7 +38,7 @@ pub fn init(client: Arc<Client>, cx: &mut AppContext) {
                 .borrow_mut()
                 .insert(editor_handle, cx.window_handle());
             let provider = all_language_settings(None, cx).inline_completions.provider;
-            assign_inline_completion_provider(editor, provider, &client, cx);
+            assign_inline_completion_provider(editor, provider, &client, user_store.clone(), cx);
         }
     })
     .detach();
@@ -44,7 +47,13 @@ pub fn init(client: Arc<Client>, cx: &mut AppContext) {
     for (editor, window) in editors.borrow().iter() {
         _ = window.update(cx, |_window, cx| {
             _ = editor.update(cx, |editor, cx| {
-                assign_inline_completion_provider(editor, provider, &client, cx);
+                assign_inline_completion_provider(
+                    editor,
+                    provider,
+                    &client,
+                    user_store.clone(),
+                    cx,
+                );
             })
         });
     }
@@ -56,9 +65,10 @@ pub fn init(client: Arc<Client>, cx: &mut AppContext) {
     cx.observe_flag::<PredictEditsFeatureFlag, _>({
         let editors = editors.clone();
         let client = client.clone();
+        let user_store = user_store.clone();
         move |active, cx| {
             let provider = all_language_settings(None, cx).inline_completions.provider;
-            assign_inline_completion_providers(&editors, provider, &client, cx);
+            assign_inline_completion_providers(&editors, provider, &client, user_store.clone(), cx);
             if active && !cx.is_action_available(&zeta::ClearHistory) {
                 cx.on_action(clear_zeta_edit_history);
             }
@@ -69,11 +79,48 @@ pub fn init(client: Arc<Client>, cx: &mut AppContext) {
     cx.observe_global::<SettingsStore>({
         let editors = editors.clone();
         let client = client.clone();
+        let user_store = user_store.clone();
         move |cx| {
             let new_provider = all_language_settings(None, cx).inline_completions.provider;
             if new_provider != provider {
                 provider = new_provider;
-                assign_inline_completion_providers(&editors, provider, &client, cx)
+                assign_inline_completion_providers(
+                    &editors,
+                    provider,
+                    &client,
+                    user_store.clone(),
+                    cx,
+                );
+
+                if !user_store
+                    .read(cx)
+                    .current_user_has_accepted_terms()
+                    .unwrap_or(false)
+                {
+                    match provider {
+                        InlineCompletionProvider::Zed => {
+                            let Some(window) = cx.active_window() else {
+                                return;
+                            };
+
+                            let Some(workspace) = window
+                                .downcast::<Workspace>()
+                                .and_then(|w| w.root_view(cx).ok())
+                            else {
+                                return;
+                            };
+
+                            window
+                                .update(cx, |_, cx| {
+                                    ZedPredictTos::toggle(workspace, user_store.clone(), cx);
+                                })
+                                .ok();
+                        }
+                        InlineCompletionProvider::None
+                        | InlineCompletionProvider::Copilot
+                        | InlineCompletionProvider::Supermaven => {}
+                    }
+                }
             }
         }
     })
@@ -90,12 +137,19 @@ fn assign_inline_completion_providers(
     editors: &Rc<RefCell<HashMap<WeakView<Editor>, AnyWindowHandle>>>,
     provider: InlineCompletionProvider,
     client: &Arc<Client>,
+    user_store: Model<UserStore>,
     cx: &mut AppContext,
 ) {
     for (editor, window) in editors.borrow().iter() {
         _ = window.update(cx, |_window, cx| {
             _ = editor.update(cx, |editor, cx| {
-                assign_inline_completion_provider(editor, provider, &client, cx);
+                assign_inline_completion_provider(
+                    editor,
+                    provider,
+                    &client,
+                    user_store.clone(),
+                    cx,
+                );
             })
         });
     }
@@ -141,6 +195,7 @@ fn assign_inline_completion_provider(
     editor: &mut Editor,
     provider: language::language_settings::InlineCompletionProvider,
     client: &Arc<Client>,
+    user_store: Model<UserStore>,
     cx: &mut ViewContext<Editor>,
 ) {
     match provider {
@@ -169,7 +224,7 @@ fn assign_inline_completion_provider(
             if cx.has_flag::<PredictEditsFeatureFlag>()
                 || (cfg!(debug_assertions) && client.status().borrow().is_connected())
             {
-                let zeta = zeta::Zeta::register(client.clone(), cx);
+                let zeta = zeta::Zeta::register(client.clone(), user_store, cx);
                 if let Some(buffer) = editor.buffer().read(cx).as_singleton() {
                     if buffer.read(cx).file().is_some() {
                         zeta.update(cx, |zeta, cx| {

crates/zed_predict_tos/Cargo.toml 🔗

@@ -0,0 +1,23 @@
+[package]
+name = "zed_predict_tos"
+version = "0.1.0"
+edition = "2021"
+publish = false
+license = "GPL-3.0-or-later"
+
+[lints]
+workspace = true
+
+[lib]
+path = "src/zed_predict_tos.rs"
+doctest = false
+
+[features]
+test-support = []
+
+[dependencies]
+client.workspace = true
+gpui.workspace = true
+ui.workspace = true
+workspace.workspace = true
+menu.workspace = true

crates/zed_predict_tos/src/zed_predict_tos.rs 🔗

@@ -0,0 +1,152 @@
+//! AI service Terms of Service acceptance modal.
+
+use client::UserStore;
+use gpui::{
+    AppContext, ClickEvent, DismissEvent, EventEmitter, FocusHandle, FocusableView, Model,
+    MouseDownEvent, Render, View,
+};
+use ui::{prelude::*, TintColor};
+use workspace::{ModalView, Workspace};
+
+/// Terms of acceptance for AI inline prediction.
+pub struct ZedPredictTos {
+    focus_handle: FocusHandle,
+    user_store: Model<UserStore>,
+    workspace: View<Workspace>,
+    viewed: bool,
+}
+
+impl ZedPredictTos {
+    fn new(
+        workspace: View<Workspace>,
+        user_store: Model<UserStore>,
+        cx: &mut ViewContext<Self>,
+    ) -> Self {
+        ZedPredictTos {
+            viewed: false,
+            focus_handle: cx.focus_handle(),
+            user_store,
+            workspace,
+        }
+    }
+    pub fn toggle(
+        workspace: View<Workspace>,
+        user_store: Model<UserStore>,
+        cx: &mut WindowContext,
+    ) {
+        workspace.update(cx, |this, cx| {
+            let workspace = cx.view().clone();
+            this.toggle_modal(cx, |cx| ZedPredictTos::new(workspace, user_store, cx));
+        });
+    }
+
+    fn view_terms(&mut self, _: &ClickEvent, cx: &mut ViewContext<Self>) {
+        self.viewed = true;
+        cx.open_url("https://zed.dev/terms-of-service");
+        cx.notify();
+    }
+
+    fn accept_terms(&mut self, _: &ClickEvent, cx: &mut ViewContext<Self>) {
+        let task = self
+            .user_store
+            .update(cx, |this, cx| this.accept_terms_of_service(cx));
+
+        let workspace = self.workspace.clone();
+
+        cx.spawn(|this, mut cx| async move {
+            match task.await {
+                Ok(_) => this.update(&mut cx, |_, cx| {
+                    cx.emit(DismissEvent);
+                }),
+                Err(err) => workspace.update(&mut cx, |this, cx| {
+                    this.show_error(&err, cx);
+                }),
+            }
+        })
+        .detach_and_log_err(cx);
+    }
+
+    fn cancel(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
+        cx.emit(DismissEvent);
+    }
+}
+
+impl EventEmitter<DismissEvent> for ZedPredictTos {}
+
+impl FocusableView for ZedPredictTos {
+    fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
+        self.focus_handle.clone()
+    }
+}
+
+impl ModalView for ZedPredictTos {}
+
+impl Render for ZedPredictTos {
+    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
+        v_flex()
+            .id("zed predict tos")
+            .track_focus(&self.focus_handle(cx))
+            .on_action(cx.listener(Self::cancel))
+            .key_context("ZedPredictTos")
+            .elevation_3(cx)
+            .w_96()
+            .items_center()
+            .p_4()
+            .gap_2()
+            .on_action(cx.listener(|_, _: &menu::Cancel, cx| {
+                cx.emit(DismissEvent);
+            }))
+            .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, cx| {
+                cx.focus(&this.focus_handle);
+            }))
+            .child(
+                h_flex()
+                    .w_full()
+                    .justify_between()
+                    .child(
+                        v_flex()
+                            .gap_0p5()
+                            .child(
+                                Label::new("Zed AI")
+                                    .size(LabelSize::Small)
+                                    .color(Color::Muted),
+                            )
+                            .child(Headline::new("Edit Prediction")),
+                    )
+                    .child(Icon::new(IconName::ZedPredict).size(IconSize::XLarge)),
+            )
+            .child(
+                Label::new(
+                    "To use Zed AI's Edit Prediction feature, please read and accept our Terms of Service.",
+                )
+                .color(Color::Muted),
+            )
+            .child(
+                v_flex()
+                    .mt_2()
+                    .gap_0p5()
+                    .w_full()
+                    .child(if self.viewed {
+                        Button::new("accept-tos", "I've Read and Accept the Terms of Service")
+                            .style(ButtonStyle::Tinted(TintColor::Accent))
+                            .full_width()
+                            .on_click(cx.listener(Self::accept_terms))
+                    } else {
+                        Button::new("view-tos", "Read Terms of Service")
+                            .style(ButtonStyle::Tinted(TintColor::Accent))
+                            .icon(IconName::ArrowUpRight)
+                            .icon_size(IconSize::XSmall)
+                            .icon_position(IconPosition::End)
+                            .full_width()
+                            .on_click(cx.listener(Self::view_terms))
+                    })
+                    .child(
+                        Button::new("cancel", "Cancel")
+                            .full_width()
+                            .on_click(cx.listener(|_, _: &ClickEvent, cx| {
+                                cx.emit(DismissEvent);
+                            })),
+                    ),
+            )
+    }
+}

crates/zeta/src/zeta.rs 🔗

@@ -6,7 +6,7 @@ pub use rate_completion_modal::*;
 
 use anyhow::{anyhow, Context as _, Result};
 use arrayvec::ArrayVec;
-use client::Client;
+use client::{Client, UserStore};
 use collections::{HashMap, HashSet, VecDeque};
 use futures::AsyncReadExt;
 use gpui::{
@@ -162,6 +162,8 @@ pub struct Zeta {
     rated_completions: HashSet<InlineCompletionId>,
     llm_token: LlmApiToken,
     _llm_token_subscription: Subscription,
+    tos_accepted: bool, // Terms of service accepted
+    _user_store_subscription: Subscription,
 }
 
 impl Zeta {
@@ -169,9 +171,13 @@ impl Zeta {
         cx.try_global::<ZetaGlobal>().map(|global| global.0.clone())
     }
 
-    pub fn register(client: Arc<Client>, cx: &mut AppContext) -> Model<Self> {
+    pub fn register(
+        client: Arc<Client>,
+        user_store: Model<UserStore>,
+        cx: &mut AppContext,
+    ) -> Model<Self> {
         Self::global(cx).unwrap_or_else(|| {
-            let model = cx.new_model(|cx| Self::new(client, cx));
+            let model = cx.new_model(|cx| Self::new(client, user_store, cx));
             cx.set_global(ZetaGlobal(model.clone()));
             model
         })
@@ -181,7 +187,7 @@ impl Zeta {
         self.events.clear();
     }
 
-    fn new(client: Arc<Client>, cx: &mut ModelContext<Self>) -> Self {
+    fn new(client: Arc<Client>, user_store: Model<UserStore>, cx: &mut ModelContext<Self>) -> Self {
         let refresh_llm_token_listener = language_models::RefreshLlmTokenListener::global(cx);
 
         Self {
@@ -203,6 +209,16 @@ impl Zeta {
                     .detach_and_log_err(cx);
                 },
             ),
+            tos_accepted: user_store
+                .read(cx)
+                .current_user_has_accepted_terms()
+                .unwrap_or(false),
+            _user_store_subscription: cx.subscribe(&user_store, |this, _, event, _| match event {
+                client::user::Event::TermsStatusUpdated { accepted } => {
+                    this.tos_accepted = *accepted;
+                }
+                _ => {}
+            }),
         }
     }
 
@@ -1021,6 +1037,10 @@ impl inline_completion::InlineCompletionProvider for ZetaInlineCompletionProvide
         settings.inline_completions_enabled(language.as_ref(), file.map(|f| f.path().as_ref()), cx)
     }
 
+    fn needs_terms_acceptance(&self, cx: &AppContext) -> bool {
+        !self.zeta.read(cx).tos_accepted
+    }
+
     fn is_refreshing(&self) -> bool {
         !self.pending_completions.is_empty()
     }
@@ -1032,6 +1052,10 @@ impl inline_completion::InlineCompletionProvider for ZetaInlineCompletionProvide
         debounce: bool,
         cx: &mut ModelContext<Self>,
     ) {
+        if !self.zeta.read(cx).tos_accepted {
+            return;
+        }
+
         let pending_completion_id = self.next_pending_completion_id;
         self.next_pending_completion_id += 1;
 
@@ -1337,8 +1361,9 @@ mod tests {
             RefreshLlmTokenListener::register(client.clone(), cx);
         });
         let server = FakeServer::for_client(42, &client, cx).await;
+        let user_store = cx.new_model(|cx| UserStore::new(client.clone(), cx));
+        let zeta = cx.new_model(|cx| Zeta::new(client, user_store, cx));
 
-        let zeta = cx.new_model(|cx| Zeta::new(client, cx));
         let buffer = cx.new_model(|cx| Buffer::local(buffer_content, cx));
         let cursor = buffer.read_with(cx, |buffer, _| buffer.anchor_before(Point::new(1, 0)));
         let completion_task =