Skip to main content

oxicode/tui_vt/
settings_defs.rs

1//! Declarative `SettingDef` table for the `/settings` TUI panel.
2//!
3//! Task 1 of the settings-panel rewrite. Owns the static metadata
4//! (key, tab, group, label, description, widget kind, conditional
5//! visibility) for every editable setting plus the typed accessors
6//! `get_display_value` / `apply_change`. The renderer, the per-widget
7//! editors, and the map-editor screens consume this table and never
8//! reach into `Settings` fields directly.
9//!
10//! The two `MapEditor` settings (`keybindings`, `model_roles`) are
11//! structured-editor territory: their rows expand into per-entry lists
12//! ([`SettingsMapRow`]) and their commits go through the typed helpers
13//! below (`set_action_combos`, `set_model_role`, `remove_model_role`),
14//! never through the scalar `apply_change`.
15//!
16//! Constraints carried from the task brief:
17//! - `Settings` itself is unchanged — this module adds ACCESS, not
18//!   persisted state.
19//! - `apply_change` for `DisabledTools` / `ModelRoles` / `Keybindings`
20//!   must `bail!` so a stray scalar call is loud, never a silent no-op
21//!   (their structured editors own those fields).
22
23use crate::store::settings::{Settings, ThinkingLevel};
24use std::str::FromStr;
25
26/// Local Display helpers for `ThinkingLevel` / `EditFormat` —
27/// kept here rather than on the source enums to avoid touching
28/// `store::settings` (Task 1 adds access only, never new
29/// persisted state or impls).
30fn 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/// Stable identifier for every editable setting in the panel.
50///
51/// `Pointer` rows (e.g. `Theme`, `Model`, `Hooks`, `*Paths`) are
52/// read-only views into out-of-band state (slash commands, `oxicode
53/// config`) and intentionally have no `apply_change` arm.
54#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
55pub enum SettingKey {
56    // Model / Defaults
57    ThinkingLevel,
58    AutoCompaction,
59    GlyphSet,
60    EditFormat,
61    // General / Behaviour
62    ExtensionsEnabled,
63    SessionHistorySize,
64    ToolTimeoutSecs,
65    AskTimeoutSecs,
66    DisabledTools,
67    CommitToolEnabled,
68    TodoPanelEnabled,
69    AgentHubEnabled,
70    MermaidRenderEnabled,
71    SnapcompactEnabled,
72    // Advisor & Memory
73    MemoryEnabled,
74    TtsrEnabled,
75    TtsrInterruptMode,
76    AdvisorEnabled,
77    AdvisorSyncBacklog,
78    AdvisorImmuneTurns,
79    // Model defaults (Text) — spec §5 / §8. Empty input clears the
80    // override (sets the field back to `None`); non-empty input is
81    // parsed and range-validated before commit. Both fields are
82    // Optional on `Settings`; the panel only ever shows them as Text,
83    // never a Toggle.
84    DefaultTemperature,
85    MaxResponseTokens,
86    // Map editors (Tasks 5/6)
87    ModelRoles,
88    Keybindings,
89    Theme,
90    Model,
91    CustomProviders,
92    Hooks,
93    ExtensionPaths,
94    SkillPaths,
95    PromptPaths,
96    ThemePaths,
97}
98
99/// Top-level tab the row renders under.
100#[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/// Widget kind used to edit the row. The renderer dispatches on this.
113#[derive(Clone, Copy, PartialEq, Eq, Debug)]
114pub enum SettingWidget {
115    /// Boolean on/off.
116    Toggle,
117    /// Discrete cycle (e.g. `Off` → `Minimal` → `Low` …).
118    Cycle,
119    /// Pick from a fixed enum-of-strings list (rendered as a submenu).
120    SubmenuSelect(&'static [&'static str]),
121    /// Free-form text input.
122    Text,
123    /// Set of strings toggled individually.
124    Multiselect,
125    /// `HashMap<String, String>` edited via a dedicated screen.
126    MapEditor,
127    /// Read-only summary pointing the user at a slash command.
128    Pointer,
129}
130
131/// Declarative metadata for a single setting row.
132pub struct SettingDef {
133    pub key: SettingKey,
134    pub tab: SettingsTab,
135    /// Group label used to render a section header. Must be non-empty;
136    /// groups are rendered contiguously in declaration order.
137    pub group: &'static str,
138    pub label: &'static str,
139    pub description: &'static str,
140    pub widget: SettingWidget,
141    /// Optional visibility predicate. `None` ⇒ always visible.
142    /// `fn` pointer (not closure) so the def table stays `const`.
143    pub condition: Option<fn(&Settings) -> bool>,
144}
145
146/// The full table. Order matters: `defs_for_tab` preserves it and the
147/// renderer renders groups contiguously, so visually related rows must
148/// sit next to each other (and a new group must start with a fresh
149/// header row).
150pub const SETTING_DEFS: &[SettingDef] = &[
151    // ── General ──────────────────────────────────────────────────────
152    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    // ── Model ────────────────────────────────────────────────────────
180    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    // ── Interaction ──────────────────────────────────────────────────
235    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    // ── Tools ────────────────────────────────────────────────────────
263    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    // ── UI ───────────────────────────────────────────────────────────
300    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    // ── Advisor & Memory ─────────────────────────────────────────────
346    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    // ── Keybindings ──────────────────────────────────────────────────
401    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    // ── Advanced ─────────────────────────────────────────────────────
411    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
449/// Return every def that belongs to `tab`, in declaration order, with
450/// each row's `condition` evaluated against `s`.
451///
452/// Groups are preserved contiguously because `SETTING_DEFS` is
453/// already sorted by (tab, group, declaration).
454pub 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
461/// Render the row's current value as a string. The renderer uses this
462/// for both the right-hand summary cell and (for `Cycle` widgets) the
463/// "next value" preview.
464pub 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        // Model defaults (Text). `None` renders as "default" so the
488        // user can distinguish "unset, model picks the value" from a
489        // numeric override.
490        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
511/// Apply a scalar edit.
512///
513/// `new` is the stringified new value:
514/// - `Toggle`: `"true"` / `"false"`
515/// - `Cycle`: the variant's `Display` form
516/// - `SubmenuSelect`: one of the allowed strings
517/// - `Text`: free-form, parsed via `FromStr` on the target field type
518///
519/// Returns `Err` on:
520/// - parse failure (unknown cycle variant, non-numeric text)
521/// - edits to `DisabledTools` / `ModelRoles` / `Keybindings` (their
522///   structured editors own those fields — must not silently no-op)
523/// - edits to any `Pointer` row (read-only by design)
524pub 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        // Model defaults (Text). Empty input clears the override
560        // (back to `None`); non-empty input is parsed + range-checked.
561        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        // Structured editors own these maps — a stray scalar call must
590        // be loud, never a silent no-op.
591        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        // Pointer rows are read-only by design (slash-command driven).
595        Theme | Model | CustomProviders | Hooks | ExtensionPaths | SkillPaths | PromptPaths
596        | ThemePaths => anyhow::bail!("read-only"),
597    }
598    Ok(())
599}
600
601/// Toggle-helper used by the `DisabledTools` multiselect editor
602/// (Task 5). `enabled = true` removes the tool from the disabled set;
603/// `enabled = false` adds it if absent.
604pub 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/// Row-kind metadata for the map-editor expansions, index-aligned with
613/// the items emitted by
614/// `settings_overlay_items` (crate::tui_vt::slash::registry).
615///
616/// `None` entries are ordinary rows (group headings, scalar setting
617/// rows); the settings panel's input handling consults this table to
618/// route `Enter` / `d` / `n` on map rows without needing a new
619/// cross-crate `InlineListSelection` variant per map entry.
620#[derive(Clone, Debug, PartialEq, Eq)]
621pub enum SettingsMapRow {
622    /// Keybindings tab: an action header row (Enter opens the
623    /// key-capture submenu).
624    KeybindingAction(crate::tui_vt::keymap::GlobalAction),
625    /// Keybindings tab: one bound combo of the action (`d` removes it).
626    KeybindingCombo(crate::tui_vt::keymap::GlobalAction, String),
627    /// Model tab: one `model_roles` entry (Enter edits the value, `d`
628    /// deletes the role).
629    ModelRole(String),
630}
631
632/// Record `action`'s full effective combo list in
633/// `settings.keybindings`. When the list is identical to the built-in
634/// default the override entry is removed instead, so the persisted map
635/// stays minimal (`Keymap::from_settings` re-seeds defaults anyway).
636pub 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
653/// Insert or update one `model_roles` entry.
654pub fn set_model_role(s: &mut Settings, role: &str, model: String) {
655    s.model_roles.insert(role.to_string(), model);
656}
657
658/// Remove one `model_roles` entry. Returns whether the role existed.
659/// Unlike keybindings there is no last-entry guard — an empty
660/// `model_roles` is a perfectly valid state.
661pub 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    /// Every entry must have a non-empty group, AND within each tab
670    /// every group label must appear in a single contiguous run —
671    /// `[Defaults, Pointers, Defaults]` is the exact regression this
672    /// test is meant to catch. Cross-group transitions is fine (e.g.
673    /// `[Defaults, Pointers]`), as long as no group label reappears
674    /// after a different one has been seen within the same tab.
675    #[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        // For each tab, walk the groups in declaration order and
686        // record which group labels we've already "completed".
687        // Re-using a completed label splits the section.
688        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    /// Round-trip `Cycle` widget: parse → display preserves the variant.
714    #[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        // Invalid input is rejected.
722        assert!(apply_change(SettingKey::ThinkingLevel, &mut s, "yikes".into()).is_err());
723    }
724
725    /// Round-trip `Toggle` widget.
726    #[test]
727    fn auto_compaction_round_trips() {
728        let mut s = Settings::default();
729        // Default is `true`; flip to `false` then back.
730        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    /// Round-trip `Text` widget: parse → display preserves the number.
738    #[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        // Non-numeric input is rejected (no silent fallback).
745        assert!(apply_change(SettingKey::ToolTimeoutSecs, &mut s, "abc".into()).is_err());
746    }
747
748    /// `defs_for_tab` respects the `condition` predicate: with the
749    /// advisor disabled, the advisor's child rows are hidden; with it
750    /// enabled, they reappear.
751    #[test]
752    fn defs_for_tab_respects_condition() {
753        let mut s = Settings::default();
754        // Default: advisor disabled → child rows hidden.
755        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        // Enable the advisor → child rows appear.
763        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        // Same predicate for TTSR.
772        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    /// Calling `apply_change` on the structured-editor rows must
782    /// surface a loud error rather than silently no-op.
783    #[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    /// Pointer rows are read-only.
795    #[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    /// Round-trip the two Model-tab Text defaults (final-fix wave):
816    /// `default_temperature` parses + range-checks + displays, empty
817    /// input clears the override back to `None` ("default"), and
818    /// out-of-range values are rejected.
819    #[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        // Empty input clears the override.
831        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        // Out-of-range and non-numeric inputs are rejected.
838        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        // Boundary values are accepted.
851        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    /// Round-trip `max_response_tokens`: parse + display, empty clears
858    /// to `None`, zero and non-numeric inputs are rejected.
859    #[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        // Empty input clears the override.
870        apply_change(SettingKey::MaxResponseTokens, &mut s, "".into()).unwrap();
871        assert_eq!(s.max_response_tokens, None);
872        // Zero and non-numeric are rejected.
873        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    /// Both new defs render on the Model tab under the Defaults group
884    /// as Text widgets (spec §8).
885    #[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    /// `toggle_disabled_tool` is idempotent: adding an already-absent
904    /// tool adds it; toggling the same tool twice is a no-op.
905    #[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}