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