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