Files
Kigi-CLI/crates/codegen/kigi-tui/src/app/dispatch/settings/ui.rs
T
ZacharyZhang-NY f403c38a94 fix(tui): a default-model persist failure no longer reverts the live model
Persist-failure ≠ switch-failure. The default_model rollback arm re-ran
set_default_model_inner(prev) AND issued a reverse SwitchModel whenever
the config.toml write failed — the only mechanism in the codebase that
deliberately re-shows the ORIGINAL model after a successful pick. On
Windows, where AV/indexer file locks routinely failed that write (until
27d009c), every /model selection appeared to not take.

The session switch succeeds independently and reports its own failures
via handle_switch_model_complete; a disk-persist failure now keeps the
live model, logs, and surfaces only the 'Could not save' toast — the
same policy PersistPreferredModel already ships ('still active for this
session').

Verified: kigi-tui 6874 tests green, clippy clean.
2026-07-22 19:55:02 -04:00

1058 lines
47 KiB
Rust

//! 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<Effect> {
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<Effect> {
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<Effect> {
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<Effect> {
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<Effect> {
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<Effect> {
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<Effect> {
// 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<Effect> {
// 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<Effect> {
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<String> {
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<Action> {
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<Effect> {
use crate::settings::SettingValue;
let companion_effects: Vec<Effect> = 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
}