1use crate::store::settings::{Settings, ThinkingLevel};
24use std::str::FromStr;
25
26fn thinking_level_to_str(v: ThinkingLevel) -> &'static str {
31 match v {
32 ThinkingLevel::Off => "off",
33 ThinkingLevel::Minimal => "minimal",
34 ThinkingLevel::Low => "low",
35 ThinkingLevel::Medium => "medium",
36 ThinkingLevel::High => "high",
37 ThinkingLevel::XHigh => "xhigh",
38 }
39}
40
41fn edit_format_to_str(v: crate::store::settings::EditFormat) -> &'static str {
42 use crate::store::settings::EditFormat;
43 match v {
44 EditFormat::Hashline => "hashline",
45 EditFormat::StrReplace => "str_replace",
46 }
47}
48
49#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
55pub enum SettingKey {
56 ThinkingLevel,
58 AutoCompaction,
59 GlyphSet,
60 EditFormat,
61 ExtensionsEnabled,
63 SessionHistorySize,
64 ToolTimeoutSecs,
65 AskTimeoutSecs,
66 DisabledTools,
67 CommitToolEnabled,
68 TodoPanelEnabled,
69 AgentHubEnabled,
70 MermaidRenderEnabled,
71 SnapcompactEnabled,
72 MemoryEnabled,
74 TtsrEnabled,
75 TtsrInterruptMode,
76 AdvisorEnabled,
77 AdvisorSyncBacklog,
78 AdvisorImmuneTurns,
79 DefaultTemperature,
85 MaxResponseTokens,
86 ModelRoles,
88 Keybindings,
89 Theme,
90 Model,
91 CustomProviders,
92 Hooks,
93 ExtensionPaths,
94 SkillPaths,
95 PromptPaths,
96 ThemePaths,
97}
98
99#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
101pub enum SettingsTab {
102 General,
103 Model,
104 Interaction,
105 Tools,
106 Ui,
107 AdvisorMemory,
108 Keybindings,
109 Advanced,
110}
111
112#[derive(Clone, Copy, PartialEq, Eq, Debug)]
114pub enum SettingWidget {
115 Toggle,
117 Cycle,
119 SubmenuSelect(&'static [&'static str]),
121 Text,
123 Multiselect,
125 MapEditor,
127 Pointer,
129}
130
131pub struct SettingDef {
133 pub key: SettingKey,
134 pub tab: SettingsTab,
135 pub group: &'static str,
138 pub label: &'static str,
139 pub description: &'static str,
140 pub widget: SettingWidget,
141 pub condition: Option<fn(&Settings) -> bool>,
144}
145
146pub const SETTING_DEFS: &[SettingDef] = &[
151 SettingDef {
153 key: SettingKey::ExtensionsEnabled,
154 tab: SettingsTab::General,
155 group: "Behavior",
156 label: "Extensions",
157 description: "Load WASM/native extensions",
158 widget: SettingWidget::Toggle,
159 condition: None,
160 },
161 SettingDef {
162 key: SettingKey::SessionHistorySize,
163 tab: SettingsTab::General,
164 group: "Behavior",
165 label: "Session history size",
166 description: "Entries kept in memory",
167 widget: SettingWidget::Text,
168 condition: None,
169 },
170 SettingDef {
171 key: SettingKey::EditFormat,
172 tab: SettingsTab::General,
173 group: "Behavior",
174 label: "Edit format",
175 description: "str_replace or hashline",
176 widget: SettingWidget::Cycle,
177 condition: None,
178 },
179 SettingDef {
181 key: SettingKey::ThinkingLevel,
182 tab: SettingsTab::Model,
183 group: "Defaults",
184 label: "Thinking level",
185 description: "Reasoning effort",
186 widget: SettingWidget::Cycle,
187 condition: None,
188 },
189 SettingDef {
190 key: SettingKey::DefaultTemperature,
191 tab: SettingsTab::Model,
192 group: "Defaults",
193 label: "Temperature",
194 description: "0.0-2.0 (empty = model default)",
195 widget: SettingWidget::Text,
196 condition: None,
197 },
198 SettingDef {
199 key: SettingKey::MaxResponseTokens,
200 tab: SettingsTab::Model,
201 group: "Defaults",
202 label: "Max response tokens",
203 description: "Per-response cap (empty = model default)",
204 widget: SettingWidget::Text,
205 condition: None,
206 },
207 SettingDef {
208 key: SettingKey::ModelRoles,
209 tab: SettingsTab::Model,
210 group: "Defaults",
211 label: "Model roles",
212 description: "role -> model pattern assignments",
213 widget: SettingWidget::MapEditor,
214 condition: None,
215 },
216 SettingDef {
217 key: SettingKey::Theme,
218 tab: SettingsTab::Model,
219 group: "Pointers",
220 label: "Theme",
221 description: "Use /theme to change",
222 widget: SettingWidget::Pointer,
223 condition: None,
224 },
225 SettingDef {
226 key: SettingKey::Model,
227 tab: SettingsTab::Model,
228 group: "Pointers",
229 label: "Model",
230 description: "Use /model to change",
231 widget: SettingWidget::Pointer,
232 condition: None,
233 },
234 SettingDef {
236 key: SettingKey::AutoCompaction,
237 tab: SettingsTab::Interaction,
238 group: "Compaction",
239 label: "Auto-compaction",
240 description: "Compact when context exceeds window",
241 widget: SettingWidget::Toggle,
242 condition: None,
243 },
244 SettingDef {
245 key: SettingKey::ToolTimeoutSecs,
246 tab: SettingsTab::Interaction,
247 group: "Timeouts",
248 label: "Tool timeout (s)",
249 description: "Tool execution timeout",
250 widget: SettingWidget::Text,
251 condition: None,
252 },
253 SettingDef {
254 key: SettingKey::AskTimeoutSecs,
255 tab: SettingsTab::Interaction,
256 group: "Timeouts",
257 label: "Ask timeout (s)",
258 description: "Ask overlay timeout",
259 widget: SettingWidget::Text,
260 condition: None,
261 },
262 SettingDef {
264 key: SettingKey::DisabledTools,
265 tab: SettingsTab::Tools,
266 group: "Tools",
267 label: "Disabled tools",
268 description: "Tools turned off for the agent",
269 widget: SettingWidget::Multiselect,
270 condition: None,
271 },
272 SettingDef {
273 key: SettingKey::CommitToolEnabled,
274 tab: SettingsTab::Tools,
275 group: "Tools",
276 label: "Commit tool",
277 description: "Enable the Commit tool",
278 widget: SettingWidget::Toggle,
279 condition: None,
280 },
281 SettingDef {
282 key: SettingKey::CustomProviders,
283 tab: SettingsTab::Tools,
284 group: "Pointers",
285 label: "Custom providers",
286 description: "Use /providers to manage",
287 widget: SettingWidget::Pointer,
288 condition: None,
289 },
290 SettingDef {
291 key: SettingKey::Hooks,
292 tab: SettingsTab::Tools,
293 group: "Pointers",
294 label: "Hooks",
295 description: "Use /hooks to view",
296 widget: SettingWidget::Pointer,
297 condition: None,
298 },
299 SettingDef {
301 key: SettingKey::GlyphSet,
302 tab: SettingsTab::Ui,
303 group: "Appearance",
304 label: "Icons",
305 description: "unicode / ascii / nerd glyph set",
306 widget: SettingWidget::Cycle,
307 condition: None,
308 },
309 SettingDef {
310 key: SettingKey::TodoPanelEnabled,
311 tab: SettingsTab::Ui,
312 group: "Panels",
313 label: "Todo panel",
314 description: "Sticky todo panel",
315 widget: SettingWidget::Toggle,
316 condition: None,
317 },
318 SettingDef {
319 key: SettingKey::AgentHubEnabled,
320 tab: SettingsTab::Ui,
321 group: "Panels",
322 label: "Agent hub",
323 description: "Ctrl+h /agents overlay",
324 widget: SettingWidget::Toggle,
325 condition: None,
326 },
327 SettingDef {
328 key: SettingKey::MermaidRenderEnabled,
329 tab: SettingsTab::Ui,
330 group: "Panels",
331 label: "Mermaid",
332 description: "Render mermaid diagrams",
333 widget: SettingWidget::Toggle,
334 condition: None,
335 },
336 SettingDef {
337 key: SettingKey::SnapcompactEnabled,
338 tab: SettingsTab::Ui,
339 group: "Panels",
340 label: "Snapcompact",
341 description: "PNG-frame compactor",
342 widget: SettingWidget::Toggle,
343 condition: None,
344 },
345 SettingDef {
347 key: SettingKey::AdvisorEnabled,
348 tab: SettingsTab::AdvisorMemory,
349 group: "Advisor",
350 label: "Advisor",
351 description: "Read-only reviewer shadowing the agent",
352 widget: SettingWidget::Toggle,
353 condition: None,
354 },
355 SettingDef {
356 key: SettingKey::AdvisorSyncBacklog,
357 tab: SettingsTab::AdvisorMemory,
358 group: "Advisor",
359 label: "Sync backlog",
360 description: "off / sync / async",
361 widget: SettingWidget::SubmenuSelect(&["off", "sync", "async"]),
362 condition: Some(|s| s.advisor.enabled),
363 },
364 SettingDef {
365 key: SettingKey::AdvisorImmuneTurns,
366 tab: SettingsTab::AdvisorMemory,
367 group: "Advisor",
368 label: "Immune turns",
369 description: "Turns the advisor skips",
370 widget: SettingWidget::Text,
371 condition: Some(|s| s.advisor.enabled),
372 },
373 SettingDef {
374 key: SettingKey::MemoryEnabled,
375 tab: SettingsTab::AdvisorMemory,
376 group: "Memory",
377 label: "Memory",
378 description: "Oxibrain durable memory",
379 widget: SettingWidget::Toggle,
380 condition: None,
381 },
382 SettingDef {
383 key: SettingKey::TtsrEnabled,
384 tab: SettingsTab::AdvisorMemory,
385 group: "Memory",
386 label: "TTSR",
387 description: "Time-traveling stream rules",
388 widget: SettingWidget::Toggle,
389 condition: None,
390 },
391 SettingDef {
392 key: SettingKey::TtsrInterruptMode,
393 tab: SettingsTab::AdvisorMemory,
394 group: "Memory",
395 label: "TTSR mode",
396 description: "prose_only or rules",
397 widget: SettingWidget::SubmenuSelect(&["prose_only", "rules"]),
398 condition: Some(|s| s.ttsr_enabled),
399 },
400 SettingDef {
402 key: SettingKey::Keybindings,
403 tab: SettingsTab::Keybindings,
404 group: "Keybindings",
405 label: "Keybindings",
406 description: "Global shortcuts",
407 widget: SettingWidget::MapEditor,
408 condition: None,
409 },
410 SettingDef {
412 key: SettingKey::ExtensionPaths,
413 tab: SettingsTab::Advanced,
414 group: "Resources",
415 label: "Extensions",
416 description: "Use `oxicode config` to manage",
417 widget: SettingWidget::Pointer,
418 condition: None,
419 },
420 SettingDef {
421 key: SettingKey::SkillPaths,
422 tab: SettingsTab::Advanced,
423 group: "Resources",
424 label: "Skills",
425 description: "Use `oxicode config` to manage",
426 widget: SettingWidget::Pointer,
427 condition: None,
428 },
429 SettingDef {
430 key: SettingKey::PromptPaths,
431 tab: SettingsTab::Advanced,
432 group: "Resources",
433 label: "Prompts",
434 description: "Use `oxicode config` to manage",
435 widget: SettingWidget::Pointer,
436 condition: None,
437 },
438 SettingDef {
439 key: SettingKey::ThemePaths,
440 tab: SettingsTab::Advanced,
441 group: "Resources",
442 label: "Themes",
443 description: "Use `oxicode config` to manage",
444 widget: SettingWidget::Pointer,
445 condition: None,
446 },
447];
448
449pub fn defs_for_tab(tab: SettingsTab, s: &Settings) -> Vec<&'static SettingDef> {
455 SETTING_DEFS
456 .iter()
457 .filter(|d| d.tab == tab && d.condition.is_none_or(|c| c(s)))
458 .collect()
459}
460
461pub fn get_display_value(key: SettingKey, s: &Settings) -> String {
465 use SettingKey::*;
466 match key {
467 ThinkingLevel => thinking_level_to_str(s.thinking_level).to_string(),
468 AutoCompaction => s.auto_compaction.to_string(),
469 GlyphSet => s.glyph_set.to_string(),
470 EditFormat => edit_format_to_str(s.edit_format).to_string(),
471 ExtensionsEnabled => s.extensions_enabled.to_string(),
472 SessionHistorySize => s.session_history_size.to_string(),
473 ToolTimeoutSecs => s.tool_timeout_seconds.to_string(),
474 AskTimeoutSecs => s.ask_timeout_secs.to_string(),
475 DisabledTools => format!("{}", s.disabled_tools.len()),
476 CommitToolEnabled => s.commit_tool_enabled.to_string(),
477 TodoPanelEnabled => s.todo_panel_enabled.to_string(),
478 AgentHubEnabled => s.agent_hub_enabled.to_string(),
479 MermaidRenderEnabled => s.mermaid_render_enabled.to_string(),
480 SnapcompactEnabled => s.snapcompact_enabled.to_string(),
481 MemoryEnabled => s.memory_enabled.to_string(),
482 TtsrEnabled => s.ttsr_enabled.to_string(),
483 TtsrInterruptMode => s.ttsr_interrupt_mode.clone(),
484 AdvisorEnabled => s.advisor.enabled.to_string(),
485 AdvisorSyncBacklog => s.advisor.sync_backlog.clone(),
486 AdvisorImmuneTurns => s.advisor.immune_turns.to_string(),
487 DefaultTemperature => s
491 .default_temperature
492 .map(|v| format!("{v}"))
493 .unwrap_or_else(|| "default".to_string()),
494 MaxResponseTokens => s
495 .max_response_tokens
496 .map(|v| v.to_string())
497 .unwrap_or_else(|| "default".to_string()),
498 ModelRoles => format!("{}", s.model_roles.len()),
499 Keybindings => format!("{}", s.keybindings.len()),
500 Theme => s.theme.clone(),
501 Model => s.last_used_model.clone().unwrap_or_else(|| "unset".into()),
502 CustomProviders => format!("{}", s.custom_providers.len()),
503 Hooks => format!("{}", s.hooks.len()),
504 ExtensionPaths => format!("{}", s.extensions.len()),
505 SkillPaths => format!("{}", s.skills.len()),
506 PromptPaths => format!("{}", s.prompts.len()),
507 ThemePaths => format!("{}", s.themes.len()),
508 }
509}
510
511pub fn apply_change(key: SettingKey, s: &mut Settings, new: String) -> anyhow::Result<()> {
525 use SettingKey::*;
526 match key {
527 AutoCompaction => s.auto_compaction = new == "true",
528 ExtensionsEnabled => s.extensions_enabled = new == "true",
529 CommitToolEnabled => s.commit_tool_enabled = new == "true",
530 TodoPanelEnabled => s.todo_panel_enabled = new == "true",
531 AgentHubEnabled => s.agent_hub_enabled = new == "true",
532 MermaidRenderEnabled => s.mermaid_render_enabled = new == "true",
533 SnapcompactEnabled => s.snapcompact_enabled = new == "true",
534 MemoryEnabled => s.memory_enabled = new == "true",
535 TtsrEnabled => s.ttsr_enabled = new == "true",
536 AdvisorEnabled => s.advisor.enabled = new == "true",
537 ThinkingLevel => {
538 s.thinking_level = crate::store::settings::parse_thinking_level(&new)
539 .ok_or_else(|| anyhow::anyhow!("invalid thinking level: {new}"))?;
540 }
541 GlyphSet => {
542 s.glyph_set = crate::symbols::GlyphSet::from_str(&new)
543 .map_err(|e| anyhow::anyhow!("invalid glyph set: {e}"))?
544 }
545 EditFormat => {
546 use crate::store::settings::EditFormat as Ef;
547 s.edit_format = match new.as_str() {
548 "hashline" => Ef::Hashline,
549 "str_replace" => Ef::StrReplace,
550 _ => anyhow::bail!("invalid edit format: {new} (expected hashline|str_replace)"),
551 };
552 }
553 SessionHistorySize => s.session_history_size = new.parse()?,
554 ToolTimeoutSecs => s.tool_timeout_seconds = new.parse()?,
555 AskTimeoutSecs => s.ask_timeout_secs = new.parse()?,
556 AdvisorImmuneTurns => s.advisor.immune_turns = new.parse()?,
557 TtsrInterruptMode => s.ttsr_interrupt_mode = new,
558 AdvisorSyncBacklog => s.advisor.sync_backlog = new,
559 DefaultTemperature => {
562 let trimmed = new.trim();
563 if trimmed.is_empty() {
564 s.default_temperature = None;
565 } else {
566 let v: f64 = trimmed.parse().map_err(|e| {
567 anyhow::anyhow!("invalid temperature '{trimmed}': {e} (expected 0.0–2.0)")
568 })?;
569 if !(0.0..=2.0).contains(&v) {
570 anyhow::bail!("temperature {v} out of range 0.0–2.0");
571 }
572 s.default_temperature = Some(v);
573 }
574 }
575 MaxResponseTokens => {
576 let trimmed = new.trim();
577 if trimmed.is_empty() {
578 s.max_response_tokens = None;
579 } else {
580 let v: usize = trimmed
581 .parse()
582 .map_err(|e| anyhow::anyhow!("invalid max response tokens '{trimmed}': {e}"))?;
583 if v == 0 {
584 anyhow::bail!("max_response_tokens must be > 0 (empty to clear)");
585 }
586 s.max_response_tokens = Some(v);
587 }
588 }
589 DisabledTools => anyhow::bail!("disabled_tools edited via its multiselect"),
592 ModelRoles => anyhow::bail!("model_roles edited via its map-editor"),
593 Keybindings => anyhow::bail!("keybindings edited via its map-editor"),
594 Theme | Model | CustomProviders | Hooks | ExtensionPaths | SkillPaths | PromptPaths
596 | ThemePaths => anyhow::bail!("read-only"),
597 }
598 Ok(())
599}
600
601pub fn toggle_disabled_tool(s: &mut Settings, tool: &str, enabled: bool) {
605 if enabled {
606 s.disabled_tools.retain(|t| t != tool);
607 } else if !s.disabled_tools.iter().any(|t| t == tool) {
608 s.disabled_tools.push(tool.to_string());
609 }
610}
611
612#[derive(Clone, Debug, PartialEq, Eq)]
621pub enum SettingsMapRow {
622 KeybindingAction(crate::tui_vt::keymap::GlobalAction),
625 KeybindingCombo(crate::tui_vt::keymap::GlobalAction, String),
627 ModelRole(String),
630}
631
632pub fn set_action_combos(
637 s: &mut Settings,
638 action: crate::tui_vt::keymap::GlobalAction,
639 combos: Vec<String>,
640) {
641 let defaults: Vec<String> = crate::tui_vt::keymap::DEFAULT_KEYBINDINGS
642 .iter()
643 .filter(|(a, _)| *a == action)
644 .map(|(_, combo)| (*combo).to_string())
645 .collect();
646 if combos == defaults {
647 s.keybindings.remove(action.name());
648 } else {
649 s.keybindings.insert(action.name().to_string(), combos);
650 }
651}
652
653pub fn set_model_role(s: &mut Settings, role: &str, model: String) {
655 s.model_roles.insert(role.to_string(), model);
656}
657
658pub fn remove_model_role(s: &mut Settings, role: &str) -> bool {
662 s.model_roles.remove(role).is_some()
663}
664
665#[cfg(test)]
666mod tests {
667 use super::*;
668
669 #[test]
676 fn groups_are_contiguous_and_nonempty() {
677 for def in SETTING_DEFS {
678 assert!(
679 !def.group.is_empty(),
680 "SettingDef for {:?} has empty group",
681 def.key,
682 );
683 }
684
685 let mut completed: std::collections::HashMap<
689 SettingsTab,
690 std::collections::HashSet<&'static str>,
691 > = std::collections::HashMap::new();
692 let mut current_group_per_tab: std::collections::HashMap<SettingsTab, &'static str> =
693 std::collections::HashMap::new();
694
695 for def in SETTING_DEFS {
696 if let Some(prev) = current_group_per_tab.get(&def.tab)
697 && *prev != def.group
698 {
699 completed.entry(def.tab).or_default().insert(*prev);
700 }
701 let done = completed
702 .get(&def.tab)
703 .is_some_and(|c| c.contains(def.group));
704 assert!(
705 !done,
706 "group {:?} reappears non-contiguously in tab {:?}",
707 def.group, def.tab,
708 );
709 current_group_per_tab.insert(def.tab, def.group);
710 }
711 }
712
713 #[test]
715 fn thinking_level_round_trips() {
716 let mut s = Settings::default();
717 apply_change(SettingKey::ThinkingLevel, &mut s, "high".into()).unwrap();
718 assert_eq!(s.thinking_level, ThinkingLevel::High);
719 assert_eq!(get_display_value(SettingKey::ThinkingLevel, &s), "high");
720
721 assert!(apply_change(SettingKey::ThinkingLevel, &mut s, "yikes".into()).is_err());
723 }
724
725 #[test]
727 fn auto_compaction_round_trips() {
728 let mut s = Settings::default();
729 apply_change(SettingKey::AutoCompaction, &mut s, "false".into()).unwrap();
731 assert!(!s.auto_compaction);
732 assert_eq!(get_display_value(SettingKey::AutoCompaction, &s), "false");
733 apply_change(SettingKey::AutoCompaction, &mut s, "true".into()).unwrap();
734 assert!(s.auto_compaction);
735 }
736
737 #[test]
739 fn tool_timeout_secs_round_trips() {
740 let mut s = Settings::default();
741 apply_change(SettingKey::ToolTimeoutSecs, &mut s, "42".into()).unwrap();
742 assert_eq!(s.tool_timeout_seconds, 42);
743 assert_eq!(get_display_value(SettingKey::ToolTimeoutSecs, &s), "42");
744 assert!(apply_change(SettingKey::ToolTimeoutSecs, &mut s, "abc".into()).is_err());
746 }
747
748 #[test]
752 fn defs_for_tab_respects_condition() {
753 let mut s = Settings::default();
754 let keys_disabled: Vec<SettingKey> = defs_for_tab(SettingsTab::AdvisorMemory, &s)
756 .iter()
757 .map(|d| d.key)
758 .collect();
759 assert!(!keys_disabled.contains(&SettingKey::AdvisorSyncBacklog));
760 assert!(!keys_disabled.contains(&SettingKey::AdvisorImmuneTurns));
761
762 s.advisor.enabled = true;
764 let keys_enabled: Vec<SettingKey> = defs_for_tab(SettingsTab::AdvisorMemory, &s)
765 .iter()
766 .map(|d| d.key)
767 .collect();
768 assert!(keys_enabled.contains(&SettingKey::AdvisorSyncBacklog));
769 assert!(keys_enabled.contains(&SettingKey::AdvisorImmuneTurns));
770
771 assert!(!keys_enabled.contains(&SettingKey::TtsrInterruptMode));
773 s.ttsr_enabled = true;
774 let keys_ttsr: Vec<SettingKey> = defs_for_tab(SettingsTab::AdvisorMemory, &s)
775 .iter()
776 .map(|d| d.key)
777 .collect();
778 assert!(keys_ttsr.contains(&SettingKey::TtsrInterruptMode));
779 }
780
781 #[test]
784 fn structured_editor_rows_reject_scalar_edits() {
785 let mut s = Settings::default();
786 let err = apply_change(SettingKey::DisabledTools, &mut s, "anything".into()).unwrap_err();
787 assert!(format!("{err}").contains("multiselect"));
788 let err = apply_change(SettingKey::ModelRoles, &mut s, "anything".into()).unwrap_err();
789 assert!(format!("{err}").contains("map-editor"));
790 let err = apply_change(SettingKey::Keybindings, &mut s, "anything".into()).unwrap_err();
791 assert!(format!("{err}").contains("map-editor"));
792 }
793
794 #[test]
796 fn pointer_rows_are_read_only() {
797 let mut s = Settings::default();
798 for key in [
799 SettingKey::Theme,
800 SettingKey::Model,
801 SettingKey::CustomProviders,
802 SettingKey::Hooks,
803 SettingKey::ExtensionPaths,
804 SettingKey::SkillPaths,
805 SettingKey::PromptPaths,
806 SettingKey::ThemePaths,
807 ] {
808 assert!(
809 apply_change(key, &mut s, "anything".into()).is_err(),
810 "{key:?} should reject scalar edits",
811 );
812 }
813 }
814
815 #[test]
820 fn default_temperature_round_trips() {
821 let mut s = Settings::default();
822 assert_eq!(
823 get_display_value(SettingKey::DefaultTemperature, &s),
824 "default",
825 "None renders as 'default'"
826 );
827 apply_change(SettingKey::DefaultTemperature, &mut s, "0.7".into()).unwrap();
828 assert_eq!(s.default_temperature, Some(0.7));
829 assert_eq!(get_display_value(SettingKey::DefaultTemperature, &s), "0.7");
830 apply_change(SettingKey::DefaultTemperature, &mut s, " ".into()).unwrap();
832 assert_eq!(s.default_temperature, None);
833 assert_eq!(
834 get_display_value(SettingKey::DefaultTemperature, &s),
835 "default"
836 );
837 assert!(
839 apply_change(SettingKey::DefaultTemperature, &mut s, "2.5".into()).is_err(),
840 "above 2.0 must be rejected"
841 );
842 assert!(
843 apply_change(SettingKey::DefaultTemperature, &mut s, "-0.1".into()).is_err(),
844 "below 0.0 must be rejected"
845 );
846 assert!(
847 apply_change(SettingKey::DefaultTemperature, &mut s, "warm".into()).is_err(),
848 "non-numeric must be rejected"
849 );
850 apply_change(SettingKey::DefaultTemperature, &mut s, "0".into()).unwrap();
852 assert_eq!(s.default_temperature, Some(0.0));
853 apply_change(SettingKey::DefaultTemperature, &mut s, "2.0".into()).unwrap();
854 assert_eq!(s.default_temperature, Some(2.0));
855 }
856
857 #[test]
860 fn max_response_tokens_round_trips() {
861 let mut s = Settings::default();
862 assert_eq!(
863 get_display_value(SettingKey::MaxResponseTokens, &s),
864 "default"
865 );
866 apply_change(SettingKey::MaxResponseTokens, &mut s, "8192".into()).unwrap();
867 assert_eq!(s.max_response_tokens, Some(8192));
868 assert_eq!(get_display_value(SettingKey::MaxResponseTokens, &s), "8192");
869 apply_change(SettingKey::MaxResponseTokens, &mut s, "".into()).unwrap();
871 assert_eq!(s.max_response_tokens, None);
872 assert!(
874 apply_change(SettingKey::MaxResponseTokens, &mut s, "0".into()).is_err(),
875 "zero must be rejected (empty clears, zero caps everything)"
876 );
877 assert!(
878 apply_change(SettingKey::MaxResponseTokens, &mut s, "many".into()).is_err(),
879 "non-numeric must be rejected"
880 );
881 }
882
883 #[test]
886 fn model_defaults_defs_exist_as_text() {
887 let s = Settings::default();
888 let model_defs: Vec<&SettingDef> = defs_for_tab(SettingsTab::Model, &s);
889 let temp = model_defs
890 .iter()
891 .find(|d| d.key == SettingKey::DefaultTemperature)
892 .expect("DefaultTemperature def on Model tab");
893 assert_eq!(temp.group, "Defaults");
894 assert!(matches!(temp.widget, SettingWidget::Text));
895 let tokens = model_defs
896 .iter()
897 .find(|d| d.key == SettingKey::MaxResponseTokens)
898 .expect("MaxResponseTokens def on Model tab");
899 assert_eq!(tokens.group, "Defaults");
900 assert!(matches!(tokens.widget, SettingWidget::Text));
901 }
902
903 #[test]
906 fn toggle_disabled_tool_idempotent() {
907 let mut s = Settings::default();
908 assert!(s.disabled_tools.is_empty());
909 toggle_disabled_tool(&mut s, "bash", false);
910 assert_eq!(s.disabled_tools, vec!["bash".to_string()]);
911 toggle_disabled_tool(&mut s, "bash", false);
912 assert_eq!(s.disabled_tools, vec!["bash".to_string()]);
913 toggle_disabled_tool(&mut s, "bash", true);
914 assert!(s.disabled_tools.is_empty());
915 }
916}