agent: Add project-level `disable_ai` setting (#47902)

Oliver Azevedo Barnes and Ben Kunkle created

Closes #47854

Move `disable_ai` from root settings to `ProjectSettingsContent` to
enable per-project AI configuration via `.zed/settings.json`.

- Update settings UI to allow viewing/editing at both user and project
level
- Update editor to check project-level settings for edit predictions and
context menus
- Prevent MCP servers from starting when AI is disabled at project level

Note: SaturatingBool ensures that if user globally disables AI, projects
cannot re-enable it. Projects can only further restrict AI, not grant
it.

Release Notes:

- added support for disabling AI in project settings 

---------

Co-authored-by: Ben Kunkle <ben.kunkle@gmail.com>

Change summary

crates/editor/src/editor.rs                       |  43 ++++--
crates/editor/src/element.rs                      |   5 
crates/editor/src/mouse_context_menu.rs           |   6 
crates/project/src/context_server_store.rs        |  15 ++
crates/project/src/project.rs                     |  20 +++
crates/project/tests/integration/project_tests.rs | 101 +++++++++++++++++
crates/settings/src/settings_store.rs             |  48 ++++++++
crates/settings/src/vscode_import.rs              |   2 
crates/settings_content/src/project.rs            |   7 +
crates/settings_content/src/settings_content.rs   |   5 
crates/settings_ui/src/page_data.rs               |   6 
crates/zed/src/zed.rs                             |   2 
12 files changed, 227 insertions(+), 33 deletions(-)

Detailed changes

crates/editor/src/editor.rs 🔗

@@ -7737,15 +7737,15 @@ impl Editor {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> Option<()> {
-        if DisableAiSettings::get_global(cx).disable_ai {
-            return None;
-        }
-
         let provider = self.edit_prediction_provider()?;
         let cursor = self.selections.newest_anchor().head();
         let (buffer, cursor_buffer_position) =
             self.buffer.read(cx).text_anchor_for_position(cursor, cx)?;
 
+        if DisableAiSettings::is_ai_disabled_for_buffer(Some(&buffer), cx) {
+            return None;
+        }
+
         if !self.edit_predictions_enabled_in_buffer(&buffer, cursor_buffer_position, cx) {
             self.discard_edit_prediction(EditPredictionDiscardReason::Ignored, cx);
             return None;
@@ -7791,19 +7791,25 @@ impl Editor {
     }
 
     pub fn update_edit_prediction_settings(&mut self, cx: &mut Context<Self>) {
-        if self.edit_prediction_provider.is_none() || DisableAiSettings::get_global(cx).disable_ai {
+        if self.edit_prediction_provider.is_none() {
             self.edit_prediction_settings = EditPredictionSettings::Disabled;
             self.discard_edit_prediction(EditPredictionDiscardReason::Ignored, cx);
-        } else {
-            let selection = self.selections.newest_anchor();
-            let cursor = selection.head();
+            return;
+        }
 
-            if let Some((buffer, cursor_buffer_position)) =
-                self.buffer.read(cx).text_anchor_for_position(cursor, cx)
-            {
-                self.edit_prediction_settings =
-                    self.edit_prediction_settings_at_position(&buffer, cursor_buffer_position, cx);
+        let selection = self.selections.newest_anchor();
+        let cursor = selection.head();
+
+        if let Some((buffer, cursor_buffer_position)) =
+            self.buffer.read(cx).text_anchor_for_position(cursor, cx)
+        {
+            if DisableAiSettings::is_ai_disabled_for_buffer(Some(&buffer), cx) {
+                self.edit_prediction_settings = EditPredictionSettings::Disabled;
+                self.discard_edit_prediction(EditPredictionDiscardReason::Ignored, cx);
+                return;
             }
+            self.edit_prediction_settings =
+                self.edit_prediction_settings_at_position(&buffer, cursor_buffer_position, cx);
         }
     }
 
@@ -8444,10 +8450,6 @@ impl Editor {
         _window: &mut Window,
         cx: &mut Context<Self>,
     ) -> Option<()> {
-        if DisableAiSettings::get_global(cx).disable_ai {
-            return None;
-        }
-
         if self.ime_transaction.is_some() {
             self.discard_edit_prediction(EditPredictionDiscardReason::Ignored, cx);
             return None;
@@ -8456,6 +8458,13 @@ impl Editor {
         let selection = self.selections.newest_anchor();
         let cursor = selection.head();
         let multibuffer = self.buffer.read(cx).snapshot(cx);
+
+        // Check project-level disable_ai setting for the current buffer
+        if let Some((buffer, _)) = self.buffer.read(cx).text_anchor_for_position(cursor, cx) {
+            if DisableAiSettings::is_ai_disabled_for_buffer(Some(&buffer), cx) {
+                return None;
+            }
+        }
         let offset_selection = selection.map(|endpoint| endpoint.to_offset(&multibuffer));
         let excerpt_id = cursor.excerpt_id;
 

crates/editor/src/element.rs 🔗

@@ -1314,7 +1314,10 @@ impl EditorElement {
         // Handle diff review indicator when gutter is hovered in diff mode with AI enabled
         let show_diff_review = editor.show_diff_review_button()
             && cx.has_flag::<DiffReviewFeatureFlag>()
-            && !DisableAiSettings::get_global(cx).disable_ai;
+            && !DisableAiSettings::is_ai_disabled_for_buffer(
+                editor.buffer.read(cx).as_singleton().as_ref(),
+                cx,
+            );
 
         let diff_review_indicator = if gutter_hovered && show_diff_review {
             let is_visible = editor

crates/editor/src/mouse_context_menu.rs 🔗

@@ -9,7 +9,6 @@ use crate::{
 use gpui::prelude::FluentBuilder;
 use gpui::{Context, DismissEvent, Entity, Focusable as _, Pixels, Point, Subscription, Window};
 use project::DisableAiSettings;
-use settings::Settings;
 use std::ops::Range;
 use text::PointUtf16;
 use workspace::OpenInTerminal;
@@ -219,7 +218,10 @@ pub fn deploy_context_menu(
 
         let evaluate_selection = window.is_action_available(&EvaluateSelectedText, cx);
         let run_to_cursor = window.is_action_available(&RunToCursor, cx);
-        let disable_ai = DisableAiSettings::get_global(cx).disable_ai;
+        let disable_ai = DisableAiSettings::is_ai_disabled_for_buffer(
+            editor.buffer.read(cx).as_singleton().as_ref(),
+            cx,
+        );
 
         let is_markdown = editor
             .buffer()

crates/project/src/context_server_store.rs 🔗

@@ -18,7 +18,7 @@ use settings::{Settings as _, SettingsStore};
 use util::{ResultExt as _, rel_path::RelPath};
 
 use crate::{
-    Project,
+    DisableAiSettings, Project,
     project_settings::{ContextServerSettings, ProjectSettings},
     worktree_store::WorktreeStore,
 };
@@ -867,6 +867,19 @@ impl ContextServerStore {
     }
 
     async fn maintain_servers(this: WeakEntity<Self>, cx: &mut AsyncApp) -> Result<()> {
+        // Don't start context servers if AI is disabled
+        let ai_disabled = this.update(cx, |_, cx| DisableAiSettings::get_global(cx).disable_ai)?;
+        if ai_disabled {
+            // Stop all running servers when AI is disabled
+            this.update(cx, |this, cx| {
+                let server_ids: Vec<_> = this.servers.keys().cloned().collect();
+                for id in server_ids {
+                    let _ = this.stop_server(&id, cx);
+                }
+            })?;
+            return Ok(());
+        }
+
         let (mut configured_servers, registry, worktree_store) = this.update(cx, |this, _| {
             (
                 this.context_server_settings.clone(),

crates/project/src/project.rs 🔗

@@ -1076,11 +1076,29 @@ pub struct DisableAiSettings {
 impl settings::Settings for DisableAiSettings {
     fn from_settings(content: &settings::SettingsContent) -> Self {
         Self {
-            disable_ai: content.disable_ai.unwrap().0,
+            disable_ai: content.project.disable_ai.unwrap().0,
         }
     }
 }
 
+impl DisableAiSettings {
+    /// Returns whether AI is disabled for the given file.
+    ///
+    /// This checks the project-level settings for the file's worktree,
+    /// allowing `disable_ai` to be configured per-project in `.zed/settings.json`.
+    pub fn is_ai_disabled_for_buffer(buffer: Option<&Entity<Buffer>>, cx: &App) -> bool {
+        Self::is_ai_disabled_for_file(buffer.and_then(|buffer| buffer.read(cx).file()), cx)
+    }
+
+    pub fn is_ai_disabled_for_file(file: Option<&Arc<dyn language::File>>, cx: &App) -> bool {
+        let location = file.map(|f| settings::SettingsLocation {
+            worktree_id: f.worktree_id(cx),
+            path: f.path().as_ref(),
+        });
+        Self::get(location, cx).disable_ai
+    }
+}
+
 impl Project {
     pub fn init(client: &Arc<Client>, cx: &mut App) {
         connection_manager::init(client.clone(), cx);

crates/project/tests/integration/project_tests.rs 🔗

@@ -12147,4 +12147,105 @@ mod disable_ai_settings_tests {
             );
         });
     }
+
+    #[gpui::test]
+    async fn test_disable_ai_project_level_settings(cx: &mut TestAppContext) {
+        use settings::{LocalSettingsKind, LocalSettingsPath, SettingsLocation, SettingsStore};
+        use worktree::WorktreeId;
+
+        cx.update(|cx| {
+            settings::init(cx);
+
+            // Default should allow AI
+            assert!(
+                !DisableAiSettings::get_global(cx).disable_ai,
+                "Default should allow AI"
+            );
+        });
+
+        let worktree_id = WorktreeId::from_usize(1);
+        let rel_path = |path: &str| -> std::sync::Arc<util::rel_path::RelPath> {
+            std::sync::Arc::from(util::rel_path::RelPath::unix(path).unwrap())
+        };
+        let project_path = rel_path("project");
+        let settings_location = SettingsLocation {
+            worktree_id,
+            path: project_path.as_ref(),
+        };
+
+        // Test: Project-level disable_ai=true should disable AI for files in that project
+        cx.update_global::<SettingsStore, _>(|store, cx| {
+            store
+                .set_local_settings(
+                    worktree_id,
+                    LocalSettingsPath::InWorktree(project_path.clone()),
+                    LocalSettingsKind::Settings,
+                    Some(r#"{ "disable_ai": true }"#),
+                    cx,
+                )
+                .unwrap();
+        });
+
+        cx.update(|cx| {
+            let settings = DisableAiSettings::get(Some(settings_location), cx);
+            assert!(
+                settings.disable_ai,
+                "Project-level disable_ai=true should disable AI for files in that project"
+            );
+            // Global should now also be true since project-level disable_ai is merged into global
+            assert!(
+                DisableAiSettings::get_global(cx).disable_ai,
+                "Global setting should be affected by project-level disable_ai=true"
+            );
+        });
+
+        // Test: Setting project-level to false should allow AI for that project
+        cx.update_global::<SettingsStore, _>(|store, cx| {
+            store
+                .set_local_settings(
+                    worktree_id,
+                    LocalSettingsPath::InWorktree(project_path.clone()),
+                    LocalSettingsKind::Settings,
+                    Some(r#"{ "disable_ai": false }"#),
+                    cx,
+                )
+                .unwrap();
+        });
+
+        cx.update(|cx| {
+            let settings = DisableAiSettings::get(Some(settings_location), cx);
+            assert!(
+                !settings.disable_ai,
+                "Project-level disable_ai=false should allow AI"
+            );
+            // Global should also be false now
+            assert!(
+                !DisableAiSettings::get_global(cx).disable_ai,
+                "Global setting should be false when project-level is false"
+            );
+        });
+
+        // Test: User-level true + project-level false = AI disabled (saturation)
+        let disable_true = serde_json::json!({ "disable_ai": true }).to_string();
+        cx.update_global::<SettingsStore, _>(|store, cx| {
+            store.set_user_settings(&disable_true, cx).unwrap();
+            store
+                .set_local_settings(
+                    worktree_id,
+                    LocalSettingsPath::InWorktree(project_path.clone()),
+                    LocalSettingsKind::Settings,
+                    Some(r#"{ "disable_ai": false }"#),
+                    cx,
+                )
+                .unwrap();
+        });
+
+        cx.update(|cx| {
+            let settings = DisableAiSettings::get(Some(settings_location), cx);
+            assert!(
+                settings.disable_ai,
+                "Project-level false cannot override user-level true (SaturatingBool)"
+            );
+        });
+    }
 }

crates/settings/src/settings_store.rs 🔗

@@ -1173,6 +1173,54 @@ impl SettingsStore {
                 merged.merge_from_option(user_settings.for_profile(cx));
             }
             merged.merge_from_option(self.server_settings.as_deref());
+
+            // Merge `disable_ai` from all project/local settings into the global value.
+            // Since `SaturatingBool` uses OR logic, if any project has `disable_ai: true`,
+            // the global value will be true. This allows project-level `disable_ai` to
+            // affect the global setting used by UI elements without file context.
+            for local_settings in self.local_settings.values() {
+                merged
+                    .project
+                    .disable_ai
+                    .merge_from(&local_settings.project.disable_ai);
+            }
+
+            self.merged_settings = Rc::new(merged);
+
+            for setting_value in self.setting_values.values_mut() {
+                let value = setting_value.from_settings(&self.merged_settings);
+                setting_value.set_global_value(value);
+            }
+        } else {
+            // When only a local path changed, we still need to recompute the global
+            // `disable_ai` value since it depends on all local settings.
+            let mut merged = (*self.merged_settings).clone();
+            // Reset disable_ai to compute fresh from base settings
+            merged.project.disable_ai = self.default_settings.project.disable_ai;
+            if let Some(global) = &self.global_settings {
+                merged
+                    .project
+                    .disable_ai
+                    .merge_from(&global.project.disable_ai);
+            }
+            if let Some(user) = &self.user_settings {
+                merged
+                    .project
+                    .disable_ai
+                    .merge_from(&user.content.project.disable_ai);
+            }
+            if let Some(server) = &self.server_settings {
+                merged
+                    .project
+                    .disable_ai
+                    .merge_from(&server.project.disable_ai);
+            }
+            for local_settings in self.local_settings.values() {
+                merged
+                    .project
+                    .disable_ai
+                    .merge_from(&local_settings.project.disable_ai);
+            }
             self.merged_settings = Rc::new(merged);
 
             for setting_value in self.setting_values.values_mut() {

crates/settings/src/vscode_import.rs 🔗

@@ -181,7 +181,6 @@ impl VsCodeSettings {
             collaboration_panel: None,
             debugger: None,
             diagnostics: None,
-            disable_ai: None,
             editor: self.editor_settings_content(),
             extension: ExtensionSettingsContent::default(),
             file_finder: None,
@@ -510,6 +509,7 @@ impl VsCodeSettings {
             load_direnv: None,
             slash_commands: None,
             git_hosting_providers: None,
+            disable_ai: None,
         }
     }
 

crates/settings_content/src/project.rs 🔗

@@ -10,7 +10,7 @@ use util::serde::default_true;
 
 use crate::{
     AllLanguageSettingsContent, DelayMs, ExtendingVec, ParseStatus, ProjectTerminalSettingsContent,
-    RootUserSettings, SlashCommandSettings, fallible_options,
+    RootUserSettings, SaturatingBool, SlashCommandSettings, fallible_options,
 };
 
 #[with_fallible_options]
@@ -79,6 +79,11 @@ pub struct ProjectSettingsContent {
 
     /// The list of custom Git hosting providers.
     pub git_hosting_providers: Option<ExtendingVec<GitHostingProviderConfig>>,
+
+    /// Whether to disable all AI features in Zed.
+    ///
+    /// Default: false
+    pub disable_ai: Option<SaturatingBool>,
 }
 
 #[with_fallible_options]

crates/settings_content/src/settings_content.rs 🔗

@@ -197,11 +197,6 @@ pub struct SettingsContent {
     // Settings related to calls in Zed
     pub calls: Option<CallSettingsContent>,
 
-    /// Whether to disable all AI features in Zed.
-    ///
-    /// Default: false
-    pub disable_ai: Option<SaturatingBool>,
-
     /// Settings for the which-key popup.
     pub which_key: Option<WhichKeySettingsContent>,
 

crates/settings_ui/src/page_data.rs 🔗

@@ -6962,13 +6962,13 @@ fn ai_page() -> SettingsPage {
                 description: "Whether to disable all AI features in Zed.",
                 field: Box::new(SettingField {
                     json_path: Some("disable_ai"),
-                    pick: |settings_content| settings_content.disable_ai.as_ref(),
+                    pick: |settings_content| settings_content.project.disable_ai.as_ref(),
                     write: |settings_content, value| {
-                        settings_content.disable_ai = value;
+                        settings_content.project.disable_ai = value;
                     },
                 }),
                 metadata: None,
-                files: USER,
+                files: USER | PROJECT,
             }),
         ]
     }

crates/zed/src/zed.rs 🔗

@@ -5222,7 +5222,7 @@ mod tests {
         cx.update(|cx| {
             SettingsStore::update_global(cx, |settings_store, cx| {
                 settings_store.update_user_settings(cx, |settings| {
-                    settings.disable_ai = Some(SaturatingBool(true));
+                    settings.project.disable_ai = Some(SaturatingBool(true));
                 });
             });
         });