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).
1869 lines
69 KiB
Rust
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),
|
|
}]
|
|
}
|