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