From 692a13778288495244fafa13afab24f784ea1e03 Mon Sep 17 00:00:00 2001 From: Oliver Azevedo Barnes Date: Fri, 13 Feb 2026 17:57:43 +0000 Subject: [PATCH] agent: Add project-level `disable_ai` setting (#47902) 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 --- 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 +++- .../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 +- .../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(-) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 824c212b22f48c0113b95e6db124d7281d3d231d..7450933bd17f7c248870f23f830ed6c633f36ac0 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -7737,15 +7737,15 @@ impl Editor { window: &mut Window, cx: &mut Context, ) -> 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) { - 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, ) -> 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; diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index e460242298e175dee1c7ce2e5c5869a2de38fd2e..764e4ae7ef7ef9f4f3dab8effb221a2b3c164b06 100644 --- a/crates/editor/src/element.rs +++ b/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::() - && !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 diff --git a/crates/editor/src/mouse_context_menu.rs b/crates/editor/src/mouse_context_menu.rs index d64183e037fb948b11c35fad65a4d6d618a5bec6..af7b256d78ecea90112ca9d23175c9d33f134d94 100644 --- a/crates/editor/src/mouse_context_menu.rs +++ b/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() diff --git a/crates/project/src/context_server_store.rs b/crates/project/src/context_server_store.rs index 93528e6c9bc9f8a9e3492e137c3a7a386f9d0a60..e4cac4768d48db8aecf0b4499cce070c2c2c914c 100644 --- a/crates/project/src/context_server_store.rs +++ b/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, 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(), diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index e0e8cc78fd6f1e99d41600aab5e4286ae2aa504e..0c70ff983a55b713cdda618f19a2a966205ac42c 100644 --- a/crates/project/src/project.rs +++ b/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>, 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>, 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, cx: &mut App) { connection_manager::init(client.clone(), cx); diff --git a/crates/project/tests/integration/project_tests.rs b/crates/project/tests/integration/project_tests.rs index f70f2589d158705b96928473b10fc7737632fd6c..2394542a761a547c45d35695d1d21fc77de5c9f9 100644 --- a/crates/project/tests/integration/project_tests.rs +++ b/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 { + 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::(|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::(|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::(|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)" + ); + }); + } } diff --git a/crates/settings/src/settings_store.rs b/crates/settings/src/settings_store.rs index 657d67fb64f33834c03125b67ea527997aaa5510..524d0f294e9f955bf05be9fc5601bcb6de1d98d0 100644 --- a/crates/settings/src/settings_store.rs +++ b/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() { diff --git a/crates/settings/src/vscode_import.rs b/crates/settings/src/vscode_import.rs index 7a49e751bb239766f8082cfa5bbd8473f0e309bb..c32178ffc25accbb0c93d122b5e54980c35b953f 100644 --- a/crates/settings/src/vscode_import.rs +++ b/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, } } diff --git a/crates/settings_content/src/project.rs b/crates/settings_content/src/project.rs index 1bcacbd325460457d285ec0577db4d37e25fb17a..59576651de0463f4460f12c7ac7417152dc30bb9 100644 --- a/crates/settings_content/src/project.rs +++ b/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>, + + /// Whether to disable all AI features in Zed. + /// + /// Default: false + pub disable_ai: Option, } #[with_fallible_options] diff --git a/crates/settings_content/src/settings_content.rs b/crates/settings_content/src/settings_content.rs index c9c01bea97debe22970e51bd10491025065134dd..788917b5ebb0fc0f4ba29e29fc95b0da148c6f0f 100644 --- a/crates/settings_content/src/settings_content.rs +++ b/crates/settings_content/src/settings_content.rs @@ -197,11 +197,6 @@ pub struct SettingsContent { // Settings related to calls in Zed pub calls: Option, - /// Whether to disable all AI features in Zed. - /// - /// Default: false - pub disable_ai: Option, - /// Settings for the which-key popup. pub which_key: Option, diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index 32fe90a9a39700d993d61051497a7c2ef1edddc9..0725917fa3d46695f90f7d989e83e9b21052c597 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/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, }), ] } diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 78ca459ad8cf0befb313914acf1456a82939ca42..157637dc8cfaa604e8c068eb12c2430f14d447e4 100644 --- a/crates/zed/src/zed.rs +++ b/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)); }); }); });