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