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