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