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 #[test]
732 fn test_compiled_regex_case_insensitive() {
733 let regex = CompiledRegex::new("rm\\s+-rf", false).unwrap();
734 assert!(regex.is_match("rm -rf /"));
735 assert!(regex.is_match("RM -RF /"));
736 assert!(regex.is_match("Rm -Rf /"));
737 }
738
739 #[test]
740 fn test_compiled_regex_case_sensitive() {
741 let regex = CompiledRegex::new("DROP\\s+TABLE", true).unwrap();
742 assert!(regex.is_match("DROP TABLE users"));
743 assert!(!regex.is_match("drop table users"));
744 }
745
746 #[test]
747 fn test_invalid_regex_returns_none() {
748 let result = CompiledRegex::new("[invalid(regex", false);
749 assert!(result.is_none());
750 }
751
752 #[test]
753 fn test_tool_permissions_parsing() {
754 let json = json!({
755 "tools": {
756 "terminal": {
757 "default": "allow",
758 "always_deny": [
759 { "pattern": "rm\\s+-rf" }
760 ],
761 "always_allow": [
762 { "pattern": "^git\\s" }
763 ]
764 }
765 }
766 });
767
768 let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
769 let permissions = compile_tool_permissions(Some(content));
770
771 let terminal_rules = permissions.tools.get("terminal").unwrap();
772 assert_eq!(terminal_rules.default, Some(ToolPermissionMode::Allow));
773 assert_eq!(terminal_rules.always_deny.len(), 1);
774 assert_eq!(terminal_rules.always_allow.len(), 1);
775 assert!(terminal_rules.always_deny[0].is_match("rm -rf /"));
776 assert!(terminal_rules.always_allow[0].is_match("git status"));
777 }
778
779 #[test]
780 fn test_tool_rules_default() {
781 let json = json!({
782 "tools": {
783 "edit_file": {
784 "default": "deny"
785 }
786 }
787 });
788
789 let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
790 let permissions = compile_tool_permissions(Some(content));
791
792 let rules = permissions.tools.get("edit_file").unwrap();
793 assert_eq!(rules.default, Some(ToolPermissionMode::Deny));
794 }
795
796 #[test]
797 fn test_tool_permissions_empty() {
798 let permissions = compile_tool_permissions(None);
799 assert!(permissions.tools.is_empty());
800 assert_eq!(permissions.default, ToolPermissionMode::Confirm);
801 }
802
803 #[test]
804 fn test_tool_rules_default_returns_confirm() {
805 let default_rules = ToolRules::default();
806 assert_eq!(default_rules.default, None);
807 assert!(default_rules.always_allow.is_empty());
808 assert!(default_rules.always_deny.is_empty());
809 assert!(default_rules.always_confirm.is_empty());
810 }
811
812 #[test]
813 fn test_tool_permissions_with_multiple_tools() {
814 let json = json!({
815 "tools": {
816 "terminal": {
817 "default": "allow",
818 "always_deny": [{ "pattern": "rm\\s+-rf" }]
819 },
820 "edit_file": {
821 "default": "confirm",
822 "always_deny": [{ "pattern": "\\.env$" }]
823 },
824 "delete_path": {
825 "default": "deny"
826 }
827 }
828 });
829
830 let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
831 let permissions = compile_tool_permissions(Some(content));
832
833 assert_eq!(permissions.tools.len(), 3);
834
835 let terminal = permissions.tools.get("terminal").unwrap();
836 assert_eq!(terminal.default, Some(ToolPermissionMode::Allow));
837 assert_eq!(terminal.always_deny.len(), 1);
838
839 let edit_file = permissions.tools.get("edit_file").unwrap();
840 assert_eq!(edit_file.default, Some(ToolPermissionMode::Confirm));
841 assert!(edit_file.always_deny[0].is_match("secrets.env"));
842
843 let delete_path = permissions.tools.get("delete_path").unwrap();
844 assert_eq!(delete_path.default, Some(ToolPermissionMode::Deny));
845 }
846
847 #[test]
848 fn test_tool_permissions_with_all_rule_types() {
849 let json = json!({
850 "tools": {
851 "terminal": {
852 "always_deny": [{ "pattern": "rm\\s+-rf" }],
853 "always_confirm": [{ "pattern": "sudo\\s" }],
854 "always_allow": [{ "pattern": "^git\\s+status" }]
855 }
856 }
857 });
858
859 let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
860 let permissions = compile_tool_permissions(Some(content));
861
862 let terminal = permissions.tools.get("terminal").unwrap();
863 assert_eq!(terminal.always_deny.len(), 1);
864 assert_eq!(terminal.always_confirm.len(), 1);
865 assert_eq!(terminal.always_allow.len(), 1);
866
867 assert!(terminal.always_deny[0].is_match("rm -rf /"));
868 assert!(terminal.always_confirm[0].is_match("sudo apt install"));
869 assert!(terminal.always_allow[0].is_match("git status"));
870 }
871
872 #[test]
873 fn test_invalid_regex_is_tracked_and_valid_ones_still_compile() {
874 let json = json!({
875 "tools": {
876 "terminal": {
877 "always_deny": [
878 { "pattern": "[invalid(regex" },
879 { "pattern": "valid_pattern" }
880 ],
881 "always_allow": [
882 { "pattern": "[another_bad" }
883 ]
884 }
885 }
886 });
887
888 let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
889 let permissions = compile_tool_permissions(Some(content));
890
891 let terminal = permissions.tools.get("terminal").unwrap();
892
893 // Valid patterns should still be compiled
894 assert_eq!(terminal.always_deny.len(), 1);
895 assert!(terminal.always_deny[0].is_match("valid_pattern"));
896
897 // Invalid patterns should be tracked (order depends on processing order)
898 assert_eq!(terminal.invalid_patterns.len(), 2);
899
900 let deny_invalid = terminal
901 .invalid_patterns
902 .iter()
903 .find(|p| p.rule_type == "always_deny")
904 .expect("should have invalid pattern from always_deny");
905 assert_eq!(deny_invalid.pattern, "[invalid(regex");
906 assert!(!deny_invalid.error.is_empty());
907
908 let allow_invalid = terminal
909 .invalid_patterns
910 .iter()
911 .find(|p| p.rule_type == "always_allow")
912 .expect("should have invalid pattern from always_allow");
913 assert_eq!(allow_invalid.pattern, "[another_bad");
914
915 // ToolPermissions helper methods should work
916 assert!(permissions.has_invalid_patterns());
917 assert_eq!(permissions.invalid_patterns().len(), 2);
918 }
919
920 #[test]
921 fn test_deny_takes_precedence_over_allow_and_confirm() {
922 let json = json!({
923 "tools": {
924 "terminal": {
925 "default": "allow",
926 "always_deny": [{ "pattern": "dangerous" }],
927 "always_confirm": [{ "pattern": "dangerous" }],
928 "always_allow": [{ "pattern": "dangerous" }]
929 }
930 }
931 });
932
933 let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
934 let permissions = compile_tool_permissions(Some(content));
935 let terminal = permissions.tools.get("terminal").unwrap();
936
937 assert!(
938 terminal.always_deny[0].is_match("run dangerous command"),
939 "Deny rule should match"
940 );
941 assert!(
942 terminal.always_allow[0].is_match("run dangerous command"),
943 "Allow rule should also match (but deny takes precedence at evaluation time)"
944 );
945 assert!(
946 terminal.always_confirm[0].is_match("run dangerous command"),
947 "Confirm rule should also match (but deny takes precedence at evaluation time)"
948 );
949 }
950
951 #[test]
952 fn test_confirm_takes_precedence_over_allow() {
953 let json = json!({
954 "tools": {
955 "terminal": {
956 "default": "allow",
957 "always_confirm": [{ "pattern": "risky" }],
958 "always_allow": [{ "pattern": "risky" }]
959 }
960 }
961 });
962
963 let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
964 let permissions = compile_tool_permissions(Some(content));
965 let terminal = permissions.tools.get("terminal").unwrap();
966
967 assert!(
968 terminal.always_confirm[0].is_match("do risky thing"),
969 "Confirm rule should match"
970 );
971 assert!(
972 terminal.always_allow[0].is_match("do risky thing"),
973 "Allow rule should also match (but confirm takes precedence at evaluation time)"
974 );
975 }
976
977 #[test]
978 fn test_regex_matches_anywhere_in_string_not_just_anchored() {
979 let json = json!({
980 "tools": {
981 "terminal": {
982 "always_deny": [
983 { "pattern": "rm\\s+-rf" },
984 { "pattern": "/etc/passwd" }
985 ]
986 }
987 }
988 });
989
990 let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
991 let permissions = compile_tool_permissions(Some(content));
992 let terminal = permissions.tools.get("terminal").unwrap();
993
994 assert!(
995 terminal.always_deny[0].is_match("echo hello && rm -rf /"),
996 "Should match rm -rf in the middle of a command chain"
997 );
998 assert!(
999 terminal.always_deny[0].is_match("cd /tmp; rm -rf *"),
1000 "Should match rm -rf after semicolon"
1001 );
1002 assert!(
1003 terminal.always_deny[1].is_match("cat /etc/passwd | grep root"),
1004 "Should match /etc/passwd in a pipeline"
1005 );
1006 assert!(
1007 terminal.always_deny[1].is_match("vim /etc/passwd"),
1008 "Should match /etc/passwd as argument"
1009 );
1010 }
1011
1012 #[test]
1013 fn test_fork_bomb_pattern_matches() {
1014 let fork_bomb_regex = CompiledRegex::new(r":\(\)\{\s*:\|:&\s*\};:", false).unwrap();
1015 assert!(
1016 fork_bomb_regex.is_match(":(){ :|:& };:"),
1017 "Should match the classic fork bomb"
1018 );
1019 assert!(
1020 fork_bomb_regex.is_match(":(){ :|:&};:"),
1021 "Should match fork bomb without spaces"
1022 );
1023 }
1024
1025 #[test]
1026 fn test_compiled_regex_stores_case_sensitivity() {
1027 let case_sensitive = CompiledRegex::new("test", true).unwrap();
1028 let case_insensitive = CompiledRegex::new("test", false).unwrap();
1029
1030 assert!(case_sensitive.case_sensitive);
1031 assert!(!case_insensitive.case_sensitive);
1032 }
1033
1034 #[test]
1035 fn test_invalid_regex_is_skipped_not_fail() {
1036 let json = json!({
1037 "tools": {
1038 "terminal": {
1039 "always_deny": [
1040 { "pattern": "[invalid(regex" },
1041 { "pattern": "valid_pattern" }
1042 ]
1043 }
1044 }
1045 });
1046
1047 let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
1048 let permissions = compile_tool_permissions(Some(content));
1049
1050 let terminal = permissions.tools.get("terminal").unwrap();
1051 assert_eq!(terminal.always_deny.len(), 1);
1052 assert!(terminal.always_deny[0].is_match("valid_pattern"));
1053 }
1054
1055 #[test]
1056 fn test_unconfigured_tool_not_in_permissions() {
1057 let json = json!({
1058 "tools": {
1059 "terminal": {
1060 "default": "allow"
1061 }
1062 }
1063 });
1064
1065 let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
1066 let permissions = compile_tool_permissions(Some(content));
1067
1068 assert!(permissions.tools.contains_key("terminal"));
1069 assert!(!permissions.tools.contains_key("edit_file"));
1070 assert!(!permissions.tools.contains_key("fetch"));
1071 }
1072
1073 #[test]
1074 fn test_always_allow_pattern_only_matches_specified_commands() {
1075 // Reproduces user-reported bug: when always_allow has pattern "^echo\s",
1076 // only "echo hello" should be allowed, not "git status".
1077 //
1078 // User config:
1079 // always_allow_tool_actions: false
1080 // tool_permissions.tools.terminal.always_allow: [{ pattern: "^echo\\s" }]
1081 let json = json!({
1082 "tools": {
1083 "terminal": {
1084 "always_allow": [
1085 { "pattern": "^echo\\s" }
1086 ]
1087 }
1088 }
1089 });
1090
1091 let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
1092 let permissions = compile_tool_permissions(Some(content));
1093
1094 let terminal = permissions.tools.get("terminal").unwrap();
1095
1096 // Verify the pattern was compiled
1097 assert_eq!(
1098 terminal.always_allow.len(),
1099 1,
1100 "Should have one always_allow pattern"
1101 );
1102
1103 // Verify the pattern matches "echo hello"
1104 assert!(
1105 terminal.always_allow[0].is_match("echo hello"),
1106 "Pattern ^echo\\s should match 'echo hello'"
1107 );
1108
1109 // Verify the pattern does NOT match "git status"
1110 assert!(
1111 !terminal.always_allow[0].is_match("git status"),
1112 "Pattern ^echo\\s should NOT match 'git status'"
1113 );
1114
1115 // Verify the pattern does NOT match "echoHello" (no space)
1116 assert!(
1117 !terminal.always_allow[0].is_match("echoHello"),
1118 "Pattern ^echo\\s should NOT match 'echoHello' (requires whitespace)"
1119 );
1120
1121 assert_eq!(
1122 terminal.default, None,
1123 "default should be None when not specified"
1124 );
1125 }
1126
1127 #[test]
1128 fn test_empty_regex_pattern_is_invalid() {
1129 let json = json!({
1130 "tools": {
1131 "terminal": {
1132 "always_allow": [
1133 { "pattern": "" }
1134 ],
1135 "always_deny": [
1136 { "case_sensitive": true }
1137 ],
1138 "always_confirm": [
1139 { "pattern": "" },
1140 { "pattern": "valid_pattern" }
1141 ]
1142 }
1143 }
1144 });
1145
1146 let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
1147 let permissions = compile_tool_permissions(Some(content));
1148
1149 let terminal = permissions.tools.get("terminal").unwrap();
1150
1151 assert_eq!(terminal.always_allow.len(), 0);
1152 assert_eq!(terminal.always_deny.len(), 0);
1153 assert_eq!(terminal.always_confirm.len(), 1);
1154 assert!(terminal.always_confirm[0].is_match("valid_pattern"));
1155
1156 assert_eq!(terminal.invalid_patterns.len(), 3);
1157 for invalid in &terminal.invalid_patterns {
1158 assert_eq!(invalid.pattern, "");
1159 assert!(invalid.error.contains("empty"));
1160 }
1161 }
1162
1163 #[test]
1164 fn test_default_json_tool_permissions_parse() {
1165 let default_json = include_str!("../../../assets/settings/default.json");
1166 let value: serde_json_lenient::Value = serde_json_lenient::from_str(default_json).unwrap();
1167 let agent = value
1168 .get("agent")
1169 .expect("default.json should have 'agent' key");
1170 let tool_permissions_value = agent
1171 .get("tool_permissions")
1172 .expect("agent should have 'tool_permissions' key");
1173
1174 let content: ToolPermissionsContent =
1175 serde_json_lenient::from_value(tool_permissions_value.clone()).unwrap();
1176 let permissions = compile_tool_permissions(Some(content));
1177
1178 assert_eq!(permissions.default, ToolPermissionMode::Confirm);
1179
1180 assert!(
1181 permissions.tools.is_empty(),
1182 "default.json should not have any active tool-specific rules, found: {:?}",
1183 permissions.tools.keys().collect::<Vec<_>>()
1184 );
1185 }
1186
1187 #[test]
1188 fn test_tool_permissions_explicit_global_default() {
1189 let json_allow = json!({
1190 "default": "allow"
1191 });
1192 let content: ToolPermissionsContent = serde_json::from_value(json_allow).unwrap();
1193 let permissions = compile_tool_permissions(Some(content));
1194 assert_eq!(permissions.default, ToolPermissionMode::Allow);
1195
1196 let json_deny = json!({
1197 "default": "deny"
1198 });
1199 let content: ToolPermissionsContent = serde_json::from_value(json_deny).unwrap();
1200 let permissions = compile_tool_permissions(Some(content));
1201 assert_eq!(permissions.default, ToolPermissionMode::Deny);
1202 }
1203
1204 #[gpui::test]
1205 fn test_get_layout(cx: &mut gpui::App) {
1206 let store = SettingsStore::test(cx);
1207 cx.set_global(store);
1208 project::DisableAiSettings::register(cx);
1209 AgentSettings::register(cx);
1210
1211 // Should be Agent with an empty user layout (user hasn't customized).
1212 let layout = AgentSettings::get_layout(cx);
1213 let WindowLayout::Agent(Some(user_layout)) = layout else {
1214 panic!("expected Agent(Some), got {:?}", layout);
1215 };
1216 assert_eq!(user_layout, PanelLayout::default());
1217
1218 // User explicitly sets agent dock to left (matching the default).
1219 // The merged result is still agent, but the user layout captures
1220 // only what the user wrote.
1221 SettingsStore::update_global(cx, |store, cx| {
1222 store
1223 .set_user_settings(r#"{ "agent": { "dock": "left" } }"#, cx)
1224 .unwrap();
1225 });
1226
1227 let layout = AgentSettings::get_layout(cx);
1228 let WindowLayout::Agent(Some(user_layout)) = layout else {
1229 panic!("expected Agent(Some), got {:?}", layout);
1230 };
1231 assert_eq!(user_layout.agent_dock, Some(DockPosition::Left));
1232 assert_eq!(user_layout.project_panel_dock, None);
1233 assert_eq!(user_layout.outline_panel_dock, None);
1234 assert_eq!(user_layout.collaboration_panel_dock, None);
1235 assert_eq!(user_layout.git_panel_dock, None);
1236
1237 // User sets a combination that doesn't match either preset:
1238 // agent on the left but project panel also on the left.
1239 SettingsStore::update_global(cx, |store, cx| {
1240 store
1241 .set_user_settings(
1242 r#"{
1243 "agent": { "dock": "left" },
1244 "project_panel": { "dock": "left" }
1245 }"#,
1246 cx,
1247 )
1248 .unwrap();
1249 });
1250
1251 let layout = AgentSettings::get_layout(cx);
1252 let WindowLayout::Custom(user_layout) = layout else {
1253 panic!("expected Custom, got {:?}", layout);
1254 };
1255 assert_eq!(user_layout.agent_dock, Some(DockPosition::Left));
1256 assert_eq!(user_layout.project_panel_dock, Some(DockSide::Left));
1257 }
1258
1259 #[gpui::test]
1260 fn test_set_layout_round_trip(cx: &mut gpui::App) {
1261 let store = SettingsStore::test(cx);
1262 cx.set_global(store);
1263 project::DisableAiSettings::register(cx);
1264 AgentSettings::register(cx);
1265
1266 // User has a custom layout: agent on the right with project panel
1267 // also on the right. This doesn't match either preset.
1268 SettingsStore::update_global(cx, |store, cx| {
1269 store
1270 .set_user_settings(
1271 r#"{
1272 "agent": { "dock": "right" },
1273 "project_panel": { "dock": "right" }
1274 }"#,
1275 cx,
1276 )
1277 .unwrap();
1278 });
1279
1280 let original = AgentSettings::get_layout(cx);
1281 let WindowLayout::Custom(ref original_user_layout) = original else {
1282 panic!("expected Custom, got {:?}", original);
1283 };
1284 assert_eq!(original_user_layout.agent_dock, Some(DockPosition::Right));
1285 assert_eq!(
1286 original_user_layout.project_panel_dock,
1287 Some(DockSide::Right)
1288 );
1289 assert_eq!(original_user_layout.outline_panel_dock, None);
1290
1291 // Switch to the agent layout. This overwrites the user settings.
1292 SettingsStore::update_global(cx, |store, cx| {
1293 store.update_user_settings(cx, |settings| {
1294 PanelLayout::AGENT.write_to(settings);
1295 });
1296 });
1297
1298 let layout = AgentSettings::get_layout(cx);
1299 assert!(matches!(layout, WindowLayout::Agent(_)));
1300
1301 // Restore the original custom layout.
1302 SettingsStore::update_global(cx, |store, cx| {
1303 store.update_user_settings(cx, |settings| {
1304 original_user_layout.write_to(settings);
1305 });
1306 });
1307
1308 // Should be back to the same custom layout.
1309 let restored = AgentSettings::get_layout(cx);
1310 let WindowLayout::Custom(restored_user_layout) = restored else {
1311 panic!("expected Custom, got {:?}", restored);
1312 };
1313 assert_eq!(restored_user_layout.agent_dock, Some(DockPosition::Right));
1314 assert_eq!(
1315 restored_user_layout.project_panel_dock,
1316 Some(DockSide::Right)
1317 );
1318 assert_eq!(restored_user_layout.outline_panel_dock, None);
1319 }
1320
1321 #[gpui::test]
1322 async fn test_set_layout_minimal_diff(cx: &mut TestAppContext) {
1323 let fs = fs::FakeFs::new(cx.background_executor.clone());
1324 fs.save(
1325 paths::settings_file().as_path(),
1326 &serde_json::json!({
1327 "agent": { "dock": "left" },
1328 "project_panel": { "dock": "left" }
1329 })
1330 .to_string()
1331 .into(),
1332 Default::default(),
1333 )
1334 .await
1335 .unwrap();
1336
1337 cx.update(|cx| {
1338 let store = SettingsStore::test(cx);
1339 cx.set_global(store);
1340 project::DisableAiSettings::register(cx);
1341 AgentSettings::register(cx);
1342
1343 // User has agent=left (matches preset) and project_panel=left (does not)
1344 SettingsStore::update_global(cx, |store, cx| {
1345 store
1346 .set_user_settings(
1347 r#"{
1348 "agent": { "dock": "left" },
1349 "project_panel": { "dock": "left" }
1350 }"#,
1351 cx,
1352 )
1353 .unwrap();
1354 });
1355
1356 let layout = AgentSettings::get_layout(cx);
1357 assert!(matches!(layout, WindowLayout::Custom(_)));
1358
1359 AgentSettings::set_layout(WindowLayout::agent(), fs.clone(), cx);
1360 });
1361
1362 cx.run_until_parked();
1363
1364 let written = fs.load(paths::settings_file().as_path()).await.unwrap();
1365 cx.update(|cx| {
1366 SettingsStore::update_global(cx, |store, cx| {
1367 store.set_user_settings(&written, cx).unwrap();
1368 });
1369
1370 // The user settings should still have agent=left (preserved)
1371 // and now project_panel=right (changed to match preset).
1372 let store = cx.global::<SettingsStore>();
1373 let user_layout = store
1374 .raw_user_settings()
1375 .map(|u| PanelLayout::read_from(u.content.as_ref()))
1376 .unwrap_or_default();
1377
1378 assert_eq!(user_layout.agent_dock, Some(DockPosition::Left));
1379 assert_eq!(user_layout.project_panel_dock, Some(DockSide::Right));
1380 // Other fields weren't in user settings and didn't need changing.
1381 assert_eq!(user_layout.outline_panel_dock, None);
1382
1383 // And the merged result should now match agent.
1384 let layout = AgentSettings::get_layout(cx);
1385 assert!(matches!(layout, WindowLayout::Agent(_)));
1386 });
1387 }
1388
1389 #[gpui::test]
1390 async fn test_backfill_editor_layout(cx: &mut TestAppContext) {
1391 let fs = fs::FakeFs::new(cx.background_executor.clone());
1392 // User has only customized project_panel to "right".
1393 fs.save(
1394 paths::settings_file().as_path(),
1395 &serde_json::json!({
1396 "project_panel": { "dock": "right" }
1397 })
1398 .to_string()
1399 .into(),
1400 Default::default(),
1401 )
1402 .await
1403 .unwrap();
1404
1405 cx.update(|cx| {
1406 let store = SettingsStore::test(cx);
1407 cx.set_global(store);
1408 project::DisableAiSettings::register(cx);
1409 AgentSettings::register(cx);
1410
1411 // Simulate pre-migration state: editor defaults (the old world).
1412 SettingsStore::update_global(cx, |store, cx| {
1413 store.update_default_settings(cx, |defaults| {
1414 PanelLayout::EDITOR.write_to(defaults);
1415 });
1416 });
1417
1418 // User has only customized project_panel to "right".
1419 SettingsStore::update_global(cx, |store, cx| {
1420 store
1421 .set_user_settings(r#"{ "project_panel": { "dock": "right" } }"#, cx)
1422 .unwrap();
1423 });
1424
1425 // Run the one-time backfill while still on old defaults.
1426 AgentSettings::backfill_editor_layout(fs.clone(), cx);
1427 });
1428
1429 cx.run_until_parked();
1430
1431 // Read back the file and apply it.
1432 let written = fs.load(paths::settings_file().as_path()).await.unwrap();
1433 cx.update(|cx| {
1434 SettingsStore::update_global(cx, |store, cx| {
1435 store.set_user_settings(&written, cx).unwrap();
1436 });
1437
1438 // The user's project_panel=right should be preserved (they set it).
1439 // All other fields should now have the editor preset values
1440 // written into user settings.
1441 let store = cx.global::<SettingsStore>();
1442 let user_layout = store
1443 .raw_user_settings()
1444 .map(|u| PanelLayout::read_from(u.content.as_ref()))
1445 .unwrap_or_default();
1446
1447 assert_eq!(user_layout.agent_dock, Some(DockPosition::Right));
1448 assert_eq!(user_layout.project_panel_dock, Some(DockSide::Right));
1449 assert_eq!(user_layout.outline_panel_dock, Some(DockSide::Left));
1450 assert_eq!(
1451 user_layout.collaboration_panel_dock,
1452 Some(DockPosition::Left)
1453 );
1454 assert_eq!(user_layout.git_panel_dock, Some(DockPosition::Left));
1455
1456 // Even though defaults are now agent, the backfilled user settings
1457 // keep everything in the editor layout. The user's experience
1458 // hasn't changed.
1459 let layout = AgentSettings::get_layout(cx);
1460 let WindowLayout::Custom(user_layout) = layout else {
1461 panic!(
1462 "expected Custom (editor values override agent defaults), got {:?}",
1463 layout
1464 );
1465 };
1466 assert_eq!(user_layout.agent_dock, Some(DockPosition::Right));
1467 assert_eq!(user_layout.project_panel_dock, Some(DockSide::Right));
1468 });
1469 }
1470}