//! Settings UI: command palette, settings modal, toggles, resets, and rollback. use super::setters::{ pr13_effective_default, set_ask_user_question_timeout_enabled_inner, set_auto_dark_theme_inner, set_auto_light_theme_inner, set_auto_update_inner, set_collapsed_edit_blocks_inner, set_compact_mode, set_compact_mode_inner, set_contextual_hint_inner, set_default_selected_permission_inner, set_display_refresh_auto_cadence_inner, set_fork_secondary_model_inner, set_group_tool_verbs_inner, set_hunk_tracker_mode_inner, set_invert_scroll_inner, set_keep_text_selection_inner, set_max_thoughts_width_inner, set_multiline_mode, set_prompt_suggestions_inner, set_remember_tool_approvals_inner, set_render_mermaid_inner, set_respect_manual_folds_inner, set_screen_mode_inner, set_scroll_lines_inner, set_scroll_mode_inner, set_scroll_speed_inner, set_show_thinking_blocks_inner, set_show_tips_inner, set_simple_mode_inner, set_theme_inner, set_timeline_inner, set_timestamps, set_timestamps_inner, set_vim_mode_inner, }; use crate::app::actions::{Action, Effect}; use crate::app::app_view::{ActiveView, AppView}; use crate::app::dispatch::ctx::with_active_agent; use crate::app::dispatch::modes::{set_yolo_mode_inner, sync_active_auto_flag}; use crate::app::dispatch::router::dispatch; use crate::app::dispatch::turn::apply_cancel_subagents_preference_global; use crate::scrollback::block::RenderBlock; use agent_client_protocol as acp; /// Format a "✓ Label: value" success toast. pub(in crate::app::dispatch) fn save_success_toast(label: &str, on: bool) -> String { let value = if on { "on" } else { "off" }; format!("\u{2713} {label}: {value}") } /// Refresh every open settings modal's `ui_snapshot` + `pager_snapshot` /// so the next render reads the latest live state. The modal stores /// snapshots by value; without this, toggles would appear stuck. pub(crate) fn refresh_open_settings_modals(app: &mut AppView) { use crate::views::modal::ActiveModal; // Early exit when no settings modal is open (common case). if !app.agents.values().any(|a| { matches!( a.active_modal, Some(ActiveModal::Settings { .. } | ActiveModal::ResetSettingsConfirm { .. }) ) }) { return; } let ui_snapshot = app.current_ui.clone(); // Capture app-level fields before the mut-borrow loop. let show_tips_from_app = app.show_tips; let auto_update_from_app = app.auto_update; let respect_manual_folds_from_app = app.appearance.scrollback.scroll.respect_manual_folds; let auto_mode_gate_from_app = app.auto_mode_gate; let ask_user_question_timeout_enabled_from_app = app.ask_user_question_timeout_enabled; for agent in app.agents.values_mut() { // Walk both `Settings` and `ResetSettingsConfirm` — the // confirm dialog embeds settings state that must stay fresh // through async persist failures. let state_opt = match agent.active_modal.as_mut() { Some(ActiveModal::Settings { state }) => Some(state.as_mut()), Some(ActiveModal::ResetSettingsConfirm { settings_state, .. }) => { Some(settings_state.as_mut()) } _ => None, }; if let Some(state) = state_opt { state.rebuild_rows(); state.ui_snapshot = ui_snapshot.clone(); state.pager_snapshot = crate::settings::PagerLocalSnapshot { multiline_mode: agent.multiline_mode, yolo_mode: agent.session.is_yolo(), auto_mode: agent.session.is_auto(), current_model_name: agent.session.models.current_model_name(), available_models: agent .session .models .available .iter() .map(|(id, info)| (info.name.clone(), id.clone())) .collect(), // Prefer optimistic pending over confirmed active. plan_mode_active: agent.plan_mode_pending.unwrap_or(agent.plan_mode_active), show_tips: show_tips_from_app, auto_update: auto_update_from_app, vim_mode: crate::appearance::cache::load_vim_mode(), scroll_speed: crate::appearance::cache::load_scroll_speed(), respect_manual_folds: respect_manual_folds_from_app, auto_mode_gate: auto_mode_gate_from_app, ask_user_question_timeout_enabled: ask_user_question_timeout_enabled_from_app, }; } } } /// Open the command palette (the `/help` slash command path). Mirrors the /// keybinding handler (`ActionId::CommandPalette`); toggles closed if already /// open. Hosted inline in minimal mode by the overlay app-modal host. pub(in crate::app::dispatch) fn dispatch_open_command_palette(app: &mut AppView) -> Vec { use crate::views::modal::ActiveModal; let ActiveView::Agent(id) = app.active_view else { return vec![]; }; let Some(agent) = app.agents.get_mut(&id) else { return vec![]; }; if matches!( &agent.active_modal, Some(ActiveModal::CommandPalette { .. }) ) { agent.active_modal = None; return vec![]; } agent.active_modal = Some(ActiveModal::CommandPalette { entries: crate::views::modal::default_palette_entries(), // Type-to-find: open in input mode (matches Ctrl+P). state: crate::views::picker::PickerState::input_active(), window: crate::views::modal_window::ModalWindowState::new(), }); vec![] } /// Open the How-to Guides doc picker (`/docs`). Toggles closed if already open. pub(in crate::app::dispatch) fn dispatch_open_howto_guides(app: &mut AppView) -> Vec { use crate::views::modal::ActiveModal; let ActiveView::Agent(id) = app.active_view else { return vec![]; }; let Some(agent) = app.agents.get_mut(&id) else { return vec![]; }; if matches!(&agent.active_modal, Some(ActiveModal::DocPicker { .. })) { agent.active_modal = None; return vec![]; } agent.active_modal = Some(crate::views::modal::howto_list_modal(None)); vec![] } /// Open the settings modal. Reads the live `UiConfig` snapshot /// (sans-IO). Single-instance: `debug_assert!` catches routing bugs. pub(in crate::app::dispatch) fn dispatch_open_settings(app: &mut AppView) -> Vec { use crate::views::modal::ActiveModal; use crate::views::settings_modal::SettingsModalState; let ActiveView::Agent(id) = app.active_view else { return vec![]; }; // Snapshot the registry + UiConfig + pager-local state BEFORE the // mutable borrow on `agent` so the borrow checker is happy. let registry = app.settings_registry.clone(); let ui_snapshot = app.current_ui.clone(); // Capture app-level fields before the mut-borrow on the agent. let show_tips_from_app = app.show_tips; let auto_update_from_app = app.auto_update; let respect_manual_folds_from_app = app.appearance.scrollback.scroll.respect_manual_folds; let auto_mode_gate_from_app = app.auto_mode_gate; let ask_user_question_timeout_enabled_from_app = app.ask_user_question_timeout_enabled; let Some(agent) = app.agents.get_mut(&id) else { return vec![]; }; debug_assert!( !matches!(&agent.active_modal, Some(ActiveModal::Settings { .. })), "OpenSettings dispatched while settings modal is already open — input routing bug" ); // Defensive close in release builds: silent no-op risk is higher // than the cost of a single extra branch on a hot path that isn't // hot. Mirrors the shortcuts-cheatsheet precedent at // `views/shortcuts_help.rs:336-340`. if matches!(&agent.active_modal, Some(ActiveModal::Settings { .. })) { agent.active_modal = None; return vec![]; } tracing::info!(target: "settings", "opened modal"); let pager_snapshot = crate::settings::PagerLocalSnapshot { multiline_mode: agent.multiline_mode, yolo_mode: agent.session.is_yolo(), auto_mode: agent.session.is_auto(), current_model_name: agent.session.models.current_model_name(), available_models: agent .session .models .available .iter() .map(|(id, info)| (info.name.clone(), id.clone())) .collect(), // Prefer optimistic pending over confirmed active. plan_mode_active: agent.plan_mode_pending.unwrap_or(agent.plan_mode_active), show_tips: show_tips_from_app, auto_update: auto_update_from_app, vim_mode: crate::appearance::cache::load_vim_mode(), scroll_speed: crate::appearance::cache::load_scroll_speed(), respect_manual_folds: respect_manual_folds_from_app, auto_mode_gate: auto_mode_gate_from_app, ask_user_question_timeout_enabled: ask_user_question_timeout_enabled_from_app, }; let state = Box::new(SettingsModalState::new( registry, ui_snapshot, pager_snapshot, )); agent.active_modal = Some(ActiveModal::Settings { state }); vec![] } /// Open the reset-settings confirmation modal. /// /// **Modal stack contract.** The current `ActiveModal::Settings` /// state is moved out and stored inside the new /// `ActiveModal::ResetSettingsConfirm { settings_state, key, .. }` /// variant — preserving the user's filter, scroll position, and /// selection across the confirm dialog. Both `ConfirmResetSetting` /// branches (Reset / Cancel) move the state back out, restoring /// `ActiveModal::Settings`. The box keeps the move a pointer swap. /// /// **Routing precondition.** This action is only emitted from the /// settings modal's `d` keystroke (`views/settings_modal.rs::handle_browse`), /// which guarantees the active modal IS `ActiveModal::Settings` at /// this point. We `debug_assert!` to catch a routing regression in /// debug builds; in release we treat the mismatch as a no-op (a /// foreign caller dispatching `OpenResetConfirm` without an open /// Settings modal would otherwise crash). pub(in crate::app::dispatch) fn dispatch_open_reset_confirm( app: &mut AppView, key: crate::settings::SettingKey, ) -> Vec { use crate::views::modal::{ActiveModal, ModalConfirmation}; let ActiveView::Agent(id) = app.active_view else { return vec![]; }; let Some(agent) = app.agents.get_mut(&id) else { return vec![]; }; // Take the Settings modal state out so we can move it into the // confirmation variant. If the active modal is anything else // (routing bug), restore the original modal and bail. The // debug_assert surfaces the bug in tests; release builds degrade // to a silent no-op rather than a panic. let prior = agent.active_modal.take(); let settings_state = match prior { Some(ActiveModal::Settings { state }) => state, other => { debug_assert!( matches!(other, Some(ActiveModal::Settings { .. })), "OpenResetConfirm dispatched without an open Settings modal — input routing bug", ); agent.active_modal = other; return vec![]; } }; // debug! level — per-keystroke logging would flood production. tracing::debug!(target: "settings", key, "opened reset-confirm modal"); agent.active_modal = Some(ActiveModal::ResetSettingsConfirm { modal: ModalConfirmation::reset_settings(), key, settings_state, }); vec![] } /// Resolve the reset-settings confirmation modal. /// /// Both Reset and Cancel branches: /// 1. Take the `ResetSettingsConfirm` variant out of `active_modal`. /// 2. Restore `ActiveModal::Settings { state }` from the /// preserved `settings_state` so the underlying modal is /// visible again with its filter/scroll/selection intact. /// /// Reset additionally: /// 3. Looks up the registered default for the target setting via /// `crate::settings::default_value_for(meta)`. /// 4. If the current value already equals the default (idempotent /// reset), shows an "Already at default" toast and skips dispatch. /// 5. Otherwise maps the default value to the matching typed /// `Action::SetX(default)` via `action_for_reset` and dispatches /// it recursively. The recursive dispatch runs the full set_X /// pipeline (persist → toast → refresh open modal snapshots), /// so the modal indicator reflects the new value on the next /// frame. /// /// **Why dispatch recursively rather than call `set_X(app, default)` /// directly?** Each typed setter's `set_X(app, new)` function is /// `fn` (not `pub fn`) — they're internal to dispatch.rs. The /// alternative would be to either (a) expose every setter publicly, /// or (b) duplicate the routing match here. Recursive dispatch is /// the lowest-coupling option: when a new setting lands, the /// `Action::SetX → set_X(app, v)` arm is added to dispatch's top- /// level match (one line) AND an `action_for_reset` arm is added /// (one line); no new public API surface. The recursion depth is /// at most 2 `dispatch` frames. pub(in crate::app::dispatch) fn dispatch_confirm_reset_setting( app: &mut AppView, choice: crate::views::modal::ResetSettingsResult, ) -> Vec { use crate::views::modal::{ActiveModal, ResetSettingsResult}; let ActiveView::Agent(id) = app.active_view else { return vec![]; }; let Some(agent) = app.agents.get_mut(&id) else { return vec![]; }; // Take the ResetSettingsConfirm modal out and restore Settings. // Take the ResetSettingsConfirm modal out and capture the // target key from the variant (single source of truth — the // Action does NOT carry the key, eliminating the desync risk // a future emitter could introduce). let prior = agent.active_modal.take(); let (key, settings_state) = match prior { Some(ActiveModal::ResetSettingsConfirm { key, settings_state, .. }) => (key, settings_state), other => { debug_assert!( matches!(other, Some(ActiveModal::ResetSettingsConfirm { .. })), "ConfirmResetSetting dispatched without an open ResetSettingsConfirm modal — \ input routing bug", ); agent.active_modal = other; return vec![]; } }; agent.active_modal = Some(ActiveModal::Settings { state: settings_state, }); match choice { ResetSettingsResult::Cancel => { tracing::debug!(target: "settings", key, "reset confirmation cancelled"); vec![] } ResetSettingsResult::Reset => { let registry_arc = app.settings_registry.clone(); let Some(meta) = registry_arc.find(key) else { tracing::error!( target: "settings", key, "reset target points at unregistered key — registry/dispatch skew", ); return vec![]; }; let default_value = crate::settings::default_value_for(meta); // Gate idempotent reset: already-at-default → toast only. let pager_snapshot = build_pager_snapshot(app); let current_value = crate::settings::current_value_for(key, &app.current_ui, &pager_snapshot); if current_value.as_ref() == Some(&default_value) { tracing::debug!( target: "settings", key, ?default_value, "reset skipped — setting already at default", ); with_active_agent(app, |agent| { agent.show_toast(&format!("{}: already at default", meta.label)); }); return vec![]; } let Some(action) = action_for_reset(key, &default_value) else { tracing::error!( target: "settings", key, ?default_value, "reset has no action_for_reset arm — registry/dispatch skew", ); return vec![]; }; tracing::info!( target: "settings", key, ?default_value, "resetting setting to default", ); // Recursive dispatch — runs the full set_X pipeline. // See the docstring above for why recursion is the // right tool here. Max depth: 2 dispatch frames. dispatch(action, app) } } } /// Toggle multiline input mode (Ctrl+M keybinding path). Delegates /// to `set_multiline_mode` (PAGER-OWNED, per-agent ephemeral). pub(in crate::app::dispatch) fn dispatch_toggle_multiline(app: &mut AppView) -> Vec { let ActiveView::Agent(id) = app.active_view else { return vec![]; }; let Some(agent) = app.agents.get(&id) else { return vec![]; }; let new = !agent.multiline_mode; set_multiline_mode(app, new) } /// Toggle compact mode (keybinding path). Delegates to the /// registry-driven `set_compact_mode` so the cache, modal snapshot, /// and `Effect::PersistSetting` all flow through one path. pub(in crate::app::dispatch) fn dispatch_toggle_compact_mode(app: &mut AppView) -> Vec { // Toggle the USER value: `appearance.prompt.compact` is the derived render // value, which auto-compact forces on short terminals regardless of it. let new = !app.current_ui.compact_mode; set_compact_mode(app, new) } /// Toggle vim-style scrollback keybindings (`/vim-mode` slash command path). /// /// When OFF (default): bare-letter and Shift+letter scrollback bindings /// (j/k/h/l/g/G/y/Y/o/O/r/x/e/E/L/H plus the `i` FocusPrompt alt) are /// suppressed; users get a "letter pressed in scrollback jumps to prompt /// and types the character" behaviour instead. Arrow/Tab/Esc/Space/PgUp/ /// PgDn and all Ctrl+letter bindings remain active in both modes. /// /// Delegates to the registry-driven `set_vim_mode` so the cache, modal /// snapshot, toast, and `Effect::PersistSetting` (which writes /// `[ui].vim_mode` to config.toml) all flow through one path — matching /// the `dispatch_toggle_multiline` / `dispatch_toggle_compact_mode` / /// `dispatch_toggle_timestamps` pattern. pub(in crate::app::dispatch) fn dispatch_toggle_vim_mode(app: &mut AppView) -> Vec { // Toggle the EFFECTIVE value (the pager cache) so `/vim-mode` works // from ANY view — including the session-less dashboard. Previously // this early-returned unless an agent was active, so running // `/vim-mode` on the dashboard was a silent no-op and the overview's // j/k navigation (which is gated on vim-mode) never turned on. let prev = crate::appearance::cache::load_vim_mode(); let enabled = !prev; // Propagate to every agent AND every nested subagent view (so // background + open subagent views pick up the change without a // restart) and mirror the pager cache. Shares `set_vim_mode_inner` // with the `SetVimMode` settings path. set_vim_mode_inner(app, enabled); refresh_open_settings_modals(app); let msg = if enabled { "Vim mode: on" } else { "Vim mode: off" }; tracing::info!(vim_mode = enabled, "Vim mode toggled"); match app.active_view { ActiveView::Agent(id) => { if let Some(agent) = app.agents.get_mut(&id) { agent .scrollback .push_block(RenderBlock::system(msg.to_string())); } } ActiveView::AgentDashboard => { // On the dashboard, j/k navigate the overview only when it // holds focus. Turning vim ON focuses the overview so the // user can navigate immediately (mirroring the agent view's // "normal mode"); turning it OFF returns focus to the input. // No agents → nothing to navigate, so stay on the input. The // focus shift (overview highlighted, input dimmed, footer // flips to nav hints) is the feedback — a toast would route // to the dashboard's red error slot. let has_agents = !app.agents.is_empty(); if let Some(d) = app.dashboard.as_mut() { d.list_focused = enabled && has_agents; } } _ => { app.show_toast(msg); } } // Persist like the shared setter so `/vim-mode` survives a restart // (writes `[ui].vim_mode` to config.toml). vec![Effect::PersistSetting { key: "vim_mode", value: crate::settings::SettingValue::Bool(enabled), rollback_value: crate::settings::SettingValue::Bool(prev), }] } /// Toggle timestamps (Ctrl+? keybinding path). Delegates to the /// registry-driven `set_timestamps` so persistence + cache + UI /// reconciliation all flow through a single code path. pub(in crate::app::dispatch) fn dispatch_toggle_timestamps(app: &mut AppView) -> Vec { let show = !app.appearance.show_timestamps; set_timestamps(app, show) } /// Toggle terminal mouse reporting (crossterm mouse capture) at the user's /// discretion. Disabling it lets the terminal handle native click-drag text /// selection and copy/paste; re-enabling restores in-app mouse handling /// (click-to-focus, scrollback selection, scrollbar drag, etc.). /// /// The on-wire enable/disable sequences mirror the auth-screen toggle in /// [`AppView::draw`]; the process-wide [`MOUSE_CAPTURE_ENABLED`] atomic is /// the single source of truth that teardown / panic paths read to decide /// whether to emit the reset sequence. /// /// [`MOUSE_CAPTURE_ENABLED`]: crate::app::MOUSE_CAPTURE_ENABLED pub(in crate::app::dispatch) fn dispatch_toggle_mouse_capture(app: &mut AppView) { use std::sync::atomic::Ordering; let was_enabled = crate::app::MOUSE_CAPTURE_ENABLED.load(Ordering::Acquire); let enable = !was_enabled; crate::unified_log::info( "mouse_reporting_toggle.toggle", None, Some(serde_json::json!({ "was_enabled": was_enabled, "now_enabled": enable, "active_view": format!("{:?}", app.active_view), })), ); kigi_shell::util::with_locked_stderr(|stderr| { let _ = if enable { crossterm::execute!(stderr, crossterm::event::EnableMouseCapture) } else { crossterm::execute!(stderr, crossterm::event::DisableMouseCapture) }; }); // On legacy conhost, DisableMouseCapture restores the *pre-capture* stdin // mode, which may itself have QuickEdit off (per-window profile or a // stale mode from a crashed run) — assert it so "mouse off" actually // hands the terminal native drag-select, the whole point of the toggle. #[cfg(windows)] if !enable { crate::app::win_native_selection::enable_native_selection(); } crate::app::MOUSE_CAPTURE_ENABLED.store(enable, Ordering::Release); // Use with_active_agent (not app.show_toast) so the toast lands on the // visible surface — same path as copy-to-clipboard toasts, including // when a subagent view is active. // // Off state: sticky banner on every agent/subagent view (capture is // process-wide; toast must survive subagent open/close and copy toasts). // On state: clear sticky everywhere + transient confirmation on active view. // Stored in the scrollback form; `AgentView::active_toast_message` swaps it // to the `/toggle-mouse-reporting` form at render time when the prompt is // focused (Ctrl+R only re-enables from scrollback). let mut toast_applied = false; if enable { for agent in app.agents.values_mut() { agent.set_sticky_toast_recursive(None); } with_active_agent(app, |agent| { toast_applied = true; agent.show_toast("Mouse reporting on"); }); } else { for agent in app.agents.values_mut() { agent.set_sticky_toast_recursive(Some(crate::app::MOUSE_OFF_HINT_SCROLLBACK)); toast_applied = true; } } crate::unified_log::info( "mouse_reporting_toggle.toggle_done", None, Some(serde_json::json!({ "now_enabled": enable, "toast_applied": toast_applied, })), ); } /// Helper to read the active agent's `multiline_mode` for the /// pager-local snapshot used in idempotent-reset detection. Returns /// `false` if no active agent (no Settings modal would be open in /// that state, so the value doesn't matter — but the helper has to /// return SOMETHING). fn agent_multiline_mode(app: &AppView) -> bool { if let ActiveView::Agent(id) = app.active_view && let Some(agent) = app.agents.get(&id) { return agent.multiline_mode; } false } /// Helper to read the active agent's `yolo_mode`. See /// [`agent_multiline_mode`] for the no-agent fallback rationale. fn agent_yolo_mode(app: &AppView) -> bool { if let ActiveView::Agent(id) = app.active_view && let Some(agent) = app.agents.get(&id) { return agent.session.is_yolo(); } false } /// Helper to read the active agent's `auto_mode`. See /// [`agent_multiline_mode`] for the no-agent fallback rationale. fn agent_auto_mode(app: &AppView) -> bool { if let ActiveView::Agent(id) = app.active_view && let Some(agent) = app.agents.get(&id) { return agent.session.is_auto(); } false } /// Effective `plan_mode` for the active agent /// (`pending.unwrap_or(active)`). fn agent_plan_mode(app: &AppView) -> bool { if let ActiveView::Agent(id) = app.active_view && let Some(agent) = app.agents.get(&id) { return agent.plan_mode_pending.unwrap_or(agent.plan_mode_active); } false } /// Helper to read the active agent's currently-selected model /// display name. Returns `None` when no agent is active OR when the /// catalog hasn't loaded yet (e.g. early startup). See /// [`agent_multiline_mode`] for the no-agent fallback rationale. /// /// Used by the `default_model` row's `current_value_for`. fn agent_current_model_name(app: &AppView) -> Option { if let ActiveView::Agent(id) = app.active_view && let Some(agent) = app.agents.get(&id) { return agent.session.models.current_model_name(); } None } /// Helper to clone the `(display_name, ModelId)` pairs from the /// active agent's catalog. Returns an empty `Vec` when no agent is /// active OR when the catalog is empty. /// /// Used by the `KnownModel` validator and "resolve user input to /// ModelId" path. fn agent_available_models(app: &AppView) -> Vec<(String, acp::ModelId)> { if let ActiveView::Agent(id) = app.active_view && let Some(agent) = app.agents.get(&id) { return agent .session .models .available .iter() .map(|(id, info)| (info.name.clone(), id.clone())) .collect(); } Vec::new() } /// Build a `PagerLocalSnapshot` from the current `AppView`. pub(crate) fn build_pager_snapshot(app: &AppView) -> crate::settings::PagerLocalSnapshot { crate::settings::PagerLocalSnapshot { multiline_mode: agent_multiline_mode(app), yolo_mode: agent_yolo_mode(app), auto_mode: agent_auto_mode(app), current_model_name: agent_current_model_name(app), available_models: agent_available_models(app), plan_mode_active: agent_plan_mode(app), show_tips: app.show_tips, auto_update: app.auto_update, vim_mode: crate::appearance::cache::load_vim_mode(), scroll_speed: crate::appearance::cache::load_scroll_speed(), respect_manual_folds: app.appearance.scrollback.scroll.respect_manual_folds, auto_mode_gate: app.auto_mode_gate, ask_user_question_timeout_enabled: app.ask_user_question_timeout_enabled, } } /// Map a `(SettingKey, SettingValue)` to the matching typed /// `Action::SetX(value)`. Used by the reset path to turn the /// registered default into a dispatchable Action. CI test /// `every_setting_has_action_for_reset_arm` pins correctness. pub(in crate::app::dispatch) fn action_for_reset( key: crate::settings::SettingKey, value: &crate::settings::SettingValue, ) -> Option { use crate::settings::SettingValue; match (key, value) { ("compact_mode", SettingValue::Bool(b)) => Some(Action::SetCompactMode(*b)), ("show_timestamps", SettingValue::Bool(b)) => Some(Action::SetTimestamps(*b)), ("show_timeline", SettingValue::Bool(b)) => Some(Action::SetTimeline(*b)), ("simple_mode", SettingValue::Bool(b)) => Some(Action::SetSimpleMode(*b)), ("contextual_hints.undo", SettingValue::Bool(b)) => Some(Action::SetContextualHintUndo(*b)), ("contextual_hints.plan_mode", SettingValue::Bool(b)) => { Some(Action::SetContextualHintPlanMode(*b)) } ("contextual_hints.image_input", SettingValue::Bool(b)) => { Some(Action::SetContextualHintImageInput(*b)) } ("contextual_hints.send_now", SettingValue::Bool(b)) => { Some(Action::SetContextualHintSendNow(*b)) } ("contextual_hints.small_screen", SettingValue::Bool(b)) => { Some(Action::SetContextualHintSmallScreen(*b)) } ("contextual_hints.word_select", SettingValue::Bool(b)) => { Some(Action::SetContextualHintWordSelect(*b)) } ("multiline_mode", SettingValue::Bool(b)) => Some(Action::SetMultilineMode(*b)), ("render_mermaid", SettingValue::Enum(s)) => { crate::appearance::RenderMermaid::from_canonical(s).map(Action::SetRenderMermaid) } ("vim_mode", SettingValue::Bool(b)) => Some(Action::SetVimMode(*b)), ("remember_tool_approvals", SettingValue::Bool(b)) => { Some(Action::SetRememberToolApprovals(*b)) } ("toolset.ask_user_question.timeout_enabled", SettingValue::Bool(b)) => { Some(Action::SetAskUserQuestionTimeoutEnabled(*b)) } ("keep_text_selection", SettingValue::Enum(s)) => { crate::appearance::TextSelection::from_canonical(s).map(Action::SetKeepTextSelection) } ("scroll_speed", SettingValue::Int(v)) => Some(Action::SetScrollSpeed(*v)), ("scroll_mode", SettingValue::Enum(s)) => { crate::appearance::ScrollMode::from_canonical(s).map(Action::SetScrollMode) } ("invert_scroll", SettingValue::Bool(b)) => Some(Action::SetInvertScroll(*b)), ("scroll_lines", SettingValue::Int(v)) => Some(Action::SetScrollLines(*v)), ("show_thinking_blocks", SettingValue::Bool(b)) => Some(Action::SetShowThinkingBlocks(*b)), ("group_tool_verbs", SettingValue::Bool(b)) => Some(Action::SetGroupToolVerbs(*b)), ("collapsed_edit_blocks", SettingValue::Bool(b)) => { Some(Action::SetCollapsedEditBlocks(*b)) } ("prompt_suggestions", SettingValue::Bool(b)) => Some(Action::SetPromptSuggestions(*b)), ("respect_manual_folds", SettingValue::Bool(b)) => Some(Action::SetRespectManualFolds(*b)), ("default_selected_permission", SettingValue::Enum(s)) => { Some(Action::SetDefaultSelectedPermission((*s).to_owned())) } ("theme", SettingValue::Enum(s)) => Some(Action::SetTheme((*s).to_owned())), ("auto_dark_theme", SettingValue::Enum(s)) => { Some(Action::SetAutoDarkTheme((*s).to_owned())) } ("auto_light_theme", SettingValue::Enum(s)) => { Some(Action::SetAutoLightTheme((*s).to_owned())) } // Three explicit arms, one per // canonical, all dispatched through the typed // `Action::SetPermissionMode(kind)` (NOT the legacy // `Action::SetYoloMode(bool)`). The reset path IS modal- // initiated (the `d` reset key is part of the // settings modal), so it falls under the same "modal commit // ↦ typed setter" rule as the picker Enter path. // // **Why all three arms (not just "ask").** The registered // default is "ask" today (defs.rs:`SettingKind::Enum { default: // "ask", ... }`), so only the "ask" arm is reachable in // production through the normal reset flow. The other two // arms exist as forcing functions: // - If the registered default changes to // "default" or "always-approve", the matching arm fires // correctly — no silent collapse onto SetYoloMode. // - The arms exercise the same canonical→kind mapping that // `action_for_enum_commit` uses, so the dispatch surface // stays consistent across modal entry points. // // The drift guard // `pr11_permission_mode_kind_canonical_strings_match_choices_catalog` // pins `len == 3` on `PERMISSION_MODE_CHOICES`, so a future // 4th canonical without a matching arm here would fail the // catalog drift test (the registered default would still be // one of the existing arms — but a fresh test // `pr11_action_for_reset_covers_every_permission_mode_canonical` // pins exhaustivity directly). ("permission_mode", SettingValue::Enum("always-approve")) => Some( Action::SetPermissionMode(crate::app::actions::PermissionModeKind::AlwaysApprove), ), ("permission_mode", SettingValue::Enum("ask")) => Some(Action::SetPermissionMode( crate::app::actions::PermissionModeKind::Ask, )), ("permission_mode", SettingValue::Enum("auto")) => Some(Action::SetPermissionMode( crate::app::actions::PermissionModeKind::Auto, )), ("permission_mode", SettingValue::Enum("default")) => Some(Action::SetPermissionMode( crate::app::actions::PermissionModeKind::Default, )), // default_model: empty string → ClearDefaultModel; non-empty // is a registry/dispatch skew guard. ("default_model", SettingValue::String(s)) => { if s.is_empty() { Some(Action::ClearDefaultModel) } else { tracing::error!( target: "settings", value = %s, "action_for_reset(default_model) received non-empty default — \ registry/dispatch skew (default should be empty string)", ); None } } // max_thoughts_width: direct round-trip. ("max_thoughts_width", SettingValue::Int(i)) => Some(Action::SetMaxThoughtsWidth(*i)), // plan_mode: "on" / "off" → PlanModeKind. // "on" arm is a skew guard (default is "off"). ("plan_mode", SettingValue::Enum("off")) => { Some(Action::SetPlanMode(crate::app::actions::PlanModeKind::Off)) } ("plan_mode", SettingValue::Enum("on")) => { Some(Action::SetPlanMode(crate::app::actions::PlanModeKind::On)) } // show_tips / auto_update / display_refresh_auto_cadence: direct bool. ("show_tips", SettingValue::Bool(b)) => Some(Action::SetShowTips(*b)), ("auto_update", SettingValue::Bool(b)) => Some(Action::SetAutoUpdate(*b)), ("display_refresh_auto_cadence", SettingValue::Bool(b)) => { Some(Action::SetDisplayRefreshAutoCadence(*b)) } // hunk_tracker_mode: canonical enum string round-trip. ("hunk_tracker_mode", SettingValue::Enum(s)) => { Some(Action::SetHunkTrackerMode((*s).to_string())) } ("screen_mode", SettingValue::Enum(s)) => Some(Action::SetScreenMode((*s).to_string())), // fork_secondary_model: empty → Clear, non-empty is skew guard. ("fork_secondary_model", SettingValue::String(s)) => { if s.is_empty() { Some(Action::ClearForkSecondaryModel) } else { tracing::error!( target: "settings", value = %s, "action_for_reset(fork_secondary_model) received non-empty default — \ registry/dispatch skew (default should be empty string)", ); None } } _ => None, } } /// Toast shown by the [`apply_setting_rollback`] catch-all when a persisted /// setting has no rollback arm. Shared with `every_persisting_setting_has_rollback_arm` /// so the guard can't be silently defeated by a wording edit. pub(crate) const ROLLBACK_NO_ARM_TOAST: &str = "Settings rolled back, but local state may be out of sync — restart to reload"; /// Apply a rollback `SettingValue` to the in-memory cache. Does NOT /// re-emit `PersistSetting` (avoids infinite loop on persistent /// failure). Can emit companion effects (e.g. reverse `SwitchModel`). /// /// New settings MUST add an arm here. Unknown keys log `error!` — the /// silent data-vs-disk inconsistency is visible to the user. pub(in crate::app::dispatch) fn apply_setting_rollback( app: &mut AppView, key: crate::settings::SettingKey, rollback_value: &crate::settings::SettingValue, ) -> Vec { use crate::settings::SettingValue; let companion_effects: Vec = Vec::new(); match (key, rollback_value) { ("compact_mode", SettingValue::Bool(b)) => set_compact_mode_inner(app, *b), ("show_timestamps", SettingValue::Bool(b)) => set_timestamps_inner(app, *b), ("show_timeline", SettingValue::Bool(b)) => set_timeline_inner(app, *b), ("simple_mode", SettingValue::Bool(b)) => set_simple_mode_inner(app, *b), ("contextual_hints.undo", SettingValue::Bool(b)) => { set_contextual_hint_inner(app, |h, v| h.undo = v, *b) } ("contextual_hints.plan_mode", SettingValue::Bool(b)) => { set_contextual_hint_inner(app, |h, v| h.plan_mode = v, *b) } ("contextual_hints.image_input", SettingValue::Bool(b)) => { set_contextual_hint_inner(app, |h, v| h.image_input = v, *b) } ("contextual_hints.send_now", SettingValue::Bool(b)) => { set_contextual_hint_inner(app, |h, v| h.send_now = v, *b) } ("contextual_hints.small_screen", SettingValue::Bool(b)) => { set_contextual_hint_inner(app, |h, v| h.small_screen = v, *b) } ("contextual_hints.word_select", SettingValue::Bool(b)) => { set_contextual_hint_inner(app, |h, v| h.word_select = v, *b) } ("respect_manual_folds", SettingValue::Bool(b)) => set_respect_manual_folds_inner(app, *b), ("theme", SettingValue::Enum(s)) => set_theme_inner(app, s), ("default_selected_permission", SettingValue::Enum(s)) => { set_default_selected_permission_inner( app, crate::appearance::permission_cursor::DefaultSelectedPermission::from_config_value( s, ), ) } ("cancel_subagents_on_turn_cancel", SettingValue::Enum("ask")) => { app.current_ui.cancel_subagents_on_turn_cancel = None; for agent in app.agents.values_mut() { agent.cancel_subagents_preference = None; } } ("cancel_subagents_on_turn_cancel", SettingValue::Enum("always_stop")) => { apply_cancel_subagents_preference_global(app, true); } ("cancel_subagents_on_turn_cancel", SettingValue::Enum("always_continue")) => { apply_cancel_subagents_preference_global(app, false); } // Rollback for corrupted auto-* = "auto" — clear to None. ("auto_dark_theme", SettingValue::Enum("auto")) => { app.current_ui.auto_dark_theme = None; crate::theme::cache::invalidate_auto_theme_config(); tracing::warn!( target: "settings", key = "auto_dark_theme", "rolled back to `auto` (invalid) — cleared in-memory override to None", ); } ("auto_light_theme", SettingValue::Enum("auto")) => { app.current_ui.auto_light_theme = None; crate::theme::cache::invalidate_auto_theme_config(); tracing::warn!( target: "settings", key = "auto_light_theme", "rolled back to `auto` (invalid) — cleared in-memory override to None", ); } ("auto_dark_theme", SettingValue::Enum(s)) => set_auto_dark_theme_inner(app, s), ("auto_light_theme", SettingValue::Enum(s)) => set_auto_light_theme_inner(app, s), // permission_mode rollback: recover kind from canonical, // run inner, then restore the canonical the inner collapsed. ("permission_mode", SettingValue::Enum(s)) => { use crate::app::actions::PermissionModeKind; let kind = match PermissionModeKind::from_canonical(s) { Some(k) => k, None => { tracing::warn!( target: "settings", key = "permission_mode", value = *s, "rollback received unknown canonical — defaulting to `ask`", ); PermissionModeKind::Ask } }; set_yolo_mode_inner(app, kind.is_always_approve()); // Restore canonical (meaningful for `Default`). app.current_ui.permission_mode = Some(kind.as_canonical().to_string()); // Sync the per-session auto flag ONLY for a permission_mode rollback; // other rollback arms must not clobber it from the global canonical. sync_active_auto_flag(app); } // default_model: deliberately NO revert. The session switch already // succeeded independently (its own failure path reports via // `handle_switch_model_complete`); this arm fires when only the // DISK write of the next-launch default failed — which must not // undo a working switch. Same policy as `PersistPreferredModel` // ("still active for this session"). Regression: reverting here // (plus a reverse SwitchModel) made every picker selection appear // to not take on Windows, where AV/indexer file locks routinely // fail config.toml persists. ("default_model", SettingValue::String(prior)) => { tracing::warn!( target: "settings", key = "default_model", prior = %prior, "default-model persist failed; keeping the live session's \ model (the switch succeeded) — only the next-launch \ default is unsaved", ); } // max_thoughts_width: direct inner call. ("max_thoughts_width", SettingValue::Int(i)) => set_max_thoughts_width_inner(app, *i), // scroll_speed: direct inner call (clamp handled by inner). ("scroll_speed", SettingValue::Int(i)) => set_scroll_speed_inner(app, *i as u8), // scroll_mode: restore the cache mirror to the canonical value. ("scroll_mode", SettingValue::Enum(s)) => { if let Some(mode) = crate::appearance::ScrollMode::from_canonical(s) { set_scroll_mode_inner(app, mode); } } // invert_scroll / scroll_lines: direct inner calls (clamp in inner). ("invert_scroll", SettingValue::Bool(b)) => set_invert_scroll_inner(app, *b), // Effective default is false → restore None (mirror stays disk-synced). ("display_refresh_auto_cadence", SettingValue::Bool(b)) => { if !*b { app.current_ui.display_refresh.auto_cadence_enabled = None; } else { set_display_refresh_auto_cadence_inner(app, *b); } } ("scroll_lines", SettingValue::Int(i)) => set_scroll_lines_inner(app, *i as u8), // vim_mode: direct inner call. ("vim_mode", SettingValue::Bool(b)) => set_vim_mode_inner(app, *b), ("remember_tool_approvals", SettingValue::Bool(b)) => { set_remember_tool_approvals_inner(app, *b) } // ask_user_question timeout: if rollback equals the effective // default, restore to None (keeps mirror in sync with disk). ("toolset.ask_user_question.timeout_enabled", SettingValue::Bool(b)) => { if Some(*b) == pr13_effective_default("toolset.ask_user_question.timeout_enabled") { app.ask_user_question_timeout_enabled = None; } else { set_ask_user_question_timeout_enabled_inner(app, *b); } } ("show_thinking_blocks", SettingValue::Bool(b)) => set_show_thinking_blocks_inner(app, *b), ("group_tool_verbs", SettingValue::Bool(b)) => set_group_tool_verbs_inner(app, *b), ("collapsed_edit_blocks", SettingValue::Bool(b)) => { set_collapsed_edit_blocks_inner(app, *b) } ("prompt_suggestions", SettingValue::Bool(b)) => set_prompt_suggestions_inner(app, *b), // keep_text_selection: restore the cache mirror to the canonical value. ("keep_text_selection", SettingValue::Enum(s)) => { if let Some(kind) = crate::appearance::TextSelection::from_canonical(s) { set_keep_text_selection_inner(kind); } } // render_mermaid: restore the cache mirror to the canonical value. ("render_mermaid", SettingValue::Enum(s)) => { if let Some(kind) = crate::appearance::RenderMermaid::from_canonical(s) { set_render_mermaid_inner(kind); } } // hunk_tracker_mode: restore the in-memory ui mirror. ("hunk_tracker_mode", SettingValue::Enum(s)) => { set_hunk_tracker_mode_inner(app, crate::settings::canonical_hunk_tracker_mode(Some(s))); } ("screen_mode", SettingValue::Enum(s)) => { set_screen_mode_inner(app, crate::settings::canonical_screen_mode(Some(s))); } // show_tips / auto_update: if rollback equals the effective // default, restore to None (keeps mirror in sync with disk). ("show_tips", SettingValue::Bool(b)) => { if Some(*b) == pr13_effective_default("show_tips") { app.show_tips = None; } else { set_show_tips_inner(app, *b); } } ("auto_update", SettingValue::Bool(b)) => { if Some(*b) == pr13_effective_default("auto_update") { app.auto_update = None; } else { set_auto_update_inner(app, *b); } } // fork_secondary_model: empty rollback restores baseline default. ("fork_secondary_model", SettingValue::String(s)) => { let restored = if s.is_empty() { kigi_shell::models::default_model().to_string() } else { s.clone() }; set_fork_secondary_model_inner(app, restored); } _ => { tracing::error!( target: "settings", ?key, ?rollback_value, "rollback path has no arm for this setting key; in-memory cache is now \ inconsistent with the on-disk state (which already failed to write)" ); app.show_toast(ROLLBACK_NO_ARM_TOAST); return companion_effects; } } // Refresh modal snapshots after rollback. refresh_open_settings_modals(app); companion_effects }