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