agent_settings.rs

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