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