1use std::collections::{BTreeMap, BTreeSet};
18use std::env;
19use std::fs;
20use std::path::{Path, PathBuf};
21use std::sync::Mutex;
22
23use pi_agent::QueueMode;
24use pi_ai::{ModelThinkingLevel, Transport};
25use serde_json::{Map, Value};
26use thiserror::Error;
27use uuid::Uuid;
28
29use super::config::{CONFIG_DIR_NAME, expand_tilde_path, get_agent_dir, resolve_path};
30use super::lockfile::LockGuard;
31use super::trust::DefaultProjectTrust;
32
33pub const DEFAULT_COMPACTION_RESERVE_TOKENS: u64 = 16384;
35pub const DEFAULT_COMPACTION_KEEP_RECENT_TOKENS: u64 = 20000;
37pub const DEFAULT_BRANCH_SUMMARY_RESERVE_TOKENS: u64 = 16384;
39pub const DEFAULT_RETRY_MAX_RETRIES: u64 = 3;
41pub const DEFAULT_RETRY_BASE_DELAY_MS: u64 = 2000;
43pub const DEFAULT_PROVIDER_MAX_RETRY_DELAY_MS: u64 = 60000;
45pub const DEFAULT_HTTP_IDLE_TIMEOUT_MS: u64 = 300_000;
47pub const DEFAULT_IMAGE_WIDTH_CELLS: u64 = 60;
49pub const DEFAULT_AUTOCOMPLETE_MAX_VISIBLE: u64 = 5;
51pub const DEFAULT_CODE_BLOCK_INDENT: &str = " ";
53
54const U64_MAX_F64: f64 = 18_446_744_073_709_551_616.0;
55const I64_MIN_F64: f64 = -9_223_372_036_854_775_808.0;
57
58const KNOWN_SETTINGS_KEYS: &[&str] = &[
59 "lastChangelogVersion",
60 "defaultProvider",
61 "defaultModel",
62 "defaultThinkingLevel",
63 "transport",
64 "steeringMode",
65 "followUpMode",
66 "theme",
67 "compaction",
68 "branchSummary",
69 "retry",
70 "hideThinkingBlock",
71 "showCacheMissNotices",
72 "externalEditor",
73 "shellPath",
74 "quietStartup",
75 "defaultProjectTrust",
76 "shellCommandPrefix",
77 "npmCommand",
78 "collapseChangelog",
79 "enableInstallTelemetry",
80 "enableAnalytics",
81 "trackingId",
82 "packages",
83 "extensions",
84 "skills",
85 "prompts",
86 "themes",
87 "enableSkillCommands",
88 "terminal",
89 "images",
90 "enabledModels",
91 "doubleEscapeAction",
92 "treeFilterMode",
93 "thinkingBudgets",
94 "editorPaddingX",
95 "outputPad",
96 "autocompleteMaxVisible",
97 "showHardwareCursor",
98 "markdown",
99 "warnings",
100 "sessionDir",
101 "httpProxy",
102 "httpIdleTimeoutMs",
103 "websocketConnectTimeoutMs",
104];
105
106const PACKAGE_SOURCE_FILTER_KEYS: &[&str] = &[
107 "source",
108 "autoload",
109 "extensions",
110 "skills",
111 "prompts",
112 "themes",
113];
114
115#[derive(Clone, Copy, Debug, Eq, PartialEq)]
117pub enum SettingsScope {
118 Global,
120 Project,
122}
123
124#[derive(Clone, Debug, Eq, PartialEq)]
126pub struct SettingsError {
127 pub scope: SettingsScope,
129 pub message: String,
131}
132
133#[derive(Debug, Error)]
135pub enum SettingsManagerError {
136 #[error("Project is not trusted; refusing to write project settings")]
138 ProjectNotTrusted,
139 #[error("Invalid {setting} setting: {value}")]
141 InvalidSetting {
142 setting: &'static str,
144 value: String,
146 },
147}
148
149#[derive(Clone, Copy, Debug, Eq, PartialEq)]
151pub struct SettingsManagerCreateOptions {
152 pub project_trusted: bool,
154}
155
156impl Default for SettingsManagerCreateOptions {
157 fn default() -> Self {
158 Self {
159 project_trusted: true,
160 }
161 }
162}
163
164impl SettingsManagerCreateOptions {
165 #[must_use]
167 pub fn new() -> Self {
168 Self::default()
169 }
170
171 #[must_use]
173 pub const fn project_trusted(mut self, project_trusted: bool) -> Self {
174 self.project_trusted = project_trusted;
175 self
176 }
177}
178
179#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
181pub enum DoubleEscapeAction {
182 Fork,
184 #[default]
186 Tree,
187 None,
189}
190
191impl DoubleEscapeAction {
192 #[must_use]
194 pub const fn as_str(self) -> &'static str {
195 match self {
196 Self::Fork => "fork",
197 Self::Tree => "tree",
198 Self::None => "none",
199 }
200 }
201
202 fn parse(value: &str) -> Option<Self> {
203 match value {
204 "fork" => Some(Self::Fork),
205 "tree" => Some(Self::Tree),
206 "none" => Some(Self::None),
207 _ => None,
208 }
209 }
210}
211
212#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
214pub enum TreeFilterMode {
215 #[default]
217 Default,
218 NoTools,
220 UserOnly,
222 LabeledOnly,
224 All,
226}
227
228impl TreeFilterMode {
229 #[must_use]
231 pub const fn as_str(self) -> &'static str {
232 match self {
233 Self::Default => "default",
234 Self::NoTools => "no-tools",
235 Self::UserOnly => "user-only",
236 Self::LabeledOnly => "labeled-only",
237 Self::All => "all",
238 }
239 }
240
241 fn parse(value: &str) -> Option<Self> {
242 match value {
243 "default" => Some(Self::Default),
244 "no-tools" => Some(Self::NoTools),
245 "user-only" => Some(Self::UserOnly),
246 "labeled-only" => Some(Self::LabeledOnly),
247 "all" => Some(Self::All),
248 _ => None,
249 }
250 }
251}
252
253#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
255pub enum OutputPad {
256 Zero,
258 #[default]
260 One,
261}
262
263impl OutputPad {
264 #[must_use]
266 pub const fn as_u64(self) -> u64 {
267 match self {
268 Self::Zero => 0,
269 Self::One => 1,
270 }
271 }
272}
273
274#[derive(Clone, Debug, Default, PartialEq)]
276pub struct CompactionSettings {
277 pub enabled: Option<bool>,
279 pub reserve_tokens: Option<u64>,
281 pub keep_recent_tokens: Option<u64>,
283 pub extra: Map<String, Value>,
285}
286
287#[derive(Clone, Debug, Default, PartialEq)]
289pub struct BranchSummarySettings {
290 pub reserve_tokens: Option<u64>,
292 pub skip_prompt: Option<bool>,
294 pub extra: Map<String, Value>,
296}
297
298#[derive(Clone, Debug, Default, PartialEq)]
300pub struct ProviderRetrySettings {
301 pub timeout_ms: Option<u64>,
303 pub max_retries: Option<u64>,
305 pub max_retry_delay_ms: Option<u64>,
307 pub extra: Map<String, Value>,
309}
310
311#[derive(Clone, Debug, Default, PartialEq)]
313pub struct RetrySettings {
314 pub enabled: Option<bool>,
316 pub max_retries: Option<u64>,
318 pub base_delay_ms: Option<u64>,
320 pub provider: Option<ProviderRetrySettings>,
322 pub extra: Map<String, Value>,
324}
325
326#[derive(Clone, Debug, Default, PartialEq)]
328pub struct TerminalSettings {
329 pub show_images: Option<bool>,
331 pub image_width_cells: Option<u64>,
333 pub clear_on_shrink: Option<bool>,
335 pub show_terminal_progress: Option<bool>,
337 pub extra: Map<String, Value>,
339}
340
341#[derive(Clone, Debug, Default, PartialEq)]
343pub struct ImageSettings {
344 pub auto_resize: Option<bool>,
346 pub block_images: Option<bool>,
348 pub extra: Map<String, Value>,
350}
351
352#[derive(Clone, Debug, Default, PartialEq)]
354pub struct ThinkingBudgetsSettings {
355 pub minimal: Option<u64>,
357 pub low: Option<u64>,
359 pub medium: Option<u64>,
361 pub high: Option<u64>,
363 pub extra: Map<String, Value>,
365}
366
367#[derive(Clone, Debug, Default, PartialEq)]
369pub struct MarkdownSettings {
370 pub code_block_indent: Option<String>,
372 pub extra: Map<String, Value>,
374}
375
376#[derive(Clone, Debug, Default, PartialEq)]
378pub struct WarningSettings {
379 pub anthropic_extra_usage: Option<bool>,
381 pub extra: Map<String, Value>,
383}
384
385#[derive(Clone, Debug, PartialEq)]
387pub enum PackageSource {
388 Source(String),
390 Filtered(PackageSourceFilter),
392}
393
394#[derive(Clone, Debug, Default, PartialEq)]
396pub struct PackageSourceFilter {
397 pub source: String,
399 pub autoload: Option<bool>,
401 pub extensions: Option<Vec<String>>,
403 pub skills: Option<Vec<String>>,
405 pub prompts: Option<Vec<String>>,
407 pub themes: Option<Vec<String>>,
409 pub extra: Map<String, Value>,
411}
412
413impl PackageSource {
414 #[must_use]
416 pub fn from_value(value: &Value) -> Option<Self> {
417 match value {
418 Value::String(source) => Some(Self::Source(source.clone())),
419 Value::Object(map) => {
420 let source = map.get("source").and_then(Value::as_str)?.to_owned();
421 Some(Self::Filtered(PackageSourceFilter {
422 source,
423 autoload: map.get("autoload").and_then(Value::as_bool),
424 extensions: string_array(map.get("extensions")),
425 skills: string_array(map.get("skills")),
426 prompts: string_array(map.get("prompts")),
427 themes: string_array(map.get("themes")),
428 extra: unknown_fields(map, PACKAGE_SOURCE_FILTER_KEYS),
429 }))
430 }
431 _ => None,
432 }
433 }
434
435 #[must_use]
437 pub fn to_value(&self) -> Value {
438 match self {
439 Self::Source(source) => Value::String(source.clone()),
440 Self::Filtered(filter) => {
441 let mut map = filter.extra.clone();
442 map.insert("source".to_owned(), Value::String(filter.source.clone()));
443 insert_opt_bool(&mut map, "autoload", filter.autoload);
444 insert_opt_strings(&mut map, "extensions", filter.extensions.as_deref());
445 insert_opt_strings(&mut map, "skills", filter.skills.as_deref());
446 insert_opt_strings(&mut map, "prompts", filter.prompts.as_deref());
447 insert_opt_strings(&mut map, "themes", filter.themes.as_deref());
448 Value::Object(map)
449 }
450 }
451 }
452}
453
454#[derive(Clone, Debug, Default, PartialEq)]
460pub struct Settings {
461 pub last_changelog_version: Option<String>,
463 pub default_provider: Option<String>,
465 pub default_model: Option<String>,
467 pub default_thinking_level: Option<ModelThinkingLevel>,
469 pub transport: Option<Transport>,
471 pub steering_mode: Option<QueueMode>,
473 pub follow_up_mode: Option<QueueMode>,
475 pub theme: Option<String>,
477 pub compaction: Option<CompactionSettings>,
479 pub branch_summary: Option<BranchSummarySettings>,
481 pub retry: Option<RetrySettings>,
483 pub hide_thinking_block: Option<bool>,
485 pub show_cache_miss_notices: Option<bool>,
487 pub external_editor: Option<String>,
489 pub shell_path: Option<String>,
491 pub quiet_startup: Option<bool>,
493 pub default_project_trust: Option<DefaultProjectTrust>,
495 pub shell_command_prefix: Option<String>,
497 pub npm_command: Option<Vec<String>>,
499 pub collapse_changelog: Option<bool>,
501 pub enable_install_telemetry: Option<bool>,
503 pub enable_analytics: Option<bool>,
505 pub tracking_id: Option<String>,
507 pub packages: Option<Vec<PackageSource>>,
509 pub extensions: Option<Vec<String>>,
511 pub skills: Option<Vec<String>>,
513 pub prompts: Option<Vec<String>>,
515 pub themes: Option<Vec<String>>,
517 pub enable_skill_commands: Option<bool>,
519 pub terminal: Option<TerminalSettings>,
521 pub images: Option<ImageSettings>,
523 pub enabled_models: Option<Vec<String>>,
525 pub double_escape_action: Option<DoubleEscapeAction>,
527 pub tree_filter_mode: Option<TreeFilterMode>,
529 pub thinking_budgets: Option<ThinkingBudgetsSettings>,
531 pub editor_padding_x: Option<u64>,
533 pub output_pad: Option<OutputPad>,
535 pub autocomplete_max_visible: Option<u64>,
537 pub show_hardware_cursor: Option<bool>,
539 pub markdown: Option<MarkdownSettings>,
541 pub warnings: Option<WarningSettings>,
543 pub session_dir: Option<String>,
545 pub http_proxy: Option<String>,
547 pub http_idle_timeout_ms: Option<u64>,
549 pub websocket_connect_timeout_ms: Option<u64>,
551 pub extra: Map<String, Value>,
553}
554
555impl Settings {
556 #[must_use]
558 pub fn from_map(map: &Map<String, Value>) -> Self {
559 Self {
560 last_changelog_version: string_field(map, "lastChangelogVersion"),
561 default_provider: string_field(map, "defaultProvider"),
562 default_model: string_field(map, "defaultModel"),
563 default_thinking_level: parse_thinking_level(map.get("defaultThinkingLevel")),
564 transport: parse_transport(map.get("transport")),
565 steering_mode: parse_queue_mode(map.get("steeringMode")),
566 follow_up_mode: parse_queue_mode(map.get("followUpMode")),
567 theme: string_field(map, "theme"),
568 compaction: nested_field(map, "compaction", CompactionSettings::from_map),
569 branch_summary: nested_field(map, "branchSummary", BranchSummarySettings::from_map),
570 retry: nested_field(map, "retry", RetrySettings::from_map),
571 hide_thinking_block: bool_field(map, "hideThinkingBlock"),
572 show_cache_miss_notices: bool_field(map, "showCacheMissNotices"),
573 external_editor: string_field(map, "externalEditor"),
574 shell_path: string_field(map, "shellPath"),
575 quiet_startup: bool_field(map, "quietStartup"),
576 default_project_trust: parse_default_project_trust(map.get("defaultProjectTrust")),
577 shell_command_prefix: string_field(map, "shellCommandPrefix"),
578 npm_command: string_array(map.get("npmCommand")),
579 collapse_changelog: bool_field(map, "collapseChangelog"),
580 enable_install_telemetry: bool_field(map, "enableInstallTelemetry"),
581 enable_analytics: bool_field(map, "enableAnalytics"),
582 tracking_id: string_field(map, "trackingId"),
583 packages: map
584 .get("packages")
585 .and_then(Value::as_array)
586 .map(|items| items.iter().filter_map(PackageSource::from_value).collect()),
587 extensions: string_array(map.get("extensions")),
588 skills: string_array(map.get("skills")),
589 prompts: string_array(map.get("prompts")),
590 themes: string_array(map.get("themes")),
591 enable_skill_commands: bool_field(map, "enableSkillCommands"),
592 terminal: nested_field(map, "terminal", TerminalSettings::from_map),
593 images: nested_field(map, "images", ImageSettings::from_map),
594 enabled_models: string_array(map.get("enabledModels")),
595 double_escape_action: map
596 .get("doubleEscapeAction")
597 .and_then(Value::as_str)
598 .and_then(DoubleEscapeAction::parse),
599 tree_filter_mode: map
600 .get("treeFilterMode")
601 .and_then(Value::as_str)
602 .and_then(TreeFilterMode::parse),
603 thinking_budgets: nested_field(
604 map,
605 "thinkingBudgets",
606 ThinkingBudgetsSettings::from_map,
607 ),
608 editor_padding_x: number_to_u64(map.get("editorPaddingX")),
609 output_pad: parse_output_pad(map.get("outputPad")),
610 autocomplete_max_visible: number_to_u64(map.get("autocompleteMaxVisible")),
611 show_hardware_cursor: bool_field(map, "showHardwareCursor"),
612 markdown: nested_field(map, "markdown", MarkdownSettings::from_map),
613 warnings: nested_field(map, "warnings", WarningSettings::from_map),
614 session_dir: string_field(map, "sessionDir"),
615 http_proxy: string_field(map, "httpProxy"),
616 http_idle_timeout_ms: map.get("httpIdleTimeoutMs").and_then(parse_timeout_ms),
617 websocket_connect_timeout_ms: map
618 .get("websocketConnectTimeoutMs")
619 .and_then(parse_timeout_ms),
620 extra: unknown_fields(map, KNOWN_SETTINGS_KEYS),
621 }
622 }
623
624 #[must_use]
629 pub fn to_map(&self) -> Map<String, Value> {
630 let mut map = self.extra.clone();
631 self.insert_scalar_fields(&mut map);
632 self.insert_nested_fields(&mut map);
633 self.insert_resource_fields(&mut map);
634 map
635 }
636
637 fn insert_scalar_fields(&self, map: &mut Map<String, Value>) {
638 insert_opt_string(
639 map,
640 "lastChangelogVersion",
641 self.last_changelog_version.as_deref(),
642 );
643 insert_opt_string(map, "defaultProvider", self.default_provider.as_deref());
644 insert_opt_string(map, "defaultModel", self.default_model.as_deref());
645 insert_opt_value(
646 map,
647 "defaultThinkingLevel",
648 self.default_thinking_level.map(thinking_level_value),
649 );
650 insert_opt_value(map, "transport", self.transport.map(transport_value));
651 insert_opt_value(
652 map,
653 "steeringMode",
654 self.steering_mode.map(queue_mode_value),
655 );
656 insert_opt_value(
657 map,
658 "followUpMode",
659 self.follow_up_mode.map(queue_mode_value),
660 );
661 insert_opt_string(map, "theme", self.theme.as_deref());
662 insert_opt_bool(map, "hideThinkingBlock", self.hide_thinking_block);
663 insert_opt_bool(map, "showCacheMissNotices", self.show_cache_miss_notices);
664 insert_opt_string(map, "externalEditor", self.external_editor.as_deref());
665 insert_opt_string(map, "shellPath", self.shell_path.as_deref());
666 insert_opt_bool(map, "quietStartup", self.quiet_startup);
667 insert_opt_value(
668 map,
669 "defaultProjectTrust",
670 self.default_project_trust
671 .map(|trust| Value::String(trust.as_str().to_owned())),
672 );
673 insert_opt_string(
674 map,
675 "shellCommandPrefix",
676 self.shell_command_prefix.as_deref(),
677 );
678 insert_opt_bool(map, "collapseChangelog", self.collapse_changelog);
679 insert_opt_bool(map, "enableInstallTelemetry", self.enable_install_telemetry);
680 insert_opt_bool(map, "enableAnalytics", self.enable_analytics);
681 insert_opt_string(map, "trackingId", self.tracking_id.as_deref());
682 insert_opt_bool(map, "enableSkillCommands", self.enable_skill_commands);
683 insert_opt_value(
684 map,
685 "doubleEscapeAction",
686 self.double_escape_action
687 .map(|action| Value::String(action.as_str().to_owned())),
688 );
689 insert_opt_value(
690 map,
691 "treeFilterMode",
692 self.tree_filter_mode
693 .map(|mode| Value::String(mode.as_str().to_owned())),
694 );
695 insert_opt_u64(map, "editorPaddingX", self.editor_padding_x);
696 insert_opt_value(
697 map,
698 "outputPad",
699 self.output_pad.map(|pad| Value::from(pad.as_u64())),
700 );
701 insert_opt_u64(map, "autocompleteMaxVisible", self.autocomplete_max_visible);
702 insert_opt_bool(map, "showHardwareCursor", self.show_hardware_cursor);
703 insert_opt_string(map, "sessionDir", self.session_dir.as_deref());
704 insert_opt_string(map, "httpProxy", self.http_proxy.as_deref());
705 insert_opt_u64(map, "httpIdleTimeoutMs", self.http_idle_timeout_ms);
706 insert_opt_u64(
707 map,
708 "websocketConnectTimeoutMs",
709 self.websocket_connect_timeout_ms,
710 );
711 }
712
713 fn insert_nested_fields(&self, map: &mut Map<String, Value>) {
714 insert_opt_value(
715 map,
716 "compaction",
717 self.compaction
718 .as_ref()
719 .map(|value| Value::Object(value.to_map())),
720 );
721 insert_opt_value(
722 map,
723 "branchSummary",
724 self.branch_summary
725 .as_ref()
726 .map(|value| Value::Object(value.to_map())),
727 );
728 insert_opt_value(
729 map,
730 "retry",
731 self.retry
732 .as_ref()
733 .map(|value| Value::Object(value.to_map())),
734 );
735 insert_opt_value(
736 map,
737 "terminal",
738 self.terminal
739 .as_ref()
740 .map(|value| Value::Object(value.to_map())),
741 );
742 insert_opt_value(
743 map,
744 "images",
745 self.images
746 .as_ref()
747 .map(|value| Value::Object(value.to_map())),
748 );
749 insert_opt_value(
750 map,
751 "thinkingBudgets",
752 self.thinking_budgets
753 .as_ref()
754 .map(|value| Value::Object(value.to_map())),
755 );
756 insert_opt_value(
757 map,
758 "markdown",
759 self.markdown
760 .as_ref()
761 .map(|value| Value::Object(value.to_map())),
762 );
763 insert_opt_value(
764 map,
765 "warnings",
766 self.warnings
767 .as_ref()
768 .map(|value| Value::Object(value.to_map())),
769 );
770 }
771
772 fn insert_resource_fields(&self, map: &mut Map<String, Value>) {
773 insert_opt_strings(map, "npmCommand", self.npm_command.as_deref());
774 insert_opt_value(
775 map,
776 "packages",
777 self.packages.as_ref().map(|packages| {
778 Value::Array(packages.iter().map(PackageSource::to_value).collect())
779 }),
780 );
781 insert_opt_strings(map, "extensions", self.extensions.as_deref());
782 insert_opt_strings(map, "skills", self.skills.as_deref());
783 insert_opt_strings(map, "prompts", self.prompts.as_deref());
784 insert_opt_strings(map, "themes", self.themes.as_deref());
785 insert_opt_strings(map, "enabledModels", self.enabled_models.as_deref());
786 }
787}
788
789impl CompactionSettings {
790 fn from_map(map: &Map<String, Value>) -> Self {
791 Self {
792 enabled: bool_field(map, "enabled"),
793 reserve_tokens: number_to_u64(map.get("reserveTokens")),
794 keep_recent_tokens: number_to_u64(map.get("keepRecentTokens")),
795 extra: unknown_fields(map, &["enabled", "reserveTokens", "keepRecentTokens"]),
796 }
797 }
798
799 fn to_map(&self) -> Map<String, Value> {
800 let mut map = self.extra.clone();
801 insert_opt_bool(&mut map, "enabled", self.enabled);
802 insert_opt_u64(&mut map, "reserveTokens", self.reserve_tokens);
803 insert_opt_u64(&mut map, "keepRecentTokens", self.keep_recent_tokens);
804 map
805 }
806}
807
808impl BranchSummarySettings {
809 fn from_map(map: &Map<String, Value>) -> Self {
810 Self {
811 reserve_tokens: number_to_u64(map.get("reserveTokens")),
812 skip_prompt: bool_field(map, "skipPrompt"),
813 extra: unknown_fields(map, &["reserveTokens", "skipPrompt"]),
814 }
815 }
816
817 fn to_map(&self) -> Map<String, Value> {
818 let mut map = self.extra.clone();
819 insert_opt_u64(&mut map, "reserveTokens", self.reserve_tokens);
820 insert_opt_bool(&mut map, "skipPrompt", self.skip_prompt);
821 map
822 }
823}
824
825impl ProviderRetrySettings {
826 fn from_map(map: &Map<String, Value>) -> Self {
827 Self {
828 timeout_ms: number_to_u64(map.get("timeoutMs")),
829 max_retries: number_to_u64(map.get("maxRetries")),
830 max_retry_delay_ms: number_to_u64(map.get("maxRetryDelayMs")),
831 extra: unknown_fields(map, &["timeoutMs", "maxRetries", "maxRetryDelayMs"]),
832 }
833 }
834
835 fn to_map(&self) -> Map<String, Value> {
836 let mut map = self.extra.clone();
837 insert_opt_u64(&mut map, "timeoutMs", self.timeout_ms);
838 insert_opt_u64(&mut map, "maxRetries", self.max_retries);
839 insert_opt_u64(&mut map, "maxRetryDelayMs", self.max_retry_delay_ms);
840 map
841 }
842}
843
844impl RetrySettings {
845 fn from_map(map: &Map<String, Value>) -> Self {
846 Self {
847 enabled: bool_field(map, "enabled"),
848 max_retries: number_to_u64(map.get("maxRetries")),
849 base_delay_ms: number_to_u64(map.get("baseDelayMs")),
850 provider: nested_field(map, "provider", ProviderRetrySettings::from_map),
851 extra: unknown_fields(map, &["enabled", "maxRetries", "baseDelayMs", "provider"]),
852 }
853 }
854
855 fn to_map(&self) -> Map<String, Value> {
856 let mut map = self.extra.clone();
857 insert_opt_bool(&mut map, "enabled", self.enabled);
858 insert_opt_u64(&mut map, "maxRetries", self.max_retries);
859 insert_opt_u64(&mut map, "baseDelayMs", self.base_delay_ms);
860 insert_opt_value(
861 &mut map,
862 "provider",
863 self.provider
864 .as_ref()
865 .map(|value| Value::Object(value.to_map())),
866 );
867 map
868 }
869}
870
871impl TerminalSettings {
872 fn from_map(map: &Map<String, Value>) -> Self {
873 Self {
874 show_images: bool_field(map, "showImages"),
875 image_width_cells: number_to_u64(map.get("imageWidthCells")),
876 clear_on_shrink: bool_field(map, "clearOnShrink"),
877 show_terminal_progress: bool_field(map, "showTerminalProgress"),
878 extra: unknown_fields(
879 map,
880 &[
881 "showImages",
882 "imageWidthCells",
883 "clearOnShrink",
884 "showTerminalProgress",
885 ],
886 ),
887 }
888 }
889
890 fn to_map(&self) -> Map<String, Value> {
891 let mut map = self.extra.clone();
892 insert_opt_bool(&mut map, "showImages", self.show_images);
893 insert_opt_u64(&mut map, "imageWidthCells", self.image_width_cells);
894 insert_opt_bool(&mut map, "clearOnShrink", self.clear_on_shrink);
895 insert_opt_bool(
896 &mut map,
897 "showTerminalProgress",
898 self.show_terminal_progress,
899 );
900 map
901 }
902}
903
904impl ImageSettings {
905 fn from_map(map: &Map<String, Value>) -> Self {
906 Self {
907 auto_resize: bool_field(map, "autoResize"),
908 block_images: bool_field(map, "blockImages"),
909 extra: unknown_fields(map, &["autoResize", "blockImages"]),
910 }
911 }
912
913 fn to_map(&self) -> Map<String, Value> {
914 let mut map = self.extra.clone();
915 insert_opt_bool(&mut map, "autoResize", self.auto_resize);
916 insert_opt_bool(&mut map, "blockImages", self.block_images);
917 map
918 }
919}
920
921impl ThinkingBudgetsSettings {
922 fn from_map(map: &Map<String, Value>) -> Self {
923 Self {
924 minimal: number_to_u64(map.get("minimal")),
925 low: number_to_u64(map.get("low")),
926 medium: number_to_u64(map.get("medium")),
927 high: number_to_u64(map.get("high")),
928 extra: unknown_fields(map, &["minimal", "low", "medium", "high"]),
929 }
930 }
931
932 fn to_map(&self) -> Map<String, Value> {
933 let mut map = self.extra.clone();
934 insert_opt_u64(&mut map, "minimal", self.minimal);
935 insert_opt_u64(&mut map, "low", self.low);
936 insert_opt_u64(&mut map, "medium", self.medium);
937 insert_opt_u64(&mut map, "high", self.high);
938 map
939 }
940}
941
942impl MarkdownSettings {
943 fn from_map(map: &Map<String, Value>) -> Self {
944 Self {
945 code_block_indent: string_field(map, "codeBlockIndent"),
946 extra: unknown_fields(map, &["codeBlockIndent"]),
947 }
948 }
949
950 fn to_map(&self) -> Map<String, Value> {
951 let mut map = self.extra.clone();
952 insert_opt_string(
953 &mut map,
954 "codeBlockIndent",
955 self.code_block_indent.as_deref(),
956 );
957 map
958 }
959}
960
961impl WarningSettings {
962 fn from_map(map: &Map<String, Value>) -> Self {
963 Self {
964 anthropic_extra_usage: bool_field(map, "anthropicExtraUsage"),
965 extra: unknown_fields(map, &["anthropicExtraUsage"]),
966 }
967 }
968
969 fn to_map(&self) -> Map<String, Value> {
970 let mut map = self.extra.clone();
971 insert_opt_bool(&mut map, "anthropicExtraUsage", self.anthropic_extra_usage);
972 map
973 }
974}
975
976#[derive(Clone, Copy, Debug, Eq, PartialEq)]
978pub struct ResolvedCompactionSettings {
979 pub enabled: bool,
981 pub reserve_tokens: u64,
983 pub keep_recent_tokens: u64,
985}
986
987#[derive(Clone, Copy, Debug, Eq, PartialEq)]
989pub struct ResolvedBranchSummarySettings {
990 pub reserve_tokens: u64,
992 pub skip_prompt: bool,
994}
995
996#[derive(Clone, Copy, Debug, Eq, PartialEq)]
998pub struct ResolvedRetrySettings {
999 pub enabled: bool,
1001 pub max_retries: u64,
1003 pub base_delay_ms: u64,
1005}
1006
1007#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1009pub struct ResolvedProviderRetrySettings {
1010 pub timeout_ms: Option<u64>,
1012 pub max_retries: Option<u64>,
1014 pub max_retry_delay_ms: u64,
1016}
1017
1018pub trait SettingsStorage: Send + Sync {
1025 fn with_lock(
1031 &mut self,
1032 scope: SettingsScope,
1033 f: &mut dyn FnMut(Option<String>) -> Result<Option<String>, String>,
1034 ) -> Result<(), String>;
1035}
1036
1037#[derive(Debug)]
1044pub struct FileSettingsStorage {
1045 global_settings_path: PathBuf,
1046 project_settings_path: PathBuf,
1047}
1048
1049impl FileSettingsStorage {
1050 #[must_use]
1052 pub fn new(cwd: impl AsRef<Path>, agent_dir: impl AsRef<Path>) -> Self {
1053 let resolved_cwd = resolve_path(path_to_string(cwd.as_ref()));
1054 let resolved_agent_dir = resolve_path(path_to_string(agent_dir.as_ref()));
1055 Self {
1056 global_settings_path: resolved_agent_dir.join("settings.json"),
1057 project_settings_path: resolved_cwd.join(CONFIG_DIR_NAME).join("settings.json"),
1058 }
1059 }
1060
1061 #[must_use]
1063 pub fn global_settings_path(&self) -> &Path {
1064 &self.global_settings_path
1065 }
1066
1067 #[must_use]
1069 pub fn project_settings_path(&self) -> &Path {
1070 &self.project_settings_path
1071 }
1072
1073 fn path_for(&self, scope: SettingsScope) -> &Path {
1074 match scope {
1075 SettingsScope::Global => &self.global_settings_path,
1076 SettingsScope::Project => &self.project_settings_path,
1077 }
1078 }
1079}
1080
1081impl SettingsStorage for FileSettingsStorage {
1082 fn with_lock(
1083 &mut self,
1084 scope: SettingsScope,
1085 f: &mut dyn FnMut(Option<String>) -> Result<Option<String>, String>,
1086 ) -> Result<(), String> {
1087 let path = self.path_for(scope).to_path_buf();
1088 let dir = path
1089 .parent()
1090 .map_or_else(|| PathBuf::from("."), Path::to_path_buf);
1091
1092 let file_exists = path.exists();
1093 let mut guard = if file_exists {
1094 Some(LockGuard::acquire(&path).map_err(|error| error.to_string())?)
1095 } else {
1096 None
1097 };
1098 let current =
1099 if file_exists {
1100 Some(fs::read_to_string(&path).map_err(|error| {
1101 format!("Failed to read settings {}: {error}", path.display())
1102 })?)
1103 } else {
1104 None
1105 };
1106 let next = f(current)?;
1107 if let Some(next) = next {
1108 if !dir.exists() {
1109 fs::create_dir_all(&dir).map_err(|error| {
1110 format!(
1111 "Failed to create settings directory {}: {error}",
1112 dir.display()
1113 )
1114 })?;
1115 }
1116 if guard.is_none() {
1117 guard = Some(LockGuard::acquire(&path).map_err(|error| error.to_string())?);
1118 }
1119 fs::write(&path, next)
1120 .map_err(|error| format!("Failed to write settings {}: {error}", path.display()))?;
1121 }
1122 drop(guard);
1123 Ok(())
1124 }
1125}
1126
1127#[derive(Debug, Default)]
1129pub struct InMemorySettingsStorage {
1130 global: Option<String>,
1131 project: Option<String>,
1132}
1133
1134impl InMemorySettingsStorage {
1135 #[must_use]
1137 pub fn new() -> Self {
1138 Self::default()
1139 }
1140}
1141
1142impl SettingsStorage for InMemorySettingsStorage {
1143 fn with_lock(
1144 &mut self,
1145 scope: SettingsScope,
1146 f: &mut dyn FnMut(Option<String>) -> Result<Option<String>, String>,
1147 ) -> Result<(), String> {
1148 let slot = match scope {
1149 SettingsScope::Global => &mut self.global,
1150 SettingsScope::Project => &mut self.project,
1151 };
1152 let next = f(slot.clone())?;
1153 if let Some(next) = next {
1154 *slot = Some(next);
1155 }
1156 Ok(())
1157 }
1158}
1159
1160pub struct SettingsManager {
1170 storage: Box<dyn SettingsStorage>,
1171 global_settings: Map<String, Value>,
1172 project_settings: Map<String, Value>,
1173 settings: Map<String, Value>,
1174 project_trusted: bool,
1175 modified_fields: BTreeSet<String>,
1176 modified_nested_fields: BTreeMap<String, BTreeSet<String>>,
1177 modified_project_fields: BTreeSet<String>,
1178 modified_project_nested_fields: BTreeMap<String, BTreeSet<String>>,
1179 global_settings_load_error: Option<String>,
1180 project_settings_load_error: Option<String>,
1181 errors: Vec<SettingsError>,
1182 write_mutex: Mutex<()>,
1184}
1185
1186impl SettingsManager {
1187 fn new(
1188 storage: Box<dyn SettingsStorage>,
1189 global_settings: Map<String, Value>,
1190 project_settings: Map<String, Value>,
1191 global_load_error: Option<String>,
1192 project_load_error: Option<String>,
1193 initial_errors: Vec<SettingsError>,
1194 project_trusted: bool,
1195 ) -> Self {
1196 let settings = deep_merge_settings(&global_settings, &project_settings);
1197 Self {
1198 storage,
1199 global_settings,
1200 project_settings,
1201 settings,
1202 project_trusted,
1203 modified_fields: BTreeSet::new(),
1204 modified_nested_fields: BTreeMap::new(),
1205 modified_project_fields: BTreeSet::new(),
1206 modified_project_nested_fields: BTreeMap::new(),
1207 global_settings_load_error: global_load_error,
1208 project_settings_load_error: project_load_error,
1209 errors: initial_errors,
1210 write_mutex: Mutex::new(()),
1211 }
1212 }
1213
1214 #[must_use]
1217 pub fn create(
1218 cwd: impl AsRef<Path>,
1219 agent_dir: Option<impl AsRef<Path>>,
1220 options: SettingsManagerCreateOptions,
1221 ) -> Self {
1222 let agent_dir = agent_dir.map_or_else(get_agent_dir, |dir| dir.as_ref().to_path_buf());
1223 Self::from_storage(Box::new(FileSettingsStorage::new(cwd, agent_dir)), options)
1224 }
1225
1226 #[must_use]
1228 pub fn from_storage(
1229 mut storage: Box<dyn SettingsStorage>,
1230 options: SettingsManagerCreateOptions,
1231 ) -> Self {
1232 let project_trusted = options.project_trusted;
1233 let (global_settings, global_error) =
1234 Self::try_load_from_storage(storage.as_mut(), SettingsScope::Global, true);
1235 let (project_settings, project_error) =
1236 Self::try_load_from_storage(storage.as_mut(), SettingsScope::Project, project_trusted);
1237 let mut initial_errors = Vec::new();
1238 if let Some(message) = &global_error {
1239 initial_errors.push(SettingsError {
1240 scope: SettingsScope::Global,
1241 message: message.clone(),
1242 });
1243 }
1244 if let Some(message) = &project_error {
1245 initial_errors.push(SettingsError {
1246 scope: SettingsScope::Project,
1247 message: message.clone(),
1248 });
1249 }
1250 Self::new(
1251 storage,
1252 global_settings,
1253 project_settings,
1254 global_error,
1255 project_error,
1256 initial_errors,
1257 project_trusted,
1258 )
1259 }
1260
1261 #[must_use]
1263 pub fn in_memory(settings: &Settings, options: SettingsManagerCreateOptions) -> Self {
1264 let mut storage = InMemorySettingsStorage::new();
1265 let mut initial = settings.to_map();
1266 migrate_settings(&mut initial);
1267 let seed =
1268 serde_json::to_string_pretty(&Value::Object(initial)).unwrap_or_else(|_| "{}".into());
1269 let _ = storage.with_lock(SettingsScope::Global, &mut |_| Ok(Some(seed.clone())));
1270 Self::from_storage(Box::new(storage), options)
1271 }
1272
1273 fn load_from_storage(
1274 storage: &mut dyn SettingsStorage,
1275 scope: SettingsScope,
1276 project_trusted: bool,
1277 ) -> Result<Map<String, Value>, String> {
1278 if scope == SettingsScope::Project && !project_trusted {
1279 return Ok(Map::new());
1280 }
1281 let mut content: Option<String> = None;
1282 storage.with_lock(scope, &mut |current| {
1283 content = current;
1284 Ok(None)
1285 })?;
1286 let Some(text) = content.filter(|text| !text.is_empty()) else {
1288 return Ok(Map::new());
1289 };
1290 parse_settings_text(&text)
1291 }
1292
1293 fn try_load_from_storage(
1294 storage: &mut dyn SettingsStorage,
1295 scope: SettingsScope,
1296 project_trusted: bool,
1297 ) -> (Map<String, Value>, Option<String>) {
1298 match Self::load_from_storage(storage, scope, project_trusted) {
1299 Ok(settings) => (settings, None),
1300 Err(message) => (Map::new(), Some(message)),
1301 }
1302 }
1303
1304 #[must_use]
1306 pub fn get_global_settings(&self) -> Settings {
1307 Settings::from_map(&self.global_settings)
1308 }
1309
1310 #[must_use]
1312 pub fn get_project_settings(&self) -> Settings {
1313 Settings::from_map(&self.project_settings)
1314 }
1315
1316 #[must_use]
1318 pub const fn is_project_trusted(&self) -> bool {
1319 self.project_trusted
1320 }
1321
1322 pub fn set_project_trusted(&mut self, trusted: bool) {
1324 if self.project_trusted == trusted {
1325 return;
1326 }
1327 self.project_trusted = trusted;
1328 self.modified_project_fields.clear();
1329 self.modified_project_nested_fields.clear();
1330 if !trusted {
1331 self.project_settings = Map::new();
1332 self.project_settings_load_error = None;
1333 self.settings = deep_merge_settings(&self.global_settings, &self.project_settings);
1334 return;
1335 }
1336 let (project_settings, project_error) =
1337 Self::try_load_from_storage(self.storage.as_mut(), SettingsScope::Project, true);
1338 self.project_settings = project_settings;
1339 self.project_settings_load_error.clone_from(&project_error);
1340 if let Some(message) = project_error {
1341 self.record_error(SettingsScope::Project, message);
1342 }
1343 self.settings = deep_merge_settings(&self.global_settings, &self.project_settings);
1344 }
1345
1346 pub fn reload(&mut self) {
1348 let (global_settings, global_error) =
1349 Self::try_load_from_storage(self.storage.as_mut(), SettingsScope::Global, true);
1350 match global_error {
1351 None => {
1352 self.global_settings = global_settings;
1353 self.global_settings_load_error = None;
1354 }
1355 Some(message) => {
1356 self.global_settings_load_error = Some(message.clone());
1357 self.record_error(SettingsScope::Global, message);
1358 }
1359 }
1360 self.modified_fields.clear();
1361 self.modified_nested_fields.clear();
1362 self.modified_project_fields.clear();
1363 self.modified_project_nested_fields.clear();
1364 let (project_settings, project_error) = Self::try_load_from_storage(
1365 self.storage.as_mut(),
1366 SettingsScope::Project,
1367 self.project_trusted,
1368 );
1369 match project_error {
1370 None => {
1371 self.project_settings = project_settings;
1372 self.project_settings_load_error = None;
1373 }
1374 Some(message) => {
1375 self.project_settings_load_error = Some(message.clone());
1376 self.record_error(SettingsScope::Project, message);
1377 }
1378 }
1379 self.settings = deep_merge_settings(&self.global_settings, &self.project_settings);
1380 }
1381
1382 pub fn apply_overrides(&mut self, overrides: &Map<String, Value>) {
1386 self.settings = deep_merge_settings(&self.settings, overrides);
1387 }
1388
1389 pub fn flush(&self) {
1395 let _guard = match self.write_mutex.lock() {
1396 Ok(guard) => guard,
1397 Err(poisoned) => poisoned.into_inner(),
1398 };
1399 }
1400
1401 #[must_use]
1403 pub fn drain_errors(&mut self) -> Vec<SettingsError> {
1404 std::mem::take(&mut self.errors)
1405 }
1406
1407 fn record_error(&mut self, scope: SettingsScope, message: String) {
1408 self.errors.push(SettingsError { scope, message });
1409 }
1410
1411 fn clear_modified_scope(&mut self, scope: SettingsScope) {
1412 match scope {
1413 SettingsScope::Global => {
1414 self.modified_fields.clear();
1415 self.modified_nested_fields.clear();
1416 }
1417 SettingsScope::Project => {
1418 self.modified_project_fields.clear();
1419 self.modified_project_nested_fields.clear();
1420 }
1421 }
1422 }
1423
1424 fn assert_project_trusted_for_write(&self) -> Result<(), SettingsManagerError> {
1425 if self.project_trusted {
1426 Ok(())
1427 } else {
1428 Err(SettingsManagerError::ProjectNotTrusted)
1429 }
1430 }
1431
1432 fn persist_scoped_settings(
1433 &mut self,
1434 scope: SettingsScope,
1435 snapshot_settings: &Map<String, Value>,
1436 modified_fields: &BTreeSet<String>,
1437 modified_nested_fields: &BTreeMap<String, BTreeSet<String>>,
1438 ) -> Result<(), String> {
1439 if scope == SettingsScope::Project && !self.project_trusted {
1440 return Err(SettingsManagerError::ProjectNotTrusted.to_string());
1441 }
1442 let _write_guard = match self.write_mutex.lock() {
1443 Ok(guard) => guard,
1444 Err(poisoned) => poisoned.into_inner(),
1445 };
1446 self.storage.with_lock(scope, &mut |current| {
1447 let current_file_settings = match current {
1448 Some(text) if !text.is_empty() => parse_settings_text(&text)?,
1449 _ => Map::new(),
1450 };
1451 let mut merged_settings = current_file_settings.clone();
1452 for field in modified_fields {
1453 let value = snapshot_settings.get(field);
1454 match (modified_nested_fields.get(field), value) {
1455 (Some(nested_modified), Some(Value::Object(in_memory_nested))) => {
1456 let mut merged_nested = match current_file_settings.get(field) {
1457 Some(Value::Object(base_nested)) => base_nested.clone(),
1458 _ => Map::new(),
1459 };
1460 for nested_key in nested_modified {
1461 match in_memory_nested.get(nested_key) {
1462 Some(nested_value) => {
1463 merged_nested.insert(nested_key.clone(), nested_value.clone());
1464 }
1465 None => {
1466 merged_nested.remove(nested_key);
1467 }
1468 }
1469 }
1470 merged_settings.insert(field.clone(), Value::Object(merged_nested));
1471 }
1472 _ => match value {
1473 Some(value) => {
1474 merged_settings.insert(field.clone(), value.clone());
1475 }
1476 None => {
1477 merged_settings.remove(field);
1478 }
1479 },
1480 }
1481 }
1482 serde_json::to_string_pretty(&Value::Object(merged_settings))
1483 .map(Some)
1484 .map_err(|error| error.to_string())
1485 })
1486 }
1487
1488 fn save(&mut self) {
1489 self.settings = deep_merge_settings(&self.global_settings, &self.project_settings);
1490 if self.global_settings_load_error.is_some() {
1491 return;
1492 }
1493 let snapshot = self.global_settings.clone();
1494 let modified_fields = self.modified_fields.clone();
1495 let modified_nested_fields = self.modified_nested_fields.clone();
1496 match self.persist_scoped_settings(
1497 SettingsScope::Global,
1498 &snapshot,
1499 &modified_fields,
1500 &modified_nested_fields,
1501 ) {
1502 Ok(()) => self.clear_modified_scope(SettingsScope::Global),
1503 Err(message) => self.record_error(SettingsScope::Global, message),
1504 }
1505 }
1506
1507 fn save_project_settings(
1508 &mut self,
1509 settings: Map<String, Value>,
1510 ) -> Result<(), SettingsManagerError> {
1511 self.assert_project_trusted_for_write()?;
1512 self.project_settings = settings;
1513 self.settings = deep_merge_settings(&self.global_settings, &self.project_settings);
1514 if self.project_settings_load_error.is_some() {
1515 return Ok(());
1516 }
1517 let snapshot = self.project_settings.clone();
1518 let modified_fields = self.modified_project_fields.clone();
1519 let modified_nested_fields = self.modified_project_nested_fields.clone();
1520 match self.persist_scoped_settings(
1521 SettingsScope::Project,
1522 &snapshot,
1523 &modified_fields,
1524 &modified_nested_fields,
1525 ) {
1526 Ok(()) => {
1527 self.clear_modified_scope(SettingsScope::Project);
1528 Ok(())
1529 }
1530 Err(message) => {
1531 self.record_error(SettingsScope::Project, message);
1532 Ok(())
1533 }
1534 }
1535 }
1536
1537 fn update_project_settings(
1538 &mut self,
1539 field: &'static str,
1540 update: impl FnOnce(&mut Map<String, Value>),
1541 ) -> Result<(), SettingsManagerError> {
1542 self.assert_project_trusted_for_write()?;
1543 let mut project_settings = self.project_settings.clone();
1544 update(&mut project_settings);
1545 self.modified_project_fields.insert(field.to_owned());
1546 self.save_project_settings(project_settings)
1547 }
1548
1549 fn set_global_field(&mut self, field: &'static str, value: Value) {
1550 self.global_settings.insert(field.to_owned(), value);
1551 self.modified_fields.insert(field.to_owned());
1552 self.save();
1553 }
1554
1555 fn set_global_optional_field(&mut self, field: &'static str, value: Option<Value>) {
1556 match value {
1557 Some(value) => {
1558 self.global_settings.insert(field.to_owned(), value);
1559 }
1560 None => {
1561 self.global_settings.remove(field);
1562 }
1563 }
1564 self.modified_fields.insert(field.to_owned());
1565 self.save();
1566 }
1567
1568 fn set_global_nested_field(
1569 &mut self,
1570 field: &'static str,
1571 nested_key: &'static str,
1572 value: Value,
1573 ) {
1574 let mut nested = match self.global_settings.get_mut(field) {
1575 Some(Value::Object(object)) => std::mem::take(object),
1576 _ => Map::new(),
1577 };
1578 nested.insert(nested_key.to_owned(), value);
1579 self.global_settings
1580 .insert(field.to_owned(), Value::Object(nested));
1581 self.modified_fields.insert(field.to_owned());
1582 self.modified_nested_fields
1583 .entry(field.to_owned())
1584 .or_default()
1585 .insert(nested_key.to_owned());
1586 self.save();
1587 }
1588
1589 fn merged_bool(&self, key: &str) -> Option<bool> {
1590 self.settings.get(key).and_then(Value::as_bool)
1591 }
1592
1593 fn merged_nested_bool(&self, key: &str, nested_key: &str) -> Option<bool> {
1594 self.settings
1595 .get(key)
1596 .and_then(Value::as_object)?
1597 .get(nested_key)?
1598 .as_bool()
1599 }
1600
1601 fn merged_nested_u64(&self, key: &str, nested_key: &str) -> Option<u64> {
1602 number_to_u64(
1603 self.settings
1604 .get(key)
1605 .and_then(Value::as_object)?
1606 .get(nested_key),
1607 )
1608 }
1609
1610 fn merged_string_array(&self, key: &str) -> Vec<String> {
1611 string_array(self.settings.get(key)).unwrap_or_default()
1612 }
1613
1614 #[must_use]
1618 pub fn get_last_changelog_version(&self) -> Option<String> {
1619 string_field(&self.settings, "lastChangelogVersion")
1620 }
1621
1622 pub fn set_last_changelog_version(&mut self, version: &str) {
1624 self.set_global_field("lastChangelogVersion", Value::String(version.to_owned()));
1625 }
1626
1627 #[must_use]
1629 pub fn get_session_dir(&self) -> Option<String> {
1630 normalize_optional_path(self.settings.get("sessionDir"))
1631 }
1632
1633 #[must_use]
1637 pub fn get_default_provider(&self) -> Option<String> {
1638 string_field(&self.settings, "defaultProvider")
1639 }
1640
1641 #[must_use]
1643 pub fn get_default_model(&self) -> Option<String> {
1644 string_field(&self.settings, "defaultModel")
1645 }
1646
1647 pub fn set_default_provider(&mut self, provider: &str) {
1649 self.set_global_field("defaultProvider", Value::String(provider.to_owned()));
1650 }
1651
1652 pub fn set_default_model(&mut self, model_id: &str) {
1654 self.set_global_field("defaultModel", Value::String(model_id.to_owned()));
1655 }
1656
1657 pub fn set_default_model_and_provider(&mut self, provider: &str, model_id: &str) {
1659 self.global_settings.insert(
1660 "defaultProvider".to_owned(),
1661 Value::String(provider.to_owned()),
1662 );
1663 self.global_settings.insert(
1664 "defaultModel".to_owned(),
1665 Value::String(model_id.to_owned()),
1666 );
1667 self.modified_fields.insert("defaultProvider".to_owned());
1668 self.modified_fields.insert("defaultModel".to_owned());
1669 self.save();
1670 }
1671
1672 #[must_use]
1674 pub fn get_default_thinking_level(&self) -> Option<ModelThinkingLevel> {
1675 parse_thinking_level(self.settings.get("defaultThinkingLevel"))
1676 }
1677
1678 pub fn set_default_thinking_level(&mut self, level: ModelThinkingLevel) {
1680 self.set_global_field("defaultThinkingLevel", thinking_level_value(level));
1681 }
1682
1683 #[must_use]
1685 pub fn get_enabled_models(&self) -> Option<Vec<String>> {
1686 string_array(self.settings.get("enabledModels"))
1687 }
1688
1689 pub fn set_enabled_models(&mut self, patterns: Option<Vec<String>>) {
1691 self.set_global_optional_field(
1692 "enabledModels",
1693 patterns
1694 .map(|patterns| Value::Array(patterns.into_iter().map(Value::String).collect())),
1695 );
1696 }
1697
1698 #[must_use]
1700 pub fn get_thinking_budgets(&self) -> Option<ThinkingBudgetsSettings> {
1701 nested_field(
1702 &self.settings,
1703 "thinkingBudgets",
1704 ThinkingBudgetsSettings::from_map,
1705 )
1706 }
1707
1708 #[must_use]
1712 pub fn get_steering_mode(&self) -> QueueMode {
1713 parse_queue_mode(self.settings.get("steeringMode")).unwrap_or_default()
1714 }
1715
1716 pub fn set_steering_mode(&mut self, mode: QueueMode) {
1718 self.set_global_field("steeringMode", queue_mode_value(mode));
1719 }
1720
1721 #[must_use]
1723 pub fn get_follow_up_mode(&self) -> QueueMode {
1724 parse_queue_mode(self.settings.get("followUpMode")).unwrap_or_default()
1725 }
1726
1727 pub fn set_follow_up_mode(&mut self, mode: QueueMode) {
1729 self.set_global_field("followUpMode", queue_mode_value(mode));
1730 }
1731
1732 #[must_use]
1734 pub fn get_transport(&self) -> Transport {
1735 parse_transport(self.settings.get("transport")).unwrap_or(Transport::Auto)
1736 }
1737
1738 pub fn set_transport(&mut self, transport: Transport) {
1740 self.set_global_field("transport", transport_value(transport));
1741 }
1742
1743 #[must_use]
1747 pub fn get_theme_setting(&self) -> Option<String> {
1748 string_field(&self.settings, "theme")
1749 }
1750
1751 #[must_use]
1753 pub fn get_theme(&self) -> Option<String> {
1754 self.get_theme_setting()
1755 .filter(|theme| !theme.contains('/'))
1756 }
1757
1758 pub fn set_theme(&mut self, theme: &str) {
1760 self.set_global_field("theme", Value::String(theme.to_owned()));
1761 }
1762
1763 #[must_use]
1765 pub fn get_theme_paths(&self) -> Vec<String> {
1766 self.merged_string_array("themes")
1767 }
1768
1769 pub fn set_theme_paths(&mut self, paths: Vec<String>) {
1771 self.set_global_field(
1772 "themes",
1773 Value::Array(paths.into_iter().map(Value::String).collect()),
1774 );
1775 }
1776
1777 pub fn set_project_theme_paths(
1783 &mut self,
1784 paths: Vec<String>,
1785 ) -> Result<(), SettingsManagerError> {
1786 self.update_project_settings("themes", |settings| {
1787 settings.insert(
1788 "themes".to_owned(),
1789 Value::Array(paths.into_iter().map(Value::String).collect()),
1790 );
1791 })
1792 }
1793
1794 #[must_use]
1798 pub fn get_compaction_enabled(&self) -> bool {
1799 self.merged_nested_bool("compaction", "enabled")
1800 .unwrap_or(true)
1801 }
1802
1803 pub fn set_compaction_enabled(&mut self, enabled: bool) {
1805 self.set_global_nested_field("compaction", "enabled", Value::Bool(enabled));
1806 }
1807
1808 #[must_use]
1810 pub fn get_compaction_reserve_tokens(&self) -> u64 {
1811 self.merged_nested_u64("compaction", "reserveTokens")
1812 .unwrap_or(DEFAULT_COMPACTION_RESERVE_TOKENS)
1813 }
1814
1815 #[must_use]
1817 pub fn get_compaction_keep_recent_tokens(&self) -> u64 {
1818 self.merged_nested_u64("compaction", "keepRecentTokens")
1819 .unwrap_or(DEFAULT_COMPACTION_KEEP_RECENT_TOKENS)
1820 }
1821
1822 #[must_use]
1824 pub fn get_compaction_settings(&self) -> ResolvedCompactionSettings {
1825 ResolvedCompactionSettings {
1826 enabled: self.get_compaction_enabled(),
1827 reserve_tokens: self.get_compaction_reserve_tokens(),
1828 keep_recent_tokens: self.get_compaction_keep_recent_tokens(),
1829 }
1830 }
1831
1832 #[must_use]
1834 pub fn get_branch_summary_settings(&self) -> ResolvedBranchSummarySettings {
1835 ResolvedBranchSummarySettings {
1836 reserve_tokens: self
1837 .merged_nested_u64("branchSummary", "reserveTokens")
1838 .unwrap_or(DEFAULT_BRANCH_SUMMARY_RESERVE_TOKENS),
1839 skip_prompt: self.get_branch_summary_skip_prompt(),
1840 }
1841 }
1842
1843 #[must_use]
1845 pub fn get_branch_summary_skip_prompt(&self) -> bool {
1846 self.merged_nested_bool("branchSummary", "skipPrompt")
1847 .unwrap_or(false)
1848 }
1849
1850 #[must_use]
1854 pub fn get_retry_enabled(&self) -> bool {
1855 self.merged_nested_bool("retry", "enabled").unwrap_or(true)
1856 }
1857
1858 pub fn set_retry_enabled(&mut self, enabled: bool) {
1860 self.set_global_nested_field("retry", "enabled", Value::Bool(enabled));
1861 }
1862
1863 #[must_use]
1865 pub fn get_retry_settings(&self) -> ResolvedRetrySettings {
1866 ResolvedRetrySettings {
1867 enabled: self.get_retry_enabled(),
1868 max_retries: self
1869 .merged_nested_u64("retry", "maxRetries")
1870 .unwrap_or(DEFAULT_RETRY_MAX_RETRIES),
1871 base_delay_ms: self
1872 .merged_nested_u64("retry", "baseDelayMs")
1873 .unwrap_or(DEFAULT_RETRY_BASE_DELAY_MS),
1874 }
1875 }
1876
1877 #[must_use]
1879 pub fn get_provider_retry_settings(&self) -> ResolvedProviderRetrySettings {
1880 let provider = self
1881 .settings
1882 .get("retry")
1883 .and_then(Value::as_object)
1884 .and_then(|retry| retry.get("provider").and_then(Value::as_object));
1885 ResolvedProviderRetrySettings {
1886 timeout_ms: provider.and_then(|p| number_to_u64(p.get("timeoutMs"))),
1887 max_retries: provider.and_then(|p| number_to_u64(p.get("maxRetries"))),
1888 max_retry_delay_ms: provider
1889 .and_then(|p| number_to_u64(p.get("maxRetryDelayMs")))
1890 .unwrap_or(DEFAULT_PROVIDER_MAX_RETRY_DELAY_MS),
1891 }
1892 }
1893
1894 pub fn get_http_idle_timeout_ms(&self) -> Result<u64, SettingsManagerError> {
1901 Ok(
1902 parse_timeout_setting(self.settings.get("httpIdleTimeoutMs"), "httpIdleTimeoutMs")?
1903 .unwrap_or(DEFAULT_HTTP_IDLE_TIMEOUT_MS),
1904 )
1905 }
1906
1907 pub fn set_http_idle_timeout_ms(
1913 &mut self,
1914 timeout_ms: f64,
1915 ) -> Result<(), SettingsManagerError> {
1916 if !timeout_ms.is_finite() || timeout_ms < 0.0 {
1917 return Err(SettingsManagerError::InvalidSetting {
1918 setting: "httpIdleTimeoutMs",
1919 value: js_number_to_string(timeout_ms),
1920 });
1921 }
1922 self.set_global_field("httpIdleTimeoutMs", json_floored_number(timeout_ms));
1923 Ok(())
1924 }
1925
1926 pub fn get_web_socket_connect_timeout_ms(&self) -> Result<Option<u64>, SettingsManagerError> {
1933 parse_timeout_setting(
1934 self.settings.get("websocketConnectTimeoutMs"),
1935 "websocketConnectTimeoutMs",
1936 )
1937 }
1938
1939 #[must_use]
1943 pub fn get_hide_thinking_block(&self) -> bool {
1944 self.merged_bool("hideThinkingBlock").unwrap_or(false)
1945 }
1946
1947 #[must_use]
1949 pub fn get_show_cache_miss_notices(&self) -> bool {
1950 self.merged_bool("showCacheMissNotices").unwrap_or(false)
1951 }
1952
1953 #[must_use]
1955 pub fn get_external_editor_command(&self) -> String {
1956 if let Some(configured) = string_field(&self.settings, "externalEditor")
1957 && !configured.trim().is_empty()
1958 {
1959 return configured;
1960 }
1961 if let Some(editor) = env::var("VISUAL").ok().filter(|value| !value.is_empty()) {
1962 return editor;
1963 }
1964 if let Some(editor) = env::var("EDITOR").ok().filter(|value| !value.is_empty()) {
1965 return editor;
1966 }
1967 if cfg!(windows) {
1968 "notepad".to_owned()
1969 } else {
1970 "nano".to_owned()
1971 }
1972 }
1973
1974 pub fn set_hide_thinking_block(&mut self, hide: bool) {
1976 self.set_global_field("hideThinkingBlock", Value::Bool(hide));
1977 }
1978
1979 pub fn set_show_cache_miss_notices(&mut self, show: bool) {
1981 self.set_global_field("showCacheMissNotices", Value::Bool(show));
1982 }
1983
1984 #[must_use]
1986 pub fn get_quiet_startup(&self) -> bool {
1987 self.merged_bool("quietStartup").unwrap_or(false)
1988 }
1989
1990 pub fn set_quiet_startup(&mut self, quiet: bool) {
1992 self.set_global_field("quietStartup", Value::Bool(quiet));
1993 }
1994
1995 #[must_use]
1997 pub fn get_double_escape_action(&self) -> DoubleEscapeAction {
1998 self.settings
1999 .get("doubleEscapeAction")
2000 .and_then(Value::as_str)
2001 .and_then(DoubleEscapeAction::parse)
2002 .unwrap_or_default()
2003 }
2004
2005 pub fn set_double_escape_action(&mut self, action: DoubleEscapeAction) {
2007 self.set_global_field(
2008 "doubleEscapeAction",
2009 Value::String(action.as_str().to_owned()),
2010 );
2011 }
2012
2013 #[must_use]
2015 pub fn get_tree_filter_mode(&self) -> TreeFilterMode {
2016 self.settings
2017 .get("treeFilterMode")
2018 .and_then(Value::as_str)
2019 .and_then(TreeFilterMode::parse)
2020 .unwrap_or_default()
2021 }
2022
2023 pub fn set_tree_filter_mode(&mut self, mode: TreeFilterMode) {
2025 self.set_global_field("treeFilterMode", Value::String(mode.as_str().to_owned()));
2026 }
2027
2028 #[must_use]
2030 pub fn get_show_hardware_cursor(&self) -> bool {
2031 self.merged_bool("showHardwareCursor")
2032 .unwrap_or_else(|| env_flag("PI_HARDWARE_CURSOR"))
2033 }
2034
2035 pub fn set_show_hardware_cursor(&mut self, enabled: bool) {
2037 self.set_global_field("showHardwareCursor", Value::Bool(enabled));
2038 }
2039
2040 #[must_use]
2042 pub fn get_editor_padding_x(&self) -> u64 {
2043 number_to_u64(self.settings.get("editorPaddingX")).unwrap_or(0)
2044 }
2045
2046 pub fn set_editor_padding_x(&mut self, padding: f64) {
2048 self.set_global_field(
2049 "editorPaddingX",
2050 js_clamped_floored_number(padding, 0.0, 3.0),
2051 );
2052 }
2053
2054 #[must_use]
2056 pub fn get_output_pad(&self) -> OutputPad {
2057 let is_zero = self
2058 .settings
2059 .get("outputPad")
2060 .and_then(Value::as_f64)
2061 .is_some_and(|value| js_number_eq(value, 0.0));
2062 if is_zero {
2063 OutputPad::Zero
2064 } else {
2065 OutputPad::One
2066 }
2067 }
2068
2069 pub fn set_output_pad(&mut self, padding: OutputPad) {
2071 self.set_global_field("outputPad", Value::from(padding.as_u64()));
2072 }
2073
2074 #[must_use]
2076 pub fn get_autocomplete_max_visible(&self) -> u64 {
2077 number_to_u64(self.settings.get("autocompleteMaxVisible"))
2078 .unwrap_or(DEFAULT_AUTOCOMPLETE_MAX_VISIBLE)
2079 }
2080
2081 pub fn set_autocomplete_max_visible(&mut self, max_visible: f64) {
2083 self.set_global_field(
2084 "autocompleteMaxVisible",
2085 js_clamped_floored_number(max_visible, 3.0, 20.0),
2086 );
2087 }
2088
2089 #[must_use]
2093 pub fn get_shell_path(&self) -> Option<String> {
2094 normalize_optional_path(self.settings.get("shellPath"))
2095 }
2096
2097 pub fn set_shell_path(&mut self, path: Option<String>) {
2099 self.set_global_optional_field("shellPath", path.map(Value::String));
2100 }
2101
2102 #[must_use]
2104 pub fn get_shell_command_prefix(&self) -> Option<String> {
2105 string_field(&self.settings, "shellCommandPrefix")
2106 }
2107
2108 pub fn set_shell_command_prefix(&mut self, prefix: Option<String>) {
2110 self.set_global_optional_field("shellCommandPrefix", prefix.map(Value::String));
2111 }
2112
2113 #[must_use]
2115 pub fn get_npm_command(&self) -> Option<Vec<String>> {
2116 string_array(self.settings.get("npmCommand"))
2117 }
2118
2119 pub fn set_npm_command(&mut self, command: Option<Vec<String>>) {
2121 self.set_global_optional_field(
2122 "npmCommand",
2123 command.map(|command| Value::Array(command.into_iter().map(Value::String).collect())),
2124 );
2125 }
2126
2127 #[must_use]
2131 pub fn get_default_project_trust(&self) -> DefaultProjectTrust {
2132 DefaultProjectTrust::parse(
2133 self.global_settings
2134 .get("defaultProjectTrust")
2135 .and_then(Value::as_str),
2136 )
2137 }
2138
2139 pub fn set_default_project_trust(&mut self, default_project_trust: DefaultProjectTrust) {
2141 self.set_global_field(
2142 "defaultProjectTrust",
2143 Value::String(default_project_trust.as_str().to_owned()),
2144 );
2145 }
2146
2147 #[must_use]
2149 pub fn get_collapse_changelog(&self) -> bool {
2150 self.merged_bool("collapseChangelog").unwrap_or(false)
2151 }
2152
2153 pub fn set_collapse_changelog(&mut self, collapse: bool) {
2155 self.set_global_field("collapseChangelog", Value::Bool(collapse));
2156 }
2157
2158 #[must_use]
2160 pub fn get_enable_install_telemetry(&self) -> bool {
2161 self.merged_bool("enableInstallTelemetry").unwrap_or(true)
2162 }
2163
2164 pub fn set_enable_install_telemetry(&mut self, enabled: bool) {
2166 self.set_global_field("enableInstallTelemetry", Value::Bool(enabled));
2167 }
2168
2169 #[must_use]
2171 pub fn get_enable_analytics(&self) -> bool {
2172 self.merged_bool("enableAnalytics").unwrap_or(false)
2173 }
2174
2175 #[must_use]
2177 pub fn get_tracking_id(&self) -> Option<String> {
2178 string_field(&self.settings, "trackingId")
2179 }
2180
2181 pub fn set_enable_analytics(&mut self, enabled: bool) {
2183 self.global_settings
2184 .insert("enableAnalytics".to_owned(), Value::Bool(enabled));
2185 self.modified_fields.insert("enableAnalytics".to_owned());
2186 let has_tracking_id = self
2187 .global_settings
2188 .get("trackingId")
2189 .is_some_and(js_truthy);
2190 if enabled && !has_tracking_id {
2191 self.global_settings.insert(
2192 "trackingId".to_owned(),
2193 Value::String(Uuid::new_v4().to_string()),
2194 );
2195 self.modified_fields.insert("trackingId".to_owned());
2196 }
2197 self.save();
2198 }
2199
2200 #[must_use]
2204 pub fn get_packages(&self) -> Vec<PackageSource> {
2205 self.settings
2206 .get("packages")
2207 .and_then(Value::as_array)
2208 .map(|items| items.iter().filter_map(PackageSource::from_value).collect())
2209 .unwrap_or_default()
2210 }
2211
2212 pub fn set_packages(&mut self, packages: &[PackageSource]) {
2214 self.set_global_field(
2215 "packages",
2216 Value::Array(packages.iter().map(PackageSource::to_value).collect()),
2217 );
2218 }
2219
2220 pub fn set_project_packages(
2226 &mut self,
2227 packages: &[PackageSource],
2228 ) -> Result<(), SettingsManagerError> {
2229 self.update_project_settings("packages", |settings| {
2230 settings.insert(
2231 "packages".to_owned(),
2232 Value::Array(packages.iter().map(PackageSource::to_value).collect()),
2233 );
2234 })
2235 }
2236
2237 #[must_use]
2239 pub fn get_extension_paths(&self) -> Vec<String> {
2240 self.merged_string_array("extensions")
2241 }
2242
2243 pub fn set_extension_paths(&mut self, paths: Vec<String>) {
2245 self.set_global_field(
2246 "extensions",
2247 Value::Array(paths.into_iter().map(Value::String).collect()),
2248 );
2249 }
2250
2251 pub fn set_project_extension_paths(
2257 &mut self,
2258 paths: Vec<String>,
2259 ) -> Result<(), SettingsManagerError> {
2260 self.update_project_settings("extensions", |settings| {
2261 settings.insert(
2262 "extensions".to_owned(),
2263 Value::Array(paths.into_iter().map(Value::String).collect()),
2264 );
2265 })
2266 }
2267
2268 #[must_use]
2270 pub fn get_skill_paths(&self) -> Vec<String> {
2271 self.merged_string_array("skills")
2272 }
2273
2274 pub fn set_skill_paths(&mut self, paths: Vec<String>) {
2276 self.set_global_field(
2277 "skills",
2278 Value::Array(paths.into_iter().map(Value::String).collect()),
2279 );
2280 }
2281
2282 pub fn set_project_skill_paths(
2288 &mut self,
2289 paths: Vec<String>,
2290 ) -> Result<(), SettingsManagerError> {
2291 self.update_project_settings("skills", |settings| {
2292 settings.insert(
2293 "skills".to_owned(),
2294 Value::Array(paths.into_iter().map(Value::String).collect()),
2295 );
2296 })
2297 }
2298
2299 #[must_use]
2301 pub fn get_prompt_template_paths(&self) -> Vec<String> {
2302 self.merged_string_array("prompts")
2303 }
2304
2305 pub fn set_prompt_template_paths(&mut self, paths: Vec<String>) {
2307 self.set_global_field(
2308 "prompts",
2309 Value::Array(paths.into_iter().map(Value::String).collect()),
2310 );
2311 }
2312
2313 pub fn set_project_prompt_template_paths(
2319 &mut self,
2320 paths: Vec<String>,
2321 ) -> Result<(), SettingsManagerError> {
2322 self.update_project_settings("prompts", |settings| {
2323 settings.insert(
2324 "prompts".to_owned(),
2325 Value::Array(paths.into_iter().map(Value::String).collect()),
2326 );
2327 })
2328 }
2329
2330 #[must_use]
2332 pub fn get_enable_skill_commands(&self) -> bool {
2333 self.merged_bool("enableSkillCommands").unwrap_or(true)
2334 }
2335
2336 pub fn set_enable_skill_commands(&mut self, enabled: bool) {
2338 self.set_global_field("enableSkillCommands", Value::Bool(enabled));
2339 }
2340
2341 #[must_use]
2345 pub fn get_show_images(&self) -> bool {
2346 self.merged_nested_bool("terminal", "showImages")
2347 .unwrap_or(true)
2348 }
2349
2350 pub fn set_show_images(&mut self, show: bool) {
2352 self.set_global_nested_field("terminal", "showImages", Value::Bool(show));
2353 }
2354
2355 #[must_use]
2357 pub fn get_image_width_cells(&self) -> u64 {
2358 let value = self
2359 .settings
2360 .get("terminal")
2361 .and_then(Value::as_object)
2362 .and_then(|terminal| terminal.get("imageWidthCells"));
2363 let Some(number) = value.and_then(Value::as_f64) else {
2364 return DEFAULT_IMAGE_WIDTH_CELLS;
2365 };
2366 if !number.is_finite() {
2367 return DEFAULT_IMAGE_WIDTH_CELLS;
2368 }
2369 floor_max_to_u64(number, 1)
2370 }
2371
2372 pub fn set_image_width_cells(&mut self, width: f64) {
2374 self.set_global_nested_field(
2375 "terminal",
2376 "imageWidthCells",
2377 js_min_floored_number(width, 1.0),
2378 );
2379 }
2380
2381 #[must_use]
2383 pub fn get_clear_on_shrink(&self) -> bool {
2384 self.merged_nested_bool("terminal", "clearOnShrink")
2385 .unwrap_or_else(|| env_flag("PI_CLEAR_ON_SHRINK"))
2386 }
2387
2388 pub fn set_clear_on_shrink(&mut self, enabled: bool) {
2390 self.set_global_nested_field("terminal", "clearOnShrink", Value::Bool(enabled));
2391 }
2392
2393 #[must_use]
2395 pub fn get_show_terminal_progress(&self) -> bool {
2396 self.merged_nested_bool("terminal", "showTerminalProgress")
2397 .unwrap_or(false)
2398 }
2399
2400 pub fn set_show_terminal_progress(&mut self, enabled: bool) {
2402 self.set_global_nested_field("terminal", "showTerminalProgress", Value::Bool(enabled));
2403 }
2404
2405 #[must_use]
2407 pub fn get_image_auto_resize(&self) -> bool {
2408 self.merged_nested_bool("images", "autoResize")
2409 .unwrap_or(true)
2410 }
2411
2412 pub fn set_image_auto_resize(&mut self, enabled: bool) {
2414 self.set_global_nested_field("images", "autoResize", Value::Bool(enabled));
2415 }
2416
2417 #[must_use]
2419 pub fn get_block_images(&self) -> bool {
2420 self.merged_nested_bool("images", "blockImages")
2421 .unwrap_or(false)
2422 }
2423
2424 pub fn set_block_images(&mut self, blocked: bool) {
2426 self.set_global_nested_field("images", "blockImages", Value::Bool(blocked));
2427 }
2428
2429 #[must_use]
2433 pub fn get_code_block_indent(&self) -> String {
2434 self.settings
2435 .get("markdown")
2436 .and_then(Value::as_object)
2437 .and_then(|markdown| markdown.get("codeBlockIndent"))
2438 .and_then(Value::as_str)
2439 .map_or_else(|| DEFAULT_CODE_BLOCK_INDENT.to_owned(), str::to_owned)
2440 }
2441
2442 #[must_use]
2444 pub fn get_warnings(&self) -> WarningSettings {
2445 nested_field(&self.settings, "warnings", WarningSettings::from_map).unwrap_or_default()
2446 }
2447
2448 pub fn set_warnings(&mut self, warnings: &WarningSettings) {
2450 self.set_global_field("warnings", Value::Object(warnings.to_map()));
2451 }
2452}
2453
2454fn path_to_string(path: &Path) -> String {
2459 path.to_string_lossy().into_owned()
2460}
2461
2462fn env_flag(name: &str) -> bool {
2463 env::var(name).is_ok_and(|value| value == "1")
2464}
2465
2466fn string_field(map: &Map<String, Value>, key: &str) -> Option<String> {
2467 map.get(key).and_then(Value::as_str).map(str::to_owned)
2468}
2469
2470fn bool_field(map: &Map<String, Value>, key: &str) -> Option<bool> {
2471 map.get(key).and_then(Value::as_bool)
2472}
2473
2474fn nested_field<T>(
2475 map: &Map<String, Value>,
2476 key: &str,
2477 from: impl FnOnce(&Map<String, Value>) -> T,
2478) -> Option<T> {
2479 map.get(key).and_then(Value::as_object).map(from)
2480}
2481
2482fn string_array(value: Option<&Value>) -> Option<Vec<String>> {
2483 value.and_then(Value::as_array).map(|items| {
2484 items
2485 .iter()
2486 .filter_map(|item| item.as_str().map(str::to_owned))
2487 .collect()
2488 })
2489}
2490
2491fn unknown_fields(map: &Map<String, Value>, known: &[&str]) -> Map<String, Value> {
2492 map.iter()
2493 .filter(|(key, _)| !known.contains(&key.as_str()))
2494 .map(|(key, value)| (key.clone(), value.clone()))
2495 .collect()
2496}
2497
2498fn insert_opt_value(map: &mut Map<String, Value>, key: &str, value: Option<Value>) {
2499 if let Some(value) = value {
2500 map.insert(key.to_owned(), value);
2501 }
2502}
2503
2504fn insert_opt_string(map: &mut Map<String, Value>, key: &str, value: Option<&str>) {
2505 insert_opt_value(map, key, value.map(|text| Value::String(text.to_owned())));
2506}
2507
2508fn insert_opt_bool(map: &mut Map<String, Value>, key: &str, value: Option<bool>) {
2509 insert_opt_value(map, key, value.map(Value::Bool));
2510}
2511
2512fn insert_opt_u64(map: &mut Map<String, Value>, key: &str, value: Option<u64>) {
2513 insert_opt_value(map, key, value.map(Value::from));
2514}
2515
2516fn insert_opt_strings(map: &mut Map<String, Value>, key: &str, values: Option<&[String]>) {
2517 insert_opt_value(
2518 map,
2519 key,
2520 values.map(|values| Value::Array(values.iter().cloned().map(Value::String).collect())),
2521 );
2522}
2523
2524fn number_to_u64(value: Option<&Value>) -> Option<u64> {
2525 let value = value?;
2526 if let Some(unsigned) = value.as_u64() {
2527 return Some(unsigned);
2528 }
2529 let number = value.as_f64()?;
2530 finite_floor_to_u64(number)
2531}
2532
2533fn js_number_eq(lhs: f64, rhs: f64) -> bool {
2534 lhs.partial_cmp(&rhs) == Some(std::cmp::Ordering::Equal)
2535}
2536
2537fn js_truthy(value: &Value) -> bool {
2538 match value {
2539 Value::Null => false,
2540 Value::Bool(flag) => *flag,
2541 Value::Number(number) => number.as_f64().is_some_and(|n| !js_number_eq(n, 0.0)),
2542 Value::String(text) => !text.is_empty(),
2543 Value::Array(_) | Value::Object(_) => true,
2544 }
2545}
2546
2547fn js_number_to_string(value: f64) -> String {
2548 if value.is_nan() {
2549 return "NaN".to_owned();
2550 }
2551 if value.is_infinite() {
2552 return if value.is_sign_positive() {
2553 "Infinity".to_owned()
2554 } else {
2555 "-Infinity".to_owned()
2556 };
2557 }
2558 if js_number_eq(value, 0.0) {
2559 return "0".to_owned();
2560 }
2561 format!("{value}")
2562}
2563
2564fn js_value_to_string(value: &Value) -> String {
2565 match value {
2566 Value::Null => "null".to_owned(),
2567 Value::Bool(flag) => flag.to_string(),
2568 Value::Number(number) => {
2569 if let Some(signed) = number.as_i64() {
2570 signed.to_string()
2571 } else if let Some(unsigned) = number.as_u64() {
2572 unsigned.to_string()
2573 } else {
2574 number
2575 .as_f64()
2576 .map_or_else(|| "0".to_owned(), js_number_to_string)
2577 }
2578 }
2579 Value::String(text) => text.clone(),
2580 Value::Array(items) => items
2581 .iter()
2582 .map(|item| match item {
2583 Value::Null => String::new(),
2584 other => js_value_to_string(other),
2585 })
2586 .collect::<Vec<_>>()
2587 .join(","),
2588 Value::Object(_) => "[object Object]".to_owned(),
2589 }
2590}
2591
2592fn json_floored_number(value: f64) -> Value {
2596 let floored = value.floor();
2597 if !floored.is_finite() {
2598 return Value::Null;
2599 }
2600 if floored >= 0.0 {
2601 match finite_floor_to_u64(floored) {
2602 Some(unsigned) => Value::from(unsigned),
2603 None => Value::Null,
2604 }
2605 } else {
2606 match finite_floor_to_i64(floored) {
2607 Some(signed) => Value::from(signed),
2608 None => Value::Null,
2609 }
2610 }
2611}
2612
2613fn js_clamped_floored_number(value: f64, min: f64, max: f64) -> Value {
2614 let floored = value.floor();
2615 if floored.is_nan() {
2616 return Value::Null;
2617 }
2618 let clamped = floored.clamp(min, max);
2620 json_floored_number(clamped)
2621}
2622
2623fn js_min_floored_number(value: f64, min: f64) -> Value {
2624 let floored = value.floor();
2625 if floored.is_nan() {
2626 return Value::Null;
2627 }
2628 if !floored.is_finite() {
2629 return if floored.is_sign_positive() {
2631 Value::Null
2632 } else {
2633 json_floored_number(min)
2634 };
2635 }
2636 json_floored_number(floored.max(min))
2637}
2638
2639fn floor_max_to_u64(value: f64, min: u64) -> u64 {
2640 let Ok(min_f) = min.to_string().parse::<f64>() else {
2641 return min;
2642 };
2643 let floored = value.floor().max(min_f);
2644 match finite_floor_to_u64(floored) {
2645 Some(result) => result,
2646 None => min,
2647 }
2648}
2649
2650fn parse_timeout_ms(value: &Value) -> Option<u64> {
2651 match value {
2652 Value::String(text) => {
2653 let trimmed = text.trim();
2654 if trimmed.eq_ignore_ascii_case("disabled") {
2655 return Some(0);
2656 }
2657 if trimmed.is_empty() {
2658 return None;
2659 }
2660 js_number_from_string(trimmed).and_then(finite_floor_to_u64)
2661 }
2662 Value::Number(number) => number.as_f64().and_then(finite_floor_to_u64),
2663 _ => None,
2664 }
2665}
2666
2667fn parse_timeout_setting(
2668 value: Option<&Value>,
2669 setting: &'static str,
2670) -> Result<Option<u64>, SettingsManagerError> {
2671 match value {
2672 None => Ok(None),
2673 Some(value) => match parse_timeout_ms(value) {
2674 Some(ms) => Ok(Some(ms)),
2675 None => Err(SettingsManagerError::InvalidSetting {
2676 setting,
2677 value: js_value_to_string(value),
2678 }),
2679 },
2680 }
2681}
2682
2683fn finite_floor_to_u64(value: f64) -> Option<u64> {
2685 if !value.is_finite() || value < 0.0 {
2686 return None;
2687 }
2688 let floored = value.floor();
2689 if floored >= U64_MAX_F64 {
2690 return Some(u64::MAX);
2691 }
2692 let text = format!("{floored:.0}");
2694 text.parse::<u64>().ok()
2695}
2696
2697fn finite_floor_to_i64(value: f64) -> Option<i64> {
2699 if !value.is_finite() {
2700 return None;
2701 }
2702 let floored = value.floor();
2703 if floored < I64_MIN_F64 {
2705 return Some(i64::MIN);
2706 }
2707 if floored > 0.0 {
2708 let text = format!("{floored:.0}");
2710 return text.parse::<i64>().ok();
2711 }
2712 let text = format!("{floored:.0}");
2713 text.parse::<i64>().ok()
2714}
2715
2716fn js_number_from_string(text: &str) -> Option<f64> {
2717 if text.is_empty() {
2718 return Some(0.0);
2719 }
2720 let (negative, rest) = match text.strip_prefix('-') {
2721 Some(rest) => (true, rest),
2722 None => (false, text.strip_prefix('+').unwrap_or(text)),
2723 };
2724 let magnitude = if rest == "Infinity" {
2725 Some(f64::INFINITY)
2726 } else if let Some(digits) = rest.strip_prefix("0x").or_else(|| rest.strip_prefix("0X")) {
2727 parse_radix(digits, 16)
2728 } else if let Some(digits) = rest.strip_prefix("0o").or_else(|| rest.strip_prefix("0O")) {
2729 parse_radix(digits, 8)
2730 } else if let Some(digits) = rest.strip_prefix("0b").or_else(|| rest.strip_prefix("0B")) {
2731 parse_radix(digits, 2)
2732 } else if is_js_decimal(rest) {
2733 rest.parse::<f64>().ok()
2734 } else {
2735 None
2736 }?;
2737 Some(if negative { -magnitude } else { magnitude })
2738}
2739
2740fn parse_radix(digits: &str, radix: u32) -> Option<f64> {
2742 if digits.is_empty() {
2743 return None;
2744 }
2745 let mut value = 0.0_f64;
2746 let radix_f = f64::from(radix);
2747 for ch in digits.chars() {
2748 let digit = ch.to_digit(radix)?;
2749 value = value.mul_add(radix_f, f64::from(digit));
2750 }
2751 Some(value)
2752}
2753
2754fn is_js_decimal(text: &str) -> bool {
2755 let (mantissa, exponent) = match text.find(['e', 'E']) {
2756 Some(index) => (&text[..index], Some(&text[index + 1..])),
2757 None => (text, None),
2758 };
2759 if let Some(exp) = exponent {
2760 let exp_digits = exp.strip_prefix(['+', '-']).unwrap_or(exp);
2761 if exp_digits.is_empty() || !exp_digits.bytes().all(|b| b.is_ascii_digit()) {
2762 return false;
2763 }
2764 }
2765 let mut seen_digit = false;
2766 let mut seen_dot = false;
2767 for ch in mantissa.chars() {
2768 if ch == '.' {
2769 if seen_dot {
2770 return false;
2771 }
2772 seen_dot = true;
2773 } else if ch.is_ascii_digit() {
2774 seen_digit = true;
2775 } else {
2776 return false;
2777 }
2778 }
2779 seen_digit
2780}
2781
2782fn parse_thinking_level(value: Option<&Value>) -> Option<ModelThinkingLevel> {
2783 match value?.as_str()? {
2784 "off" => Some(ModelThinkingLevel::Off),
2785 "minimal" => Some(ModelThinkingLevel::Minimal),
2786 "low" => Some(ModelThinkingLevel::Low),
2787 "medium" => Some(ModelThinkingLevel::Medium),
2788 "high" => Some(ModelThinkingLevel::High),
2789 "xhigh" => Some(ModelThinkingLevel::Xhigh),
2790 "max" => Some(ModelThinkingLevel::Max),
2791 _ => None,
2792 }
2793}
2794
2795fn thinking_level_value(level: ModelThinkingLevel) -> Value {
2796 Value::String(
2797 match level {
2798 ModelThinkingLevel::Off => "off",
2799 ModelThinkingLevel::Minimal => "minimal",
2800 ModelThinkingLevel::Low => "low",
2801 ModelThinkingLevel::Medium => "medium",
2802 ModelThinkingLevel::High => "high",
2803 ModelThinkingLevel::Xhigh => "xhigh",
2804 ModelThinkingLevel::Max => "max",
2805 }
2806 .to_owned(),
2807 )
2808}
2809
2810fn parse_transport(value: Option<&Value>) -> Option<Transport> {
2811 match value?.as_str()? {
2812 "sse" => Some(Transport::Sse),
2813 "websocket" => Some(Transport::Websocket),
2814 "websocket-cached" => Some(Transport::WebsocketCached),
2815 "auto" => Some(Transport::Auto),
2816 _ => None,
2817 }
2818}
2819
2820fn transport_value(transport: Transport) -> Value {
2821 Value::String(
2822 match transport {
2823 Transport::Sse => "sse",
2824 Transport::Websocket => "websocket",
2825 Transport::WebsocketCached => "websocket-cached",
2826 Transport::Auto => "auto",
2827 }
2828 .to_owned(),
2829 )
2830}
2831
2832fn parse_queue_mode(value: Option<&Value>) -> Option<QueueMode> {
2833 match value?.as_str()? {
2834 "all" => Some(QueueMode::All),
2835 "one-at-a-time" => Some(QueueMode::OneAtATime),
2836 _ => None,
2837 }
2838}
2839
2840fn queue_mode_value(mode: QueueMode) -> Value {
2841 Value::String(
2842 match mode {
2843 QueueMode::All => "all",
2844 QueueMode::OneAtATime => "one-at-a-time",
2845 }
2846 .to_owned(),
2847 )
2848}
2849
2850fn parse_default_project_trust(value: Option<&Value>) -> Option<DefaultProjectTrust> {
2851 match value?.as_str()? {
2852 "ask" => Some(DefaultProjectTrust::Ask),
2853 "always" => Some(DefaultProjectTrust::Always),
2854 "never" => Some(DefaultProjectTrust::Never),
2855 _ => None,
2856 }
2857}
2858
2859fn parse_output_pad(value: Option<&Value>) -> Option<OutputPad> {
2860 let number = value?.as_f64()?;
2861 if js_number_eq(number, 0.0) {
2862 Some(OutputPad::Zero)
2863 } else if js_number_eq(number, 1.0) {
2864 Some(OutputPad::One)
2865 } else {
2866 None
2867 }
2868}
2869
2870fn normalize_optional_path(value: Option<&Value>) -> Option<String> {
2871 let raw = value?.as_str()?;
2872 if raw.is_empty() {
2873 return Some(raw.to_owned());
2874 }
2875 Some(path_to_string(&expand_tilde_path(raw)))
2876}
2877
2878fn parse_settings_text(text: &str) -> Result<Map<String, Value>, String> {
2879 let value: Value = serde_json::from_str(text).map_err(|error| error.to_string())?;
2880 match value {
2881 Value::Object(mut map) => {
2882 migrate_settings(&mut map);
2883 Ok(map)
2884 }
2885 Value::Array(items) => {
2886 Ok(spread_array(&items))
2888 }
2889 other => Err(format!(
2890 "Cannot use 'in' operator to search for 'queueMode' in {}",
2891 js_value_to_string(&other)
2892 )),
2893 }
2894}
2895
2896fn spread_array(items: &[Value]) -> Map<String, Value> {
2897 items
2898 .iter()
2899 .enumerate()
2900 .map(|(index, value)| (index.to_string(), value.clone()))
2901 .collect()
2902}
2903
2904fn deep_merge_settings(
2906 base: &Map<String, Value>,
2907 overrides: &Map<String, Value>,
2908) -> Map<String, Value> {
2909 let mut result = base.clone();
2910 for (key, override_value) in overrides {
2911 let merged_value = match (base.get(key), override_value) {
2912 (Some(Value::Object(base_object)), Value::Object(override_object)) => {
2913 let mut merged = base_object.clone();
2914 for (nested_key, nested_value) in override_object {
2915 merged.insert(nested_key.clone(), nested_value.clone());
2916 }
2917 Value::Object(merged)
2918 }
2919 _ => override_value.clone(),
2920 };
2921 result.insert(key.clone(), merged_value);
2922 }
2923 result
2924}
2925
2926fn migrate_settings(settings: &mut Map<String, Value>) {
2928 if settings.contains_key("queueMode")
2930 && !settings.contains_key("steeringMode")
2931 && let Some(value) = settings.remove("queueMode")
2932 {
2933 settings.insert("steeringMode".to_owned(), value);
2934 }
2935
2936 if !settings.contains_key("transport")
2938 && let Some(Value::Bool(websockets)) = settings.get("websockets").cloned()
2939 {
2940 settings.remove("websockets");
2941 settings.insert(
2942 "transport".to_owned(),
2943 Value::String(if websockets {
2944 "websocket".to_owned()
2945 } else {
2946 "sse".to_owned()
2947 }),
2948 );
2949 }
2950
2951 if let Some(Value::Object(skills_object)) = settings.get("skills").cloned() {
2953 if let Some(enable) = skills_object.get("enableSkillCommands")
2954 && !settings.contains_key("enableSkillCommands")
2955 {
2956 settings.insert("enableSkillCommands".to_owned(), enable.clone());
2957 }
2958 match skills_object.get("customDirectories") {
2959 Some(Value::Array(dirs)) if !dirs.is_empty() => {
2960 settings.insert("skills".to_owned(), Value::Array(dirs.clone()));
2961 }
2962 _ => {
2963 settings.remove("skills");
2964 }
2965 }
2966 }
2967
2968 if let Some(Value::Object(retry_object)) = settings.get("retry").cloned() {
2970 let mut retry_object = retry_object;
2971 let max_delay = retry_object
2972 .get("maxDelayMs")
2973 .filter(|value| value.is_number())
2974 .cloned();
2975 if let Some(max_delay_value) = max_delay {
2976 let provider_object_like: Option<Map<String, Value>> =
2977 match retry_object.get("provider") {
2978 Some(Value::Object(map)) => Some(map.clone()),
2979 Some(Value::Array(items)) => Some(spread_array(items)),
2980 _ => None,
2981 };
2982 let current_max = provider_object_like
2983 .as_ref()
2984 .and_then(|provider| provider.get("maxRetryDelayMs"));
2985 let needs_migration = matches!(
2986 (provider_object_like.is_some(), current_max),
2987 (false, _) | (true, None | Some(Value::Null))
2988 );
2989 if needs_migration {
2990 let mut provider = provider_object_like.unwrap_or_default();
2991 provider.insert("maxRetryDelayMs".to_owned(), max_delay_value);
2992 retry_object.insert("provider".to_owned(), Value::Object(provider));
2993 }
2994 }
2995 retry_object.remove("maxDelayMs");
2996 settings.insert("retry".to_owned(), Value::Object(retry_object));
2997 }
2998}
2999
3000#[cfg(test)]
3005mod tests {
3006 use super::*;
3007 use std::time::{SystemTime, UNIX_EPOCH};
3008
3009 type TestResult = Result<(), String>;
3010
3011 fn unique_temp_dir(label: &str) -> Result<PathBuf, String> {
3012 let nanos = SystemTime::now()
3013 .duration_since(UNIX_EPOCH)
3014 .map_err(|error| error.to_string())?
3015 .as_nanos();
3016 let dir = env::temp_dir().join(format!("pi-settings-{label}-{nanos}"));
3017 fs::create_dir_all(&dir).map_err(|error| error.to_string())?;
3018 Ok(dir)
3019 }
3020
3021 fn write_settings_file(path: &Path, contents: &str) -> Result<(), String> {
3022 if let Some(parent) = path.parent() {
3023 fs::create_dir_all(parent).map_err(|error| error.to_string())?;
3024 }
3025 fs::write(path, contents).map_err(|error| error.to_string())
3026 }
3027
3028 fn read_text(path: &Path) -> Result<String, String> {
3029 fs::read_to_string(path).map_err(|error| error.to_string())
3030 }
3031
3032 fn parse_file(path: &Path) -> Result<Value, String> {
3033 let text = read_text(path)?;
3034 serde_json::from_str(&text).map_err(|error| error.to_string())
3035 }
3036
3037 fn make_dirs(label: &str) -> Result<(PathBuf, PathBuf, PathBuf), String> {
3038 let root = unique_temp_dir(label)?;
3039 let agent = root.join("agent");
3040 let project = root.join("project");
3041 fs::create_dir_all(&agent).map_err(|error| error.to_string())?;
3042 fs::create_dir_all(&project).map_err(|error| error.to_string())?;
3043 Ok((root, agent, project))
3044 }
3045
3046 fn create_manager(project: &Path, agent: &Path, trusted: bool) -> SettingsManager {
3047 SettingsManager::create(
3048 project,
3049 Some(agent),
3050 SettingsManagerCreateOptions::default().project_trusted(trusted),
3051 )
3052 }
3053
3054 #[test]
3055 fn defaults_on_empty_settings() -> TestResult {
3056 let manager = SettingsManager::in_memory(
3057 &Settings::default(),
3058 SettingsManagerCreateOptions::default(),
3059 );
3060 assert_eq!(manager.get_transport(), Transport::Auto);
3061 assert_eq!(manager.get_steering_mode(), QueueMode::OneAtATime);
3062 assert_eq!(manager.get_follow_up_mode(), QueueMode::OneAtATime);
3063 assert!(manager.get_compaction_enabled());
3064 assert_eq!(manager.get_compaction_reserve_tokens(), 16384);
3065 assert_eq!(manager.get_compaction_keep_recent_tokens(), 20000);
3066 let branch = manager.get_branch_summary_settings();
3067 assert_eq!(branch.reserve_tokens, 16384);
3068 assert!(!branch.skip_prompt);
3069 let retry = manager.get_retry_settings();
3070 assert!(retry.enabled);
3071 assert_eq!(retry.max_retries, 3);
3072 assert_eq!(retry.base_delay_ms, 2000);
3073 assert_eq!(
3074 manager.get_provider_retry_settings().max_retry_delay_ms,
3075 60000
3076 );
3077 let idle = manager
3078 .get_http_idle_timeout_ms()
3079 .map_err(|error| error.to_string())?;
3080 assert_eq!(idle, 300_000);
3081 assert_eq!(manager.get_image_width_cells(), 60);
3082 assert_eq!(manager.get_editor_padding_x(), 0);
3083 assert_eq!(manager.get_output_pad(), OutputPad::One);
3084 assert_eq!(manager.get_autocomplete_max_visible(), 5);
3085 assert_eq!(manager.get_code_block_indent(), " ");
3086 assert_eq!(manager.get_tree_filter_mode(), TreeFilterMode::Default);
3087 assert_eq!(manager.get_double_escape_action(), DoubleEscapeAction::Tree);
3088 assert!(manager.get_enable_install_telemetry());
3089 assert!(!manager.get_enable_analytics());
3090 assert!(manager.get_enable_skill_commands());
3091 assert!(manager.get_show_images());
3092 assert!(manager.get_image_auto_resize());
3093 assert!(!manager.get_block_images());
3094 assert!(!manager.get_show_terminal_progress());
3095 assert!(!manager.get_quiet_startup());
3096 assert!(!manager.get_hide_thinking_block());
3097 assert!(!manager.get_show_cache_miss_notices());
3098 assert!(!manager.get_collapse_changelog());
3099 assert_eq!(
3100 manager.get_default_project_trust(),
3101 DefaultProjectTrust::Ask
3102 );
3103 assert!(manager.get_theme().is_none());
3104 if env::var_os("PI_CLEAR_ON_SHRINK").is_none() {
3105 assert!(!manager.get_clear_on_shrink());
3106 }
3107 if env::var_os("PI_HARDWARE_CURSOR").is_none() {
3108 assert!(!manager.get_show_hardware_cursor());
3109 }
3110 if env::var_os("VISUAL").is_none() && env::var_os("EDITOR").is_none() {
3111 assert_eq!(
3112 manager.get_external_editor_command(),
3113 if cfg!(windows) { "notepad" } else { "nano" }
3114 );
3115 }
3116 Ok(())
3117 }
3118
3119 #[test]
3120 fn hardware_cursor_setting_precedes_environment_fallback() {
3121 let mut manager = SettingsManager::in_memory(
3122 &Settings::default(),
3123 SettingsManagerCreateOptions::default(),
3124 );
3125 assert_eq!(
3126 manager.get_show_hardware_cursor(),
3127 env::var("PI_HARDWARE_CURSOR").is_ok_and(|value| value == "1")
3128 );
3129
3130 manager.set_show_hardware_cursor(false);
3131 assert!(!manager.get_show_hardware_cursor());
3132 manager.set_show_hardware_cursor(true);
3133 assert!(manager.get_show_hardware_cursor());
3134 }
3135
3136 #[test]
3137 fn clamps_and_floor_behaviors() -> TestResult {
3138 let mut manager = SettingsManager::in_memory(
3139 &Settings::default(),
3140 SettingsManagerCreateOptions::default(),
3141 );
3142 manager.set_editor_padding_x(9.7);
3143 assert_eq!(manager.get_editor_padding_x(), 3);
3144 manager.set_editor_padding_x(-2.3);
3145 assert_eq!(manager.get_editor_padding_x(), 0);
3146 manager.set_autocomplete_max_visible(100.0);
3147 assert_eq!(manager.get_autocomplete_max_visible(), 20);
3148 manager.set_autocomplete_max_visible(2.0);
3149 assert_eq!(manager.get_autocomplete_max_visible(), 3);
3150 manager.set_autocomplete_max_visible(7.9);
3151 assert_eq!(manager.get_autocomplete_max_visible(), 7);
3152 manager.set_image_width_cells(0.2);
3153 assert_eq!(manager.get_image_width_cells(), 1);
3154 manager.set_image_width_cells(80.9);
3155 assert_eq!(manager.get_image_width_cells(), 80);
3156 manager
3157 .set_http_idle_timeout_ms(1500.7)
3158 .map_err(|error| error.to_string())?;
3159 let idle = manager
3160 .get_http_idle_timeout_ms()
3161 .map_err(|error| error.to_string())?;
3162 assert_eq!(idle, 1500);
3163 let error = match manager.set_http_idle_timeout_ms(-1.0) {
3164 Ok(()) => return Err("expected negative rejection".into()),
3165 Err(error) => error,
3166 };
3167 assert_eq!(error.to_string(), "Invalid httpIdleTimeoutMs setting: -1");
3168 let error = match manager.set_http_idle_timeout_ms(f64::NAN) {
3169 Ok(()) => return Err("expected nan rejection".into()),
3170 Err(error) => error,
3171 };
3172 assert_eq!(error.to_string(), "Invalid httpIdleTimeoutMs setting: NaN");
3173 let error = match manager.set_http_idle_timeout_ms(f64::INFINITY) {
3174 Ok(()) => return Err("expected inf rejection".into()),
3175 Err(error) => error,
3176 };
3177 assert_eq!(
3178 error.to_string(),
3179 "Invalid httpIdleTimeoutMs setting: Infinity"
3180 );
3181 Ok(())
3182 }
3183
3184 #[test]
3185 fn http_timeout_parsing_and_exact_errors() -> TestResult {
3186 let (_root, agent, project) = make_dirs("timeout-parse")?;
3187 write_settings_file(
3188 &agent.join("settings.json"),
3189 r#"{
3190 "httpIdleTimeoutMs": "disabled",
3191 "websocketConnectTimeoutMs": "5000"
3192}"#,
3193 )?;
3194 let manager = create_manager(&project, &agent, true);
3195 assert_eq!(
3196 manager
3197 .get_http_idle_timeout_ms()
3198 .map_err(|e| e.to_string())?,
3199 0
3200 );
3201 assert_eq!(
3202 manager
3203 .get_web_socket_connect_timeout_ms()
3204 .map_err(|e| e.to_string())?,
3205 Some(5000)
3206 );
3207
3208 write_settings_file(
3209 &agent.join("settings.json"),
3210 r#"{ "websocketConnectTimeoutMs": "abc" }"#,
3211 )?;
3212 let manager = create_manager(&project, &agent, true);
3213 let Err(error) = manager.get_web_socket_connect_timeout_ms() else {
3214 return Err("expected invalid stored timeout".into());
3215 };
3216 assert_eq!(
3217 error.to_string(),
3218 "Invalid websocketConnectTimeoutMs setting: abc"
3219 );
3220 Ok(())
3221 }
3222
3223 #[test]
3224 fn migrations_exact_order_and_conditions() -> TestResult {
3225 let (_root, agent, project) = make_dirs("migrations")?;
3226 write_settings_file(
3227 &agent.join("settings.json"),
3228 r#"{
3229 "queueMode": "all",
3230 "websockets": false,
3231 "skills": {
3232 "enableSkillCommands": false,
3233 "customDirectories": ["/x"]
3234 },
3235 "retry": {
3236 "maxDelayMs": 7000
3237 }
3238}"#,
3239 )?;
3240 let mut manager = create_manager(&project, &agent, true);
3241 assert_eq!(manager.get_steering_mode(), QueueMode::All);
3242 assert_eq!(manager.get_transport(), Transport::Sse);
3243 assert_eq!(manager.get_skill_paths(), vec!["/x".to_owned()]);
3244 assert!(!manager.get_enable_skill_commands());
3245 assert_eq!(
3246 manager.get_provider_retry_settings().max_retry_delay_ms,
3247 7000
3248 );
3249 manager.set_quiet_startup(true);
3250 let value = parse_file(&agent.join("settings.json"))?;
3251 assert_eq!(value["steeringMode"], "all");
3252 assert!(value.get("queueMode").is_none());
3253 assert_eq!(value["transport"], "sse");
3254 assert!(value.get("websockets").is_none());
3255 assert_eq!(
3256 value["skills"],
3257 Value::Array(vec![Value::String("/x".into())])
3258 );
3259 assert_eq!(value["enableSkillCommands"], false);
3260 assert_eq!(value["retry"]["provider"]["maxRetryDelayMs"], 7000);
3261 assert!(value["retry"].get("maxDelayMs").is_none());
3262 assert_eq!(value["quietStartup"], true);
3263
3264 write_settings_file(
3266 &agent.join("settings.json"),
3267 r#"{
3268 "queueMode": "all",
3269 "steeringMode": "one-at-a-time",
3270 "websockets": true,
3271 "transport": "sse",
3272 "skills": { "customDirectories": [] },
3273 "enableSkillCommands": true,
3274 "retry": { "maxDelayMs": 5000, "provider": { "maxRetryDelayMs": 1000 } }
3275}"#,
3276 )?;
3277 let mut manager = create_manager(&project, &agent, true);
3278 assert_eq!(manager.get_steering_mode(), QueueMode::OneAtATime);
3279 assert_eq!(manager.get_transport(), Transport::Sse);
3280 assert!(manager.get_skill_paths().is_empty());
3281 assert!(manager.get_enable_skill_commands());
3282 assert_eq!(
3283 manager.get_provider_retry_settings().max_retry_delay_ms,
3284 1000
3285 );
3286 manager.set_theme("dark");
3287 let value = parse_file(&agent.join("settings.json"))?;
3288 assert_eq!(value["queueMode"], "all");
3289 assert_eq!(value["websockets"], true);
3290 assert!(value.get("skills").is_none());
3291 assert!(value["retry"].get("maxDelayMs").is_none());
3292 assert_eq!(value["retry"]["provider"]["maxRetryDelayMs"], 1000);
3293
3294 write_settings_file(
3296 &agent.join("settings.json"),
3297 r#"{
3298 "retry": { "maxDelayMs": 5000, "provider": { "maxRetryDelayMs": null } }
3299}"#,
3300 )?;
3301 let manager = create_manager(&project, &agent, true);
3302 assert_eq!(
3303 manager.get_provider_retry_settings().max_retry_delay_ms,
3304 5000
3305 );
3306 Ok(())
3307 }
3308
3309 #[test]
3310 fn unknown_keys_roundtrip_top_and_nested() -> TestResult {
3311 let (_root, agent, project) = make_dirs("unknown")?;
3312 write_settings_file(
3313 &agent.join("settings.json"),
3314 r#"{
3315 "futureThing": { "a": 1 },
3316 "compaction": { "enabled": false, "futureNested": [1, 2] },
3317 "theme": "dark"
3318}"#,
3319 )?;
3320 let mut manager = create_manager(&project, &agent, true);
3321 let view = manager.get_global_settings();
3322 assert!(view.extra.contains_key("futureThing"));
3323 assert_eq!(
3324 view.compaction
3325 .as_ref()
3326 .and_then(|c| c.extra.get("futureNested")),
3327 Some(&Value::Array(vec![Value::from(1), Value::from(2)]))
3328 );
3329 manager.set_quiet_startup(true);
3330 let value = parse_file(&agent.join("settings.json"))?;
3331 assert_eq!(value["futureThing"]["a"], 1);
3332 assert_eq!(value["compaction"]["enabled"], false);
3333 assert_eq!(
3334 value["compaction"]["futureNested"],
3335 Value::Array(vec![Value::from(1), Value::from(2)])
3336 );
3337 assert_eq!(value["theme"], "dark");
3338 assert_eq!(value["quietStartup"], true);
3339 Ok(())
3340 }
3341
3342 #[test]
3343 fn arrays_replace_and_nested_objects_merge_one_level() -> TestResult {
3344 let (_root, agent, project) = make_dirs("arrays")?;
3345 write_settings_file(
3346 &agent.join("settings.json"),
3347 r#"{
3348 "extensions": ["g1", "g2"],
3349 "retry": { "enabled": false, "provider": { "timeoutMs": 5 } },
3350 "terminal": { "showImages": false, "imageWidthCells": 80 }
3351}"#,
3352 )?;
3353 write_settings_file(
3354 &project.join(".pi").join("settings.json"),
3355 r#"{
3356 "extensions": ["p1"],
3357 "retry": { "provider": { "maxRetries": 2 } },
3358 "terminal": { "showImages": true }
3359}"#,
3360 )?;
3361 let manager = create_manager(&project, &agent, true);
3362 assert_eq!(manager.get_extension_paths(), vec!["p1".to_owned()]);
3363 assert!(!manager.get_retry_enabled());
3364 let provider = manager.get_provider_retry_settings();
3365 assert_eq!(provider.max_retries, Some(2));
3366 assert_eq!(provider.timeout_ms, None);
3367 assert!(manager.get_show_images());
3368 assert_eq!(manager.get_image_width_cells(), 80);
3369 Ok(())
3370 }
3371
3372 #[test]
3373 fn parse_error_refuses_save_without_clobber() -> TestResult {
3374 let (_root, agent, project) = make_dirs("parse-error")?;
3375 let bad = "{ not json";
3376 write_settings_file(&agent.join("settings.json"), bad)?;
3377 let mut manager = create_manager(&project, &agent, true);
3378 let errors = manager.drain_errors();
3379 assert_eq!(errors.len(), 1);
3380 assert_eq!(errors[0].scope, SettingsScope::Global);
3381 manager.set_theme("dark");
3382 assert_eq!(manager.get_theme().as_deref(), Some("dark"));
3384 assert_eq!(read_text(&agent.join("settings.json"))?, bad);
3385
3386 write_settings_file(&agent.join("settings.json"), r#"{ "theme": "light" }"#)?;
3388 manager.reload();
3389 assert!(manager.drain_errors().is_empty());
3390 assert_eq!(manager.get_theme().as_deref(), Some("light"));
3391 manager.set_quiet_startup(true);
3392 let value = parse_file(&agent.join("settings.json"))?;
3393 assert_eq!(value["theme"], "light");
3394 assert_eq!(value["quietStartup"], true);
3395 Ok(())
3396 }
3397
3398 #[test]
3399 fn concurrent_external_changes_are_merged() -> TestResult {
3400 let (_root, agent, project) = make_dirs("external")?;
3401 write_settings_file(
3402 &agent.join("settings.json"),
3403 r#"{
3404 "theme": "dark",
3405 "customUnknown": 1,
3406 "compaction": {
3407 "enabled": false,
3408 "reserveTokens": 999,
3409 "futureNested": "keep"
3410 }
3411}"#,
3412 )?;
3413 let mut manager = create_manager(&project, &agent, true);
3414
3415 write_settings_file(
3417 &agent.join("settings.json"),
3418 r#"{
3419 "theme": "light",
3420 "customUnknown": 1,
3421 "externalNew": true,
3422 "compaction": {
3423 "enabled": false,
3424 "reserveTokens": 12345,
3425 "futureNested": "keep"
3426 }
3427}"#,
3428 )?;
3429 manager.set_default_model("gpt-5");
3430 manager.set_compaction_enabled(true);
3431 let value = parse_file(&agent.join("settings.json"))?;
3432 assert_eq!(value["theme"], "light");
3433 assert_eq!(value["externalNew"], true);
3434 assert_eq!(value["customUnknown"], 1);
3435 assert_eq!(value["defaultModel"], "gpt-5");
3436 assert_eq!(value["compaction"]["enabled"], true);
3437 assert_eq!(value["compaction"]["reserveTokens"], 12345);
3438 assert_eq!(value["compaction"]["futureNested"], "keep");
3439 assert_eq!(manager.get_compaction_reserve_tokens(), 999);
3441 Ok(())
3442 }
3443
3444 #[test]
3445 fn project_write_when_untrusted_fails_exact() -> TestResult {
3446 let (_root, agent, project) = make_dirs("trust")?;
3447 write_settings_file(
3448 &project.join(".pi").join("settings.json"),
3449 r#"{ "extensions": ["/secret"] }"#,
3450 )?;
3451 let mut manager = create_manager(&project, &agent, false);
3452 assert!(!manager.is_project_trusted());
3453 assert!(manager.get_extension_paths().is_empty());
3454 assert!(manager.drain_errors().is_empty());
3455 let error = match manager.set_project_skill_paths(vec!["s".into()]) {
3456 Ok(()) => return Err("expected trust error".into()),
3457 Err(error) => error,
3458 };
3459 assert_eq!(
3460 error.to_string(),
3461 "Project is not trusted; refusing to write project settings"
3462 );
3463 assert!(
3464 !project.join(".pi").join("settings.json").exists()
3465 || read_text(&project.join(".pi").join("settings.json"))?
3466 == r#"{ "extensions": ["/secret"] }"#
3467 );
3468
3469 manager.set_project_trusted(true);
3470 assert_eq!(manager.get_extension_paths(), vec!["/secret".to_owned()]);
3471 manager.set_project_trusted(false);
3472 assert!(manager.get_extension_paths().is_empty());
3473 Ok(())
3474 }
3475
3476 #[test]
3477 fn project_resource_setters_write_project_file_only() -> TestResult {
3478 let (_root, agent, project) = make_dirs("project-set")?;
3479 write_settings_file(
3480 &project.join(".pi").join("settings.json"),
3481 r#"{ "futureKey": 7 }"#,
3482 )?;
3483 let mut manager = create_manager(&project, &agent, true);
3484 manager
3485 .set_project_extension_paths(vec!["./ext".into()])
3486 .map_err(|e| e.to_string())?;
3487 manager
3488 .set_project_skill_paths(vec!["./skill".into()])
3489 .map_err(|e| e.to_string())?;
3490 manager
3491 .set_project_prompt_template_paths(vec!["./prompt".into()])
3492 .map_err(|e| e.to_string())?;
3493 manager
3494 .set_project_theme_paths(vec!["./theme".into()])
3495 .map_err(|e| e.to_string())?;
3496 manager
3497 .set_project_packages(&[PackageSource::Source("npm:pkg".into())])
3498 .map_err(|e| e.to_string())?;
3499
3500 assert!(!agent.join("settings.json").exists());
3501 let value = parse_file(&project.join(".pi").join("settings.json"))?;
3502 assert_eq!(value["futureKey"], 7);
3503 assert_eq!(
3504 value["extensions"],
3505 Value::Array(vec![Value::String("./ext".into())])
3506 );
3507 assert_eq!(
3508 value["skills"],
3509 Value::Array(vec![Value::String("./skill".into())])
3510 );
3511 assert_eq!(
3512 value["prompts"],
3513 Value::Array(vec![Value::String("./prompt".into())])
3514 );
3515 assert_eq!(
3516 value["themes"],
3517 Value::Array(vec![Value::String("./theme".into())])
3518 );
3519 assert_eq!(
3520 value["packages"],
3521 Value::Array(vec![Value::String("npm:pkg".into())])
3522 );
3523 assert_eq!(manager.get_extension_paths(), vec!["./ext".to_owned()]);
3524 assert_eq!(manager.get_skill_paths(), vec!["./skill".to_owned()]);
3525 assert_eq!(
3526 manager.get_prompt_template_paths(),
3527 vec!["./prompt".to_owned()]
3528 );
3529 assert_eq!(manager.get_theme_paths(), vec!["./theme".to_owned()]);
3530 assert_eq!(
3531 manager.get_packages(),
3532 vec![PackageSource::Source("npm:pkg".into())]
3533 );
3534
3535 write_settings_file(&project.join(".pi").join("settings.json"), "{ bad")?;
3537 let mut manager = create_manager(&project, &agent, true);
3538 let errors = manager.drain_errors();
3539 assert_eq!(errors.len(), 1);
3540 assert_eq!(errors[0].scope, SettingsScope::Project);
3541 manager
3542 .set_project_extension_paths(vec!["x".into()])
3543 .map_err(|e| e.to_string())?;
3544 assert_eq!(
3545 read_text(&project.join(".pi").join("settings.json"))?,
3546 "{ bad"
3547 );
3548 Ok(())
3549 }
3550
3551 #[test]
3552 fn no_trailing_newline_and_pretty_format() -> TestResult {
3553 let (_root, agent, project) = make_dirs("newline")?;
3554 let mut manager = create_manager(&project, &agent, true);
3555 manager.set_theme("dark");
3556 manager.set_quiet_startup(true);
3557 let text = read_text(&agent.join("settings.json"))?;
3558 assert!(
3559 !text.ends_with('\n'),
3560 "must not force trailing newline: {text:?}"
3561 );
3562 let expected = serde_json::to_string_pretty(&serde_json::json!({
3563 "quietStartup": true,
3564 "theme": "dark"
3565 }))
3566 .map_err(|e| e.to_string())?;
3567 assert_eq!(text, expected);
3568 Ok(())
3569 }
3570
3571 #[test]
3572 fn theme_path_hiding() {
3573 let mut manager = SettingsManager::in_memory(
3574 &Settings::default(),
3575 SettingsManagerCreateOptions::default(),
3576 );
3577 manager.set_theme("foo/bar");
3578 assert_eq!(manager.get_theme_setting().as_deref(), Some("foo/bar"));
3579 assert!(manager.get_theme().is_none());
3580 manager.set_theme("plain");
3581 assert_eq!(manager.get_theme().as_deref(), Some("plain"));
3582 }
3583
3584 #[test]
3585 fn analytics_tracking_id_lifecycle() -> TestResult {
3586 let mut manager = SettingsManager::in_memory(
3587 &Settings::default(),
3588 SettingsManagerCreateOptions::default(),
3589 );
3590 assert!(manager.get_tracking_id().is_none());
3591 manager.set_enable_analytics(true);
3592 let first = manager
3593 .get_tracking_id()
3594 .ok_or_else(|| "expected generated tracking id".to_owned())?;
3595 assert!(Uuid::parse_str(&first).is_ok());
3596 manager.set_enable_analytics(false);
3597 manager.set_enable_analytics(true);
3598 assert_eq!(manager.get_tracking_id().as_deref(), Some(first.as_str()));
3599
3600 let seed = Settings {
3601 tracking_id: Some("fixed-id".into()),
3602 enable_analytics: Some(false),
3603 ..Settings::default()
3604 };
3605 let mut manager =
3606 SettingsManager::in_memory(&seed, SettingsManagerCreateOptions::default());
3607 manager.set_enable_analytics(true);
3608 assert_eq!(manager.get_tracking_id().as_deref(), Some("fixed-id"));
3609 Ok(())
3610 }
3611
3612 #[test]
3613 fn default_project_trust_is_global_only() -> TestResult {
3614 let (_root, agent, project) = make_dirs("dpt")?;
3615 write_settings_file(
3616 &project.join(".pi").join("settings.json"),
3617 r#"{ "defaultProjectTrust": "always" }"#,
3618 )?;
3619 let manager = create_manager(&project, &agent, true);
3620 assert_eq!(
3621 manager.get_default_project_trust(),
3622 DefaultProjectTrust::Ask
3623 );
3624
3625 write_settings_file(
3626 &agent.join("settings.json"),
3627 r#"{ "defaultProjectTrust": "always" }"#,
3628 )?;
3629 let manager = create_manager(&project, &agent, true);
3630 assert_eq!(
3631 manager.get_default_project_trust(),
3632 DefaultProjectTrust::Always
3633 );
3634
3635 write_settings_file(
3636 &agent.join("settings.json"),
3637 r#"{ "defaultProjectTrust": "bogus" }"#,
3638 )?;
3639 let manager = create_manager(&project, &agent, true);
3640 assert_eq!(
3641 manager.get_default_project_trust(),
3642 DefaultProjectTrust::Ask
3643 );
3644 Ok(())
3645 }
3646
3647 #[test]
3648 fn session_dir_normalization() -> TestResult {
3649 let (_root, agent, project) = make_dirs("session-dir")?;
3650 write_settings_file(
3651 &agent.join("settings.json"),
3652 r#"{ "sessionDir": "/abs/path" }"#,
3653 )?;
3654 let manager = create_manager(&project, &agent, true);
3655 assert_eq!(manager.get_session_dir().as_deref(), Some("/abs/path"));
3656 write_settings_file(&agent.join("settings.json"), r#"{ "sessionDir": "" }"#)?;
3657 let manager = create_manager(&project, &agent, true);
3658 assert_eq!(manager.get_session_dir().as_deref(), Some(""));
3659 Ok(())
3660 }
3661
3662 #[test]
3663 fn apply_overrides_remerged_on_save() {
3664 let mut manager = SettingsManager::in_memory(
3665 &Settings::default(),
3666 SettingsManagerCreateOptions::default(),
3667 );
3668 let mut overrides = Map::new();
3669 overrides.insert("theme".into(), Value::String("override".into()));
3670 manager.apply_overrides(&overrides);
3671 assert_eq!(manager.get_theme().as_deref(), Some("override"));
3672 manager.set_quiet_startup(true);
3673 assert!(manager.get_theme().is_none());
3675 assert!(manager.get_quiet_startup());
3676 }
3677
3678 #[test]
3679 fn reload_picks_up_external_changes() -> TestResult {
3680 let (_root, agent, project) = make_dirs("reload")?;
3681 write_settings_file(&agent.join("settings.json"), r#"{ "theme": "dark" }"#)?;
3682 let mut manager = create_manager(&project, &agent, true);
3683 assert_eq!(manager.get_theme().as_deref(), Some("dark"));
3684 write_settings_file(&agent.join("settings.json"), r#"{ "theme": "light" }"#)?;
3685 assert_eq!(manager.get_theme().as_deref(), Some("dark"));
3686 manager.reload();
3687 assert_eq!(manager.get_theme().as_deref(), Some("light"));
3688 Ok(())
3689 }
3690
3691 #[test]
3692 fn in_memory_storage_roundtrip() -> TestResult {
3693 let mut storage = InMemorySettingsStorage::new();
3694 storage.with_lock(SettingsScope::Global, &mut |_| {
3695 Ok(Some(r#"{"theme":"dark"}"#.to_owned()))
3696 })?;
3697 let mut manager = SettingsManager::from_storage(
3698 Box::new(storage),
3699 SettingsManagerCreateOptions::default(),
3700 );
3701 assert_eq!(manager.get_theme().as_deref(), Some("dark"));
3702 manager.set_theme("light");
3703 assert_eq!(
3704 manager.get_global_settings().theme.as_deref(),
3705 Some("light")
3706 );
3707 Ok(())
3708 }
3709
3710 #[test]
3711 fn missing_file_load_creates_no_lock_artifact() -> TestResult {
3712 let (root, agent, project) = make_dirs("lock-artifact")?;
3713 let manager = create_manager(&project, &agent, true);
3714 assert!(!agent.join("settings.json.lock").exists());
3715 drop(manager);
3716 assert!(!agent.join("settings.json").exists());
3717 assert!(!agent.join("settings.json.lock").exists());
3718
3719 let nested_agent = root.join("nested").join("agent");
3721 let mut manager = create_manager(&project, &nested_agent, true);
3722 manager.set_theme("dark");
3723 assert!(nested_agent.join("settings.json").exists());
3724 assert!(!nested_agent.join("settings.json.lock").exists());
3725 Ok(())
3726 }
3727
3728 #[test]
3729 fn output_pad_strict_zero() -> TestResult {
3730 let (_root, agent, project) = make_dirs("output-pad")?;
3731 write_settings_file(&agent.join("settings.json"), r#"{ "outputPad": 0 }"#)?;
3732 let manager = create_manager(&project, &agent, true);
3733 assert_eq!(manager.get_output_pad(), OutputPad::Zero);
3734 write_settings_file(&agent.join("settings.json"), r#"{ "outputPad": 2 }"#)?;
3735 let manager = create_manager(&project, &agent, true);
3736 assert_eq!(manager.get_output_pad(), OutputPad::One);
3737 write_settings_file(&agent.join("settings.json"), r#"{ "outputPad": "0" }"#)?;
3738 let manager = create_manager(&project, &agent, true);
3739 assert_eq!(manager.get_output_pad(), OutputPad::One);
3740 Ok(())
3741 }
3742
3743 #[test]
3744 fn package_sources_typed_roundtrip() {
3745 let mut manager = SettingsManager::in_memory(
3746 &Settings::default(),
3747 SettingsManagerCreateOptions::default(),
3748 );
3749 let packages = vec![
3750 PackageSource::Source("npm:a".into()),
3751 PackageSource::Filtered(PackageSourceFilter {
3752 source: "git:b".into(),
3753 autoload: Some(false),
3754 extensions: Some(vec!["e".into()]),
3755 skills: None,
3756 prompts: None,
3757 themes: None,
3758 extra: {
3759 let mut extra = Map::new();
3760 extra.insert("future".into(), Value::from(1));
3761 extra
3762 },
3763 }),
3764 ];
3765 manager.set_packages(&packages);
3766 assert_eq!(manager.get_packages(), packages);
3767 let view = manager.get_global_settings();
3768 assert_eq!(view.packages, Some(packages));
3769 }
3770
3771 #[test]
3772 fn set_default_model_and_provider_marks_both() -> TestResult {
3773 let (_root, agent, project) = make_dirs("both")?;
3774 let mut manager = create_manager(&project, &agent, true);
3775 manager.set_default_model_and_provider("openai", "gpt-5");
3776 let value = parse_file(&agent.join("settings.json"))?;
3777 assert_eq!(value["defaultProvider"], "openai");
3778 assert_eq!(value["defaultModel"], "gpt-5");
3779 Ok(())
3780 }
3781
3782 #[test]
3783 fn typed_settings_from_map_to_map_preserves_extra() {
3784 let mut map = Map::new();
3785 map.insert("theme".into(), Value::String("dark".into()));
3786 map.insert("future".into(), Value::from(42));
3787 let settings = Settings::from_map(&map);
3788 assert_eq!(settings.theme.as_deref(), Some("dark"));
3789 assert_eq!(settings.extra.get("future"), Some(&Value::from(42)));
3790 let roundtrip = settings.to_map();
3791 assert_eq!(roundtrip.get("theme"), Some(&Value::String("dark".into())));
3792 assert_eq!(roundtrip.get("future"), Some(&Value::from(42)));
3793 }
3794}