Introduce global ai setting

Nate Butler created

Change summary

Cargo.lock                                |   2 
assets/settings/default.json              |   6 
crates/agent_ui/src/inline_assistant.rs   |   3 
crates/language/src/language_settings.rs  |  21 ++++
crates/onboarding_ui/Cargo.toml           |   1 
crates/onboarding_ui/src/onboarding_ui.rs | 129 +++++++++++++++++++-----
crates/zed/Cargo.toml                     |   1 
crates/zed/src/zed.rs                     |  50 +++++++++
crates/zed_actions/src/lib.rs             |   4 
9 files changed, 188 insertions(+), 29 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -10826,6 +10826,7 @@ dependencies = [
  "editor",
  "feature_flags",
  "gpui",
+ "language",
  "menu",
  "project",
  "serde_json",
@@ -19999,6 +20000,7 @@ dependencies = [
  "collab_ui",
  "collections",
  "command_palette",
+ "command_palette_hooks",
  "component",
  "copilot",
  "dap",

assets/settings/default.json 🔗

@@ -25,7 +25,11 @@
   // Features that can be globally enabled or disabled
   "features": {
     // Which edit prediction provider to use.
-    "edit_prediction_provider": "zed"
+    "edit_prediction_provider": "zed",
+    // A globally enable or disable AI features.
+    //
+    // This setting supersedes all other settings related to AI features.
+    "ai_assistance": true
   },
   // The name of a font to use for rendering text in the editor
   "buffer_font_family": "Zed Plex Mono",

crates/agent_ui/src/inline_assistant.rs 🔗

@@ -33,6 +33,7 @@ use gpui::{
     App, Context, Entity, Focusable, Global, HighlightStyle, Subscription, Task, UpdateGlobal,
     WeakEntity, Window, point,
 };
+use language::language_settings;
 use language::{Buffer, Point, Selection, TransactionId};
 use language_model::{
     ConfigurationError, ConfiguredModel, LanguageModelRegistry, report_assistant_event,
@@ -1768,7 +1769,7 @@ impl CodeActionProvider for AssistantCodeActionProvider {
         _: &mut Window,
         cx: &mut App,
     ) -> Task<Result<Vec<CodeAction>>> {
-        if !AgentSettings::get_global(cx).enabled {
+        if !AgentSettings::get_global(cx).enabled || !language_settings::ai_enabled(cx) {
             return Task::ready(Ok(Vec::new()));
         }
 

crates/language/src/language_settings.rs 🔗

@@ -54,11 +54,18 @@ pub fn all_language_settings<'a>(
     AllLanguageSettings::get(location, cx)
 }
 
+/// Returns whether AI assistance is globally enabled or disabled.
+pub fn ai_enabled(cx: &App) -> bool {
+    all_language_settings(None, cx).ai_assistance
+}
+
 /// The settings for all languages.
 #[derive(Debug, Clone)]
 pub struct AllLanguageSettings {
     /// The edit prediction settings.
     pub edit_predictions: EditPredictionSettings,
+    /// Whether AI assistance is enabled.
+    pub ai_assistance: bool,
     pub defaults: LanguageSettings,
     languages: HashMap<LanguageName, LanguageSettings>,
     pub(crate) file_types: FxHashMap<Arc<str>, GlobSet>,
@@ -646,6 +653,8 @@ pub struct CopilotSettingsContent {
 pub struct FeaturesContent {
     /// Determines which edit prediction provider to use.
     pub edit_prediction_provider: Option<EditPredictionProvider>,
+    /// Whether AI assistance is enabled.
+    pub ai_assistance: Option<bool>,
 }
 
 /// Controls the soft-wrapping behavior in the editor.
@@ -1122,6 +1131,11 @@ impl AllLanguageSettings {
     pub fn edit_predictions_mode(&self) -> EditPredictionsMode {
         self.edit_predictions.mode
     }
+
+    /// Returns whether AI assistance is enabled.
+    pub fn is_ai_assistance_enabled(&self) -> bool {
+        self.ai_assistance
+    }
 }
 
 fn merge_with_editorconfig(settings: &mut LanguageSettings, cfg: &EditorconfigProperties) {
@@ -1247,6 +1261,12 @@ impl settings::Settings for AllLanguageSettings {
             .map(|settings| settings.enabled_in_text_threads)
             .unwrap_or(true);
 
+        let ai_assistance = default_value
+            .features
+            .as_ref()
+            .and_then(|f| f.ai_assistance)
+            .unwrap_or(true);
+
         let mut file_types: FxHashMap<Arc<str>, GlobSet> = FxHashMap::default();
 
         for (language, patterns) in &default_value.file_types {
@@ -1359,6 +1379,7 @@ impl settings::Settings for AllLanguageSettings {
                 copilot: copilot_settings,
                 enabled_in_text_threads,
             },
+            ai_assistance,
             defaults,
             languages,
             file_types,

crates/onboarding_ui/Cargo.toml 🔗

@@ -23,6 +23,7 @@ component.workspace = true
 db.workspace = true
 feature_flags.workspace = true
 gpui.workspace = true
+language.workspace = true
 menu.workspace = true
 project.workspace = true
 serde_json.workspace = true

crates/onboarding_ui/src/onboarding_ui.rs 🔗

@@ -10,20 +10,22 @@ use client::{Client, TelemetrySettings};
 use command_palette_hooks::CommandPaletteFilter;
 use feature_flags::FeatureFlagAppExt as _;
 use gpui::{
-    Action, Entity, EventEmitter, FocusHandle, Focusable, FontWeight, KeyBinding, Task,
-    UpdateGlobal, WeakEntity, actions, prelude::*, svg, transparent_black,
+    Action, Entity, EventEmitter, FocusHandle, Focusable, FontWeight, KeyBinding, Subscription,
+    Task, UpdateGlobal, WeakEntity, actions, prelude::*, svg, transparent_black,
 };
 use menu;
 use persistence::ONBOARDING_DB;
 
+use language::language_settings::{AllLanguageSettings, ai_enabled, all_language_settings};
 use project::Project;
 use serde_json;
-use settings::{Settings, SettingsStore};
+use settings::{Settings, SettingsStore, update_settings_file};
 use settings_ui::SettingsUiFeatureFlag;
+use std::collections::HashSet;
 use std::sync::Arc;
 use theme::{Theme, ThemeRegistry, ThemeSettings};
 use ui::{
-    CheckboxWithLabel, ContentGroup, KeybindingHint, ListItem, Ring, ToggleState, Vector,
+    CheckboxWithLabel, ContentGroup, FocusOutline, KeybindingHint, ListItem, ToggleState, Vector,
     VectorName, prelude::*,
 };
 use util::ResultExt;
@@ -87,7 +89,7 @@ fn feature_gate_onboarding_ui_actions(cx: &mut App) {
     .detach();
 }
 
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
 pub enum OnboardingPage {
     Basics,
     Editing,
@@ -139,7 +141,7 @@ pub struct OnboardingUI {
     current_page: OnboardingPage,
     nav_focus: NavigationFocusItem,
     page_focus: [PageFocusItem; 4],
-    completed_pages: [bool; 4],
+    completed_pages: HashSet<OnboardingPage>,
     focus_area: FocusArea,
 
     // Workspace reference for Item trait
@@ -147,6 +149,7 @@ pub struct OnboardingUI {
     workspace_id: Option<WorkspaceId>,
     client: Arc<Client>,
     welcome_page: Option<Entity<WelcomePage>>,
+    _settings_subscription: Option<Subscription>,
 }
 
 impl OnboardingUI {}
@@ -179,6 +182,8 @@ impl Render for OnboardingUI {
             .on_action(cx.listener(Self::confirm))
             .on_action(cx.listener(Self::cancel))
             .on_action(cx.listener(Self::toggle_focus))
+            .on_action(cx.listener(Self::handle_enable_ai_assistance))
+            .on_action(cx.listener(Self::handle_disable_ai_assistance))
             .flex()
             .items_center()
             .justify_center()
@@ -218,31 +223,57 @@ impl Render for OnboardingUI {
 
 impl OnboardingUI {
     pub fn new(workspace: &Workspace, client: Arc<Client>, cx: &mut Context<Self>) -> Self {
+        let settings_subscription = cx.observe_global::<SettingsStore>(|_, cx| {
+            cx.notify();
+        });
+
         Self {
             focus_handle: cx.focus_handle(),
             current_page: OnboardingPage::Basics,
             nav_focus: NavigationFocusItem::Basics,
             page_focus: [PageFocusItem(0); 4],
-            completed_pages: [false; 4],
+            completed_pages: HashSet::new(),
             focus_area: FocusArea::Navigation,
             workspace: workspace.weak_handle(),
             workspace_id: workspace.database_id(),
             client,
             welcome_page: None,
+            _settings_subscription: Some(settings_subscription),
         }
     }
 
     fn completed_pages_to_string(&self) -> String {
-        self.completed_pages
-            .iter()
-            .map(|&completed| if completed { '1' } else { '0' })
-            .collect()
+        let mut result = String::new();
+        for i in 0..4 {
+            let page = match i {
+                0 => OnboardingPage::Basics,
+                1 => OnboardingPage::Editing,
+                2 => OnboardingPage::AiSetup,
+                3 => OnboardingPage::Welcome,
+                _ => unreachable!(),
+            };
+            result.push(if self.completed_pages.contains(&page) {
+                '1'
+            } else {
+                '0'
+            });
+        }
+        result
     }
 
-    fn completed_pages_from_string(s: &str) -> [bool; 4] {
-        let mut result = [false; 4];
+    fn completed_pages_from_string(s: &str) -> HashSet<OnboardingPage> {
+        let mut result = HashSet::new();
         for (i, ch) in s.chars().take(4).enumerate() {
-            result[i] = ch == '1';
+            if ch == '1' {
+                let page = match i {
+                    0 => OnboardingPage::Basics,
+                    1 => OnboardingPage::Editing,
+                    2 => OnboardingPage::AiSetup,
+                    3 => OnboardingPage::Welcome,
+                    _ => continue,
+                };
+                result.insert(page);
+            }
         }
         result
     }
@@ -275,7 +306,7 @@ impl OnboardingUI {
     fn reset(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) {
         self.current_page = OnboardingPage::Basics;
         self.focus_area = FocusArea::Navigation;
-        self.completed_pages = [false; 4];
+        self.completed_pages = HashSet::new();
         cx.notify();
     }
 
@@ -460,13 +491,7 @@ impl OnboardingUI {
         _window: &mut gpui::Window,
         cx: &mut Context<Self>,
     ) {
-        let index = match page {
-            OnboardingPage::Basics => 0,
-            OnboardingPage::Editing => 1,
-            OnboardingPage::AiSetup => 2,
-            OnboardingPage::Welcome => 3,
-        };
-        self.completed_pages[index] = true;
+        self.completed_pages.insert(page);
         cx.notify();
     }
 
@@ -510,6 +535,40 @@ impl OnboardingUI {
         self.next_page(window, cx);
     }
 
+    fn handle_enable_ai_assistance(
+        &mut self,
+        _: &zed_actions::EnableAiAssistance,
+        _window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        if let Some(workspace) = self.workspace.upgrade() {
+            let fs = workspace.read(cx).app_state().fs.clone();
+            update_settings_file::<AllLanguageSettings>(fs, cx, move |file, _| {
+                file.features
+                    .get_or_insert(Default::default())
+                    .ai_assistance = Some(true);
+            });
+            cx.notify();
+        }
+    }
+
+    fn handle_disable_ai_assistance(
+        &mut self,
+        _: &zed_actions::DisableAiAssistance,
+        _window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        if let Some(workspace) = self.workspace.upgrade() {
+            let fs = workspace.read(cx).app_state().fs.clone();
+            update_settings_file::<AllLanguageSettings>(fs, cx, move |file, _| {
+                file.features
+                    .get_or_insert(Default::default())
+                    .ai_assistance = Some(false);
+            });
+            cx.notify();
+        }
+    }
+
     fn handle_previous_page(
         &mut self,
         _: &PreviousPage,
@@ -626,7 +685,7 @@ impl OnboardingUI {
 
         let area_focused = self.focus_area == FocusArea::Navigation;
 
-        Ring::new(corner_radius, item_focused)
+        FocusOutline::new(corner_radius, item_focused, px(2.))
             .active(area_focused && item_focused)
             .child(
                 h_flex()
@@ -1024,6 +1083,10 @@ impl OnboardingUI {
         let focused_item = self.page_focus[page_index].0;
         let is_page_focused = self.focus_area == FocusArea::PageContent;
 
+        let ai_enabled = ai_enabled(cx);
+
+        let workspace = self.workspace.clone();
+
         v_flex()
             .h_full()
             .w_full()
@@ -1035,8 +1098,22 @@ impl OnboardingUI {
                 CheckboxWithLabel::new(
                 "disable_ai",
                 Label::new("Enable AI Features"),
-                ToggleState::Selected,
-                |_, _, cx| todo!("implement ai toggle"),
+                if ai_enabled {
+                    ToggleState::Selected
+                } else {
+                    ToggleState::Unselected
+                },
+                move |state, _, cx| {
+                    let enabled = state == &ToggleState::Selected;
+                    if let Some(workspace) = workspace.upgrade() {
+                        let fs = workspace.read(cx).app_state().fs.clone();
+                        update_settings_file::<AllLanguageSettings>(fs, cx, move |file, _| {
+                            file.features
+                                .get_or_insert(Default::default())
+                                .ai_assistance = Some(enabled);
+                        });
+                    }
+                },
             )))
             .child(
                 CalloutRow::new("We don't use your code to train AI models")
@@ -1148,7 +1225,7 @@ impl SerializableItem for OnboardingUI {
                 let completed = OnboardingUI::completed_pages_from_string(&completed_str);
                 (page, completed)
             } else {
-                (OnboardingPage::Basics, [false; 4])
+                (OnboardingPage::Basics, HashSet::new())
             };
 
             cx.update(|window, cx| {

crates/zed/Cargo.toml 🔗

@@ -42,6 +42,7 @@ client.workspace = true
 collab_ui.workspace = true
 collections.workspace = true
 command_palette.workspace = true
+command_palette_hooks.workspace = true
 component.workspace = true
 copilot.workspace = true
 dap_adapters.workspace = true

crates/zed/src/zed.rs 🔗

@@ -16,6 +16,7 @@ use assets::Assets;
 use breadcrumbs::Breadcrumbs;
 use client::zed_urls;
 use collections::VecDeque;
+use command_palette_hooks::CommandPaletteFilter;
 use debugger_ui::debugger_panel::DebugPanel;
 use editor::ProposedChangesEditorToolbar;
 use editor::{Editor, MultiBuffer};
@@ -52,6 +53,7 @@ use settings::{
     Settings, SettingsStore, VIM_KEYMAP_PATH, initial_local_debug_tasks_content,
     initial_project_settings_content, initial_tasks_content, update_settings_file,
 };
+use std::any::TypeId;
 use std::path::PathBuf;
 use std::sync::atomic::{self, AtomicBool};
 use std::{borrow::Cow, path::Path, sync::Arc};
@@ -72,7 +74,8 @@ use workspace::{
 use workspace::{CloseIntent, CloseWindow, RestoreBanner, with_active_or_new_workspace};
 use workspace::{Pane, notifications::DetachAndPromptErr};
 use zed_actions::{
-    OpenAccountSettings, OpenBrowser, OpenDocs, OpenServerSettings, OpenSettings, OpenZedUrl, Quit,
+    DisableAiAssistance, EnableAiAssistance, OpenAccountSettings, OpenBrowser, OpenDocs,
+    OpenServerSettings, OpenSettings, OpenZedUrl, Quit,
 };
 
 actions!(
@@ -215,6 +218,51 @@ pub fn init(cx: &mut App) {
             );
         });
     });
+    cx.on_action(|_: &EnableAiAssistance, cx| {
+        with_active_or_new_workspace(cx, |workspace, _, cx| {
+            let fs = workspace.app_state().fs.clone();
+            update_settings_file::<language::language_settings::AllLanguageSettings>(
+                fs,
+                cx,
+                move |file, _| {
+                    file.features
+                        .get_or_insert(Default::default())
+                        .ai_assistance = Some(true);
+                },
+            );
+        });
+    });
+    cx.on_action(|_: &DisableAiAssistance, cx| {
+        with_active_or_new_workspace(cx, |workspace, _, cx| {
+            let fs = workspace.app_state().fs.clone();
+            update_settings_file::<language::language_settings::AllLanguageSettings>(
+                fs,
+                cx,
+                move |file, _| {
+                    file.features
+                        .get_or_insert(Default::default())
+                        .ai_assistance = Some(false);
+                },
+            );
+        });
+    });
+
+    // Filter AI assistance actions based on current state
+    cx.observe_global::<SettingsStore>(move |cx| {
+        let ai_enabled =
+            language::language_settings::all_language_settings(None, cx).is_ai_assistance_enabled();
+
+        CommandPaletteFilter::update_global(cx, |filter, _cx| {
+            if ai_enabled {
+                filter.hide_action_types(&[TypeId::of::<EnableAiAssistance>()]);
+                filter.show_action_types([TypeId::of::<DisableAiAssistance>()].iter());
+            } else {
+                filter.show_action_types([TypeId::of::<EnableAiAssistance>()].iter());
+                filter.hide_action_types(&[TypeId::of::<DisableAiAssistance>()]);
+            }
+        });
+    })
+    .detach();
 }
 
 fn bind_on_window_closed(cx: &mut App) -> Option<gpui::Subscription> {

crates/zed_actions/src/lib.rs 🔗

@@ -50,6 +50,10 @@ actions!(
         OpenLicenses,
         /// Opens the telemetry log.
         OpenTelemetryLog,
+        /// Enables AI assistance features.
+        EnableAiAssistance,
+        /// Disables AI assistance features.
+        DisableAiAssistance,
     ]
 );