agent_settings.rs

   1mod agent_profile;
   2
   3use std::path::{Component, Path};
   4use std::sync::{Arc, LazyLock};
   5
   6use agent_client_protocol::ModelId;
   7use collections::{HashSet, IndexMap};
   8use fs::Fs;
   9use futures::channel::oneshot;
  10use gpui::{App, Pixels, px};
  11use language_model::LanguageModel;
  12use project::DisableAiSettings;
  13use schemars::JsonSchema;
  14use serde::{Deserialize, Serialize};
  15use settings::{
  16    DockPosition, DockSide, LanguageModelParameters, LanguageModelSelection, NewThreadLocation,
  17    NotifyWhenAgentWaiting, PlaySoundWhenAgentDone, RegisterSetting, Settings, SettingsContent,
  18    SettingsStore, SidebarDockPosition, SidebarSide, ThinkingBlockDisplay, ToolPermissionMode,
  19    update_settings_file, update_settings_file_with_completion,
  20};
  21
  22pub use crate::agent_profile::*;
  23
  24pub const SUMMARIZE_THREAD_PROMPT: &str = include_str!("prompts/summarize_thread_prompt.txt");
  25pub const SUMMARIZE_THREAD_DETAILED_PROMPT: &str =
  26    include_str!("prompts/summarize_thread_detailed_prompt.txt");
  27
  28#[derive(Debug, Clone, Default, PartialEq, Eq)]
  29pub struct PanelLayout {
  30    pub(crate) agent_dock: Option<DockPosition>,
  31    pub(crate) project_panel_dock: Option<DockSide>,
  32    pub(crate) outline_panel_dock: Option<DockSide>,
  33    pub(crate) collaboration_panel_dock: Option<DockPosition>,
  34    pub(crate) git_panel_dock: Option<DockPosition>,
  35}
  36
  37impl PanelLayout {
  38    const AGENT: Self = Self {
  39        agent_dock: Some(DockPosition::Left),
  40        project_panel_dock: Some(DockSide::Right),
  41        outline_panel_dock: Some(DockSide::Right),
  42        collaboration_panel_dock: Some(DockPosition::Right),
  43        git_panel_dock: Some(DockPosition::Right),
  44    };
  45
  46    const EDITOR: Self = Self {
  47        agent_dock: Some(DockPosition::Right),
  48        project_panel_dock: Some(DockSide::Left),
  49        outline_panel_dock: Some(DockSide::Left),
  50        collaboration_panel_dock: Some(DockPosition::Left),
  51        git_panel_dock: Some(DockPosition::Left),
  52    };
  53
  54    pub fn is_agent_layout(&self) -> bool {
  55        *self == Self::AGENT
  56    }
  57
  58    pub fn is_editor_layout(&self) -> bool {
  59        *self == Self::EDITOR
  60    }
  61
  62    fn read_from(content: &SettingsContent) -> Self {
  63        Self {
  64            agent_dock: content.agent.as_ref().and_then(|a| a.dock),
  65            project_panel_dock: content.project_panel.as_ref().and_then(|p| p.dock),
  66            outline_panel_dock: content.outline_panel.as_ref().and_then(|p| p.dock),
  67            collaboration_panel_dock: content.collaboration_panel.as_ref().and_then(|p| p.dock),
  68            git_panel_dock: content.git_panel.as_ref().and_then(|p| p.dock),
  69        }
  70    }
  71
  72    fn write_to(&self, settings: &mut SettingsContent) {
  73        settings.agent.get_or_insert_default().dock = self.agent_dock;
  74        settings.project_panel.get_or_insert_default().dock = self.project_panel_dock;
  75        settings.outline_panel.get_or_insert_default().dock = self.outline_panel_dock;
  76        settings.collaboration_panel.get_or_insert_default().dock = self.collaboration_panel_dock;
  77        settings.git_panel.get_or_insert_default().dock = self.git_panel_dock;
  78    }
  79
  80    fn write_diff_to(&self, current_merged: &PanelLayout, settings: &mut SettingsContent) {
  81        if self.agent_dock != current_merged.agent_dock {
  82            settings.agent.get_or_insert_default().dock = self.agent_dock;
  83        }
  84        if self.project_panel_dock != current_merged.project_panel_dock {
  85            settings.project_panel.get_or_insert_default().dock = self.project_panel_dock;
  86        }
  87        if self.outline_panel_dock != current_merged.outline_panel_dock {
  88            settings.outline_panel.get_or_insert_default().dock = self.outline_panel_dock;
  89        }
  90        if self.collaboration_panel_dock != current_merged.collaboration_panel_dock {
  91            settings.collaboration_panel.get_or_insert_default().dock =
  92                self.collaboration_panel_dock;
  93        }
  94        if self.git_panel_dock != current_merged.git_panel_dock {
  95            settings.git_panel.get_or_insert_default().dock = self.git_panel_dock;
  96        }
  97    }
  98
  99    fn backfill_to(&self, user_layout: &PanelLayout, settings: &mut SettingsContent) {
 100        if user_layout.agent_dock.is_none() {
 101            settings.agent.get_or_insert_default().dock = self.agent_dock;
 102        }
 103        if user_layout.project_panel_dock.is_none() {
 104            settings.project_panel.get_or_insert_default().dock = self.project_panel_dock;
 105        }
 106        if user_layout.outline_panel_dock.is_none() {
 107            settings.outline_panel.get_or_insert_default().dock = self.outline_panel_dock;
 108        }
 109        if user_layout.collaboration_panel_dock.is_none() {
 110            settings.collaboration_panel.get_or_insert_default().dock =
 111                self.collaboration_panel_dock;
 112        }
 113        if user_layout.git_panel_dock.is_none() {
 114            settings.git_panel.get_or_insert_default().dock = self.git_panel_dock;
 115        }
 116    }
 117}
 118
 119#[derive(Debug, Clone, PartialEq, Eq)]
 120pub enum WindowLayout {
 121    Editor(Option<PanelLayout>),
 122    Agent(Option<PanelLayout>),
 123    Custom(PanelLayout),
 124}
 125
 126impl WindowLayout {
 127    pub fn agent() -> Self {
 128        Self::Agent(None)
 129    }
 130
 131    pub fn editor() -> Self {
 132        Self::Editor(None)
 133    }
 134}
 135
 136#[derive(Clone, Debug, RegisterSetting)]
 137pub struct AgentSettings {
 138    pub enabled: bool,
 139    pub button: bool,
 140    pub dock: DockPosition,
 141    pub flexible: bool,
 142    pub sidebar_side: SidebarDockPosition,
 143    pub default_width: Pixels,
 144    pub default_height: Pixels,
 145    pub max_content_width: Pixels,
 146    pub default_model: Option<LanguageModelSelection>,
 147    pub inline_assistant_model: Option<LanguageModelSelection>,
 148    pub inline_assistant_use_streaming_tools: bool,
 149    pub commit_message_model: Option<LanguageModelSelection>,
 150    pub thread_summary_model: Option<LanguageModelSelection>,
 151    pub inline_alternatives: Vec<LanguageModelSelection>,
 152    pub favorite_models: Vec<LanguageModelSelection>,
 153    pub default_profile: AgentProfileId,
 154    pub profiles: IndexMap<AgentProfileId, AgentProfileSettings>,
 155
 156    pub notify_when_agent_waiting: NotifyWhenAgentWaiting,
 157    pub play_sound_when_agent_done: PlaySoundWhenAgentDone,
 158    pub single_file_review: bool,
 159    pub model_parameters: Vec<LanguageModelParameters>,
 160    pub enable_feedback: bool,
 161    pub expand_edit_card: bool,
 162    pub expand_terminal_card: bool,
 163    pub thinking_display: ThinkingBlockDisplay,
 164    pub cancel_generation_on_terminal_stop: bool,
 165    pub use_modifier_to_send: bool,
 166    pub message_editor_min_lines: usize,
 167    pub show_turn_stats: bool,
 168    pub show_merge_conflict_indicator: bool,
 169    pub tool_permissions: ToolPermissions,
 170    pub new_thread_location: NewThreadLocation,
 171}
 172
 173impl AgentSettings {
 174    pub fn enabled(&self, cx: &App) -> bool {
 175        self.enabled && !DisableAiSettings::get_global(cx).disable_ai
 176    }
 177
 178    pub fn temperature_for_model(model: &Arc<dyn LanguageModel>, cx: &App) -> Option<f32> {
 179        let settings = Self::get_global(cx);
 180        for setting in settings.model_parameters.iter().rev() {
 181            if let Some(provider) = &setting.provider
 182                && provider.0 != model.provider_id().0
 183            {
 184                continue;
 185            }
 186            if let Some(setting_model) = &setting.model
 187                && *setting_model != model.id().0
 188            {
 189                continue;
 190            }
 191            return setting.temperature;
 192        }
 193        return None;
 194    }
 195
 196    pub fn sidebar_side(&self) -> SidebarSide {
 197        match self.sidebar_side {
 198            SidebarDockPosition::Left => SidebarSide::Left,
 199            SidebarDockPosition::Right => SidebarSide::Right,
 200        }
 201    }
 202
 203    pub fn set_message_editor_max_lines(&self) -> usize {
 204        self.message_editor_min_lines * 2
 205    }
 206
 207    pub fn favorite_model_ids(&self) -> HashSet<ModelId> {
 208        self.favorite_models
 209            .iter()
 210            .map(|sel| ModelId::new(format!("{}/{}", sel.provider.0, sel.model)))
 211            .collect()
 212    }
 213
 214    pub fn get_layout(cx: &App) -> WindowLayout {
 215        let store = cx.global::<SettingsStore>();
 216        let merged = store.merged_settings();
 217        let user_layout = store
 218            .raw_user_settings()
 219            .map(|u| PanelLayout::read_from(u.content.as_ref()))
 220            .unwrap_or_default();
 221        let merged_layout = PanelLayout::read_from(merged);
 222
 223        if merged_layout.is_agent_layout() {
 224            return WindowLayout::Agent(Some(user_layout));
 225        }
 226
 227        if merged_layout.is_editor_layout() {
 228            return WindowLayout::Editor(Some(user_layout));
 229        }
 230
 231        WindowLayout::Custom(user_layout)
 232    }
 233
 234    pub fn backfill_editor_layout(fs: Arc<dyn Fs>, cx: &App) {
 235        let user_layout = cx
 236            .global::<SettingsStore>()
 237            .raw_user_settings()
 238            .map(|u| PanelLayout::read_from(u.content.as_ref()))
 239            .unwrap_or_default();
 240
 241        update_settings_file(fs, cx, move |settings, _cx| {
 242            PanelLayout::EDITOR.backfill_to(&user_layout, settings);
 243        });
 244    }
 245
 246    pub fn set_layout(
 247        layout: WindowLayout,
 248        fs: Arc<dyn Fs>,
 249        cx: &App,
 250    ) -> oneshot::Receiver<anyhow::Result<()>> {
 251        let merged = PanelLayout::read_from(cx.global::<SettingsStore>().merged_settings());
 252
 253        match layout {
 254            WindowLayout::Agent(None) => {
 255                update_settings_file_with_completion(fs, cx, move |settings, _cx| {
 256                    PanelLayout::AGENT.write_diff_to(&merged, settings);
 257                })
 258            }
 259            WindowLayout::Editor(None) => {
 260                update_settings_file_with_completion(fs, cx, move |settings, _cx| {
 261                    PanelLayout::EDITOR.write_diff_to(&merged, settings);
 262                })
 263            }
 264            WindowLayout::Agent(Some(saved))
 265            | WindowLayout::Editor(Some(saved))
 266            | WindowLayout::Custom(saved) => {
 267                update_settings_file_with_completion(fs, cx, move |settings, _cx| {
 268                    saved.write_to(settings);
 269                })
 270            }
 271        }
 272    }
 273}
 274
 275#[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize, JsonSchema)]
 276pub struct AgentProfileId(pub Arc<str>);
 277
 278impl AgentProfileId {
 279    pub fn as_str(&self) -> &str {
 280        &self.0
 281    }
 282}
 283
 284impl std::fmt::Display for AgentProfileId {
 285    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
 286        write!(f, "{}", self.0)
 287    }
 288}
 289
 290impl Default for AgentProfileId {
 291    fn default() -> Self {
 292        Self("write".into())
 293    }
 294}
 295
 296#[derive(Clone, Debug, Default)]
 297pub struct ToolPermissions {
 298    /// Global default permission when no tool-specific rules or patterns match.
 299    pub default: ToolPermissionMode,
 300    pub tools: collections::HashMap<Arc<str>, ToolRules>,
 301}
 302
 303impl ToolPermissions {
 304    /// Returns all invalid regex patterns across all tools.
 305    pub fn invalid_patterns(&self) -> Vec<&InvalidRegexPattern> {
 306        self.tools
 307            .values()
 308            .flat_map(|rules| rules.invalid_patterns.iter())
 309            .collect()
 310    }
 311
 312    /// Returns true if any tool has invalid regex patterns.
 313    pub fn has_invalid_patterns(&self) -> bool {
 314        self.tools
 315            .values()
 316            .any(|rules| !rules.invalid_patterns.is_empty())
 317    }
 318}
 319
 320/// Represents a regex pattern that failed to compile.
 321#[derive(Clone, Debug)]
 322pub struct InvalidRegexPattern {
 323    /// The pattern string that failed to compile.
 324    pub pattern: String,
 325    /// Which rule list this pattern was in (e.g., "always_deny", "always_allow", "always_confirm").
 326    pub rule_type: String,
 327    /// The error message from the regex compiler.
 328    pub error: String,
 329}
 330
 331#[derive(Clone, Debug, Default)]
 332pub struct ToolRules {
 333    pub default: Option<ToolPermissionMode>,
 334    pub always_allow: Vec<CompiledRegex>,
 335    pub always_deny: Vec<CompiledRegex>,
 336    pub always_confirm: Vec<CompiledRegex>,
 337    /// Patterns that failed to compile. If non-empty, tool calls should be blocked.
 338    pub invalid_patterns: Vec<InvalidRegexPattern>,
 339}
 340
 341#[derive(Clone)]
 342pub struct CompiledRegex {
 343    pub pattern: String,
 344    pub case_sensitive: bool,
 345    pub regex: regex::Regex,
 346}
 347
 348impl std::fmt::Debug for CompiledRegex {
 349    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
 350        f.debug_struct("CompiledRegex")
 351            .field("pattern", &self.pattern)
 352            .field("case_sensitive", &self.case_sensitive)
 353            .finish()
 354    }
 355}
 356
 357impl CompiledRegex {
 358    pub fn new(pattern: &str, case_sensitive: bool) -> Option<Self> {
 359        Self::try_new(pattern, case_sensitive).ok()
 360    }
 361
 362    pub fn try_new(pattern: &str, case_sensitive: bool) -> Result<Self, regex::Error> {
 363        let regex = regex::RegexBuilder::new(pattern)
 364            .case_insensitive(!case_sensitive)
 365            .build()?;
 366        Ok(Self {
 367            pattern: pattern.to_string(),
 368            case_sensitive,
 369            regex,
 370        })
 371    }
 372
 373    pub fn is_match(&self, input: &str) -> bool {
 374        self.regex.is_match(input)
 375    }
 376}
 377
 378pub const HARDCODED_SECURITY_DENIAL_MESSAGE: &str = "Blocked by built-in security rule. This operation is considered too \
 379     harmful to be allowed, and cannot be overridden by settings.";
 380
 381/// Security rules that are always enforced and cannot be overridden by any setting.
 382/// These protect against catastrophic operations like wiping filesystems.
 383pub struct HardcodedSecurityRules {
 384    pub terminal_deny: Vec<CompiledRegex>,
 385}
 386
 387pub static HARDCODED_SECURITY_RULES: LazyLock<HardcodedSecurityRules> = LazyLock::new(|| {
 388    const FLAGS: &str = r"(--[a-zA-Z0-9][-a-zA-Z0-9_]*(=[^\s]*)?\s+|-[a-zA-Z]+\s+)*";
 389    const TRAILING_FLAGS: &str = r"(\s+--[a-zA-Z0-9][-a-zA-Z0-9_]*(=[^\s]*)?|\s+-[a-zA-Z]+)*\s*";
 390
 391    HardcodedSecurityRules {
 392        terminal_deny: vec![
 393            // Recursive deletion of root - "rm -rf /", "rm -rf /*"
 394            CompiledRegex::new(
 395                &format!(r"\brm\s+{FLAGS}(--\s+)?/\*?{TRAILING_FLAGS}$"),
 396                false,
 397            )
 398            .expect("hardcoded regex should compile"),
 399            // Recursive deletion of home via tilde - "rm -rf ~", "rm -rf ~/"
 400            CompiledRegex::new(
 401                &format!(r"\brm\s+{FLAGS}(--\s+)?~/?\*?{TRAILING_FLAGS}$"),
 402                false,
 403            )
 404            .expect("hardcoded regex should compile"),
 405            // Recursive deletion of home via env var - "rm -rf $HOME", "rm -rf ${HOME}"
 406            CompiledRegex::new(
 407                &format!(r"\brm\s+{FLAGS}(--\s+)?(\$HOME|\$\{{HOME\}})/?(\*)?{TRAILING_FLAGS}$"),
 408                false,
 409            )
 410            .expect("hardcoded regex should compile"),
 411            // Recursive deletion of current directory - "rm -rf .", "rm -rf ./"
 412            CompiledRegex::new(
 413                &format!(r"\brm\s+{FLAGS}(--\s+)?\./?\*?{TRAILING_FLAGS}$"),
 414                false,
 415            )
 416            .expect("hardcoded regex should compile"),
 417            // Recursive deletion of parent directory - "rm -rf ..", "rm -rf ../"
 418            CompiledRegex::new(
 419                &format!(r"\brm\s+{FLAGS}(--\s+)?\.\./?\*?{TRAILING_FLAGS}$"),
 420                false,
 421            )
 422            .expect("hardcoded regex should compile"),
 423        ],
 424    }
 425});
 426
 427/// Checks if input matches any hardcoded security rules that cannot be bypassed.
 428/// Returns the denial reason string if blocked, None otherwise.
 429///
 430/// `terminal_tool_name` should be the tool name used for the terminal tool
 431/// (e.g. `"terminal"`). `extracted_commands` can optionally provide parsed
 432/// sub-commands for chained command checking; callers with access to a shell
 433/// parser should extract sub-commands and pass them here.
 434pub fn check_hardcoded_security_rules(
 435    tool_name: &str,
 436    terminal_tool_name: &str,
 437    input: &str,
 438    extracted_commands: Option<&[String]>,
 439) -> Option<String> {
 440    if tool_name != terminal_tool_name {
 441        return None;
 442    }
 443
 444    let rules = &*HARDCODED_SECURITY_RULES;
 445    let terminal_patterns = &rules.terminal_deny;
 446
 447    if matches_hardcoded_patterns(input, terminal_patterns) {
 448        return Some(HARDCODED_SECURITY_DENIAL_MESSAGE.into());
 449    }
 450
 451    if let Some(commands) = extracted_commands {
 452        for command in commands {
 453            if matches_hardcoded_patterns(command, terminal_patterns) {
 454                return Some(HARDCODED_SECURITY_DENIAL_MESSAGE.into());
 455            }
 456        }
 457    }
 458
 459    None
 460}
 461
 462fn matches_hardcoded_patterns(command: &str, patterns: &[CompiledRegex]) -> bool {
 463    for pattern in patterns {
 464        if pattern.is_match(command) {
 465            return true;
 466        }
 467    }
 468
 469    for expanded in expand_rm_to_single_path_commands(command) {
 470        for pattern in patterns {
 471            if pattern.is_match(&expanded) {
 472                return true;
 473            }
 474        }
 475    }
 476
 477    false
 478}
 479
 480fn expand_rm_to_single_path_commands(command: &str) -> Vec<String> {
 481    let trimmed = command.trim();
 482
 483    let first_token = trimmed.split_whitespace().next();
 484    if !first_token.is_some_and(|t| t.eq_ignore_ascii_case("rm")) {
 485        return vec![];
 486    }
 487
 488    let parts: Vec<&str> = trimmed.split_whitespace().collect();
 489    let mut flags = Vec::new();
 490    let mut paths = Vec::new();
 491    let mut past_double_dash = false;
 492
 493    for part in parts.iter().skip(1) {
 494        if !past_double_dash && *part == "--" {
 495            past_double_dash = true;
 496            flags.push(*part);
 497            continue;
 498        }
 499        if !past_double_dash && part.starts_with('-') {
 500            flags.push(*part);
 501        } else {
 502            paths.push(*part);
 503        }
 504    }
 505
 506    let flags_str = if flags.is_empty() {
 507        String::new()
 508    } else {
 509        format!("{} ", flags.join(" "))
 510    };
 511
 512    let mut results = Vec::new();
 513    for path in &paths {
 514        if path.starts_with('$') {
 515            let home_prefix = if path.starts_with("${HOME}") {
 516                Some("${HOME}")
 517            } else if path.starts_with("$HOME") {
 518                Some("$HOME")
 519            } else {
 520                None
 521            };
 522
 523            if let Some(prefix) = home_prefix {
 524                let suffix = &path[prefix.len()..];
 525                if suffix.is_empty() {
 526                    results.push(format!("rm {flags_str}{path}"));
 527                } else if suffix.starts_with('/') {
 528                    let normalized_suffix = normalize_path(suffix);
 529                    let reconstructed = if normalized_suffix == "/" {
 530                        prefix.to_string()
 531                    } else {
 532                        format!("{prefix}{normalized_suffix}")
 533                    };
 534                    results.push(format!("rm {flags_str}{reconstructed}"));
 535                } else {
 536                    results.push(format!("rm {flags_str}{path}"));
 537                }
 538            } else {
 539                results.push(format!("rm {flags_str}{path}"));
 540            }
 541            continue;
 542        }
 543
 544        let mut normalized = normalize_path(path);
 545        if normalized.is_empty() && !Path::new(path).has_root() {
 546            normalized = ".".to_string();
 547        }
 548
 549        results.push(format!("rm {flags_str}{normalized}"));
 550    }
 551
 552    results
 553}
 554
 555pub fn normalize_path(raw: &str) -> String {
 556    let is_absolute = Path::new(raw).has_root();
 557    let mut components: Vec<&str> = Vec::new();
 558    for component in Path::new(raw).components() {
 559        match component {
 560            Component::CurDir => {}
 561            Component::ParentDir => {
 562                if components.last() == Some(&"..") {
 563                    components.push("..");
 564                } else if !components.is_empty() {
 565                    components.pop();
 566                } else if !is_absolute {
 567                    components.push("..");
 568                }
 569            }
 570            Component::Normal(segment) => {
 571                if let Some(s) = segment.to_str() {
 572                    components.push(s);
 573                }
 574            }
 575            Component::RootDir | Component::Prefix(_) => {}
 576        }
 577    }
 578    let joined = components.join("/");
 579    if is_absolute {
 580        format!("/{joined}")
 581    } else {
 582        joined
 583    }
 584}
 585
 586impl Settings for AgentSettings {
 587    fn from_settings(content: &settings::SettingsContent) -> Self {
 588        let agent = content.agent.clone().unwrap();
 589        Self {
 590            enabled: agent.enabled.unwrap(),
 591            button: agent.button.unwrap(),
 592            dock: agent.dock.unwrap(),
 593            sidebar_side: agent.sidebar_side.unwrap(),
 594            default_width: px(agent.default_width.unwrap()),
 595            default_height: px(agent.default_height.unwrap()),
 596            max_content_width: px(agent.max_content_width.unwrap()),
 597            flexible: agent.flexible.unwrap(),
 598            default_model: Some(agent.default_model.unwrap()),
 599            inline_assistant_model: agent.inline_assistant_model,
 600            inline_assistant_use_streaming_tools: agent
 601                .inline_assistant_use_streaming_tools
 602                .unwrap_or(true),
 603            commit_message_model: agent.commit_message_model,
 604            thread_summary_model: agent.thread_summary_model,
 605            inline_alternatives: agent.inline_alternatives.unwrap_or_default(),
 606            favorite_models: agent.favorite_models,
 607            default_profile: AgentProfileId(agent.default_profile.unwrap()),
 608            profiles: agent
 609                .profiles
 610                .unwrap()
 611                .into_iter()
 612                .map(|(key, val)| (AgentProfileId(key), val.into()))
 613                .collect(),
 614
 615            notify_when_agent_waiting: agent.notify_when_agent_waiting.unwrap(),
 616            play_sound_when_agent_done: agent.play_sound_when_agent_done.unwrap_or_default(),
 617            single_file_review: agent.single_file_review.unwrap(),
 618            model_parameters: agent.model_parameters,
 619            enable_feedback: agent.enable_feedback.unwrap(),
 620            expand_edit_card: agent.expand_edit_card.unwrap(),
 621            expand_terminal_card: agent.expand_terminal_card.unwrap(),
 622            thinking_display: agent.thinking_display.unwrap(),
 623            cancel_generation_on_terminal_stop: agent.cancel_generation_on_terminal_stop.unwrap(),
 624            use_modifier_to_send: agent.use_modifier_to_send.unwrap(),
 625            message_editor_min_lines: agent.message_editor_min_lines.unwrap(),
 626            show_turn_stats: agent.show_turn_stats.unwrap(),
 627            show_merge_conflict_indicator: agent.show_merge_conflict_indicator.unwrap(),
 628            tool_permissions: compile_tool_permissions(agent.tool_permissions),
 629            new_thread_location: agent.new_thread_location.unwrap_or_default(),
 630        }
 631    }
 632}
 633
 634fn compile_tool_permissions(content: Option<settings::ToolPermissionsContent>) -> ToolPermissions {
 635    let Some(content) = content else {
 636        return ToolPermissions::default();
 637    };
 638
 639    let tools = content
 640        .tools
 641        .into_iter()
 642        .map(|(tool_name, rules_content)| {
 643            let mut invalid_patterns = Vec::new();
 644
 645            let (always_allow, allow_errors) = compile_regex_rules(
 646                rules_content.always_allow.map(|v| v.0).unwrap_or_default(),
 647                "always_allow",
 648            );
 649            invalid_patterns.extend(allow_errors);
 650
 651            let (always_deny, deny_errors) = compile_regex_rules(
 652                rules_content.always_deny.map(|v| v.0).unwrap_or_default(),
 653                "always_deny",
 654            );
 655            invalid_patterns.extend(deny_errors);
 656
 657            let (always_confirm, confirm_errors) = compile_regex_rules(
 658                rules_content
 659                    .always_confirm
 660                    .map(|v| v.0)
 661                    .unwrap_or_default(),
 662                "always_confirm",
 663            );
 664            invalid_patterns.extend(confirm_errors);
 665
 666            // Log invalid patterns for debugging. Users will see an error when they
 667            // attempt to use a tool with invalid patterns in their settings.
 668            for invalid in &invalid_patterns {
 669                log::error!(
 670                    "Invalid regex pattern in tool_permissions for '{}' tool ({}): '{}' - {}",
 671                    tool_name,
 672                    invalid.rule_type,
 673                    invalid.pattern,
 674                    invalid.error,
 675                );
 676            }
 677
 678            let rules = ToolRules {
 679                // Preserve tool-specific default; None means fall back to global default at decision time
 680                default: rules_content.default,
 681                always_allow,
 682                always_deny,
 683                always_confirm,
 684                invalid_patterns,
 685            };
 686            (tool_name, rules)
 687        })
 688        .collect();
 689
 690    ToolPermissions {
 691        default: content.default.unwrap_or_default(),
 692        tools,
 693    }
 694}
 695
 696fn compile_regex_rules(
 697    rules: Vec<settings::ToolRegexRule>,
 698    rule_type: &str,
 699) -> (Vec<CompiledRegex>, Vec<InvalidRegexPattern>) {
 700    let mut compiled = Vec::new();
 701    let mut errors = Vec::new();
 702
 703    for rule in rules {
 704        if rule.pattern.is_empty() {
 705            errors.push(InvalidRegexPattern {
 706                pattern: rule.pattern,
 707                rule_type: rule_type.to_string(),
 708                error: "empty regex patterns are not allowed".to_string(),
 709            });
 710            continue;
 711        }
 712        let case_sensitive = rule.case_sensitive.unwrap_or(false);
 713        match CompiledRegex::try_new(&rule.pattern, case_sensitive) {
 714            Ok(regex) => compiled.push(regex),
 715            Err(error) => {
 716                errors.push(InvalidRegexPattern {
 717                    pattern: rule.pattern,
 718                    rule_type: rule_type.to_string(),
 719                    error: error.to_string(),
 720                });
 721            }
 722        }
 723    }
 724
 725    (compiled, errors)
 726}
 727
 728#[cfg(test)]
 729mod tests {
 730    use super::*;
 731    use gpui::{TestAppContext, UpdateGlobal};
 732    use serde_json::json;
 733    use settings::ToolPermissionMode;
 734    use settings::ToolPermissionsContent;
 735
 736    #[test]
 737    fn test_compiled_regex_case_insensitive() {
 738        let regex = CompiledRegex::new("rm\\s+-rf", false).unwrap();
 739        assert!(regex.is_match("rm -rf /"));
 740        assert!(regex.is_match("RM -RF /"));
 741        assert!(regex.is_match("Rm -Rf /"));
 742    }
 743
 744    #[test]
 745    fn test_compiled_regex_case_sensitive() {
 746        let regex = CompiledRegex::new("DROP\\s+TABLE", true).unwrap();
 747        assert!(regex.is_match("DROP TABLE users"));
 748        assert!(!regex.is_match("drop table users"));
 749    }
 750
 751    #[test]
 752    fn test_invalid_regex_returns_none() {
 753        let result = CompiledRegex::new("[invalid(regex", false);
 754        assert!(result.is_none());
 755    }
 756
 757    #[test]
 758    fn test_tool_permissions_parsing() {
 759        let json = json!({
 760            "tools": {
 761                "terminal": {
 762                    "default": "allow",
 763                    "always_deny": [
 764                        { "pattern": "rm\\s+-rf" }
 765                    ],
 766                    "always_allow": [
 767                        { "pattern": "^git\\s" }
 768                    ]
 769                }
 770            }
 771        });
 772
 773        let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
 774        let permissions = compile_tool_permissions(Some(content));
 775
 776        let terminal_rules = permissions.tools.get("terminal").unwrap();
 777        assert_eq!(terminal_rules.default, Some(ToolPermissionMode::Allow));
 778        assert_eq!(terminal_rules.always_deny.len(), 1);
 779        assert_eq!(terminal_rules.always_allow.len(), 1);
 780        assert!(terminal_rules.always_deny[0].is_match("rm -rf /"));
 781        assert!(terminal_rules.always_allow[0].is_match("git status"));
 782    }
 783
 784    #[test]
 785    fn test_tool_rules_default() {
 786        let json = json!({
 787            "tools": {
 788                "edit_file": {
 789                    "default": "deny"
 790                }
 791            }
 792        });
 793
 794        let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
 795        let permissions = compile_tool_permissions(Some(content));
 796
 797        let rules = permissions.tools.get("edit_file").unwrap();
 798        assert_eq!(rules.default, Some(ToolPermissionMode::Deny));
 799    }
 800
 801    #[test]
 802    fn test_tool_permissions_empty() {
 803        let permissions = compile_tool_permissions(None);
 804        assert!(permissions.tools.is_empty());
 805        assert_eq!(permissions.default, ToolPermissionMode::Confirm);
 806    }
 807
 808    #[test]
 809    fn test_tool_rules_default_returns_confirm() {
 810        let default_rules = ToolRules::default();
 811        assert_eq!(default_rules.default, None);
 812        assert!(default_rules.always_allow.is_empty());
 813        assert!(default_rules.always_deny.is_empty());
 814        assert!(default_rules.always_confirm.is_empty());
 815    }
 816
 817    #[test]
 818    fn test_tool_permissions_with_multiple_tools() {
 819        let json = json!({
 820            "tools": {
 821                "terminal": {
 822                    "default": "allow",
 823                    "always_deny": [{ "pattern": "rm\\s+-rf" }]
 824                },
 825                "edit_file": {
 826                    "default": "confirm",
 827                    "always_deny": [{ "pattern": "\\.env$" }]
 828                },
 829                "delete_path": {
 830                    "default": "deny"
 831                }
 832            }
 833        });
 834
 835        let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
 836        let permissions = compile_tool_permissions(Some(content));
 837
 838        assert_eq!(permissions.tools.len(), 3);
 839
 840        let terminal = permissions.tools.get("terminal").unwrap();
 841        assert_eq!(terminal.default, Some(ToolPermissionMode::Allow));
 842        assert_eq!(terminal.always_deny.len(), 1);
 843
 844        let edit_file = permissions.tools.get("edit_file").unwrap();
 845        assert_eq!(edit_file.default, Some(ToolPermissionMode::Confirm));
 846        assert!(edit_file.always_deny[0].is_match("secrets.env"));
 847
 848        let delete_path = permissions.tools.get("delete_path").unwrap();
 849        assert_eq!(delete_path.default, Some(ToolPermissionMode::Deny));
 850    }
 851
 852    #[test]
 853    fn test_tool_permissions_with_all_rule_types() {
 854        let json = json!({
 855            "tools": {
 856                "terminal": {
 857                    "always_deny": [{ "pattern": "rm\\s+-rf" }],
 858                    "always_confirm": [{ "pattern": "sudo\\s" }],
 859                    "always_allow": [{ "pattern": "^git\\s+status" }]
 860                }
 861            }
 862        });
 863
 864        let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
 865        let permissions = compile_tool_permissions(Some(content));
 866
 867        let terminal = permissions.tools.get("terminal").unwrap();
 868        assert_eq!(terminal.always_deny.len(), 1);
 869        assert_eq!(terminal.always_confirm.len(), 1);
 870        assert_eq!(terminal.always_allow.len(), 1);
 871
 872        assert!(terminal.always_deny[0].is_match("rm -rf /"));
 873        assert!(terminal.always_confirm[0].is_match("sudo apt install"));
 874        assert!(terminal.always_allow[0].is_match("git status"));
 875    }
 876
 877    #[test]
 878    fn test_invalid_regex_is_tracked_and_valid_ones_still_compile() {
 879        let json = json!({
 880            "tools": {
 881                "terminal": {
 882                    "always_deny": [
 883                        { "pattern": "[invalid(regex" },
 884                        { "pattern": "valid_pattern" }
 885                    ],
 886                    "always_allow": [
 887                        { "pattern": "[another_bad" }
 888                    ]
 889                }
 890            }
 891        });
 892
 893        let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
 894        let permissions = compile_tool_permissions(Some(content));
 895
 896        let terminal = permissions.tools.get("terminal").unwrap();
 897
 898        // Valid patterns should still be compiled
 899        assert_eq!(terminal.always_deny.len(), 1);
 900        assert!(terminal.always_deny[0].is_match("valid_pattern"));
 901
 902        // Invalid patterns should be tracked (order depends on processing order)
 903        assert_eq!(terminal.invalid_patterns.len(), 2);
 904
 905        let deny_invalid = terminal
 906            .invalid_patterns
 907            .iter()
 908            .find(|p| p.rule_type == "always_deny")
 909            .expect("should have invalid pattern from always_deny");
 910        assert_eq!(deny_invalid.pattern, "[invalid(regex");
 911        assert!(!deny_invalid.error.is_empty());
 912
 913        let allow_invalid = terminal
 914            .invalid_patterns
 915            .iter()
 916            .find(|p| p.rule_type == "always_allow")
 917            .expect("should have invalid pattern from always_allow");
 918        assert_eq!(allow_invalid.pattern, "[another_bad");
 919
 920        // ToolPermissions helper methods should work
 921        assert!(permissions.has_invalid_patterns());
 922        assert_eq!(permissions.invalid_patterns().len(), 2);
 923    }
 924
 925    #[test]
 926    fn test_deny_takes_precedence_over_allow_and_confirm() {
 927        let json = json!({
 928            "tools": {
 929                "terminal": {
 930                    "default": "allow",
 931                    "always_deny": [{ "pattern": "dangerous" }],
 932                    "always_confirm": [{ "pattern": "dangerous" }],
 933                    "always_allow": [{ "pattern": "dangerous" }]
 934                }
 935            }
 936        });
 937
 938        let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
 939        let permissions = compile_tool_permissions(Some(content));
 940        let terminal = permissions.tools.get("terminal").unwrap();
 941
 942        assert!(
 943            terminal.always_deny[0].is_match("run dangerous command"),
 944            "Deny rule should match"
 945        );
 946        assert!(
 947            terminal.always_allow[0].is_match("run dangerous command"),
 948            "Allow rule should also match (but deny takes precedence at evaluation time)"
 949        );
 950        assert!(
 951            terminal.always_confirm[0].is_match("run dangerous command"),
 952            "Confirm rule should also match (but deny takes precedence at evaluation time)"
 953        );
 954    }
 955
 956    #[test]
 957    fn test_confirm_takes_precedence_over_allow() {
 958        let json = json!({
 959            "tools": {
 960                "terminal": {
 961                    "default": "allow",
 962                    "always_confirm": [{ "pattern": "risky" }],
 963                    "always_allow": [{ "pattern": "risky" }]
 964                }
 965            }
 966        });
 967
 968        let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
 969        let permissions = compile_tool_permissions(Some(content));
 970        let terminal = permissions.tools.get("terminal").unwrap();
 971
 972        assert!(
 973            terminal.always_confirm[0].is_match("do risky thing"),
 974            "Confirm rule should match"
 975        );
 976        assert!(
 977            terminal.always_allow[0].is_match("do risky thing"),
 978            "Allow rule should also match (but confirm takes precedence at evaluation time)"
 979        );
 980    }
 981
 982    #[test]
 983    fn test_regex_matches_anywhere_in_string_not_just_anchored() {
 984        let json = json!({
 985            "tools": {
 986                "terminal": {
 987                    "always_deny": [
 988                        { "pattern": "rm\\s+-rf" },
 989                        { "pattern": "/etc/passwd" }
 990                    ]
 991                }
 992            }
 993        });
 994
 995        let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
 996        let permissions = compile_tool_permissions(Some(content));
 997        let terminal = permissions.tools.get("terminal").unwrap();
 998
 999        assert!(
1000            terminal.always_deny[0].is_match("echo hello && rm -rf /"),
1001            "Should match rm -rf in the middle of a command chain"
1002        );
1003        assert!(
1004            terminal.always_deny[0].is_match("cd /tmp; rm -rf *"),
1005            "Should match rm -rf after semicolon"
1006        );
1007        assert!(
1008            terminal.always_deny[1].is_match("cat /etc/passwd | grep root"),
1009            "Should match /etc/passwd in a pipeline"
1010        );
1011        assert!(
1012            terminal.always_deny[1].is_match("vim /etc/passwd"),
1013            "Should match /etc/passwd as argument"
1014        );
1015    }
1016
1017    #[test]
1018    fn test_fork_bomb_pattern_matches() {
1019        let fork_bomb_regex = CompiledRegex::new(r":\(\)\{\s*:\|:&\s*\};:", false).unwrap();
1020        assert!(
1021            fork_bomb_regex.is_match(":(){ :|:& };:"),
1022            "Should match the classic fork bomb"
1023        );
1024        assert!(
1025            fork_bomb_regex.is_match(":(){ :|:&};:"),
1026            "Should match fork bomb without spaces"
1027        );
1028    }
1029
1030    #[test]
1031    fn test_compiled_regex_stores_case_sensitivity() {
1032        let case_sensitive = CompiledRegex::new("test", true).unwrap();
1033        let case_insensitive = CompiledRegex::new("test", false).unwrap();
1034
1035        assert!(case_sensitive.case_sensitive);
1036        assert!(!case_insensitive.case_sensitive);
1037    }
1038
1039    #[test]
1040    fn test_invalid_regex_is_skipped_not_fail() {
1041        let json = json!({
1042            "tools": {
1043                "terminal": {
1044                    "always_deny": [
1045                        { "pattern": "[invalid(regex" },
1046                        { "pattern": "valid_pattern" }
1047                    ]
1048                }
1049            }
1050        });
1051
1052        let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
1053        let permissions = compile_tool_permissions(Some(content));
1054
1055        let terminal = permissions.tools.get("terminal").unwrap();
1056        assert_eq!(terminal.always_deny.len(), 1);
1057        assert!(terminal.always_deny[0].is_match("valid_pattern"));
1058    }
1059
1060    #[test]
1061    fn test_unconfigured_tool_not_in_permissions() {
1062        let json = json!({
1063            "tools": {
1064                "terminal": {
1065                    "default": "allow"
1066                }
1067            }
1068        });
1069
1070        let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
1071        let permissions = compile_tool_permissions(Some(content));
1072
1073        assert!(permissions.tools.contains_key("terminal"));
1074        assert!(!permissions.tools.contains_key("edit_file"));
1075        assert!(!permissions.tools.contains_key("fetch"));
1076    }
1077
1078    #[test]
1079    fn test_always_allow_pattern_only_matches_specified_commands() {
1080        // Reproduces user-reported bug: when always_allow has pattern "^echo\s",
1081        // only "echo hello" should be allowed, not "git status".
1082        //
1083        // User config:
1084        //   always_allow_tool_actions: false
1085        //   tool_permissions.tools.terminal.always_allow: [{ pattern: "^echo\\s" }]
1086        let json = json!({
1087            "tools": {
1088                "terminal": {
1089                    "always_allow": [
1090                        { "pattern": "^echo\\s" }
1091                    ]
1092                }
1093            }
1094        });
1095
1096        let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
1097        let permissions = compile_tool_permissions(Some(content));
1098
1099        let terminal = permissions.tools.get("terminal").unwrap();
1100
1101        // Verify the pattern was compiled
1102        assert_eq!(
1103            terminal.always_allow.len(),
1104            1,
1105            "Should have one always_allow pattern"
1106        );
1107
1108        // Verify the pattern matches "echo hello"
1109        assert!(
1110            terminal.always_allow[0].is_match("echo hello"),
1111            "Pattern ^echo\\s should match 'echo hello'"
1112        );
1113
1114        // Verify the pattern does NOT match "git status"
1115        assert!(
1116            !terminal.always_allow[0].is_match("git status"),
1117            "Pattern ^echo\\s should NOT match 'git status'"
1118        );
1119
1120        // Verify the pattern does NOT match "echoHello" (no space)
1121        assert!(
1122            !terminal.always_allow[0].is_match("echoHello"),
1123            "Pattern ^echo\\s should NOT match 'echoHello' (requires whitespace)"
1124        );
1125
1126        assert_eq!(
1127            terminal.default, None,
1128            "default should be None when not specified"
1129        );
1130    }
1131
1132    #[test]
1133    fn test_empty_regex_pattern_is_invalid() {
1134        let json = json!({
1135            "tools": {
1136                "terminal": {
1137                    "always_allow": [
1138                        { "pattern": "" }
1139                    ],
1140                    "always_deny": [
1141                        { "case_sensitive": true }
1142                    ],
1143                    "always_confirm": [
1144                        { "pattern": "" },
1145                        { "pattern": "valid_pattern" }
1146                    ]
1147                }
1148            }
1149        });
1150
1151        let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
1152        let permissions = compile_tool_permissions(Some(content));
1153
1154        let terminal = permissions.tools.get("terminal").unwrap();
1155
1156        assert_eq!(terminal.always_allow.len(), 0);
1157        assert_eq!(terminal.always_deny.len(), 0);
1158        assert_eq!(terminal.always_confirm.len(), 1);
1159        assert!(terminal.always_confirm[0].is_match("valid_pattern"));
1160
1161        assert_eq!(terminal.invalid_patterns.len(), 3);
1162        for invalid in &terminal.invalid_patterns {
1163            assert_eq!(invalid.pattern, "");
1164            assert!(invalid.error.contains("empty"));
1165        }
1166    }
1167
1168    #[test]
1169    fn test_default_json_tool_permissions_parse() {
1170        let default_json = include_str!("../../../assets/settings/default.json");
1171        let value: serde_json_lenient::Value = serde_json_lenient::from_str(default_json).unwrap();
1172        let agent = value
1173            .get("agent")
1174            .expect("default.json should have 'agent' key");
1175        let tool_permissions_value = agent
1176            .get("tool_permissions")
1177            .expect("agent should have 'tool_permissions' key");
1178
1179        let content: ToolPermissionsContent =
1180            serde_json_lenient::from_value(tool_permissions_value.clone()).unwrap();
1181        let permissions = compile_tool_permissions(Some(content));
1182
1183        assert_eq!(permissions.default, ToolPermissionMode::Confirm);
1184
1185        assert!(
1186            permissions.tools.is_empty(),
1187            "default.json should not have any active tool-specific rules, found: {:?}",
1188            permissions.tools.keys().collect::<Vec<_>>()
1189        );
1190    }
1191
1192    #[test]
1193    fn test_tool_permissions_explicit_global_default() {
1194        let json_allow = json!({
1195            "default": "allow"
1196        });
1197        let content: ToolPermissionsContent = serde_json::from_value(json_allow).unwrap();
1198        let permissions = compile_tool_permissions(Some(content));
1199        assert_eq!(permissions.default, ToolPermissionMode::Allow);
1200
1201        let json_deny = json!({
1202            "default": "deny"
1203        });
1204        let content: ToolPermissionsContent = serde_json::from_value(json_deny).unwrap();
1205        let permissions = compile_tool_permissions(Some(content));
1206        assert_eq!(permissions.default, ToolPermissionMode::Deny);
1207    }
1208
1209    #[gpui::test]
1210    fn test_get_layout(cx: &mut gpui::App) {
1211        let store = SettingsStore::test(cx);
1212        cx.set_global(store);
1213        project::DisableAiSettings::register(cx);
1214        AgentSettings::register(cx);
1215
1216        // Should be Agent with an empty user layout (user hasn't customized).
1217        let layout = AgentSettings::get_layout(cx);
1218        let WindowLayout::Agent(Some(user_layout)) = layout else {
1219            panic!("expected Agent(Some), got {:?}", layout);
1220        };
1221        assert_eq!(user_layout, PanelLayout::default());
1222
1223        // User explicitly sets agent dock to left (matching the default).
1224        // The merged result is still agent, but the user layout captures
1225        // only what the user wrote.
1226        SettingsStore::update_global(cx, |store, cx| {
1227            store
1228                .set_user_settings(r#"{ "agent": { "dock": "left" } }"#, cx)
1229                .unwrap();
1230        });
1231
1232        let layout = AgentSettings::get_layout(cx);
1233        let WindowLayout::Agent(Some(user_layout)) = layout else {
1234            panic!("expected Agent(Some), got {:?}", layout);
1235        };
1236        assert_eq!(user_layout.agent_dock, Some(DockPosition::Left));
1237        assert_eq!(user_layout.project_panel_dock, None);
1238        assert_eq!(user_layout.outline_panel_dock, None);
1239        assert_eq!(user_layout.collaboration_panel_dock, None);
1240        assert_eq!(user_layout.git_panel_dock, None);
1241
1242        // User sets a combination that doesn't match either preset:
1243        // agent on the left but project panel also on the left.
1244        SettingsStore::update_global(cx, |store, cx| {
1245            store
1246                .set_user_settings(
1247                    r#"{
1248                        "agent": { "dock": "left" },
1249                        "project_panel": { "dock": "left" }
1250                    }"#,
1251                    cx,
1252                )
1253                .unwrap();
1254        });
1255
1256        let layout = AgentSettings::get_layout(cx);
1257        let WindowLayout::Custom(user_layout) = layout else {
1258            panic!("expected Custom, got {:?}", layout);
1259        };
1260        assert_eq!(user_layout.agent_dock, Some(DockPosition::Left));
1261        assert_eq!(user_layout.project_panel_dock, Some(DockSide::Left));
1262    }
1263
1264    #[gpui::test]
1265    fn test_set_layout_round_trip(cx: &mut gpui::App) {
1266        let store = SettingsStore::test(cx);
1267        cx.set_global(store);
1268        project::DisableAiSettings::register(cx);
1269        AgentSettings::register(cx);
1270
1271        // User has a custom layout: agent on the right with project panel
1272        // also on the right. This doesn't match either preset.
1273        SettingsStore::update_global(cx, |store, cx| {
1274            store
1275                .set_user_settings(
1276                    r#"{
1277                        "agent": { "dock": "right" },
1278                        "project_panel": { "dock": "right" }
1279                    }"#,
1280                    cx,
1281                )
1282                .unwrap();
1283        });
1284
1285        let original = AgentSettings::get_layout(cx);
1286        let WindowLayout::Custom(ref original_user_layout) = original else {
1287            panic!("expected Custom, got {:?}", original);
1288        };
1289        assert_eq!(original_user_layout.agent_dock, Some(DockPosition::Right));
1290        assert_eq!(
1291            original_user_layout.project_panel_dock,
1292            Some(DockSide::Right)
1293        );
1294        assert_eq!(original_user_layout.outline_panel_dock, None);
1295
1296        // Switch to the agent layout. This overwrites the user settings.
1297        SettingsStore::update_global(cx, |store, cx| {
1298            store.update_user_settings(cx, |settings| {
1299                PanelLayout::AGENT.write_to(settings);
1300            });
1301        });
1302
1303        let layout = AgentSettings::get_layout(cx);
1304        assert!(matches!(layout, WindowLayout::Agent(_)));
1305
1306        // Restore the original custom layout.
1307        SettingsStore::update_global(cx, |store, cx| {
1308            store.update_user_settings(cx, |settings| {
1309                original_user_layout.write_to(settings);
1310            });
1311        });
1312
1313        // Should be back to the same custom layout.
1314        let restored = AgentSettings::get_layout(cx);
1315        let WindowLayout::Custom(restored_user_layout) = restored else {
1316            panic!("expected Custom, got {:?}", restored);
1317        };
1318        assert_eq!(restored_user_layout.agent_dock, Some(DockPosition::Right));
1319        assert_eq!(
1320            restored_user_layout.project_panel_dock,
1321            Some(DockSide::Right)
1322        );
1323        assert_eq!(restored_user_layout.outline_panel_dock, None);
1324    }
1325
1326    #[gpui::test]
1327    async fn test_set_layout_minimal_diff(cx: &mut TestAppContext) {
1328        let fs = fs::FakeFs::new(cx.background_executor.clone());
1329        fs.save(
1330            paths::settings_file().as_path(),
1331            &serde_json::json!({
1332                "agent": { "dock": "left" },
1333                "project_panel": { "dock": "left" }
1334            })
1335            .to_string()
1336            .into(),
1337            Default::default(),
1338        )
1339        .await
1340        .unwrap();
1341
1342        cx.update(|cx| {
1343            let store = SettingsStore::test(cx);
1344            cx.set_global(store);
1345            project::DisableAiSettings::register(cx);
1346            AgentSettings::register(cx);
1347
1348            // User has agent=left (matches preset) and project_panel=left (does not)
1349            SettingsStore::update_global(cx, |store, cx| {
1350                store
1351                    .set_user_settings(
1352                        r#"{
1353                            "agent": { "dock": "left" },
1354                            "project_panel": { "dock": "left" }
1355                        }"#,
1356                        cx,
1357                    )
1358                    .unwrap();
1359            });
1360
1361            let layout = AgentSettings::get_layout(cx);
1362            assert!(matches!(layout, WindowLayout::Custom(_)));
1363
1364            AgentSettings::set_layout(WindowLayout::agent(), fs.clone(), cx)
1365        })
1366        .await
1367        .ok();
1368
1369        cx.run_until_parked();
1370
1371        let written = fs.load(paths::settings_file().as_path()).await.unwrap();
1372        cx.update(|cx| {
1373            SettingsStore::update_global(cx, |store, cx| {
1374                store.set_user_settings(&written, cx).unwrap();
1375            });
1376
1377            // The user settings should still have agent=left (preserved)
1378            // and now project_panel=right (changed to match preset).
1379            let store = cx.global::<SettingsStore>();
1380            let user_layout = store
1381                .raw_user_settings()
1382                .map(|u| PanelLayout::read_from(u.content.as_ref()))
1383                .unwrap_or_default();
1384
1385            assert_eq!(user_layout.agent_dock, Some(DockPosition::Left));
1386            assert_eq!(user_layout.project_panel_dock, Some(DockSide::Right));
1387            // Other fields weren't in user settings and didn't need changing.
1388            assert_eq!(user_layout.outline_panel_dock, None);
1389
1390            // And the merged result should now match agent.
1391            let layout = AgentSettings::get_layout(cx);
1392            assert!(matches!(layout, WindowLayout::Agent(_)));
1393        });
1394    }
1395
1396    #[gpui::test]
1397    async fn test_backfill_editor_layout(cx: &mut TestAppContext) {
1398        let fs = fs::FakeFs::new(cx.background_executor.clone());
1399        // User has only customized project_panel to "right".
1400        fs.save(
1401            paths::settings_file().as_path(),
1402            &serde_json::json!({
1403                "project_panel": { "dock": "right" }
1404            })
1405            .to_string()
1406            .into(),
1407            Default::default(),
1408        )
1409        .await
1410        .unwrap();
1411
1412        cx.update(|cx| {
1413            let store = SettingsStore::test(cx);
1414            cx.set_global(store);
1415            project::DisableAiSettings::register(cx);
1416            AgentSettings::register(cx);
1417
1418            // Simulate pre-migration state: editor defaults (the old world).
1419            SettingsStore::update_global(cx, |store, cx| {
1420                store.update_default_settings(cx, |defaults| {
1421                    PanelLayout::EDITOR.write_to(defaults);
1422                });
1423            });
1424
1425            // User has only customized project_panel to "right".
1426            SettingsStore::update_global(cx, |store, cx| {
1427                store
1428                    .set_user_settings(r#"{ "project_panel": { "dock": "right" } }"#, cx)
1429                    .unwrap();
1430            });
1431
1432            // Run the one-time backfill while still on old defaults.
1433            AgentSettings::backfill_editor_layout(fs.clone(), cx);
1434        });
1435
1436        cx.run_until_parked();
1437
1438        // Read back the file and apply it.
1439        let written = fs.load(paths::settings_file().as_path()).await.unwrap();
1440        cx.update(|cx| {
1441            SettingsStore::update_global(cx, |store, cx| {
1442                store.set_user_settings(&written, cx).unwrap();
1443            });
1444
1445            // The user's project_panel=right should be preserved (they set it).
1446            // All other fields should now have the editor preset values
1447            // written into user settings.
1448            let store = cx.global::<SettingsStore>();
1449            let user_layout = store
1450                .raw_user_settings()
1451                .map(|u| PanelLayout::read_from(u.content.as_ref()))
1452                .unwrap_or_default();
1453
1454            assert_eq!(user_layout.agent_dock, Some(DockPosition::Right));
1455            assert_eq!(user_layout.project_panel_dock, Some(DockSide::Right));
1456            assert_eq!(user_layout.outline_panel_dock, Some(DockSide::Left));
1457            assert_eq!(
1458                user_layout.collaboration_panel_dock,
1459                Some(DockPosition::Left)
1460            );
1461            assert_eq!(user_layout.git_panel_dock, Some(DockPosition::Left));
1462
1463            // Even though defaults are now agent, the backfilled user settings
1464            // keep everything in the editor layout. The user's experience
1465            // hasn't changed.
1466            let layout = AgentSettings::get_layout(cx);
1467            let WindowLayout::Custom(user_layout) = layout else {
1468                panic!(
1469                    "expected Custom (editor values override agent defaults), got {:?}",
1470                    layout
1471                );
1472            };
1473            assert_eq!(user_layout.agent_dock, Some(DockPosition::Right));
1474            assert_eq!(user_layout.project_panel_dock, Some(DockSide::Right));
1475        });
1476    }
1477}