Files
Kigi-CLI/crates/codegen/kigi-tui/src/app/dispatch/settings/setters.rs
T
ZacharyZhang-NY f656627c10 fix(tui): start the session a sessionless model switch defers into
Launching kigi in a non-project directory (~/Downloads, ~, /tmp — anywhere
`is_project_dir` rejects) leaves the agent view session-less behind the
project-picker question, which only a PLAIN prompt can open. Slash
commands still execute there, so `/model <name> <effort>` stashed its
switch in `deferred_model_switch` — a stash that assumes a create is in
flight — and with none pending it dangled forever with zero feedback:
the picker rendered, the user chose model and effort, and nothing
changed. Not a 0.1.10 regression: 0.1.9 pty-reproduces identically; the
report correlated with the upgrade only because every earlier launch
happened to be from a project directory.

Apply the QueueCommand precedent ("queued slash work bypasses the
picker, so create the deferred session or it never drains") to both
deferral sites — Action::SwitchModel in the router and
set_default_model's no-session branch — via
skip_picker_and_create_session, whose in-flight guard already makes it a
no-op while a create is pending, so the racing-create case is unchanged.
SessionCreated then applies the stash through the existing
apply_deferred_model_switch path; pty-verified end-to-end from
~/Downloads (session created, model changed, effort applied).
2026-07-27 15:55:57 -04:00

1869 lines
69 KiB
Rust

//! Individual setting setters with persistence effects and toasts.
use super::ui::{refresh_open_settings_modals, save_success_toast};
use crate::app::actions::Effect;
use crate::app::app_view::{ActiveView, AppView};
use agent_client_protocol as acp;
/// Set multiline input mode — swap Enter and Shift+Enter behavior.
///
/// PAGER-OWNED: ephemeral, no `Effect::PersistSetting`. On the agent
/// view this is per-session (`AgentView::multiline_mode`); on the
/// dashboard it lives on `DashboardState::multiline_mode`. Idempotent.
pub(in crate::app::dispatch) fn set_multiline_mode(app: &mut AppView, new: bool) -> Vec<Effect> {
if matches!(app.active_view, ActiveView::AgentDashboard) {
let Some(d) = app.dashboard.as_mut() else {
return vec![];
};
if d.multiline_mode == new {
return vec![];
}
d.multiline_mode = new;
tracing::info!(
target: "settings",
key = "multiline_mode",
value = new,
surface = "dashboard",
"setting changed",
);
app.show_toast(&save_success_toast("Multiline", new));
return vec![];
}
let ActiveView::Agent(id) = app.active_view else {
return vec![];
};
let Some(agent) = app.agents.get_mut(&id) else {
return vec![];
};
if agent.multiline_mode == new {
return vec![];
}
agent.multiline_mode = new;
refresh_open_settings_modals(app);
tracing::info!(
target: "settings",
key = "multiline_mode",
value = new,
"setting changed",
);
app.show_toast(&save_success_toast("Multiline", new));
vec![]
}
/// State-only mutation for `render_mermaid`: update the process-wide cache so
/// every block picks up the new value on the next frame. The cache is the
/// render-path source of truth; the disk write goes through `PersistSetting`.
pub(super) fn set_render_mermaid_inner(kind: crate::appearance::RenderMermaid) {
crate::appearance::cache::set_render_mermaid(kind);
}
/// Set how ` ```mermaid ` code blocks render (auto/on/off).
///
/// SHELL-OWNED: persisted to `[ui].render_mermaid` via `Effect::PersistSetting`
/// (parity with `vim_mode`), with the cache mirror updated optimistically.
pub(in crate::app::dispatch) fn set_render_mermaid(
app: &mut AppView,
kind: crate::appearance::RenderMermaid,
) -> Vec<Effect> {
let prev = crate::appearance::cache::load_render_mermaid();
if prev == kind {
return vec![];
}
set_render_mermaid_inner(kind);
refresh_open_settings_modals(app);
tracing::info!(
target: "settings",
key = "render_mermaid",
value = kind.as_canonical(),
"setting changed",
);
app.show_toast(&format!("\u{2713} Mermaid: {}", kind.as_canonical()));
vec![Effect::PersistSetting {
key: "render_mermaid",
value: crate::settings::SettingValue::Enum(kind.as_canonical()),
rollback_value: crate::settings::SettingValue::Enum(prev.as_canonical()),
}]
}
/// Mirror the canonical mode into `app.current_ui` so `current_value_for` stays
/// in sync. Called by the commit path AND by [`apply_setting_rollback`](super::ui::apply_setting_rollback).
pub(super) fn set_hunk_tracker_mode_inner(app: &mut AppView, canonical: &str) {
app.current_ui.hunk_tracker_mode = Some(canonical.to_string());
}
pub(super) fn set_screen_mode_inner(app: &mut AppView, canonical: &str) {
app.current_ui.screen_mode = Some(canonical.to_string());
}
/// Persist `[ui].screen_mode` (`fullscreen` | `minimal`). Restart-required.
///
/// Unset is *displayed* as Fullscreen but is not an explicit on-disk value —
/// choosing Fullscreen when missing must still write, or legacy pager.toml /
/// leaky-terminal paths can keep applying after the user confirmed Fullscreen.
pub(in crate::app::dispatch) fn set_screen_mode(app: &mut AppView, value: String) -> Vec<Effect> {
let canonical = crate::settings::canonical_screen_mode(Some(&value));
let prev_raw = app.current_ui.screen_mode.as_deref();
let prev = crate::settings::canonical_screen_mode(prev_raw);
if screen_mode_raw_matches_canonical(prev_raw, canonical) {
return vec![];
}
set_screen_mode_inner(app, canonical);
refresh_open_settings_modals(app);
tracing::info!(target: "settings", key = "screen_mode", value = canonical, "setting changed");
app.show_toast(&format!(
"\u{2713} Screen mode: {canonical} (restart to apply)"
));
vec![Effect::PersistSetting {
key: "screen_mode",
value: crate::settings::SettingValue::Enum(canonical),
rollback_value: crate::settings::SettingValue::Enum(prev),
}]
}
fn screen_mode_raw_matches_canonical(raw: Option<&str>, canonical: &str) -> bool {
let Some(raw) = raw.map(str::trim).filter(|s| !s.is_empty()) else {
return false;
};
match canonical {
"minimal" => raw.eq_ignore_ascii_case("minimal"),
"fullscreen" => raw.eq_ignore_ascii_case("fullscreen") || raw.eq_ignore_ascii_case("full"),
_ => false,
}
}
/// Set the hunk-tracker mode (registry-driven path).
///
/// SHELL-owned, restart-required: persists to `[ui].hunk_tracker_mode` via
/// `Effect::PersistSetting`. The agent re-reads the mode only on the next
/// session connect, so the toast cues a restart.
pub(in crate::app::dispatch) fn set_hunk_tracker_mode(
app: &mut AppView,
value: String,
) -> Vec<Effect> {
let canonical = crate::settings::canonical_hunk_tracker_mode(Some(&value));
let prev =
crate::settings::canonical_hunk_tracker_mode(app.current_ui.hunk_tracker_mode.as_deref());
if prev == canonical {
return vec![];
}
set_hunk_tracker_mode_inner(app, canonical);
refresh_open_settings_modals(app);
tracing::info!(target: "settings", key = "hunk_tracker_mode", value = canonical, "setting changed");
app.show_toast(&format!(
"\u{2713} Hunk tracker: {canonical} (restart to apply)"
));
vec![Effect::PersistSetting {
key: "hunk_tracker_mode",
value: crate::settings::SettingValue::Enum(canonical),
rollback_value: crate::settings::SettingValue::Enum(prev),
}]
}
/// State-only mutation for `vim_mode`. Propagates to every in-process
/// agent so background subagents and side panes pick up the change
/// without restart. The cache mirror lets new agents created later
/// read the same value via `cache::load_vim_mode()` in `AgentView::new`.
pub(super) fn set_vim_mode_inner(app: &mut AppView, new: bool) {
for agent in app.agents.values_mut() {
// Recursive so open subagent views also pick up the change —
// otherwise their scrollback j/k stay in the vim-OFF fallback.
agent.set_vim_mode_recursive(new);
}
crate::appearance::cache::set_vim_mode(new);
}
/// Set vim-mode scrollback keybindings (registry-driven path).
///
/// SHELL-OWNED: persisted to `[ui].vim_mode` in config.toml via
/// `Effect::PersistSetting`. Propagates to every in-process agent.
pub(in crate::app::dispatch) fn set_vim_mode(app: &mut AppView, new: bool) -> Vec<Effect> {
let prev = crate::appearance::cache::load_vim_mode();
if prev == new && app.agents.values().all(|a| a.vim_mode == new) {
return vec![];
}
set_vim_mode_inner(app, new);
refresh_open_settings_modals(app);
tracing::info!(
target: "settings",
key = "vim_mode",
value = new,
"setting changed",
);
app.show_toast(&save_success_toast("Vim scrollback", new));
vec![Effect::PersistSetting {
key: "vim_mode",
value: crate::settings::SettingValue::Bool(new),
rollback_value: crate::settings::SettingValue::Bool(prev),
}]
}
/// Mirror the user-config layer in `current_ui` so the modal reflects it
/// (the effective gate is resolved shell-side at session spawn).
pub(super) fn set_remember_tool_approvals_inner(app: &mut AppView, new: bool) {
app.current_ui.remember_tool_approvals = Some(new);
}
/// SHELL-owned setter for `remember_tool_approvals`; persists via
/// `Effect::PersistSetting`. Applies to new sessions (restart-required).
pub(in crate::app::dispatch) fn set_remember_tool_approvals(
app: &mut AppView,
new: bool,
) -> Vec<Effect> {
let prev_state = app.current_ui.remember_tool_approvals;
let prev_effective = prev_state.unwrap_or(false);
if prev_effective == new && prev_state.is_some() {
return vec![];
}
set_remember_tool_approvals_inner(app, new);
refresh_open_settings_modals(app);
tracing::info!(
target: "settings",
key = "remember_tool_approvals",
value = new,
"setting changed",
);
app.show_toast(&format!(
"{} (restart to apply)",
save_success_toast("Remember tool approvals", new),
));
vec![Effect::PersistSetting {
key: "remember_tool_approvals",
value: crate::settings::SettingValue::Bool(new),
rollback_value: crate::settings::SettingValue::Bool(prev_effective),
}]
}
/// Mirror the just-written TOML value in `app` so the modal reflects it (the
/// effective timeout is re-resolved shell-side at agent build).
pub(super) fn set_ask_user_question_timeout_enabled_inner(app: &mut AppView, new: bool) {
app.ask_user_question_timeout_enabled = Some(new);
}
/// SHELL-owned setter for the ask_user_question timeout gate; persists via
/// `Effect::PersistSetting`. Applies to new sessions (restart-required).
pub(in crate::app::dispatch) fn set_ask_user_question_timeout_enabled(
app: &mut AppView,
new: bool,
) -> Vec<Effect> {
use kigi_tools::implementations::kigi::ask_user_question;
let prev_state = app.ask_user_question_timeout_enabled;
let prev_effective =
prev_state.unwrap_or(ask_user_question::DEFAULT_ASK_USER_QUESTION_TIMEOUT_ENABLED);
if prev_effective == new && prev_state.is_some() {
return vec![];
}
set_ask_user_question_timeout_enabled_inner(app, new);
refresh_open_settings_modals(app);
tracing::info!(
target: "settings",
key = "toolset.ask_user_question.timeout_enabled",
value = new,
"setting changed",
);
app.show_toast(&format!(
"{} (restart to apply)",
save_success_toast("Ask-Question timeout", new),
));
vec![Effect::PersistSetting {
key: "toolset.ask_user_question.timeout_enabled",
value: crate::settings::SettingValue::Bool(new),
rollback_value: crate::settings::SettingValue::Bool(prev_effective),
}]
}
pub(super) fn set_show_thinking_blocks_inner(app: &mut AppView, new: bool) {
crate::appearance::cache::set_show_thinking_blocks(new);
// Thinking visibility reshapes verb-group runs (shown thoughts claim
// into folds) AND dense N-more runs (hidden thoughts drop out of
// truncation participation), so expansion ids describe the OLD grouping
// shape even with `group_tool_verbs` off — drop them like
// `set_group_tool_verbs_inner`.
for agent in app.agents.values_mut() {
agent.scrollback.clear_group_expansion();
agent.scrollback.invalidate_heights();
for child in agent.subagent_views.values_mut() {
child.scrollback.clear_group_expansion();
child.scrollback.invalidate_heights();
}
}
}
/// Set whether agent thinking blocks appear in scrollback.
///
/// SHELL-OWNED: cache mirror + `[ui].show_thinking_blocks` via `Effect::PersistSetting`.
/// Live hide/show: layout treats thinking as zero-height when off; create-gate
/// still skips new thinking while off.
pub(in crate::app::dispatch) fn set_show_thinking_blocks(
app: &mut AppView,
new: bool,
) -> Vec<Effect> {
let prev = crate::appearance::cache::load_show_thinking_blocks();
if prev == new {
return vec![];
}
set_show_thinking_blocks_inner(app, new);
refresh_open_settings_modals(app);
tracing::info!(
target: "settings",
key = "show_thinking_blocks",
value = new,
"setting changed",
);
app.show_toast(&save_success_toast("Thinking blocks", new));
vec![Effect::PersistSetting {
key: "show_thinking_blocks",
value: crate::settings::SettingValue::Bool(new),
rollback_value: crate::settings::SettingValue::Bool(prev),
}]
}
pub(super) fn set_group_tool_verbs_inner(app: &mut AppView, new: bool) {
crate::appearance::cache::set_group_tool_verbs(new);
// Expansion ids describe the OLD grouping shape; drop them so stale ids
// can't reopen a verb slot expanded or mark a coincident dense group
// expanded after the re-fold (see `clear_group_expansion`).
for agent in app.agents.values_mut() {
agent.scrollback.clear_group_expansion();
agent.scrollback.invalidate_heights();
for child in agent.subagent_views.values_mut() {
child.scrollback.clear_group_expansion();
child.scrollback.invalidate_heights();
}
}
}
/// Set whether runs of consecutive non-destructive tool calls and subagent
/// rows are grouped into one transcript row.
///
/// SHELL-OWNED: cache mirror + `[ui].group_tool_verbs` via `Effect::PersistSetting`.
pub(in crate::app::dispatch) fn set_group_tool_verbs(app: &mut AppView, new: bool) -> Vec<Effect> {
let prev = crate::appearance::cache::load_group_tool_verbs();
if prev == new {
return vec![];
}
set_group_tool_verbs_inner(app, new);
refresh_open_settings_modals(app);
tracing::info!(
target: "settings",
key = "group_tool_verbs",
value = new,
"setting changed",
);
app.show_toast(&save_success_toast("Group tool calls", new));
vec![Effect::PersistSetting {
key: "group_tool_verbs",
value: crate::settings::SettingValue::Bool(new),
rollback_value: crate::settings::SettingValue::Bool(prev),
}]
}
pub(super) fn set_collapsed_edit_blocks_inner(app: &mut AppView, new: bool) {
// Read prev from the cache so a rollback restore walks the same flip.
let prev = crate::appearance::cache::load_collapsed_edit_blocks();
crate::appearance::cache::set_collapsed_edit_blocks(new);
if prev == new {
return;
}
// Re-materialize on-default Edit rows + repaint the live +N/-M suffix
// (the flip policy lives on ScrollbackState).
for agent in app.agents.values_mut() {
agent.scrollback.apply_collapsed_edit_blocks_flip(prev, new);
for child in agent.subagent_views.values_mut() {
child.scrollback.apply_collapsed_edit_blocks_flip(prev, new);
}
}
}
/// Set whether Edit blocks default to the collapsed one-line `+N/-M`
/// diffstat summary (expand for the diff).
///
/// SHELL-OWNED: cache mirror + `[ui].collapsed_edit_blocks` via
/// `Effect::PersistSetting`. Explicit pager.toml
/// `[scrollback.blocks.edit]` shape keys override the flag.
pub(in crate::app::dispatch) fn set_collapsed_edit_blocks(
app: &mut AppView,
new: bool,
) -> Vec<Effect> {
let prev = crate::appearance::cache::load_collapsed_edit_blocks();
if prev == new {
return vec![];
}
set_collapsed_edit_blocks_inner(app, new);
refresh_open_settings_modals(app);
tracing::info!(
target: "settings",
key = "collapsed_edit_blocks",
value = new,
"setting changed",
);
app.show_toast(&save_success_toast("Collapsed edit blocks", new));
vec![Effect::PersistSetting {
key: "collapsed_edit_blocks",
value: crate::settings::SettingValue::Bool(new),
rollback_value: crate::settings::SettingValue::Bool(prev),
}]
}
pub(super) fn set_prompt_suggestions_inner(app: &mut AppView, new: bool) {
crate::appearance::cache::set_prompt_suggestions(new);
app.current_ui.prompt_suggestions = Some(new);
}
/// Set whether the predicted-next-prompt ghost (tab autocomplete) is offered.
///
/// SHELL-OWNED: cache mirror + `[ui].prompt_suggestions` via
/// `Effect::PersistSetting`. Read at turn end (fetch gate) and per frame
/// (display gate), so toggling applies without a restart. The
/// `KIGI_PROMPT_SUGGESTIONS` env var overrides the effective value.
pub(in crate::app::dispatch) fn set_prompt_suggestions(
app: &mut AppView,
new: bool,
) -> Vec<Effect> {
let prev = crate::appearance::cache::load_prompt_suggestions();
if prev == new {
return vec![];
}
set_prompt_suggestions_inner(app, new);
refresh_open_settings_modals(app);
tracing::info!(
target: "settings",
key = "prompt_suggestions",
value = new,
"setting changed",
);
app.show_toast(&save_success_toast("Prompt suggestions", new));
vec![Effect::PersistSetting {
key: "prompt_suggestions",
value: crate::settings::SettingValue::Bool(new),
rollback_value: crate::settings::SettingValue::Bool(prev),
}]
}
pub(super) fn set_keep_text_selection_inner(kind: crate::appearance::TextSelection) {
crate::appearance::cache::set_keep_text_selection(kind);
}
/// Set the unified scrollback text-selection mode (`flash` | `hold` |
/// `word_select`): highlight lifetime + double-click action, kept in sync.
///
/// SHELL-OWNED: persisted to `[ui].keep_text_selection` via `Effect::PersistSetting`.
pub(in crate::app::dispatch) fn set_keep_text_selection(
app: &mut AppView,
kind: crate::appearance::TextSelection,
) -> Vec<Effect> {
let prev = crate::appearance::cache::load_keep_text_selection();
if prev == kind {
return vec![];
}
set_keep_text_selection_inner(kind);
refresh_open_settings_modals(app);
tracing::info!(
target: "settings",
key = "keep_text_selection",
value = kind.as_canonical(),
"setting changed",
);
app.show_toast(&format!("\u{2713} Text selection: {}", kind.as_canonical()));
vec![Effect::PersistSetting {
key: "keep_text_selection",
value: crate::settings::SettingValue::Enum(kind.as_canonical()),
rollback_value: crate::settings::SettingValue::Enum(prev.as_canonical()),
}]
}
/// State-only mutation for `scroll_speed`. Updates the process-wide
/// cache and recomputes `app.scroll_config`.
pub(super) fn set_scroll_speed_inner(app: &mut AppView, clamped: u8) {
crate::appearance::cache::set_scroll_speed(clamped);
// Full settings-cache rebuild: preserves the other scroll overrides
// (mode/invert/lines) instead of resetting them to profile defaults.
app.scroll_config = crate::input::mouse::ScrollConfig::from_settings();
}
/// Set mouse-wheel scroll speed (registry-driven path).
///
/// SHELL-OWNED: persisted to `[ui].scroll_speed` in config.toml via
/// `Effect::PersistSetting`. Clamps to `[1, 100]` to match the
/// registry's `Int { min: 1, max: 100 }` bounds.
pub(in crate::app::dispatch) fn set_scroll_speed(app: &mut AppView, raw: i64) -> Vec<Effect> {
let clamped = raw.clamp(1, 100) as u8;
let prev = crate::appearance::cache::load_scroll_speed();
if prev == clamped {
return vec![];
}
set_scroll_speed_inner(app, clamped);
refresh_open_settings_modals(app);
tracing::info!(
target: "settings",
key = "scroll_speed",
value = clamped,
"setting changed",
);
app.show_toast(&format!("\u{2713} Scroll speed: {clamped}"));
vec![Effect::PersistSetting {
key: "scroll_speed",
value: crate::settings::SettingValue::Int(clamped as i64),
rollback_value: crate::settings::SettingValue::Int(prev as i64),
}]
}
/// State-only mutation for `scroll_mode`. Updates the process-wide cache
/// and recomputes `app.scroll_config`.
pub(super) fn set_scroll_mode_inner(app: &mut AppView, mode: crate::appearance::ScrollMode) {
crate::appearance::cache::set_scroll_mode(mode);
app.scroll_config = crate::input::mouse::ScrollConfig::from_settings();
}
/// Set the scroll input classification (`auto` | `wheel` | `trackpad`).
///
/// SHELL-OWNED: persisted to `[ui].scroll_mode` via `Effect::PersistSetting`.
pub(in crate::app::dispatch) fn set_scroll_mode(
app: &mut AppView,
mode: crate::appearance::ScrollMode,
) -> Vec<Effect> {
let prev = crate::appearance::cache::load_scroll_mode();
if prev == mode {
return vec![];
}
set_scroll_mode_inner(app, mode);
refresh_open_settings_modals(app);
tracing::info!(
target: "settings",
key = "scroll_mode",
value = mode.as_canonical(),
"setting changed",
);
app.show_toast(&format!("\u{2713} Scroll input: {}", mode.as_canonical()));
vec![Effect::PersistSetting {
key: "scroll_mode",
value: crate::settings::SettingValue::Enum(mode.as_canonical()),
rollback_value: crate::settings::SettingValue::Enum(prev.as_canonical()),
}]
}
/// State-only mutation for `invert_scroll`. Updates the process-wide cache
/// and recomputes `app.scroll_config`.
pub(super) fn set_invert_scroll_inner(app: &mut AppView, enabled: bool) {
crate::appearance::cache::set_invert_scroll(enabled);
app.scroll_config = crate::input::mouse::ScrollConfig::from_settings();
}
/// Set whether vertical scroll direction is inverted ("natural" scrolling).
///
/// SHELL-OWNED: persisted to `[ui].invert_scroll` via `Effect::PersistSetting`.
pub(in crate::app::dispatch) fn set_invert_scroll(app: &mut AppView, new: bool) -> Vec<Effect> {
let prev = crate::appearance::cache::load_invert_scroll();
if prev == new {
return vec![];
}
set_invert_scroll_inner(app, new);
refresh_open_settings_modals(app);
tracing::info!(
target: "settings",
key = "invert_scroll",
value = new,
"setting changed",
);
app.show_toast(&save_success_toast("Invert scroll", new));
vec![Effect::PersistSetting {
key: "invert_scroll",
value: crate::settings::SettingValue::Bool(new),
rollback_value: crate::settings::SettingValue::Bool(prev),
}]
}
/// State-only mutation for `scroll_lines`. Updates the process-wide cache
/// and recomputes `app.scroll_config`.
pub(super) fn set_scroll_lines_inner(app: &mut AppView, clamped: u8) {
crate::appearance::cache::set_scroll_lines(clamped);
app.scroll_config = crate::input::mouse::ScrollConfig::from_settings();
}
/// Set the lines-per-tick for both wheel and trackpad scrolling.
///
/// SHELL-OWNED: persisted to `[ui].scroll_lines` via `Effect::PersistSetting`.
/// Clamps to `[1, 10]` to match the registry's `Int { min: 1, max: 10 }`
/// bounds.
pub(in crate::app::dispatch) fn set_scroll_lines(app: &mut AppView, raw: i64) -> Vec<Effect> {
let clamped = raw.clamp(1, 10) as u8;
// `None` (never configured → profile default) counts as changed: an
// explicit user value must stick even when it matches the profile.
let prev = crate::appearance::cache::load_scroll_lines();
if prev == Some(clamped) {
return vec![];
}
set_scroll_lines_inner(app, clamped);
refresh_open_settings_modals(app);
tracing::info!(
target: "settings",
key = "scroll_lines",
value = clamped,
"setting changed",
);
app.show_toast(&format!("\u{2713} Scroll lines: {clamped}"));
vec![Effect::PersistSetting {
key: "scroll_lines",
value: crate::settings::SettingValue::Int(clamped as i64),
// prev=None (never configured) can't round-trip through
// SettingValue::Int — rolling back to an explicit 3 is the same
// deliberate one-way door as the `d`-reset path.
rollback_value: crate::settings::SettingValue::Int(prev.unwrap_or(3) as i64),
}]
}
pub(super) fn set_respect_manual_folds_inner(app: &mut AppView, new: bool) {
let mut config = app.appearance.clone();
config.scrollback.scroll.respect_manual_folds = new;
app.set_appearance(config);
}
/// Set `respect_manual_folds` (registry-driven path).
///
/// PAGER-OWNED: live-applied to every agent via `AppView::set_appearance`
/// and persisted to `[scrollback.scroll]` in pager.toml via
/// `Effect::PersistSetting`.
pub(in crate::app::dispatch) fn set_respect_manual_folds(
app: &mut AppView,
new: bool,
) -> Vec<Effect> {
let prev = app.appearance.scrollback.scroll.respect_manual_folds;
if prev == new {
return vec![];
}
set_respect_manual_folds_inner(app, new);
refresh_open_settings_modals(app);
tracing::info!(
target: "settings",
key = "respect_manual_folds",
value = new,
"setting changed",
);
app.show_toast(&save_success_toast("Respect manual folds", new));
vec![Effect::PersistSetting {
key: "respect_manual_folds",
value: crate::settings::SettingValue::Bool(new),
rollback_value: crate::settings::SettingValue::Bool(prev),
}]
}
/// Set the cursor preselection canonical (registry-driven path).
///
/// SHELL-OWNED: persisted to `[ui].default_selected_permission` via
/// `Effect::PersistSetting`. The `always_allow_all_sessions` canonical is the
/// effective default (the cursor falls to the enable-always-approve row).
pub(in crate::app::dispatch) fn set_default_selected_permission(
app: &mut AppView,
new: String,
) -> Vec<Effect> {
use crate::appearance::permission_cursor::DefaultSelectedPermission;
// All callers pass a registry canonical; parsing is total (unknown →
// `Default`), so a garbage input degrades to the safe "no preselection"
// value. `debug_assert` catches a dispatch bug in tests without a parallel
// validator on the hot path.
let parsed = DefaultSelectedPermission::from_config_value(&new);
debug_assert_eq!(
parsed.as_canonical(),
new.as_str(),
"SetDefaultSelectedPermission expects a registry canonical",
);
let new_canonical = parsed.as_canonical();
let prev_canonical = app
.current_ui
.default_selected_permission
.as_deref()
.map_or(DefaultSelectedPermission::AlwaysAllowAllSessions, |s| {
DefaultSelectedPermission::from_config_value(s)
})
.as_canonical();
if prev_canonical == new_canonical {
return vec![];
}
set_default_selected_permission_inner(app, parsed);
refresh_open_settings_modals(app);
tracing::info!(
target: "settings",
key = "default_selected_permission",
value = new_canonical,
"setting changed",
);
app.show_toast(&format!(
"\u{2713} Default selected permission: {}",
parsed.display(),
));
vec![Effect::PersistSetting {
key: "default_selected_permission",
value: crate::settings::SettingValue::Enum(new_canonical),
rollback_value: crate::settings::SettingValue::Enum(prev_canonical),
}]
}
/// State + cache mutation for `default_selected_permission`. Called by the
/// commit path AND by [`apply_setting_rollback`](super::ui::apply_setting_rollback) on persist failure.
pub(super) fn set_default_selected_permission_inner(
app: &mut AppView,
value: crate::appearance::permission_cursor::DefaultSelectedPermission,
) {
// Mirror to in-memory `UiConfig` so `current_value_for` stays in sync
// with the cache for the open settings modal.
app.current_ui.default_selected_permission = Some(value.as_canonical().to_string());
// Update the process-wide cache so the next permission prompt picks up
// the new value without a restart.
crate::appearance::permission_cursor::set_default_selected_permission(value);
}
// Settings setters — unified dispatch for the settings modal and slash
// commands.
//
// SHARED setters use inner/outer split:
// - `set_X_inner`: state-only mutation. Called on success AND rollback.
// - `set_X`: calls inner, refreshes modals, toasts, emits PersistSetting.
// - Rollback (`apply_setting_rollback`) calls inner only — no re-emit.
//
// PAGER setters (e.g. `set_multiline_mode`) have no persist/rollback,
// so they skip the split.
/// State-only mutation for `compact_mode`. Updates the in-memory
/// `current_ui` snapshot (read by the modal) and the thread-local cache
/// used by hot reads with the USER value, then re-derives the render
/// value (auto-compact on short terminals) into the appearance snapshot
/// and the agents' prompt widgets. Never touches disk; never emits effects.
pub(super) fn set_compact_mode_inner(app: &mut AppView, new: bool) {
app.current_ui.compact_mode = new;
crate::appearance::cache::set(new);
app.apply_effective_compact();
}
/// Set compact mode. Idempotent: skips if `new == prev`.
pub(in crate::app::dispatch) fn set_compact_mode(app: &mut AppView, new: bool) -> Vec<Effect> {
let prev = app.current_ui.compact_mode;
if prev == new {
return vec![];
}
set_compact_mode_inner(app, new);
refresh_open_settings_modals(app);
tracing::info!(target: "settings", key = "compact_mode", value = new, "setting changed");
// Turning the setting off while the short-terminal derivation holds keeps
// the UI compact; say so instead of implying the layout will loosen.
if !new && crate::views::agent::effective_compact(false, app.last_known_terminal_rows) {
app.show_toast("\u{2713} Compact mode: off (auto-compact active on small terminal)");
} else {
app.show_toast(&save_success_toast("Compact mode", new));
}
vec![Effect::PersistSetting {
key: "compact_mode",
value: crate::settings::SettingValue::Bool(new),
rollback_value: crate::settings::SettingValue::Bool(prev),
}]
}
/// State-only mutation for `show_timestamps`. Idempotent fast path
/// mirrors `set_compact_mode_inner`.
pub(super) fn set_timestamps_inner(app: &mut AppView, new: bool) {
app.current_ui.show_timestamps = Some(new);
if app.appearance.show_timestamps == new {
crate::appearance::cache::set_timestamps(new);
return;
}
let mut config = app.appearance.clone();
config.show_timestamps = new;
app.set_appearance(config);
crate::appearance::cache::set_timestamps(new);
}
pub(in crate::app::dispatch) fn set_timestamps(app: &mut AppView, new: bool) -> Vec<Effect> {
let prev = app.current_ui.show_timestamps.unwrap_or(true);
if prev == new {
return vec![];
}
set_timestamps_inner(app, new);
refresh_open_settings_modals(app);
tracing::info!(target: "settings", key = "show_timestamps", value = new, "setting changed");
app.show_toast(&save_success_toast("Timestamps", new));
vec![Effect::PersistSetting {
key: "show_timestamps",
value: crate::settings::SettingValue::Bool(new),
rollback_value: crate::settings::SettingValue::Bool(prev),
}]
}
/// State-only mutation for `show_timeline`. Mirrors `set_timestamps_inner`.
pub(super) fn set_timeline_inner(app: &mut AppView, new: bool) {
app.current_ui.show_timeline = Some(new);
if app.appearance.show_timeline == new {
crate::appearance::cache::set_show_timeline(new);
return;
}
let mut config = app.appearance.clone();
config.show_timeline = new;
app.set_appearance(config);
crate::appearance::cache::set_show_timeline(new);
}
pub(in crate::app::dispatch) fn set_timeline(app: &mut AppView, new: bool) -> Vec<Effect> {
// Gate on the displayed state (`appearance.show_timeline`, what the rail
// renders from and what `/timeline` toggles against) — not the separately
// hydrated `current_ui`, which could disagree and make the toggle no-op.
let prev = app.appearance.show_timeline;
if prev == new {
return vec![];
}
set_timeline_inner(app, new);
refresh_open_settings_modals(app);
tracing::info!(target: "settings", key = "show_timeline", value = new, "setting changed");
app.show_toast(&save_success_toast("Timeline sidebar", new));
vec![Effect::PersistSetting {
key: "show_timeline",
value: crate::settings::SettingValue::Bool(new),
rollback_value: crate::settings::SettingValue::Bool(prev),
}]
}
/// State-only mutation for `simple_mode`.
///
/// Propagates to every agent's `input_mode` so the toggle takes
/// effect immediately (not just on new agents).
pub(super) fn set_simple_mode_inner(app: &mut AppView, new: bool) {
app.current_ui.simple_mode = Some(new);
crate::appearance::cache::set_simple_mode(new);
// Reconcile every agent's `input_mode` — global toggle.
let target_mode = if new {
crate::views::agent::InputMode::Simple
} else {
crate::views::agent::InputMode::Vim
};
for agent in app.agents.values_mut() {
if agent.input_mode != target_mode {
agent.set_input_mode(target_mode);
}
}
}
pub(in crate::app::dispatch) fn set_simple_mode(app: &mut AppView, new: bool) -> Vec<Effect> {
let prev = app.current_ui.simple_mode.unwrap_or(true);
// No idempotency gate — fans out to every agent's `input_mode`.
// A newly-inserted agent may have a stale default, so we always
// propagate. The inner is per-agent idempotent.
set_simple_mode_inner(app, new);
refresh_open_settings_modals(app);
tracing::info!(target: "settings", key = "simple_mode", value = new, "setting changed");
// Toast label matches the registry's display label
// ("Disable vim input mode"), not the setting's internal name, so
// the modal and toast agree.
app.show_toast(&save_success_toast("Disable vim input mode", new));
vec![Effect::PersistSetting {
key: "simple_mode",
value: crate::settings::SettingValue::Bool(new),
rollback_value: crate::settings::SettingValue::Bool(prev),
}]
}
// Contextual-hint tips: the `contextual_hints.*` per-tip toggles.
//
// SHELL-owned: persisted to `[ui.contextual_hints]`. Each setter writes the
// user-config Option, then re-resolves ALL tips (env master + remote) and
// re-propagates the prompt gates to every agent, so a toggle takes effect at
// runtime (not just on next launch). `write` is a non-capturing closure that
// coerces to `fn` so the tips share one inner.
/// State-only mutation: write one tip's user-config Option, then re-resolve and
/// fan the resolved gates out to `app` + every agent prompt.
pub(super) fn set_contextual_hint_inner(
app: &mut AppView,
write: fn(&mut kigi_shell::agent::config::ContextualHints, Option<bool>),
new: bool,
) {
write(&mut app.current_ui.contextual_hints, Some(new));
let resolved = kigi_shell::util::config::resolve_contextual_hints(
&app.current_ui.contextual_hints,
app.remote_contextual_hints.as_ref(),
);
app.apply_contextual_hints(resolved);
}
/// Shared outer for the per-tip toggles: idempotency gate, state mutation,
/// modal refresh, toast, and `Effect::PersistSetting`.
fn set_contextual_hint(
app: &mut AppView,
key: crate::settings::SettingKey,
label: &str,
prev: Option<bool>,
write: fn(&mut kigi_shell::agent::config::ContextualHints, Option<bool>),
new: bool,
) -> Vec<Effect> {
// Skip when the stored user-explicit value already matches (no-op toggle).
if prev == Some(new) {
return vec![];
}
// `None` (inherit) collapses to the default ON for rollback — same lossy
// shape the other `Option<bool>` settings use.
let rollback = prev.unwrap_or(true);
set_contextual_hint_inner(app, write, new);
refresh_open_settings_modals(app);
tracing::info!(target: "settings", key, value = new, "setting changed");
app.show_toast(&save_success_toast(label, new));
vec![Effect::PersistSetting {
key,
value: crate::settings::SettingValue::Bool(new),
rollback_value: crate::settings::SettingValue::Bool(rollback),
}]
}
pub(in crate::app::dispatch) fn set_contextual_hint_undo(
app: &mut AppView,
new: bool,
) -> Vec<Effect> {
let prev = app.current_ui.contextual_hints.undo;
set_contextual_hint(
app,
"contextual_hints.undo",
"Undo hint",
prev,
|h, v| h.undo = v,
new,
)
}
pub(in crate::app::dispatch) fn set_contextual_hint_plan_mode(
app: &mut AppView,
new: bool,
) -> Vec<Effect> {
let prev = app.current_ui.contextual_hints.plan_mode;
set_contextual_hint(
app,
"contextual_hints.plan_mode",
"Plan mode hint",
prev,
|h, v| h.plan_mode = v,
new,
)
}
pub(in crate::app::dispatch) fn set_contextual_hint_image_input(
app: &mut AppView,
new: bool,
) -> Vec<Effect> {
let prev = app.current_ui.contextual_hints.image_input;
set_contextual_hint(
app,
"contextual_hints.image_input",
"Image input hint",
prev,
|h, v| h.image_input = v,
new,
)
}
pub(in crate::app::dispatch) fn set_contextual_hint_send_now(
app: &mut AppView,
new: bool,
) -> Vec<Effect> {
let prev = app.current_ui.contextual_hints.send_now;
set_contextual_hint(
app,
"contextual_hints.send_now",
"Send now hint",
prev,
|h, v| h.send_now = v,
new,
)
}
pub(in crate::app::dispatch) fn set_contextual_hint_small_screen(
app: &mut AppView,
new: bool,
) -> Vec<Effect> {
let prev = app.current_ui.contextual_hints.small_screen;
set_contextual_hint(
app,
"contextual_hints.small_screen",
"Small screen hint",
prev,
|h, v| h.small_screen = v,
new,
)
}
pub(in crate::app::dispatch) fn set_contextual_hint_word_select(
app: &mut AppView,
new: bool,
) -> Vec<Effect> {
let prev = app.current_ui.contextual_hints.word_select;
set_contextual_hint(
app,
"contextual_hints.word_select",
"Word select hint",
prev,
|h, v| h.word_select = v,
new,
)
}
// Theme settings: `theme`, `auto_dark_theme`, `auto_light_theme`.
//
// Each has a preview/commit split:
// - `SetX` (commit): state + visual + persist + toast.
// - `PreviewX` (preview): visual only, no persist/toast.
//
// Auto-theme setters apply visually only when `theme="auto"` AND the
// system is in the matching mode; otherwise the value is just stored.
//
// Unknown names: `error!` in outer (registry skew), `warn!` in inner
// (rollback of corrupted config).
/// Format a "✓ <Label>: <value>" toast for theme-family settings.
/// `value` is the user-friendly display name, not the canonical.
fn save_theme_toast(label: &str, value: &str) -> String {
format!("\u{2713} {label}: {value}")
}
/// Apply a (non-auto) theme to the live display.
///
/// Centralised so `set_theme_inner` and `preview_theme_inner` share the
/// same visual-mutation path. Resolves `Auto` via `theme::cache::resolve_auto`
/// (does NOT toggle `AUTO_MODE`); concrete kinds go through
/// `Theme::apply_kind` directly.
fn apply_theme_kind_for_display(kind: crate::theme::ThemeKind) {
if kind.is_auto() {
let resolved = crate::theme::cache::resolve_auto();
crate::theme::Theme::apply_kind(resolved);
} else {
crate::theme::Theme::apply_kind(kind);
}
}
/// Whether the system is in the appearance mode matching the given
/// auto-* key. Gates visual application of dormant auto-theme values.
fn system_is_in_matching_mode(key: &str) -> bool {
use crate::theme::system_appearance;
let Some(appearance) = system_appearance::detect() else {
// Detection failure: fail closed (don't apply) — matches the
// `resolve_auto` fallback behaviour.
return false;
};
matches!(
(key, appearance),
("auto_dark_theme", system_appearance::SystemAppearance::Dark)
| (
"auto_light_theme",
system_appearance::SystemAppearance::Light
)
)
}
/// Whether the given auto-* setting is currently "live" — `theme="auto"`
/// AND system is in the matching mode.
fn auto_theme_setting_is_live(key: &str) -> bool {
crate::theme::cache::is_auto_mode() && system_is_in_matching_mode(key)
}
/// State + cache + visual mutation for `theme`. **Commit path.**
/// Updates `app.current_ui.theme`, toggles `AUTO_MODE` based on
/// whether the value is `"auto"`, and applies the live theme.
///
/// Also called from `apply_setting_rollback` — when a disk persist
/// fails, we replay the inner with the prior value so both the
/// snapshot and the visual revert. Unknown / unrecognised names log
/// at `warn` (defensive — a malformed `rollback_value` is a softer
/// failure mode than an unknown commit-time value) and no-op.
pub(super) fn set_theme_inner(app: &mut AppView, value: &str) {
let Some(kind) = crate::theme::ThemeKind::from_name(value) else {
tracing::warn!(
target: "settings",
key = "theme",
value = value,
"unknown theme name — set_theme_inner no-op",
);
return;
};
let canonical = kind.display_name();
app.current_ui.theme = Some(canonical.to_string());
crate::theme::cache::set_auto_mode(kind.is_auto());
apply_theme_kind_for_display(kind);
}
/// State + cache + persist for `theme` commits.
pub(in crate::app::dispatch) fn set_theme(app: &mut AppView, new: String) -> Vec<Effect> {
let prev_canonical: &'static str = app
.current_ui
.theme
.as_deref()
.and_then(crate::theme::canonical_name)
.unwrap_or_else(|| crate::theme::cache::current_kind().display_name());
let new_canonical = match crate::theme::canonical_name(&new) {
Some(c) => c,
None => {
tracing::error!(
target: "settings",
key = "theme",
value = %new,
"Action::SetTheme dispatched with unknown name — no-op",
);
return vec![];
}
};
set_theme_inner(app, &new);
refresh_open_settings_modals(app);
tracing::info!(
target: "settings",
key = "theme",
value = %new_canonical,
"setting changed",
);
app.show_toast(&save_theme_toast(
"Theme",
crate::theme::display_name_for_canonical(new_canonical),
));
vec![Effect::PersistSetting {
key: "theme",
value: crate::settings::SettingValue::Enum(new_canonical),
rollback_value: crate::settings::SettingValue::Enum(prev_canonical),
}]
}
/// Preview-only mutation for `theme`. Applies the live visual without
/// modifying state, toggling `AUTO_MODE`, persisting, or toasting.
/// For `"auto"`, resolves and applies the theme but does NOT toggle
/// `AUTO_MODE` (commit-only side effect).
fn preview_theme_inner(value: &str) {
let Some(kind) = crate::theme::ThemeKind::from_name(value) else {
tracing::warn!(
target: "settings",
key = "theme",
value = value,
"unknown theme name — preview_theme_inner no-op",
);
return;
};
apply_theme_kind_for_display(kind);
}
pub(in crate::app::dispatch) fn preview_theme(_app: &mut AppView, new: String) -> Vec<Effect> {
if crate::theme::canonical_name(&new).is_none() {
tracing::error!(
target: "settings",
key = "theme",
value = %new,
"Action::PreviewTheme dispatched with unknown name — no-op",
);
return vec![];
}
preview_theme_inner(&new);
vec![]
}
/// State + cache + visual mutation for `auto_dark_theme`. Commit path.
/// Applies visually only when the setting is live (auto mode + dark).
/// Rejects `"auto"` as an invalid value (log + no-op).
pub(super) fn set_auto_dark_theme_inner(app: &mut AppView, value: &str) {
let Some(kind) = crate::theme::ThemeKind::from_name(value) else {
tracing::warn!(
target: "settings",
key = "auto_dark_theme",
value = value,
"unknown theme name — set_auto_dark_theme_inner no-op",
);
return;
};
if kind.is_auto() {
tracing::warn!(
target: "settings",
key = "auto_dark_theme",
"Auto is not a valid auto_dark_theme value — no-op",
);
return;
}
let canonical = kind.display_name();
app.current_ui.auto_dark_theme = Some(canonical.to_string());
crate::theme::cache::invalidate_auto_theme_config();
if auto_theme_setting_is_live("auto_dark_theme") {
crate::theme::Theme::apply_kind(kind);
}
}
pub(in crate::app::dispatch) fn set_auto_dark_theme(app: &mut AppView, new: String) -> Vec<Effect> {
let prev_canonical: &'static str = app
.current_ui
.auto_dark_theme
.as_deref()
.and_then(crate::theme::canonical_name)
.filter(|s| *s != "auto")
// No prior config: fall back to KigiNight (the default).
.unwrap_or_else(|| crate::theme::ThemeKind::KigiNight.display_name());
let new_canonical = match crate::theme::canonical_name(&new) {
Some(c) if c != crate::theme::ThemeKind::Auto.display_name() => c,
_ => {
tracing::error!(
target: "settings",
key = "auto_dark_theme",
value = %new,
"Action::SetAutoDarkTheme dispatched with invalid name — no-op",
);
return vec![];
}
};
set_auto_dark_theme_inner(app, &new);
refresh_open_settings_modals(app);
tracing::info!(
target: "settings",
key = "auto_dark_theme",
value = %new_canonical,
"setting changed",
);
app.show_toast(&save_theme_toast(
"Auto dark theme",
crate::theme::display_name_for_canonical(new_canonical),
));
vec![Effect::PersistSetting {
key: "auto_dark_theme",
value: crate::settings::SettingValue::Enum(new_canonical),
rollback_value: crate::settings::SettingValue::Enum(prev_canonical),
}]
}
/// Preview-only mutation for `auto_dark_theme`. Visual only when live.
fn preview_auto_dark_theme_inner(value: &str) {
let Some(kind) = crate::theme::ThemeKind::from_name(value) else {
tracing::warn!(
target: "settings",
key = "auto_dark_theme",
value = value,
"unknown theme name — preview_auto_dark_theme_inner no-op",
);
return;
};
if kind.is_auto() {
return;
}
if auto_theme_setting_is_live("auto_dark_theme") {
crate::theme::Theme::apply_kind(kind);
}
}
pub(in crate::app::dispatch) fn preview_auto_dark_theme(
_app: &mut AppView,
new: String,
) -> Vec<Effect> {
match crate::theme::canonical_name(&new) {
Some(c) if c != crate::theme::ThemeKind::Auto.display_name() => {}
_ => {
tracing::error!(
target: "settings",
key = "auto_dark_theme",
value = %new,
"Action::PreviewAutoDarkTheme dispatched with invalid name — no-op",
);
return vec![];
}
};
preview_auto_dark_theme_inner(&new);
vec![]
}
/// State + cache + visual mutation for `auto_light_theme`. Commit path.
/// Mirror of `set_auto_dark_theme_inner` for the light bucket.
pub(super) fn set_auto_light_theme_inner(app: &mut AppView, value: &str) {
let Some(kind) = crate::theme::ThemeKind::from_name(value) else {
tracing::warn!(
target: "settings",
key = "auto_light_theme",
value = value,
"unknown theme name — set_auto_light_theme_inner no-op",
);
return;
};
if kind.is_auto() {
tracing::warn!(
target: "settings",
key = "auto_light_theme",
"Auto is not a valid auto_light_theme value — no-op",
);
return;
}
let canonical = kind.display_name();
app.current_ui.auto_light_theme = Some(canonical.to_string());
crate::theme::cache::invalidate_auto_theme_config();
if auto_theme_setting_is_live("auto_light_theme") {
crate::theme::Theme::apply_kind(kind);
}
}
pub(in crate::app::dispatch) fn set_auto_light_theme(
app: &mut AppView,
new: String,
) -> Vec<Effect> {
let prev_canonical: &'static str = app
.current_ui
.auto_light_theme
.as_deref()
.and_then(crate::theme::canonical_name)
.filter(|s| *s != "auto")
.unwrap_or_else(|| crate::theme::ThemeKind::KigiDay.display_name());
let new_canonical = match crate::theme::canonical_name(&new) {
Some(c) if c != crate::theme::ThemeKind::Auto.display_name() => c,
_ => {
tracing::error!(
target: "settings",
key = "auto_light_theme",
value = %new,
"Action::SetAutoLightTheme dispatched with invalid name — no-op",
);
return vec![];
}
};
set_auto_light_theme_inner(app, &new);
refresh_open_settings_modals(app);
tracing::info!(
target: "settings",
key = "auto_light_theme",
value = %new_canonical,
"setting changed",
);
app.show_toast(&save_theme_toast(
"Auto light theme",
crate::theme::display_name_for_canonical(new_canonical),
));
vec![Effect::PersistSetting {
key: "auto_light_theme",
value: crate::settings::SettingValue::Enum(new_canonical),
rollback_value: crate::settings::SettingValue::Enum(prev_canonical),
}]
}
/// Preview-only mutation for `auto_light_theme`. Mirror of
/// `preview_auto_dark_theme_inner` for the light bucket.
fn preview_auto_light_theme_inner(value: &str) {
let Some(kind) = crate::theme::ThemeKind::from_name(value) else {
tracing::warn!(
target: "settings",
key = "auto_light_theme",
value = value,
"unknown theme name — preview_auto_light_theme_inner no-op",
);
return;
};
if kind.is_auto() {
return;
}
if auto_theme_setting_is_live("auto_light_theme") {
crate::theme::Theme::apply_kind(kind);
}
}
pub(in crate::app::dispatch) fn preview_auto_light_theme(
_app: &mut AppView,
new: String,
) -> Vec<Effect> {
match crate::theme::canonical_name(&new) {
Some(c) if c != crate::theme::ThemeKind::Auto.display_name() => {}
_ => {
tracing::error!(
target: "settings",
key = "auto_light_theme",
value = %new,
"Action::PreviewAutoLightTheme dispatched with invalid name — no-op",
);
return vec![];
}
};
preview_auto_light_theme_inner(&new);
vec![]
}
// default_model — resolves display name to `ModelId`, then emits both
// `Effect::SwitchModel` (active session) and `Effect::PersistSetting`
// (next-session default). No live preview (model switch has ACP side
// effects).
/// State-only mutation for `default_model`: set
/// `agent.session.models.current` to the supplied id. Returns `true`
/// if the catalog contains `id`; `false` otherwise.
pub(in crate::app::dispatch) fn set_default_model_inner(
app: &mut AppView,
id: &acp::ModelId,
) -> bool {
let ActiveView::Agent(aid) = app.active_view else {
return false;
};
{
let Some(agent) = app.agents.get_mut(&aid) else {
return false;
};
if !agent.session.models.available.contains_key(id) {
return false;
}
// Update the agent's session model state's current pointer so
// subsequent reads (e.g. `current_model_name` via the pager
// snapshot) reflect the new selection without waiting for the
// ACP roundtrip.
//
// `set_current(_, None)` resets `reasoning_effort` to model default.
agent.session.models.set_current(id.clone(), None);
}
// Mirror the new default into the app-level model state too. A later `/new`
// or `/clear` creates a fresh session by cloning `app.models`
// (`dispatch_new_session_inner_with_id`), so without this the new session —
// and the welcome card it commits — would show the previous default until
// the next `kigi/models/update` roundtrip.
if app.models.available.contains_key(id) {
app.models.set_current(id.clone(), None);
}
true
}
/// Toast format for `default_model`. Mirrors `save_theme_toast` —
/// renders the user-friendly model name (NOT the internal id) so the
/// toast text matches what the user typed.
fn save_default_model_toast(value: &str) -> String {
format!("\u{2713} Default model: {value}")
}
/// Outer dispatcher for `Action::SetDefaultModel`. Switches and persists
/// and toasts. `PersistSetting` emitted first for consistent rollback;
/// `SwitchModel` second. Idempotent: same model already active → no-op.
pub(in crate::app::dispatch) fn set_default_model(
app: &mut AppView,
new_id: acp::ModelId,
) -> Vec<Effect> {
let ActiveView::Agent(aid) = app.active_view else {
tracing::error!(
target: "settings",
key = "default_model",
"Action::SetDefaultModel dispatched with no active agent — no-op",
);
return vec![];
};
// Snapshot previous id + display name from the active agent's
// session (the same source `set_default_model_inner` mutates
// and the modal reads).
let (prev_id, session_id, available_has_new, new_display) = {
let Some(agent) = app.agents.get(&aid) else {
tracing::error!(
target: "settings",
key = "default_model",
"Action::SetDefaultModel: active_view::Agent points to missing agent",
);
return vec![];
};
let prev_id = agent.session.models.current.clone();
let session_id = agent.session.session_id.clone();
let available_has_new = agent.session.models.available.contains_key(&new_id);
let new_display = agent.session.models.display_name_for(&new_id);
(prev_id, session_id, available_has_new, new_display)
};
if !available_has_new {
tracing::error!(
target: "settings",
key = "default_model",
id = ?new_id,
"Action::SetDefaultModel dispatched with model id not in catalog — \
validator skew; no-op",
);
return vec![];
}
if prev_id.as_ref() == Some(&new_id) {
return vec![];
}
let did_mutate = set_default_model_inner(app, &new_id);
debug_assert!(did_mutate, "available_has_new gate guarantees mutation");
refresh_open_settings_modals(app);
tracing::info!(
target: "settings",
key = "default_model",
new = ?new_display,
new_id = %new_id.0,
prev_id = ?prev_id.as_ref().map(|id| id.0.as_ref()),
"setting changed",
);
app.show_toast(&save_default_model_toast(&new_display));
// Persist the **model ID** (catalog key), not the display name.
// The shell's `resolve_default_model` matches by slug / map key,
// so persisting the human-readable name (e.g. "Kigi")
// would silently fail to resolve on the next startup.
//
// Chat (`--chat` / KIGI_CHAT_MODE) catalogs use opaque `/rest/modes`
// slugs that must not become the global Build `default_model`.
let mut effects: Vec<Effect> = Vec::new();
if !kigi_shell::agent::chat_modes::process_chat_mode_enabled() {
let new_id_str = new_id.0.to_string();
let prev_id_str = prev_id
.as_ref()
.map(|id| id.0.to_string())
.unwrap_or_default();
effects.push(Effect::PersistSetting {
key: "default_model",
value: crate::settings::SettingValue::String(new_id_str),
rollback_value: crate::settings::SettingValue::String(prev_id_str),
});
}
// Best-effort session-level switch. The `Effect::SwitchModel`
// pipeline handles its own deferred-switch semantics for the
// no-session-id-yet case (see `deferred_model_switch` below).
if let Some(sid) = session_id {
// We already hold a reference path to the agent above; re-borrow
// mutably here to flip `model_switch_pending`.
if let Some(agent) = app.agents.get_mut(&aid) {
agent.session.model_switch_pending = true;
}
effects.push(Effect::SwitchModel {
agent_id: aid,
session_id: sid,
model_id: new_id,
effort: None,
prev_model_id: prev_id.clone(),
});
} else {
if let Some(agent) = app.agents.get_mut(&aid) {
// No session id yet — stash for
// `EventLoop::on_session_created` to apply once the session
// id materialises. Mirrors the deferred-switch handling in
// `Action::SwitchModel`.
agent.session.deferred_model_switch = Some((new_id, None));
}
// With no create in flight (project question pending), the stash
// would dangle forever — start the session it drains into. No-op
// when a create is already pending.
effects.extend(
crate::app::dispatch::session::lifecycle::skip_picker_and_create_session(app, aid),
);
}
effects
}
/// Clear the default model override. Persists `[models].default = None`;
/// does NOT mutate the active session's current model.
pub(in crate::app::dispatch) fn clear_default_model(app: &mut AppView) -> Vec<Effect> {
// Active-agent snapshot: prev model ID for the rollback payload.
// Use the model ID (catalog key), not the display name, so that
// rollback persists a value `resolve_default_model` can match.
let prev_id_str = if let ActiveView::Agent(aid) = app.active_view
&& let Some(agent) = app.agents.get(&aid)
{
agent
.session
.models
.current
.as_ref()
.map(|id| id.0.to_string())
.unwrap_or_default()
} else {
String::new()
};
// During the startup window `current` is None even if a default
// is on disk. Emit persist unconditionally — the shell de-dupes.
if prev_id_str.is_empty() {
// Cosmetic toast even when the persist may be a no-op.
tracing::info!(
target: "settings",
key = "default_model",
value = "<cleared>",
"setting changed (startup-window clear — pager mirror was already None; \
persist proceeds to ensure disk state matches user intent)",
);
app.show_toast("\u{2713} Default model: cleared");
return vec![Effect::PersistSetting {
key: "default_model",
value: crate::settings::SettingValue::String(String::new()),
rollback_value: crate::settings::SettingValue::String(String::new()),
}];
}
tracing::info!(
target: "settings",
key = "default_model",
value = "<cleared>",
prev_id = %prev_id_str,
"setting changed",
);
refresh_open_settings_modals(app);
app.show_toast("\u{2713} Default model: cleared");
vec![Effect::PersistSetting {
key: "default_model",
value: crate::settings::SettingValue::String(String::new()),
rollback_value: crate::settings::SettingValue::String(prev_id_str),
}]
}
// Model-family settings: fork_secondary_model.
//
// SHELL-OWNED. Unlike `default_model`, these do NOT mutate live
// runtime state — they update `current_ui` mirrors and persist.
// No live preview. Rollback is purely disk + mirror.
/// State-only mutation for `fork_secondary_model`. Updates the
/// `app.current_ui.fork_secondary_model` mirror so the modal
/// indicator stays in sync; the shell's config-reloader propagates
/// the disk change to any running agents on the next fork.
pub(super) fn set_fork_secondary_model_inner(app: &mut AppView, value: String) {
app.current_ui.fork_secondary_model = value;
}
/// Toast format for `fork_secondary_model`. Mirrors
/// `save_default_model_toast` — renders the user-friendly model
/// name (NOT the internal id).
fn save_fork_secondary_model_toast(value: &str) -> String {
format!("\u{2713} Fork secondary model: {value}")
}
/// Outer dispatcher for `Action::SetForkSecondaryModel`.
/// Mirror + persist + toast. Idempotent: same-id → no-op.
pub(in crate::app::dispatch) fn set_fork_secondary_model(
app: &mut AppView,
new_id: acp::ModelId,
) -> Vec<Effect> {
let ActiveView::Agent(aid) = app.active_view else {
tracing::error!(
target: "settings",
key = "fork_secondary_model",
"Action::SetForkSecondaryModel dispatched with no active agent — no-op",
);
return vec![];
};
let (new_display, available_has_new) = {
let Some(agent) = app.agents.get(&aid) else {
tracing::error!(
target: "settings",
key = "fork_secondary_model",
"Action::SetForkSecondaryModel: active_view::Agent points to missing agent",
);
return vec![];
};
let display = agent.session.models.display_name_for(&new_id);
let has = agent.session.models.available.contains_key(&new_id);
(display, has)
};
if !available_has_new {
tracing::error!(
target: "settings",
key = "fork_secondary_model",
id = ?new_id,
"Action::SetForkSecondaryModel dispatched with id not in catalog — \
validator skew; no-op",
);
return vec![];
}
// Mirror, idempotency, persist, and rollback all use the model ID
// (catalog key), not the display name. The shell's
// `cfg.ui.fork_secondary_model` consumers match by slug / map key,
// and the `current_value_for` fold-to-empty comparison checks
// against `models::default_model()` (also a slug).
let new_id_str = new_id.0.to_string();
let prev_id_str = app.current_ui.fork_secondary_model.clone();
if prev_id_str == new_id_str {
return vec![];
}
set_fork_secondary_model_inner(app, new_id_str.clone());
refresh_open_settings_modals(app);
tracing::info!(
target: "settings",
key = "fork_secondary_model",
new = ?new_display,
new_id = %new_id_str,
prev_id = %prev_id_str,
"setting changed",
);
app.show_toast(&save_fork_secondary_model_toast(&new_display));
vec![Effect::PersistSetting {
key: "fork_secondary_model",
value: crate::settings::SettingValue::String(new_id_str),
rollback_value: crate::settings::SettingValue::String(prev_id_str),
}]
}
/// Outer dispatcher for `Action::ClearForkSecondaryModel`. Resets
/// the persisted override to the built-in baseline
/// (`kigi_shell::models::default_model()`).
pub(in crate::app::dispatch) fn clear_fork_secondary_model(app: &mut AppView) -> Vec<Effect> {
let baseline = kigi_shell::models::default_model().to_string();
let prev_id_str = app.current_ui.fork_secondary_model.clone();
if prev_id_str == baseline {
app.show_toast("\u{2713} Fork secondary model: already at default");
return vec![];
}
tracing::info!(
target: "settings",
key = "fork_secondary_model",
value = "<cleared>",
prev_id = %prev_id_str,
"setting changed",
);
set_fork_secondary_model_inner(app, baseline);
refresh_open_settings_modals(app);
app.show_toast("\u{2713} Fork secondary model: cleared");
vec![Effect::PersistSetting {
key: "fork_secondary_model",
// Persist payload is the empty-sentinel — the shell helper
// interprets empty as "restore the baseline default", same
// contract as `default_model`'s empty payload.
value: crate::settings::SettingValue::String(String::new()),
rollback_value: crate::settings::SettingValue::String(prev_id_str),
}]
}
// max_thoughts_width — Int-valued setting. Registry surface is `i64`;
// clamped to `(min, max)` bounds and cast to `u16`. Live application
// via `app.current_ui.max_thoughts_width`.
/// Clamp `i64` to the registered `max_thoughts_width` bounds.
/// Bounds imported from `settings::defs` (single source of truth).
fn clamp_max_thoughts_width(value: i64) -> i64 {
value.clamp(
crate::settings::defs::MAX_THOUGHTS_WIDTH_MIN,
crate::settings::defs::MAX_THOUGHTS_WIDTH_MAX,
)
}
/// State-only mutation for `max_thoughts_width`. Updates the
/// `app.current_ui.max_thoughts_width` field; the renderer picks up
/// the new value on the next frame.
pub(super) fn set_max_thoughts_width_inner(app: &mut AppView, value: i64) {
app.current_ui.max_thoughts_width = clamp_max_thoughts_width(value) as u16;
}
/// Outer dispatcher for `Action::SetMaxThoughtsWidth`. State +
/// refresh + persist + toast.
pub(in crate::app::dispatch) fn set_max_thoughts_width(app: &mut AppView, new: i64) -> Vec<Effect> {
let prev = app.current_ui.max_thoughts_width as i64;
let clamped = clamp_max_thoughts_width(new);
if prev == clamped {
// Matches the bool setters' idempotency contract.
return vec![];
}
set_max_thoughts_width_inner(app, new);
refresh_open_settings_modals(app);
tracing::info!(
target: "settings",
key = "max_thoughts_width",
value = clamped,
"setting changed",
);
app.show_toast(&format!("\u{2713} Max thoughts width: {clamped}"));
vec![Effect::PersistSetting {
key: "max_thoughts_width",
value: crate::settings::SettingValue::Int(clamped),
rollback_value: crate::settings::SettingValue::Int(prev),
}]
}
// show_tips, auto_update — SHELL-OWNED `Option<bool>` setters.
// Changes take effect on next session start (restart_required: true).
// Standard inner/outer split. First commit of the default value
// persists (so the resolver sees user intent vs managed default).
// Rollback restores `None` when the target equals the effective
// default (keeps mirror in sync with on-disk state after failure).
/// Effective-default lookup for the `Option<bool>` AppView mirrors
/// (`show_tips`, `auto_update`, ask_user_question timeout).
/// Matches the consumer's `.unwrap_or(...)` fallback.
pub(super) fn pr13_effective_default(key: &str) -> Option<bool> {
use kigi_tools::implementations::kigi::ask_user_question;
match key {
"show_tips" => Some(true),
"auto_update" => Some(true),
"toolset.ask_user_question.timeout_enabled" => {
Some(ask_user_question::DEFAULT_ASK_USER_QUESTION_TIMEOUT_ENABLED)
}
_ => None,
}
}
/// State-only mutation for `show_tips`.
pub(super) fn set_show_tips_inner(app: &mut AppView, value: bool) {
app.show_tips = Some(value);
}
/// Outer dispatcher for `Action::SetShowTips`.
pub(in crate::app::dispatch) fn set_show_tips(app: &mut AppView, new: bool) -> Vec<Effect> {
let prev_state = app.show_tips;
let prev_effective = prev_state.unwrap_or(true);
if prev_effective == new && prev_state.is_some() {
// `.is_some()` lets the first commit of the default value
// persist (so the resolver sees user intent).
return vec![];
}
set_show_tips_inner(app, new);
refresh_open_settings_modals(app);
tracing::info!(target: "settings", key = "show_tips", value = new, "setting changed");
// Restart-required: cue in the toast.
app.show_toast(&format!(
"{} (restart to apply)",
save_success_toast("Show tips", new),
));
vec![Effect::PersistSetting {
key: "show_tips",
value: crate::settings::SettingValue::Bool(new),
rollback_value: crate::settings::SettingValue::Bool(prev_effective),
}]
}
/// State-only mutation for `auto_update`.
pub(super) fn set_auto_update_inner(app: &mut AppView, value: bool) {
app.auto_update = Some(value);
}
/// Outer dispatcher for `Action::SetAutoUpdate`.
pub(in crate::app::dispatch) fn set_auto_update(app: &mut AppView, new: bool) -> Vec<Effect> {
let prev_state = app.auto_update;
let prev_effective = prev_state.unwrap_or(true);
if prev_effective == new && prev_state.is_some() {
return vec![];
}
set_auto_update_inner(app, new);
refresh_open_settings_modals(app);
tracing::info!(target: "settings", key = "auto_update", value = new, "setting changed");
app.show_toast(&format!(
"{} (restart to apply)",
save_success_toast("Auto-update", new),
));
vec![Effect::PersistSetting {
key: "auto_update",
value: crate::settings::SettingValue::Bool(new),
rollback_value: crate::settings::SettingValue::Bool(prev_effective),
}]
}
// display_refresh_auto_cadence — SHELL-OWNED nested Option on
// `[ui.display_refresh].auto_cadence_enabled`. Restart-required.
/// State-only mutation for `display_refresh_auto_cadence`.
pub(super) fn set_display_refresh_auto_cadence_inner(app: &mut AppView, value: bool) {
app.current_ui.display_refresh.auto_cadence_enabled = Some(value);
}
/// Outer dispatcher for `Action::SetDisplayRefreshAutoCadence`.
pub(in crate::app::dispatch) fn set_display_refresh_auto_cadence(
app: &mut AppView,
new: bool,
) -> Vec<Effect> {
let prev_state = app.current_ui.display_refresh.auto_cadence_enabled;
let prev_effective = prev_state.unwrap_or(false);
if prev_effective == new && prev_state.is_some() {
return vec![];
}
set_display_refresh_auto_cadence_inner(app, new);
refresh_open_settings_modals(app);
tracing::info!(
target: "settings",
key = "display_refresh_auto_cadence",
value = new,
"setting changed",
);
app.show_toast(&format!(
"{} (restart to apply)",
save_success_toast("Match display refresh rate", new),
));
vec![Effect::PersistSetting {
key: "display_refresh_auto_cadence",
value: crate::settings::SettingValue::Bool(new),
rollback_value: crate::settings::SettingValue::Bool(prev_effective),
}]
}