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