//! Tests for settings setters, toggles, resets, and rollback.
use super::*;
/// `Action::ToggleVimMode` flips the active agent's `vim_mode` field,
/// updates the in-process pager cache so future agents pick it up
/// via `load_vim_mode`, emits `Effect::PersistSetting` so the new
/// value is written to `[ui].vim_mode` in config.toml, and a second
/// toggle restores the original.
#[test]
fn toggle_vim_mode_flips_state_and_persistence_cache() {
    crate::appearance::cache::set_vim_mode(false);
    let mut app = test_app_with_agent();
    let id = AgentId(0);
    if let Some(agent) = app.agents.get_mut(&id) {
        agent.vim_mode = false;
    }
    let effects = dispatch(Action::ToggleVimMode, &mut app);
    assert_eq!(
        effects.len(),
        1,
        "toggle must emit exactly one Effect::PersistSetting so the \
             new value lands in config.toml, got {effects:?}",
    );
    assert!(
        matches!(
            &effects[0],
            Effect::PersistSetting {
                key: "vim_mode",
                value: crate::settings::SettingValue::Bool(true),
                rollback_value: crate::settings::SettingValue::Bool(false),
            }
        ),
        "unexpected effect: {:?}",
        effects[0],
    );
    assert!(
        app.agents[&id].vim_mode,
        "active agent should be in vim mode after toggle"
    );
    assert!(
        crate::appearance::cache::load_vim_mode(),
        "pager cache must reflect new value so future agents pick it up"
    );
    let effects = dispatch(Action::ToggleVimMode, &mut app);
    assert!(
        matches!(
            effects.as_slice(),
            [Effect::PersistSetting {
                key: "vim_mode",
                value: crate::settings::SettingValue::Bool(false),
                rollback_value: crate::settings::SettingValue::Bool(true),
            }]
        ),
        "second toggle must also persist, got {effects:?}",
    );
    assert!(!app.agents[&id].vim_mode, "toggling again flips it back");
    assert!(
        !crate::appearance::cache::load_vim_mode(),
        "cache must follow the second toggle"
    );
}
/// End-to-end: in vim mode, Tab from the prompt focuses scrollback,
/// and then `j` navigates the scrollback (does NOT bounce back to the
/// prompt). Drives the real handle_input → dispatch loop.
#[test]
fn agent_vim_tab_focuses_scrollback_then_j_navigates() {
    use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
    crate::appearance::cache::set_vim_mode(true);
    let mut app = test_app_with_agent();
    let id = AgentId(0);
    app.active_view = ActiveView::Agent(id);
    {
        let agent = app.agents.get_mut(&id).unwrap();
        agent.vim_mode = true;
        agent.active_pane = ActivePane::Prompt;
    }
    let tab = Event::Key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
    if let crate::app::app_view::InputOutcome::Action(a) = app.handle_input(&tab) {
        let _ = dispatch(a, &mut app);
    }
    assert_eq!(
        app.agents[&id].active_pane,
        ActivePane::Scrollback,
        "Tab in the prompt must focus scrollback",
    );
    let j = Event::Key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE));
    let outcome = app.handle_input(&j);
    assert!(
        matches!(
            outcome,
            crate::app::app_view::InputOutcome::Action(Action::SelectNext)
        ),
        "vim j in scrollback must navigate, got {outcome:?}",
    );
    assert_eq!(
        app.agents[&id].active_pane,
        ActivePane::Scrollback,
        "j must keep focus on scrollback, not bounce to the prompt",
    );
    crate::appearance::cache::set_vim_mode(false);
}
/// `/vim-mode` (ToggleVimMode) must propagate to OPEN subagent views,
/// not just top-level agents. Otherwise a user inside a subagent view
/// toggles vim, presses Tab + j, and the keystroke forwards to the
/// prompt (vim-OFF fallback) because the subagent view kept its stale
/// `vim_mode = false`.
#[test]
fn toggle_vim_mode_propagates_to_open_subagent_views() {
    crate::appearance::cache::set_vim_mode(false);
    let mut app = test_app_with_agent();
    let id = AgentId(0);
    let child_session = make_test_agent_session(&app, AgentId(0), "child-session");
    let mut child = AgentView::new(child_session, ScrollbackState::new());
    child.vim_mode = false;
    {
        let parent = app.agents.get_mut(&id).unwrap();
        parent.vim_mode = false;
        parent
            .subagent_views
            .insert("child-1".to_string(), Box::new(child));
    }
    let _ = dispatch(Action::ToggleVimMode, &mut app);
    assert!(app.agents[&id].vim_mode, "parent picks up the toggle");
    assert!(
        app.agents[&id].subagent_views["child-1"].vim_mode,
        "an open subagent view must also pick up the vim toggle",
    );
}
/// `/vim-mode` must toggle vim from the DASHBOARD too (not just an
/// agent view) — previously it early-returned unless an agent was
/// active, so it was a silent no-op and the overview's j/k never
/// turned on. Turning vim ON also focuses the overview so j/k
/// navigate immediately; turning it OFF returns focus to the input.
#[serial_test::serial(KIGI_AGENT_DASHBOARD)]
#[test]
fn toggle_vim_mode_works_on_dashboard_and_focuses_overview() {
    crate::appearance::cache::set_vim_mode(false);
    let mut app = test_app_with_agent();
    open_dashboard(&mut app);
    assert!(matches!(app.active_view, ActiveView::AgentDashboard));
    app.dashboard.as_mut().unwrap().list_focused = false;
    let _ = dispatch(Action::ToggleVimMode, &mut app);
    assert!(
        crate::appearance::cache::load_vim_mode(),
        "/vim-mode must toggle vim ON from the dashboard",
    );
    assert!(
        app.dashboard.as_ref().unwrap().list_focused,
        "turning vim on focuses the overview so j/k navigate immediately",
    );
    let _ = dispatch(Action::ToggleVimMode, &mut app);
    assert!(
        !crate::appearance::cache::load_vim_mode(),
        "second /vim-mode must toggle vim OFF",
    );
    assert!(
        !app.dashboard.as_ref().unwrap().list_focused,
        "turning vim off returns focus to the input",
    );
    crate::appearance::cache::set_vim_mode(false);
}
#[test]
fn cancel_before_first_activity_resets_state_and_discards_orphan_response() {
    let mut app = test_app_with_agent();
    let id = AgentId(0);
    dispatch(Action::SendPrompt("rewind me".into()), &mut app);
    assert!(app.agents[&id].session.state.is_turn_running());
    assert_eq!(app.agents[&id].scrollback.len(), 1);
    assert!(app.agents[&id].turn_started_at.is_some());
    let cancelled_pid = app.agents[&id].session.current_prompt_id.clone();
    assert!(cancelled_pid.is_some());
    let effects = dispatch(Action::CancelTurn, &mut app);
    assert_eq!(effects.len(), 1);
    assert!(matches!(&effects[0], Effect::CancelTurn { .. }));
    assert!(app.agents[&id].session.state.is_idle());
    assert_eq!(app.agents[&id].prompt.text(), "rewind me");
    assert_eq!(app.agents[&id].scrollback.len(), 0);
    assert!(app.agents[&id].turn_started_at.is_none());
    assert!(app.agents[&id].activity_started_at.is_none());
    assert!(app.agents[&id].last_activity.is_none());
    assert!(app.agents[&id].session.in_flight_prompt.is_none());
    assert!(app.agents[&id].session.current_prompt_id.is_none());
    dispatch(
        Action::TaskComplete(TaskResult::PromptResponse {
            agent_id: id,
            result: Ok(acp::PromptResponse::new(acp::StopReason::Cancelled).meta(
                serde_json::json!({ "promptId" : cancelled_pid })
                    .as_object()
                    .cloned(),
            )),
            http_status: None,
            prompt_id: None,
        }),
        &mut app,
    );
    assert!(app.agents[&id].session.state.is_idle());
    assert_eq!(app.agents[&id].scrollback.len(), 0);
}
#[test]
fn set_default_model_allowed_when_agent_chat_kind() {
    let mut app = test_app_with_agent();
    let id = AgentId(0);
    let model_id = acp::ModelId::new(std::sync::Arc::from("auto"));
    app.agents
        .get_mut(&id)
        .unwrap()
        .session
        .models
        .available
        .insert(
            model_id.clone(),
            acp::ModelInfo::new(model_id.clone(), "Auto".to_string()),
        );
    app.agents.get_mut(&id).unwrap().chat_kind = true;
    let effects = dispatch(Action::SetDefaultModel(model_id.clone()), &mut app);
    assert!(
        effects
            .iter()
            .any(|e| matches!(e, Effect::SwitchModel { model_id : mid, .. }
        if mid == & model_id)),
        "chat_kind must still emit SwitchModel for live chat mode switches"
    );
    assert!(app.agents[&id].session.model_switch_pending);
}
/// `/model <name>` dispatches `SetDefaultModel` which routes
/// through both `PersistSetting` and `SwitchModel`.
#[test]
fn slash_model_valid_dispatches_set_default_model_with_switch_and_persist() {
    let mut app = test_app_with_agent();
    let id = AgentId(0);
    let model_id = acp::ModelId::new(std::sync::Arc::from("kigi-4.5"));
    app.agents
        .get_mut(&id)
        .unwrap()
        .session
        .models
        .available
        .insert(
            model_id.clone(),
            acp::ModelInfo::new(model_id.clone(), "Kigi 4.5".to_string()),
        );
    let effects = dispatch(Action::SendPrompt("/model Kigi 4.5".into()), &mut app);
    assert_eq!(
        effects.len(),
        2,
        "expected PersistSetting + SwitchModel effects, got {effects:?}",
    );
    assert!(
        matches!(
            &effects[0],
            Effect::PersistSetting {
                key: "default_model",
                ..
            }
        ),
        "first effect must be PersistSetting(default_model), got {:?}",
        effects[0],
    );
    assert!(
        matches!(& effects[1], Effect::SwitchModel { model_id : mid, .. } if mid == &
        model_id),
        "second effect must be SwitchModel(<resolved id>), got {:?}",
        effects[1],
    );
    assert!(app.agents[&id].session.model_switch_pending);
}
#[test]
fn model_switch_pending_resets_correctly_across_success_and_failure() {
    let mut app = test_app_with_agent();
    let id = AgentId(0);
    let model_a = acp::ModelId::new(std::sync::Arc::from("model-a"));
    let model_b = acp::ModelId::new(std::sync::Arc::from("model-b"));
    dispatch(
        Action::SwitchModel {
            model_id: model_a.clone(),
            effort: None,
        },
        &mut app,
    );
    assert!(app.agents[&id].session.model_switch_pending);
    dispatch(
        Action::TaskComplete(TaskResult::SwitchModelComplete {
            agent_id: id,
            model_id: model_a,
            effort: None,
            result: Ok(()),
            prev_model_id: None,
        }),
        &mut app,
    );
    assert!(!app.agents[&id].session.model_switch_pending);
    dispatch(
        Action::SwitchModel {
            model_id: model_b.clone(),
            effort: None,
        },
        &mut app,
    );
    assert!(app.agents[&id].session.model_switch_pending);
    dispatch(
        Action::TaskComplete(TaskResult::SwitchModelComplete {
            agent_id: id,
            model_id: model_b,
            effort: None,
            result: Err(SwitchModelError::Other("network error".into())),
            prev_model_id: None,
        }),
        &mut app,
    );
    assert!(!app.agents[&id].session.model_switch_pending);
}
/// `set_compact_mode(app, new)` emits exactly one
/// `Effect::PersistSetting` with the correct payload — `value`
/// matches `new`, `rollback_value` matches the prior cache value.
#[test]
fn set_compact_mode_emits_persist_setting_with_correct_payload() {
    use crate::settings::SettingValue;
    let mut app = test_app_with_agent();
    let effects = dispatch(Action::SetCompactMode(true), &mut app);
    assert_eq!(effects.len(), 1);
    match &effects[0] {
        Effect::PersistSetting {
            key,
            value,
            rollback_value,
        } => {
            assert_eq!(*key, "compact_mode");
            assert_eq!(value, &SettingValue::Bool(true));
            assert_eq!(rollback_value, &SettingValue::Bool(false));
        }
        other => panic!("expected PersistSetting, got {other:?}"),
    }
    assert!(app.current_ui.compact_mode);
}
/// Resizing to a short terminal (at or below `AUTO_COMPACT_MAX_ROWS`) flips
/// the derived render value (`appearance.prompt.compact`) while the user
/// setting stays untouched; growing back restores it. Resize handling emits
/// no effects at all, so nothing can persist.
#[test]
fn resize_below_threshold_derives_compact_without_touching_user_setting() {
    use crossterm::event::Event;
    let mut app = test_app_with_agent();
    assert!(!app.current_ui.compact_mode);
    assert!(!app.appearance.prompt.compact);
    let _ = app.handle_input(&Event::Resize(100, 14));
    assert!(
        app.appearance.prompt.compact,
        "short terminal must force the render value on"
    );
    assert!(
        !app.current_ui.compact_mode,
        "the user setting must stay off"
    );
    let _ = app.handle_input(&Event::Resize(100, 32));
    assert!(
        !app.appearance.prompt.compact,
        "growing back must restore the user value"
    );
    assert!(!app.current_ui.compact_mode);
}
/// Threshold boundary: 20 rows engages auto-compact, 21 does not — and the
/// deeper `SHORT_TERMINAL_ROWS` degradation zone stays compact too.
#[test]
fn auto_compact_threshold_boundary() {
    use crossterm::event::Event;
    let mut app = test_app_with_agent();
    let _ = app.handle_input(&Event::Resize(100, 21));
    assert!(!app.appearance.prompt.compact, "21 rows stays non-compact");
    let _ = app.handle_input(&Event::Resize(100, 20));
    assert!(
        app.appearance.prompt.compact,
        "20 rows engages auto-compact"
    );
    let _ = app.handle_input(&Event::Resize(100, 16));
    assert!(
        app.appearance.prompt.compact,
        "16 rows (the short-terminal degradation zone) stays compact"
    );
}
/// The resize derivation never writes the thread-local user cache — the
/// cache (like `current_ui` and disk) holds the USER value only. Fresh
/// thread because the cache thread-locals are sticky.
#[test]
fn resize_derivation_never_writes_user_cache() {
    std::thread::spawn(|| {
        use crossterm::event::Event;
        let mut app = test_app_with_agent();
        crate::appearance::cache::set(false);
        let _ = app.handle_input(&Event::Resize(100, 14));
        assert!(app.appearance.prompt.compact);
        assert!(
            !crate::appearance::cache::load(),
            "resize must not write the user cache"
        );
    })
    .join()
    .unwrap();
}
/// User compact ON survives every resize: tiny stays compact (both flags
/// agree) and growing past the threshold keeps compact (the user value wins).
#[test]
fn user_compact_on_survives_growing_past_threshold() {
    use crossterm::event::Event;
    let mut app = test_app_with_agent();
    let _ = dispatch(Action::SetCompactMode(true), &mut app);
    let _ = app.handle_input(&Event::Resize(100, 14));
    assert!(app.appearance.prompt.compact);
    let _ = app.handle_input(&Event::Resize(100, 40));
    assert!(
        app.appearance.prompt.compact,
        "user value must win on tall terminals"
    );
    assert!(app.current_ui.compact_mode);
}
/// The startup seed derives from a pre-hydration disk read; once `current_ui`
/// holds the hydrated layered value, the canonical re-derive hook must
/// correct a divergent render value and fan it out to the prompt widgets —
/// without it the wrong seed sticks until a resize or toggle.
#[test]
fn hydration_rederive_corrects_divergent_startup_seed() {
    let mut app = test_app_with_agent();
    let id = AgentId(0);
    app.last_known_terminal_rows = 40;
    assert!(!app.appearance.prompt.compact);
    app.current_ui.compact_mode = true;
    app.apply_effective_compact();
    assert!(
        app.appearance.prompt.compact,
        "hydrated user value must win over the stale seed"
    );
    assert!(
        app.agents[&id].prompt.compact(),
        "the correction must reach the prompt widget"
    );
}
/// Hot-reload keeps the PRE-reload render value in the config it applies and
/// routes any correction through the single writer, because `set_appearance`
/// alone never syncs `PromptWidget.compact`. A pending divergence must land
/// on every agent's widget — background agents included.
#[test]
fn hot_reload_rederive_syncs_prompt_widgets_on_every_agent() {
    let mut app = test_app_with_agent();
    let id_a = AgentId(0);
    let id_b = AgentId(1);
    let session_b = make_test_agent_session(&app, id_b, "test-session-b");
    app.agents
        .insert(id_b, AgentView::new(session_b, ScrollbackState::new()));
    app.next_agent_id = 2;
    app.last_known_terminal_rows = 18;
    assert!(!app.appearance.prompt.compact);
    assert!(!app.agents[&id_a].prompt.compact());
    assert!(!app.agents[&id_b].prompt.compact());
    let mut config = app.appearance.clone();
    config.prompt.compact = app.appearance.prompt.compact;
    app.set_appearance(config);
    app.apply_effective_compact();
    assert!(app.appearance.prompt.compact, "derived value applied");
    assert!(
        app.agents[&id_a].prompt.compact() && app.agents[&id_b].prompt.compact(),
        "the re-derive must sync every agent's prompt widget, not just the active one"
    );
}
/// Toggling the setting OFF while auto-compact is active persists the user
/// value (false) but keeps the derived render value on, and the toast says
/// why the layout stays compact.
#[test]
fn toggle_off_while_short_keeps_render_compact_and_persists_user_value() {
    use crate::settings::SettingValue;
    use crossterm::event::Event;
    let mut app = test_app_with_agent();
    let _ = dispatch(Action::SetCompactMode(true), &mut app);
    let _ = app.handle_input(&Event::Resize(100, 14));
    let effects = dispatch(Action::SetCompactMode(false), &mut app);
    assert!(!app.current_ui.compact_mode, "user setting turned off");
    assert!(
        app.appearance.prompt.compact,
        "auto-compact keeps the render value on at 14 rows"
    );
    match &effects[0] {
        Effect::PersistSetting { key, value, .. } => {
            assert_eq!(*key, "compact_mode");
            assert_eq!(value, &SettingValue::Bool(false), "persists the USER value");
        }
        other => panic!("expected PersistSetting, got {other:?}"),
    }
    let toast = read_toast(&app);
    assert!(
        toast.contains("auto-compact"),
        "toast must note auto-compact stays active, got {toast:?}"
    );
}
#[test]
fn set_timestamps_emits_persist_setting_with_correct_payload() {
    use crate::settings::SettingValue;
    let mut app = test_app_with_agent();
    let effects = dispatch(Action::SetTimestamps(false), &mut app);
    assert_eq!(effects.len(), 1);
    match &effects[0] {
        Effect::PersistSetting {
            key,
            value,
            rollback_value,
        } => {
            assert_eq!(*key, "show_timestamps");
            assert_eq!(value, &SettingValue::Bool(false));
            assert_eq!(rollback_value, &SettingValue::Bool(true));
        }
        other => panic!("expected PersistSetting, got {other:?}"),
    }
    assert_eq!(app.current_ui.show_timestamps, Some(false));
}
#[test]
fn set_timeline_emits_persist_setting_with_correct_payload() {
    use crate::settings::SettingValue;
    let mut app = test_app_with_agent();
    let default_on = app.current_ui.show_timeline_enabled();
    let effects = dispatch(Action::SetTimeline(!default_on), &mut app);
    assert_eq!(effects.len(), 1);
    match &effects[0] {
        Effect::PersistSetting {
            key,
            value,
            rollback_value,
        } => {
            assert_eq!(*key, "show_timeline");
            assert_eq!(value, &SettingValue::Bool(!default_on));
            assert_eq!(rollback_value, &SettingValue::Bool(default_on));
        }
        other => panic!("expected PersistSetting, got {other:?}"),
    }
    assert_eq!(app.current_ui.show_timeline, Some(!default_on));
    assert_eq!(
        crate::appearance::cache::load_show_timeline(),
        !default_on,
        "set_timeline must update the appearance cache"
    );
    assert_eq!(
        app.appearance.show_timeline, !default_on,
        "set_timeline must update the live appearance config"
    );
}
#[test]
fn set_timeline_toggles_displayed_state_when_current_ui_diverges() {
    let mut app = test_app_with_agent();
    app.appearance.show_timeline = true;
    crate::appearance::cache::set_show_timeline(true);
    app.current_ui.show_timeline = None;
    assert!(
        !app.current_ui.show_timeline_enabled(),
        "current_ui resolves to the OFF default (the divergence)"
    );
    let new = !crate::appearance::cache::load_show_timeline();
    assert!(!new, "toggle target is OFF");
    let effects = dispatch(Action::SetTimeline(new), &mut app);
    assert_eq!(effects.len(), 1, "toggle must persist, not silently no-op");
    assert!(!app.appearance.show_timeline, "the rail is now hidden");
    assert_eq!(app.current_ui.show_timeline, Some(false));
}
#[test]
fn set_simple_mode_emits_persist_setting_with_correct_payload() {
    use crate::settings::SettingValue;
    let mut app = test_app_with_agent();
    let effects = dispatch(Action::SetSimpleMode(false), &mut app);
    assert_eq!(effects.len(), 1);
    match &effects[0] {
        Effect::PersistSetting { key, value, .. } => {
            assert_eq!(*key, "simple_mode");
            assert_eq!(value, &SettingValue::Bool(false));
        }
        other => panic!("expected PersistSetting, got {other:?}"),
    }
    assert_eq!(app.current_ui.simple_mode, Some(false));
}
/// `dispatch_open_settings` runs once: the modal opens on the
/// active agent. A second dispatch — only structurally reachable
/// if input routing breaks — closes the modal defensively
/// (debug_assert guards this in dev builds).
#[test]
fn dispatch_open_settings_opens_then_close_on_reentry() {
    use crate::views::modal::ActiveModal;
    let mut app = test_app_with_agent();
    let _ = dispatch(Action::OpenSettings, &mut app);
    let agent = app
        .agents
        .get(&AgentId(0))
        .expect("agent should exist after test setup");
    assert!(
        matches!(agent.active_modal, Some(ActiveModal::Settings { .. })),
        "first dispatch should open the modal"
    );
    if !cfg!(debug_assertions) {
        let _ = dispatch(Action::OpenSettings, &mut app);
        let agent = app.agents.get(&AgentId(0)).unwrap();
        assert!(
            agent.active_modal.is_none(),
            "second dispatch should close the modal"
        );
    }
}
/// `dispatch_open_reset_confirm` moves the Settings modal state
/// into the new `ResetSettingsConfirm` variant, preserving it
/// across the confirm dialog's lifecycle. The dispatch arm is
/// only reachable from an open Settings modal (the `d` keystroke
/// in `views/settings_modal.rs::handle_browse`).
#[test]
fn dispatch_open_reset_confirm_moves_settings_state_into_confirm_variant() {
    use crate::views::modal::ActiveModal;
    let mut app = test_app_with_agent();
    let _ = dispatch(Action::OpenSettings, &mut app);
    let agent = app.agents.get(&AgentId(0)).expect("agent must exist");
    assert!(
        matches!(agent.active_modal, Some(ActiveModal::Settings { .. })),
        "Settings modal must be open before OpenResetConfirm"
    );
    let _ = dispatch(
        Action::OpenResetConfirm {
            key: "compact_mode",
        },
        &mut app,
    );
    let agent = app.agents.get(&AgentId(0)).expect("agent must exist");
    match &agent.active_modal {
        Some(ActiveModal::ResetSettingsConfirm { key, .. }) => {
            assert_eq!(*key, "compact_mode");
        }
        _ => panic!("expected ResetSettingsConfirm modal to be active"),
    }
}
/// `ConfirmResetSetting { choice: Cancel }` restores the Settings
/// modal AND preserves the user's filter/scroll/selection state
/// byte-identically via the box-move. Pins query, selected,
/// scroll_offset, mode AND the snapshot (not just
/// ui_snapshot.compact_mode).
#[test]
fn dispatch_confirm_reset_setting_cancel_preserves_modal_state() {
    use crate::views::modal::{ActiveModal, ResetSettingsResult};
    use crate::views::settings_modal::SettingsModalMode;
    let mut app = test_app_with_agent();
    let _ = dispatch(Action::SetCompactMode(true), &mut app);
    assert!(app.current_ui.compact_mode);
    let _ = dispatch(Action::OpenSettings, &mut app);
    {
        let agent = app.agents.get_mut(&AgentId(0)).expect("agent must exist");
        if let Some(ActiveModal::Settings { state }) = &mut agent.active_modal {
            state.query.push_str("stamp");
            state.selected = 3;
            state.scroll_offset = 1;
            state.mode = SettingsModalMode::FilterFocused;
        } else {
            panic!("expected Settings modal");
        }
    }
    let _ = dispatch(
        Action::OpenResetConfirm {
            key: "compact_mode",
        },
        &mut app,
    );
    let effects = dispatch(
        Action::ConfirmResetSetting {
            choice: ResetSettingsResult::Cancel,
        },
        &mut app,
    );
    assert!(
        effects.is_empty(),
        "Cancel branch must not emit any Effects, got {effects:?}",
    );
    assert!(
        app.current_ui.compact_mode,
        "Cancel must not mutate the underlying setting",
    );
    let agent = app.agents.get(&AgentId(0)).expect("agent must exist");
    match &agent.active_modal {
        Some(ActiveModal::Settings { state }) => {
            assert!(state.ui_snapshot.compact_mode, "ui_snapshot.compact_mode");
            assert_eq!(state.query, "stamp", "query preserved");
            assert_eq!(state.selected, 3, "selected preserved");
            assert_eq!(state.scroll_offset, 1, "scroll_offset preserved");
            assert!(
                matches!(state.mode, SettingsModalMode::FilterFocused),
                "mode preserved (FilterFocused)"
            );
        }
        _ => panic!("expected Settings modal after Cancel"),
    }
}
/// `ConfirmResetSetting { Reset }` on a
/// PAGER-owned setting (`multiline_mode`) flips the agent's flag
/// back to default WITHOUT emitting any `Effect` (PAGER setters
/// short-circuit on no-disk-write). Pins the cross-owner
/// behavioral parity.
#[test]
fn dispatch_confirm_reset_setting_reset_dispatches_typed_setter_for_pager_bool() {
    use crate::views::modal::ResetSettingsResult;
    let mut app = test_app_with_agent();
    let _ = dispatch(Action::SetMultilineMode(true), &mut app);
    assert!(app.agents[&AgentId(0)].multiline_mode);
    setup_reset_confirm_open(&mut app, "multiline_mode");
    let effects = dispatch(
        Action::ConfirmResetSetting {
            choice: ResetSettingsResult::Reset,
        },
        &mut app,
    );
    assert!(
        effects.is_empty(),
        "PAGER reset must NOT emit Effects (in-memory only), got {effects:?}",
    );
    assert!(
        !app.agents[&AgentId(0)].multiline_mode,
        "agent.multiline_mode must be reset to default",
    );
}
/// Idempotent Reset is gated. When
/// the focused row is already at its registered default, the
/// Reset branch emits a short "already at default" toast and
/// skips the recursive dispatch — unifying behavior across
/// SHARED/PAGER/SHELL owner classes.
#[test]
fn dispatch_confirm_reset_setting_reset_on_already_default_is_no_op_with_toast() {
    use crate::views::modal::ResetSettingsResult;
    let mut app = test_app_with_agent();
    assert!(!app.current_ui.compact_mode);
    setup_reset_confirm_open(&mut app, "compact_mode");
    let effects = dispatch(
        Action::ConfirmResetSetting {
            choice: ResetSettingsResult::Reset,
        },
        &mut app,
    );
    assert!(
        effects.is_empty(),
        "idempotent reset must emit NO Effects, got {effects:?}",
    );
    assert!(!app.current_ui.compact_mode);
    let agent = app.agents.get(&AgentId(0)).expect("agent must exist");
    let toast_text = agent
        .toast
        .as_ref()
        .map(|(s, _)| s.clone())
        .unwrap_or_default();
    assert!(
        toast_text.contains("already at default"),
        "expected 'already at default' toast, got: {toast_text:?}",
    );
}
/// `refresh_open_settings_modals` walks into
/// `ResetSettingsConfirm.settings_state` so a `set_X` (or
/// rollback) running while the confirm dialog is open keeps the
/// boxed snapshot fresh. Without this, Cancel would restore a
/// stale snapshot.
#[test]
fn refresh_open_settings_modals_updates_reset_confirm_settings_state() {
    use crate::views::modal::ActiveModal;
    let mut app = test_app_with_agent();
    setup_reset_confirm_open(&mut app, "compact_mode");
    {
        let agent = app.agents.get(&AgentId(0)).expect("agent must exist");
        if let Some(ActiveModal::ResetSettingsConfirm { settings_state, .. }) = &agent.active_modal
        {
            assert!(!settings_state.ui_snapshot.compact_mode);
        } else {
            panic!("expected ResetSettingsConfirm");
        }
    }
    let _ = dispatch(Action::SetCompactMode(true), &mut app);
    let agent = app.agents.get(&AgentId(0)).expect("agent must exist");
    match &agent.active_modal {
        Some(ActiveModal::ResetSettingsConfirm { settings_state, .. }) => {
            assert!(
                settings_state.ui_snapshot.compact_mode,
                "refresh_open_settings_modals must update the boxed snapshot \
                     inside ResetSettingsConfirm — without this, Cancel restores stale state",
            );
        }
        _ => panic!("expected ResetSettingsConfirm still active"),
    }
}
/// The
/// dispatch-arm bail-out path is verified directly via
/// `#[should_panic]` in debug mode rather than the previous
/// `if cfg!(debug_assertions) { return; }` placebo which gave
/// debug-mode CI no coverage of the routing-bug bail-out.
#[test]
#[cfg(debug_assertions)]
#[should_panic(expected = "OpenResetConfirm dispatched without an open Settings modal")]
fn dispatch_open_reset_confirm_panics_in_debug_when_no_settings_modal() {
    let mut app = test_app_with_agent();
    let agent = app.agents.get(&AgentId(0)).unwrap();
    assert!(agent.active_modal.is_none());
    let _ = dispatch(
        Action::OpenResetConfirm {
            key: "compact_mode",
        },
        &mut app,
    );
}
/// Release-mode mirror: same bail-out but verified via the
/// active_modal staying None (debug_assert! is compiled out).
#[test]
#[cfg(not(debug_assertions))]
fn dispatch_open_reset_confirm_no_op_in_release_when_no_settings_modal() {
    let mut app = test_app_with_agent();
    let agent = app.agents.get(&AgentId(0)).unwrap();
    assert!(agent.active_modal.is_none());
    let effects = dispatch(
        Action::OpenResetConfirm {
            key: "compact_mode",
        },
        &mut app,
    );
    assert!(effects.is_empty());
    let agent = app.agents.get(&AgentId(0)).unwrap();
    assert!(
        agent.active_modal.is_none(),
        "active_modal must stay None on routing-bug bail-out",
    );
}
/// The CI guard for `action_for_reset` asserts more than
/// `action.is_some()`, which would pass for a mis-mapped arm
/// like `("compact_mode", _) => Some(Action::SetTheme(...))`.
/// This version dispatches the returned action and asserts the
/// setting reads back at its registered default — catching both
/// presence AND payload correctness.
#[test]
fn every_setting_has_action_for_reset_arm() {
    use crate::settings::current_value_for;
    let reg = crate::settings::SettingsRegistry::defaults();
    for meta in reg.all() {
        if matches!(meta.kind, crate::settings::SettingKind::Group { .. }) {
            continue;
        }
        let default_value = crate::settings::default_value_for(meta);
        let action = action_for_reset(meta.key, &default_value);
        assert!(
            action.is_some(),
            "Setting `{}` has no `action_for_reset` arm — `d`-reset would silently \
                 no-op. Add an arm to `action_for_reset` in dispatch.rs.",
            meta.key,
        );
        if meta.key == "default_model" {
            continue;
        }
        let mut app = test_app_with_agent();
        move_setting_away_from_default(&mut app, meta.key);
        let _ = dispatch(action.unwrap(), &mut app);
        let pager = build_pager_snapshot(&app);
        let reread = current_value_for(meta.key, &app.current_ui, &pager);
        assert_eq!(
            reread,
            Some(default_value.clone()),
            "action_for_reset({}, ...) round-trip drift: dispatch did not restore the default",
            meta.key,
        );
    }
}
/// Every setting whose setter emits `Effect::PersistSetting` MUST have an
/// `apply_setting_rollback` arm: a failed disk write rolls back through it,
/// and a missing arm silently diverges in-memory state from `config.toml`.
/// Drives each setter (move-away → reset) to discover which keys actually
/// persist, then asserts the rollback hits a real arm (not the catch-all).
#[test]
fn every_persisting_setting_has_rollback_arm() {
    let reg = crate::settings::SettingsRegistry::defaults();
    for meta in reg.all() {
        let default_value = crate::settings::default_value_for(meta);
        let Some(reset_action) = action_for_reset(meta.key, &default_value) else {
            continue;
        };
        let mut app = test_app_with_agent();
        move_setting_away_from_default(&mut app, meta.key);
        for eff in dispatch(reset_action, &mut app) {
            let Effect::PersistSetting {
                key,
                rollback_value,
                ..
            } = eff
            else {
                continue;
            };
            let mut rb_app = test_app_with_agent();
            let _ = apply_setting_rollback(&mut rb_app, key, &rollback_value);
            let toast = rb_app
                .agents
                .get(&AgentId(0))
                .and_then(|a| a.toast.as_ref())
                .map(|(s, _)| s.as_str().to_owned());
            assert_ne!(
                toast.as_deref(),
                Some(crate::app::dispatch::ROLLBACK_NO_ARM_TOAST),
                "Setting `{key}` emits Effect::PersistSetting but apply_setting_rollback has \
                     no arm — a failed persist diverges in-memory state from config.toml. Add an arm.",
            );
        }
    }
}
/// Pin the
/// asymmetric clear-default semantics. `Action::ClearDefaultModel`
/// persists `cfg.models.default = None` AND emits the
/// "cleared" toast, but deliberately does NOT mutate
/// `agent.session.models.current`.
#[test]
fn clear_default_model_persists_but_keeps_live_current() {
    use agent_client_protocol as acp;
    use std::sync::Arc;
    let mut app = test_app_with_agent();
    let id = acp::ModelId::new(Arc::from("kigi-test"));
    let info = acp::ModelInfo::new(id.clone(), "Kigi Test".to_string());
    let agent_id = AgentId(0);
    app.agents
        .get_mut(&agent_id)
        .unwrap()
        .session
        .models
        .available
        .insert(id.clone(), info);
    app.agents
        .get_mut(&agent_id)
        .unwrap()
        .session
        .models
        .set_current(id.clone(), None);
    let effects = dispatch(Action::ClearDefaultModel, &mut app);
    assert_eq!(
        effects.len(),
        1,
        "expected exactly one PersistSetting effect"
    );
    assert!(
        matches!(& effects[0], Effect::PersistSetting { key : "default_model", value :
        crate ::settings::SettingValue::String(s), .. } if s.is_empty()),
        "expected PersistSetting(default_model, ''), got {:?}",
        effects[0],
    );
    assert_eq!(
        app.agents[&agent_id].session.models.current,
        Some(id),
        "clear_default_model must NOT mutate live agent.session.models.current",
    );
}
/// `Action::SetDefaultModel(<known id>)` resolves the
/// id against the live catalog, mutates current, and emits both
/// PersistSetting + SwitchModel effects. This is the
/// dispatch-level analog of the slash-command's
/// `slash_model_valid_dispatches_set_default_model_with_switch_and_persist`
/// test.
#[test]
fn set_default_model_resolves_known_name() {
    use agent_client_protocol as acp;
    use std::sync::Arc;
    let mut app = test_app_with_agent();
    let id = acp::ModelId::new(Arc::from("kigi-4.5"));
    let info = acp::ModelInfo::new(id.clone(), "Kigi 4.5".to_string());
    let agent_id = AgentId(0);
    app.agents
        .get_mut(&agent_id)
        .unwrap()
        .session
        .models
        .available
        .insert(id.clone(), info);
    let effects = dispatch(Action::SetDefaultModel(id.clone()), &mut app);
    assert_eq!(effects.len(), 2);
    assert!(
        matches!(& effects[0], Effect::PersistSetting { key : "default_model", value :
        crate ::settings::SettingValue::String(s), .. } if s == "kigi-4.5")
    );
    assert!(matches!(& effects[1], Effect::SwitchModel { model_id : mid, .. } if mid == & id));
    assert_eq!(app.agents[&agent_id].session.models.current, Some(id));
}
/// Re-dispatching the same model
/// id is idempotent — no PersistSetting, no SwitchModel, no
/// reasoning_effort reset.
#[test]
fn set_default_model_idempotent_when_already_current() {
    use agent_client_protocol as acp;
    use std::sync::Arc;
    let mut app = test_app_with_agent();
    let id = acp::ModelId::new(Arc::from("kigi-already"));
    let info = acp::ModelInfo::new(id.clone(), "Kigi Already".to_string());
    let agent_id = AgentId(0);
    app.agents
        .get_mut(&agent_id)
        .unwrap()
        .session
        .models
        .available
        .insert(id.clone(), info);
    app.agents
        .get_mut(&agent_id)
        .unwrap()
        .session
        .models
        .set_current(id.clone(), None);
    let effects = dispatch(Action::SetDefaultModel(id), &mut app);
    assert!(
        effects.is_empty(),
        "re-dispatching same model must be idempotent (no effects), got {effects:?}",
    );
}
/// `clamp_max_thoughts_width` clamps
/// out-of-range values to the registered `[40, 500]` bounds.
#[test]
fn set_max_thoughts_width_clamps_out_of_range() {
    let mut app = test_app_with_agent();
    let _ = dispatch(Action::SetMaxThoughtsWidth(10), &mut app);
    assert_eq!(app.current_ui.max_thoughts_width, 40);
    let _ = dispatch(Action::SetMaxThoughtsWidth(9999), &mut app);
    assert_eq!(app.current_ui.max_thoughts_width, 500);
    let _ = dispatch(Action::SetMaxThoughtsWidth(200), &mut app);
    assert_eq!(app.current_ui.max_thoughts_width, 200);
}
#[test]
fn pr13_set_show_tips_emits_persist_with_rollback() {
    use crate::settings::SettingValue;
    let mut app = test_app_with_agent();
    assert_eq!(app.show_tips, None);
    let effects = dispatch(Action::SetShowTips(false), &mut app);
    assert_eq!(effects.len(), 1, "must emit exactly one effect");
    match &effects[0] {
        Effect::PersistSetting {
            key,
            value,
            rollback_value,
        } => {
            assert_eq!(*key, "show_tips");
            assert_eq!(*value, SettingValue::Bool(false));
            assert_eq!(*rollback_value, SettingValue::Bool(true));
        }
        other => panic!("expected PersistSetting effect, got {other:?}"),
    }
    assert_eq!(app.show_tips, Some(false));
    let effects = dispatch(Action::SetShowTips(true), &mut app);
    assert_eq!(effects.len(), 1);
    match &effects[0] {
        Effect::PersistSetting {
            value,
            rollback_value,
            ..
        } => {
            assert_eq!(*value, SettingValue::Bool(true));
            assert_eq!(*rollback_value, SettingValue::Bool(false));
        }
        other => panic!("expected PersistSetting effect, got {other:?}"),
    }
    assert_eq!(app.show_tips, Some(true));
}
/// Idempotency — re-committing the same value is a no-op
/// (no Effect). Mirror of `set_auto_compact_threshold_percent_idempotent_re_commit`.
#[test]
fn pr13_set_show_tips_idempotent_re_commit() {
    let mut app = test_app_with_agent();
    let _ = dispatch(Action::SetShowTips(false), &mut app);
    assert_eq!(app.show_tips, Some(false));
    let effects = dispatch(Action::SetShowTips(false), &mut app);
    assert!(
        effects.is_empty(),
        "re-committing the same value must be idempotent (no effects), got {effects:?}"
    );
}
/// The first-ever commit of the default value MUST persist
/// (mirror of `set_auto_compact_threshold_percent_first_commit_of_default_persists`).
/// Without this special case, a user opting in to the default
/// would see no Effect but the on-disk state would stay `None` —
/// the next session's managed-config layer could then override
/// silently.
#[test]
fn pr13_set_show_tips_first_commit_of_default_persists() {
    let mut app = test_app_with_agent();
    assert_eq!(app.show_tips, None);
    let effects = dispatch(Action::SetShowTips(true), &mut app);
    assert_eq!(
        effects.len(),
        1,
        "first commit of the default must persist (not silently no-op)"
    );
    assert_eq!(app.show_tips, Some(true));
}
/// Subsequent commits of the same default hit the
/// idempotent fast-path. Pins the symmetric half of the
/// `_first_commit_of_default_persists` contract.
#[test]
fn pr13_set_show_tips_second_commit_of_default_no_ops() {
    let mut app = test_app_with_agent();
    let _ = dispatch(Action::SetShowTips(true), &mut app);
    assert_eq!(app.show_tips, Some(true));
    let effects = dispatch(Action::SetShowTips(true), &mut app);
    assert!(
        effects.is_empty(),
        "second commit of the default must hit the idempotent fast-path \
             (effects.is_empty()), got {effects:?}"
    );
}
/// Rollback from prev=None state restores `None` (not
/// `Some(default)`) — keeps AppView in sync with disk. Mirror of
/// `auto_compact_rollback_from_none_state_restores_none`.
#[test]
fn pr13_show_tips_rollback_from_none_state_restores_none() {
    use crate::settings::SettingValue;
    let mut app = test_app_with_agent();
    assert_eq!(app.show_tips, None);
    let effects = dispatch(Action::SetShowTips(false), &mut app);
    let rollback_value = match &effects[0] {
        Effect::PersistSetting { rollback_value, .. } => rollback_value.clone(),
        _ => panic!("expected PersistSetting"),
    };
    assert_eq!(rollback_value, SettingValue::Bool(true));
    assert_eq!(app.show_tips, Some(false));
    apply_setting_rollback(&mut app, "show_tips", &rollback_value);
    assert_eq!(
        app.show_tips, None,
        "PR 13: rollback from prev=None must restore None (not Some(true)) — \
             otherwise the AppView mirror drifts from disk, which is still None \
             after the failed persist"
    );
}
/// Toast text includes the "(restart to apply)" cue —
/// matches the modal pill so the user gets consistent restart
/// feedback through both surfaces. Mirror of
/// `set_auto_compact_threshold_percent_toast_includes_restart_marker`.
#[test]
fn pr13_set_show_tips_toast_includes_restart_marker() {
    let mut app = test_app_with_agent();
    let _ = dispatch(Action::SetShowTips(false), &mut app);
    let toast = read_toast(&app);
    assert!(
        toast.contains("Show tips"),
        "toast must include the setting label, got {toast:?}"
    );
    assert!(
        toast.contains("off"),
        "toast must include the value, got {toast:?}"
    );
    assert!(
        toast.contains("restart to apply"),
        "toast must include the deferred-effect cue, got {toast:?}"
    );
}
/// Helper for `every_setting_has_action_for_reset_arm`. Flips the
/// setting to a non-default value so the round-trip dispatch has
/// an observable effect (otherwise the assertion would pass
/// vacuously when current == default).
fn move_setting_away_from_default(app: &mut AppView, key: crate::settings::SettingKey) {
    match key {
        "compact_mode" => {
            let _ = dispatch(Action::SetCompactMode(true), app);
        }
        "show_timestamps" => {
            let _ = dispatch(Action::SetTimestamps(false), app);
        }
        "show_timeline" => {
            let away = !app.current_ui.show_timeline_enabled();
            let _ = dispatch(Action::SetTimeline(away), app);
        }
        "simple_mode" => {
            let _ = dispatch(Action::SetSimpleMode(false), app);
        }
        "contextual_hints.undo" => {
            let _ = dispatch(Action::SetContextualHintUndo(false), app);
        }
        "contextual_hints.plan_mode" => {
            let _ = dispatch(Action::SetContextualHintPlanMode(false), app);
        }
        "contextual_hints.image_input" => {
            let _ = dispatch(Action::SetContextualHintImageInput(false), app);
        }
        "contextual_hints.send_now" => {
            let _ = dispatch(Action::SetContextualHintSendNow(false), app);
        }
        "contextual_hints.small_screen" => {
            let _ = dispatch(Action::SetContextualHintSmallScreen(false), app);
        }
        "contextual_hints.word_select" => {
            let _ = dispatch(Action::SetContextualHintWordSelect(false), app);
        }
        "multiline_mode" => {
            let _ = dispatch(Action::SetMultilineMode(true), app);
        }
        "render_mermaid" => {
            let _ = dispatch(
                Action::SetRenderMermaid(crate::appearance::RenderMermaid::Off),
                app,
            );
        }
        "theme" => {
            let _ = dispatch(Action::SetTheme("tokyonight".to_owned()), app);
        }
        "auto_dark_theme" => {
            let _ = dispatch(Action::SetAutoDarkTheme("tokyonight".to_owned()), app);
        }
        "auto_light_theme" => {
            let _ = dispatch(Action::SetAutoLightTheme("rosepine-dawn".to_owned()), app);
        }
        "permission_mode" => {
            let _ = dispatch(Action::SetYoloMode(true), app);
        }
        "default_model" => {
            use agent_client_protocol as acp;
            use std::sync::Arc;
            if let ActiveView::Agent(aid) = app.active_view
                && let Some(agent) = app.agents.get_mut(&aid)
            {
                let id = acp::ModelId::new(Arc::from("test-model-move"));
                let info = acp::ModelInfo::new(id.clone(), "Test Model Move".to_string());
                agent.session.models.available.insert(id.clone(), info);
                agent.session.models.set_current(id, None);
            }
        }
        "max_thoughts_width" => {
            let _ = dispatch(Action::SetMaxThoughtsWidth(200), app);
        }
        "plan_mode" => {
            let _ = dispatch(
                Action::SetPlanMode(crate::app::actions::PlanModeKind::On),
                app,
            );
        }
        "show_tips" => {
            let _ = dispatch(Action::SetShowTips(false), app);
        }
        "auto_update" => {
            let _ = dispatch(Action::SetAutoUpdate(false), app);
        }
        "vim_mode" => {
            let _ = dispatch(Action::SetVimMode(true), app);
        }
        "remember_tool_approvals" => {
            let _ = dispatch(Action::SetRememberToolApprovals(true), app);
        }
        "toolset.ask_user_question.timeout_enabled" => {
            let _ = dispatch(Action::SetAskUserQuestionTimeoutEnabled(false), app);
        }
        "keep_text_selection" => {
            let _ = dispatch(
                Action::SetKeepTextSelection(crate::appearance::TextSelection::Hold),
                app,
            );
        }
        "scroll_speed" => {
            let _ = dispatch(Action::SetScrollSpeed(75), app);
        }
        "scroll_mode" => {
            let _ = dispatch(
                Action::SetScrollMode(crate::appearance::ScrollMode::Wheel),
                app,
            );
        }
        "invert_scroll" => {
            let _ = dispatch(Action::SetInvertScroll(true), app);
        }
        "display_refresh_auto_cadence" => {
            let _ = dispatch(Action::SetDisplayRefreshAutoCadence(true), app);
        }
        "scroll_lines" => {
            let _ = dispatch(Action::SetScrollLines(5), app);
        }
        "show_thinking_blocks" => {
            let _ = dispatch(Action::SetShowThinkingBlocks(true), app);
        }
        "group_tool_verbs" => {
            let _ = dispatch(Action::SetGroupToolVerbs(false), app);
        }
        "collapsed_edit_blocks" => {
            let _ = dispatch(Action::SetCollapsedEditBlocks(true), app);
        }
        "prompt_suggestions" => {
            let _ = dispatch(Action::SetPromptSuggestions(false), app);
        }
        "respect_manual_folds" => {
            let _ = dispatch(
                Action::SetRespectManualFolds(
                    !crate::appearance::ScrollConfig::default().respect_manual_folds,
                ),
                app,
            );
        }
        "hunk_tracker_mode" => {
            let _ = dispatch(Action::SetHunkTrackerMode("all_dirty".to_string()), app);
        }
        "screen_mode" => {
            let _ = dispatch(Action::SetScreenMode("minimal".to_string()), app);
        }
        "fork_secondary_model" => {
            use agent_client_protocol as acp;
            use std::sync::Arc;
            if let ActiveView::Agent(aid) = app.active_view
                && let Some(agent) = app.agents.get_mut(&aid)
            {
                let id = acp::ModelId::new(Arc::from("test-fork-move"));
                let info = acp::ModelInfo::new(id.clone(), "Test Fork Move".to_string());
                agent.session.models.available.insert(id.clone(), info);
                let _ = dispatch(Action::SetForkSecondaryModel(id), app);
            }
        }
        "default_selected_permission" => {
            let _ = dispatch(
                Action::SetDefaultSelectedPermission("allow_once".to_string()),
                app,
            );
        }
        other => {
            panic!(
                "move_setting_away_from_default: no arm for `{other}`. \
                 Add one when registering a new setting."
            )
        }
    }
}
#[test]
fn set_compact_mode_toast_format() {
    let mut app = test_app_with_agent();
    let _ = dispatch(Action::SetCompactMode(true), &mut app);
    let toast = read_toast(&app);
    assert!(toast.contains("Compact mode"));
    assert!(toast.contains("on"));
    assert!(toast.contains('\u{2713}'));
    let _ = dispatch(Action::SetCompactMode(false), &mut app);
    let toast = read_toast(&app);
    assert!(toast.contains("Compact mode"));
    assert!(toast.contains("off"));
}
/// `set_simple_mode_inner` propagates to the active agent's
/// `input_mode` — without this, the toast says
/// "Simple mode: on" but the current session keeps Vim input.
#[test]
fn set_simple_mode_propagates_to_active_agent() {
    let mut app = test_app_with_agent();
    let agent = app.agents.get_mut(&AgentId(0)).unwrap();
    agent.input_mode = crate::views::agent::InputMode::Vim;
    let _ = dispatch(Action::SetSimpleMode(true), &mut app);
    let agent = app.agents.get(&AgentId(0)).unwrap();
    assert_eq!(
        agent.input_mode,
        crate::views::agent::InputMode::Simple,
        "set_simple_mode(true) must switch the active agent into Simple input mode"
    );
    let _ = dispatch(Action::SetSimpleMode(false), &mut app);
    let agent = app.agents.get(&AgentId(0)).unwrap();
    assert_eq!(
        agent.input_mode,
        crate::views::agent::InputMode::Vim,
        "set_simple_mode(false) must switch back to Vim input mode"
    );
}
/// `set_simple_mode_inner` is a no-op on the agent-propagation
/// path when there's no active agent — the persist effect still
/// fires (the setting is global, not agent-local).
#[test]
fn set_simple_mode_no_op_when_no_active_agent() {
    let mut app = test_app();
    let effects = dispatch(Action::SetSimpleMode(true), &mut app);
    assert_eq!(effects.len(), 1);
    match &effects[0] {
        Effect::PersistSetting { key, .. } => assert_eq!(*key, "simple_mode"),
        other => panic!("expected PersistSetting, got {other:?}"),
    }
    assert_eq!(app.current_ui.simple_mode, Some(true));
}
/// `set_simple_mode_inner` propagates to **every** agent, not just
/// the active one — without iterating
/// over `app.agents.values_mut()`, agent B's input_mode stays
/// stale when the user toggles simple-mode while agent A is active.
#[test]
fn set_simple_mode_propagates_to_every_agent() {
    let mut app = test_app_with_agent();
    let id_b = AgentId(1);
    let mut agent_b = AgentView::new(
        AgentSession {
            id: id_b,
            acp_tx: app.acp_tx.clone(),
            session_id: Some("test-session-b".into()),
            models: ModelState::default(),
            state: AgentState::Idle,
            tracker: AcpUpdateTracker::new(),
            cwd: PathBuf::from("/tmp"),
            is_worktree: false,
            forked_from: None,
            pending_prompts: std::collections::VecDeque::new(),
            next_queue_id: 0,
            yolo_mode: false,
            auto_mode: false,
            prompt_history: Vec::new(),
            prompt_history_loading: false,
            loading_replay: false,
            restore_degree: None,
            rate_limited: false,
            model_incompatible: false,
            available_commands: Vec::new(),
            available_commands_generation: 0,
            available_tools: None,
            model_switch_pending: false,
            user_model_preference: None,
            deferred_model_switch: None,
            bg_tasks: std::collections::BTreeMap::new(),
            bg_tool_call_to_task: std::collections::HashMap::new(),
            scheduled_tasks: std::collections::HashMap::new(),
            in_flight_prompt: None,
            current_prompt_id: None,
            created_via_new: false,
        },
        ScrollbackState::new(),
    );
    agent_b.input_mode = crate::views::agent::InputMode::Vim;
    app.agents.insert(id_b, agent_b);
    app.next_agent_id = 2;
    let _ = dispatch(Action::SetSimpleMode(true), &mut app);
    for (id, agent) in &app.agents {
        assert_eq!(
            agent.input_mode,
            crate::views::agent::InputMode::Simple,
            "agent {id:?} input_mode did not propagate to Simple"
        );
    }
}
/// Setters must update BOTH `app.current_ui` AND
/// `crate::appearance::cache`. The cache is the render hot path's
/// source of truth; if a future refactor accidentally drops the
/// `cache::set(new)` call, `app.current_ui` would still show the
/// new value but the renderer would revert on next frame. Runs
/// in a fresh thread because the thread-locals are sticky.
#[test]
fn set_x_propagates_to_thread_local_cache() {
    std::thread::spawn(|| {
        let mut app = test_app_with_agent();
        let _ = dispatch(Action::SetCompactMode(true), &mut app);
        assert!(
            crate::appearance::cache::load(),
            "set_compact_mode must update the cache"
        );
        let _ = dispatch(Action::SetCompactMode(false), &mut app);
        assert!(
            !crate::appearance::cache::load(),
            "set_compact_mode(false) must update the cache"
        );
        let _ = dispatch(Action::SetTimestamps(false), &mut app);
        assert!(
            !crate::appearance::cache::load_timestamps(),
            "set_timestamps must update the cache"
        );
        let _ = dispatch(Action::SetSimpleMode(false), &mut app);
        assert!(
            !crate::appearance::cache::load_simple_mode(),
            "set_simple_mode must update the cache"
        );
    })
    .join()
    .unwrap();
}
/// In debug builds, dispatching `Action::OpenSettings` twice in a
/// row panics on the `debug_assert!` (input-routing bug guard).
/// Pairs with
/// `dispatch_open_settings_opens_then_close_on_reentry` (which
/// exercises the release-mode silent-close path).
#[test]
#[should_panic(expected = "OpenSettings dispatched while settings modal is already open")]
#[cfg(debug_assertions)]
fn dispatch_open_settings_double_dispatch_panics_in_debug() {
    let mut app = test_app_with_agent();
    let _ = dispatch(Action::OpenSettings, &mut app);
    let _ = dispatch(Action::OpenSettings, &mut app);
}
/// `set_multiline_mode(app, true)` mutates the active agent's
/// flag, fires a toast, and emits NO effects.
#[test]
fn set_multiline_mode_mutates_agent_and_emits_no_effect() {
    let mut app = test_app_with_agent();
    assert!(!app.agents[&AgentId(0)].multiline_mode);
    let effects = dispatch(Action::SetMultilineMode(true), &mut app);
    assert!(
        effects.is_empty(),
        "PAGER-owned setter must NOT emit Effect::PersistSetting, got {effects:?}",
    );
    assert!(
        app.agents[&AgentId(0)].multiline_mode,
        "agent.multiline_mode must reflect the new value",
    );
}
/// Idempotent fast path: re-emitting the same value is a UX
/// no-op (no toast, no Effect).
///
/// **Contract divergence from SHARED setters:**
/// `set_compact_mode_inner` only skips
/// the `app.set_appearance` fan-out — the outer `set_compact_mode`
/// still toasts AND emits `Effect::PersistSetting` on every
/// dispatch, including no-ops. PAGER setters can be stricter
/// because there's no disk write to confirm: re-toasting on a
/// same-value re-dispatch is pure UI noise, so we skip the toast
/// at the outer level too.
#[test]
fn set_multiline_mode_idempotent_no_toast() {
    let mut app = test_app_with_agent();
    let _ = dispatch(Action::SetMultilineMode(true), &mut app);
    let agent = app.agents.get_mut(&AgentId(0)).unwrap();
    assert!(agent.toast.is_some(), "first set must toast");
    agent.toast = None;
    let effects = dispatch(Action::SetMultilineMode(true), &mut app);
    assert!(effects.is_empty());
    assert!(
        app.agents[&AgentId(0)].toast.is_none(),
        "redundant set must not re-toast",
    );
    assert!(
        app.agents[&AgentId(0)].multiline_mode,
        "idempotent re-set must preserve value",
    );
}
/// Initial-idempotence: dispatching the same value as the agent's
/// default produces no toast and no effect.
/// Pins "no UX side effects on a redundant initial dispatch" —
/// matches the user-visible "open modal, click Space twice on a
/// default-false setting" flow.
#[test]
fn set_multiline_mode_initial_same_value_is_no_op() {
    let mut app = test_app_with_agent();
    let effects = dispatch(Action::SetMultilineMode(false), &mut app);
    assert!(effects.is_empty());
    assert!(
        app.agents[&AgentId(0)].toast.is_none(),
        "matching-default dispatch must not toast",
    );
}
/// Toast string format matches the `"\u{2713} {label}: {value}"`
/// format exactly. Exact-equality strictly catches
/// format drift (e.g., dropping the `:` separator, adding suffix)
/// that substring `contains` checks would miss.
#[test]
fn set_multiline_mode_toast_format() {
    let mut app = test_app_with_agent();
    let _ = dispatch(Action::SetMultilineMode(true), &mut app);
    let toast = app.agents[&AgentId(0)]
        .toast
        .as_ref()
        .map(|(s, _)| s.clone())
        .expect("toast must be set");
    assert_eq!(toast, "\u{2713} Multiline: on");
    let _ = dispatch(Action::SetMultilineMode(false), &mut app);
    let toast = app.agents[&AgentId(0)]
        .toast
        .as_ref()
        .map(|(s, _)| s.clone())
        .expect("toast must be set");
    assert_eq!(toast, "\u{2713} Multiline: off");
}
/// No active agent → no-op (no panic, no effect, no mutation).
/// Differs from `set_simple_mode_no_op_when_no_active_agent`: SHARED
/// settings persist globally even without an agent, but PAGER
/// settings have nowhere to write without an agent's state to
/// mutate.
#[test]
fn set_multiline_mode_no_op_when_no_active_agent() {
    let mut app = test_app();
    let compact_before = app.current_ui.compact_mode;
    let timestamps_before = app.current_ui.show_timestamps;
    let simple_before = app.current_ui.simple_mode;
    let effects = dispatch(Action::SetMultilineMode(true), &mut app);
    assert!(
        effects.is_empty(),
        "no active agent → no Effect (the setting is per-agent)",
    );
    assert_eq!(app.current_ui.compact_mode, compact_before);
    assert_eq!(app.current_ui.show_timestamps, timestamps_before);
    assert_eq!(app.current_ui.simple_mode, simple_before);
    assert!(
        matches!(app.active_view, ActiveView::Welcome),
        "active_view must not flip on no-agent dispatch",
    );
}
/// Dashboard surface owns its own compose flag: `SetMultilineMode` /
/// `/multiline` flip `dashboard.multiline_mode` and leave agent flags alone.
#[test]
fn set_multiline_mode_on_dashboard_toggles_dashboard_not_agents() {
    let mut app = test_app_with_agent();
    insert_placeholder_agent(&mut app, AgentId(1));
    app.dashboard = Some(crate::views::dashboard::DashboardState::new());
    app.active_view = ActiveView::AgentDashboard;
    assert!(!app.dashboard.as_ref().unwrap().multiline_mode);
    assert!(!app.agents[&AgentId(0)].multiline_mode);
    assert!(!app.agents[&AgentId(1)].multiline_mode);
    let effects = dispatch(Action::SetMultilineMode(true), &mut app);
    assert!(effects.is_empty());
    assert!(
        app.dashboard.as_ref().unwrap().multiline_mode,
        "dashboard flag must flip on"
    );
    assert!(
        !app.agents[&AgentId(0)].multiline_mode && !app.agents[&AgentId(1)].multiline_mode,
        "agent flags must stay put"
    );
    let _ = dispatch(Action::SetMultilineMode(false), &mut app);
    assert!(!app.dashboard.as_ref().unwrap().multiline_mode);
    let _ = dispatch_dashboard_dispatch_slash(&mut app, "/multiline".into());
    assert!(
        app.dashboard.as_ref().unwrap().multiline_mode,
        "/multiline must toggle dashboard on"
    );
    assert!(!app.agents[&AgentId(0)].multiline_mode);
}
/// Multi-agent fan-out. `set_multiline_mode`
/// mutates only the ACTIVE agent's `multiline_mode`, never
/// other agents in the registry. The setter currently uses
/// `app.agents.get_mut(&active)` (index by active), but a
/// future refactor that loops over `app.agents.values_mut()`
/// or that touches SHARED `app.current_ui` would silently
/// regress this contract — without a multi-agent test, the
/// regression wouldn't surface until the user opened two
/// agents and noticed cross-contamination.
#[test]
fn set_multiline_mode_mutates_only_active_agent_not_others() {
    let mut app = test_app_with_agent();
    insert_placeholder_agent(&mut app, AgentId(1));
    assert!(!app.agents[&AgentId(0)].multiline_mode);
    assert!(!app.agents[&AgentId(1)].multiline_mode);
    let _ = dispatch(Action::SetMultilineMode(true), &mut app);
    assert!(
        app.agents[&AgentId(0)].multiline_mode,
        "active agent (id=0) must flip to true",
    );
    assert!(
        !app.agents[&AgentId(1)].multiline_mode,
        "non-active agent (id=1) must NOT be touched — PAGER-owned \
             setters address the active agent only",
    );
}
/// Regression test.
///
/// Open the settings modal, dispatch `SetMultilineMode(true)`,
/// assert the modal's `pager_snapshot.multiline_mode` is refreshed
/// to `true`. Without `refresh_open_settings_modals`, the snapshot
/// stays at the open-time value and the user gets stuck — the
/// indicator shows the wrong state AND subsequent toggles are
/// no-ops via the idempotent guard.
#[test]
fn set_multiline_mode_refreshes_open_modal_pager_snapshot() {
    use crate::views::modal::ActiveModal;
    let mut app = test_app_with_agent();
    let _ = dispatch(Action::OpenSettings, &mut app);
    let agent = app.agents.get(&AgentId(0)).unwrap();
    let Some(ActiveModal::Settings { state }) = &agent.active_modal else {
        panic!("expected Settings modal after OpenSettings dispatch")
    };
    assert!(
        !state.pager_snapshot.multiline_mode,
        "snapshot at open should be false (agent default)",
    );
    let _ = dispatch(Action::SetMultilineMode(true), &mut app);
    let agent = app.agents.get(&AgentId(0)).unwrap();
    let Some(ActiveModal::Settings { state }) = &agent.active_modal else {
        panic!("Settings modal must remain open across the dispatch")
    };
    assert!(
        state.pager_snapshot.multiline_mode,
        "snapshot must be refreshed to true after SetMultilineMode(true) — \
             stale-snapshot bug (Round-2 Issue 1) would leave it false",
    );
    let cur_value = crate::settings::current_value_for(
        "multiline_mode",
        &state.ui_snapshot,
        &state.pager_snapshot,
    )
    .expect("multiline_mode must resolve");
    assert_eq!(
        cur_value,
        crate::settings::SettingValue::Bool(true),
        "current_value_for must read the refreshed snapshot",
    );
}
/// Regression test (SHARED path).
///
/// Same bug pattern as the multiline test above, but for
/// `compact_mode` (SHARED). The bug is fixed for the entire
/// SHARED family by calling `refresh_open_settings_modals` from
/// every `set_X` outer, not just for multiline.
#[test]
fn set_compact_mode_refreshes_open_modal_ui_snapshot() {
    use crate::views::modal::ActiveModal;
    let mut app = test_app_with_agent();
    let _ = dispatch(Action::OpenSettings, &mut app);
    let agent = app.agents.get(&AgentId(0)).unwrap();
    let Some(ActiveModal::Settings { state }) = &agent.active_modal else {
        panic!("expected Settings modal after OpenSettings dispatch")
    };
    assert!(!state.ui_snapshot.compact_mode);
    let _ = dispatch(Action::SetCompactMode(true), &mut app);
    let agent = app.agents.get(&AgentId(0)).unwrap();
    let Some(ActiveModal::Settings { state }) = &agent.active_modal else {
        panic!("Settings modal must remain open across the dispatch")
    };
    assert!(
        state.ui_snapshot.compact_mode,
        "ui_snapshot must be refreshed to true after SetCompactMode(true) — \
             stale-snapshot bug (Round-2 Issue 1) would leave it false",
    );
}
/// `Action::SetVimMode(true)` flips the cache + every agent's
/// field, emits `Effect::PersistSetting`, and writes a toast.
#[test]
fn set_vim_mode_mutates_all_agents_and_cache_no_effect() {
    crate::appearance::cache::set_vim_mode(false);
    let mut app = test_app_with_agent();
    insert_placeholder_agent(&mut app, AgentId(1));
    assert!(!app.agents[&AgentId(0)].vim_mode);
    assert!(!app.agents[&AgentId(1)].vim_mode);
    let effects = dispatch(Action::SetVimMode(true), &mut app);
    assert_eq!(
        effects.len(),
        1,
        "SHELL-owned setter must emit exactly one Effect::PersistSetting, got {effects:?}",
    );
    assert!(
        matches!(
            &effects[0],
            Effect::PersistSetting {
                key: "vim_mode",
                value: crate::settings::SettingValue::Bool(true),
                rollback_value: crate::settings::SettingValue::Bool(false),
            }
        ),
        "unexpected effect: {:?}",
        effects[0],
    );
    assert!(
        crate::appearance::cache::load_vim_mode(),
        "cache must mirror the new value so newly-created agents pick it up",
    );
    assert!(
        app.agents[&AgentId(0)].vim_mode && app.agents[&AgentId(1)].vim_mode,
        "vim_mode must fan out to EVERY agent (background subagents \
             + side panes pick up the change without restart)",
    );
}
/// Re-dispatching the same value is a no-op (no toast, no fan-out).
#[test]
fn set_vim_mode_idempotent_no_toast() {
    crate::appearance::cache::set_vim_mode(false);
    let mut app = test_app_with_agent();
    let _ = dispatch(Action::SetVimMode(true), &mut app);
    let agent = app.agents.get_mut(&AgentId(0)).unwrap();
    assert!(agent.toast.is_some(), "first set must toast");
    agent.toast = None;
    let effects = dispatch(Action::SetVimMode(true), &mut app);
    assert!(effects.is_empty());
    assert!(
        app.agents[&AgentId(0)].toast.is_none(),
        "redundant set must not re-toast",
    );
}
#[test]
fn set_keep_text_selection_emits_persist_and_updates_cache() {
    use crate::appearance::TextSelection;
    crate::appearance::cache::set_keep_text_selection(TextSelection::Flash);
    let mut app = test_app_with_agent();
    let effects = dispatch(Action::SetKeepTextSelection(TextSelection::Hold), &mut app);
    assert_eq!(
        crate::appearance::cache::load_keep_text_selection(),
        TextSelection::Hold
    );
    assert!(
        matches!(
            effects.as_slice(),
            [Effect::PersistSetting {
                key: "keep_text_selection",
                value: crate::settings::SettingValue::Enum("hold"),
                rollback_value: crate::settings::SettingValue::Enum("flash"),
            }]
        ),
        "unexpected effects: {effects:?}"
    );
}
#[test]
fn set_keep_text_selection_rollback_restores_state() {
    use crate::appearance::TextSelection;
    crate::appearance::cache::set_keep_text_selection(TextSelection::Flash);
    let mut app = test_app_with_agent();
    let _ = dispatch(Action::SetKeepTextSelection(TextSelection::Hold), &mut app);
    assert_eq!(
        crate::appearance::cache::load_keep_text_selection(),
        TextSelection::Hold
    );
    let _ = apply_setting_rollback(
        &mut app,
        "keep_text_selection",
        &crate::settings::SettingValue::Enum("flash"),
    );
    assert_eq!(
        crate::appearance::cache::load_keep_text_selection(),
        TextSelection::Flash,
        "rollback must restore cache"
    );
}
/// `Action::SetVimMode` rollback restores the cache + agents.
#[test]
fn set_vim_mode_rollback_restores_state() {
    crate::appearance::cache::set_vim_mode(false);
    let mut app = test_app_with_agent();
    let _ = dispatch(Action::SetVimMode(true), &mut app);
    assert!(crate::appearance::cache::load_vim_mode());
    assert!(app.agents[&AgentId(0)].vim_mode);
    let _ = apply_setting_rollback(
        &mut app,
        "vim_mode",
        &crate::settings::SettingValue::Bool(false),
    );
    assert!(
        !crate::appearance::cache::load_vim_mode(),
        "rollback must restore cache",
    );
    assert!(
        !app.agents[&AgentId(0)].vim_mode,
        "rollback must restore agent field",
    );
}
#[test]
fn set_show_thinking_blocks_applies_persists_and_rolls_back() {
    crate::appearance::cache::set_show_thinking_blocks(true);
    let mut app = test_app_with_agent();
    let effects = dispatch(Action::SetShowThinkingBlocks(false), &mut app);
    assert!(
        matches!(
            effects.as_slice(),
            [Effect::PersistSetting {
                key: "show_thinking_blocks",
                value: crate::settings::SettingValue::Bool(false),
                rollback_value: crate::settings::SettingValue::Bool(true),
            }]
        ),
        "expected exactly one PersistSetting effect, got {effects:?}",
    );
    assert!(!crate::appearance::cache::load_show_thinking_blocks());
    let effects = dispatch(Action::SetShowThinkingBlocks(false), &mut app);
    assert!(effects.is_empty(), "redundant set must be a no-op");
    let _ = apply_setting_rollback(
        &mut app,
        "show_thinking_blocks",
        &crate::settings::SettingValue::Bool(true),
    );
    assert!(
        crate::appearance::cache::load_show_thinking_blocks(),
        "rollback must restore cache",
    );
}
#[test]
fn set_group_tool_verbs_applies_persists_and_rolls_back() {
    crate::appearance::cache::set_group_tool_verbs(true);
    let mut app = test_app_with_agent();
    let effects = dispatch(Action::SetGroupToolVerbs(false), &mut app);
    assert!(
        matches!(
            effects.as_slice(),
            [Effect::PersistSetting {
                key: "group_tool_verbs",
                value: crate::settings::SettingValue::Bool(false),
                rollback_value: crate::settings::SettingValue::Bool(true),
            }]
        ),
        "expected exactly one PersistSetting effect, got {effects:?}",
    );
    assert!(!crate::appearance::cache::load_group_tool_verbs());
    let effects = dispatch(Action::SetGroupToolVerbs(false), &mut app);
    assert!(effects.is_empty(), "redundant set must be a no-op");
    let _ = apply_setting_rollback(
        &mut app,
        "group_tool_verbs",
        &crate::settings::SettingValue::Bool(true),
    );
    assert!(
        crate::appearance::cache::load_group_tool_verbs(),
        "rollback must restore cache",
    );
}
/// Toggling the setting re-folds the existing transcript in place
/// (no restart, no unrelated structural event needed).
#[test]
fn set_group_tool_verbs_refolds_live_transcript() {
    use crate::scrollback::block::RenderBlock;
    crate::appearance::cache::set_group_tool_verbs(true);
    let mut app = test_app_with_agent();
    {
        let sb = &mut app.agents.get_mut(&AgentId(0)).unwrap().scrollback;
        sb.push_block(RenderBlock::read("a.rs", None));
        sb.push_block(RenderBlock::read("b.rs", None));
        sb.prepare_layout(80, 40);
        assert_eq!(
            sb.get_cached_entry_height(1),
            Some(0),
            "run folds while the setting is on"
        );
    }
    let _ = dispatch(Action::SetGroupToolVerbs(false), &mut app);
    let sb = &app.agents[&AgentId(0)].scrollback;
    assert!(
        sb.get_cached_entry_height(1).unwrap_or(0) > 0,
        "toggle off must unfold the transcript immediately"
    );
    let _ = dispatch(Action::SetGroupToolVerbs(true), &mut app);
    let sb = &app.agents[&AgentId(0)].scrollback;
    assert_eq!(
        sb.get_cached_entry_height(1),
        Some(0),
        "toggle on must re-fold the transcript immediately"
    );
    crate::appearance::cache::set_group_tool_verbs(true);
}
/// Flipping the setting drops manual group expansions: the id set is shared
/// with N-more dense groups, so a stale verb-slot id must neither mark the
/// coincident dense run expanded after OFF nor reopen the verb slot expanded
/// after ON.
#[test]
fn set_group_tool_verbs_flip_resets_stale_group_expansion() {
    use crate::scrollback::block::RenderBlock;
    crate::appearance::cache::set_group_tool_verbs(true);
    let mut app = test_app_with_agent();
    {
        let sb = &mut app.agents.get_mut(&AgentId(0)).unwrap().scrollback;
        let mut appearance = crate::appearance::AppearanceConfig::default();
        appearance.scrollback.display.group_max_visible = 3;
        sb.set_appearance(appearance);
        for i in 0..6 {
            sb.push_block(RenderBlock::read(format!("f{i}.rs"), None));
        }
        sb.prepare_layout(80, 40);
        sb.set_selected(Some(0));
        assert!(sb.toggle_group_expansion());
        sb.prepare_layout(80, 40);
        let info = sb.get_cached_entry_layouts().unwrap()[0];
        assert!(info.group_collapse_header, "expanded verb slot armed");
    }
    let _ = dispatch(Action::SetGroupToolVerbs(false), &mut app);
    {
        let sb = &mut app.agents.get_mut(&AgentId(0)).unwrap().scrollback;
        sb.prepare_layout(80, 40);
        let info = sb.get_cached_entry_layouts().unwrap()[0];
        assert!(
            !info.group_collapse_header,
            "stale expansion must not mark the dense run expanded after OFF"
        );
    }
    let _ = dispatch(Action::SetGroupToolVerbs(true), &mut app);
    {
        let sb = &mut app.agents.get_mut(&AgentId(0)).unwrap().scrollback;
        sb.prepare_layout(80, 40);
        let info = sb.get_cached_entry_layouts().unwrap()[0];
        assert!(info.verb_group_header, "run refolds as a verb group");
        assert!(
            !info.group_collapse_header,
            "refold must be collapsed, not a reopened expanded slot"
        );
        assert_eq!(
            sb.get_cached_entry_height(1),
            Some(0),
            "members fold behind the collapsed header"
        );
    }
    crate::appearance::cache::set_group_tool_verbs(true);
}
#[test]
fn set_collapsed_edit_blocks_applies_persists_and_rolls_back() {
    crate::appearance::cache::set_collapsed_edit_blocks(false);
    let mut app = test_app_with_agent();
    let effects = dispatch(Action::SetCollapsedEditBlocks(true), &mut app);
    assert!(
        matches!(
            effects.as_slice(),
            [Effect::PersistSetting {
                key: "collapsed_edit_blocks",
                value: crate::settings::SettingValue::Bool(true),
                rollback_value: crate::settings::SettingValue::Bool(false),
            }]
        ),
        "expected exactly one PersistSetting effect, got {effects:?}",
    );
    assert!(crate::appearance::cache::load_collapsed_edit_blocks());
    let effects = dispatch(Action::SetCollapsedEditBlocks(true), &mut app);
    assert!(effects.is_empty(), "redundant set must be a no-op");
    let _ = apply_setting_rollback(
        &mut app,
        "collapsed_edit_blocks",
        &crate::settings::SettingValue::Bool(false),
    );
    assert!(
        !crate::appearance::cache::load_collapsed_edit_blocks(),
        "rollback must restore cache",
    );
}
/// Toggling the flag re-materializes on-default Edit rows in the live
/// transcript (no restart) — the dispatch-level pin of
/// `apply_collapsed_edit_blocks_flip`'s agent walk.
#[test]
fn set_collapsed_edit_blocks_refolds_live_edit_rows() {
    use crate::scrollback::block::RenderBlock;
    use crate::scrollback::blocks::tool::{EditToolCallBlock, ToolCallBlock};
    use crate::scrollback::types::DisplayMode;
    crate::appearance::cache::set_collapsed_edit_blocks(false);
    let mut app = test_app_with_agent();
    let id = {
        let sb = &mut app.agents.get_mut(&AgentId(0)).unwrap().scrollback;
        sb.push_block(RenderBlock::ToolCall(ToolCallBlock::Edit(
            EditToolCallBlock::new("f.rs", vec![]),
        )))
    };
    assert_eq!(
        app.agents[&AgentId(0)]
            .scrollback
            .get_by_id(id)
            .unwrap()
            .display_mode,
        DisplayMode::Expanded,
        "flag off materializes expanded"
    );
    let _ = dispatch(Action::SetCollapsedEditBlocks(true), &mut app);
    assert_eq!(
        app.agents[&AgentId(0)]
            .scrollback
            .get_by_id(id)
            .unwrap()
            .display_mode,
        DisplayMode::Collapsed,
        "toggle on must collapse the on-default Edit row immediately"
    );
    let _ = dispatch(Action::SetCollapsedEditBlocks(false), &mut app);
    assert_eq!(
        app.agents[&AgentId(0)]
            .scrollback
            .get_by_id(id)
            .unwrap()
            .display_mode,
        DisplayMode::Expanded,
        "toggle off must restore the expanded default"
    );
    crate::appearance::cache::set_collapsed_edit_blocks(false);
}
#[test]
fn set_show_thinking_blocks_hides_and_restores_existing_thinking_height() {
    use crate::scrollback::block::RenderBlock;
    crate::appearance::cache::set_show_thinking_blocks(true);
    let mut app = test_app_with_agent();
    let agent = app.agents.get_mut(&AgentId(0)).expect("agent 0");
    agent.scrollback.push_block(RenderBlock::thinking(
        "THINKING_HEIGHT_SENTINEL reason step",
    ));
    agent.scrollback.prepare_layout(80, 40);
    let think_idx = (0..agent.scrollback.len())
        .find(|&i| {
            agent
                .scrollback
                .entry(i)
                .is_some_and(|e| e.block.is_thinking())
        })
        .expect("thinking entry present");
    let h_on = agent
        .scrollback
        .get_cached_entry_height(think_idx)
        .expect("cached height after prepare_layout");
    assert!(h_on > 0, "thinking visible when shown, got {h_on}");
    let _ = dispatch(Action::SetShowThinkingBlocks(false), &mut app);
    assert!(!crate::appearance::cache::load_show_thinking_blocks());
    let agent = app.agents.get_mut(&AgentId(0)).expect("agent 0");
    agent.scrollback.prepare_layout(80, 40);
    let h_off = agent
        .scrollback
        .get_cached_entry_height(think_idx)
        .expect("cached height after hide");
    assert_eq!(h_off, 0, "thinking height 0 after hide");
    let _ = dispatch(Action::SetShowThinkingBlocks(true), &mut app);
    let agent = app.agents.get_mut(&AgentId(0)).expect("agent 0");
    agent.scrollback.prepare_layout(80, 40);
    let h_restored = agent
        .scrollback
        .get_cached_entry_height(think_idx)
        .expect("cached height after restore");
    assert!(h_restored > 0, "thinking restored, got {h_restored}");
    crate::appearance::cache::set_show_thinking_blocks(true);
}
/// Collapsed thinking as group_start must not steal the tool "N more" header when hidden.
#[test]
fn set_show_thinking_blocks_off_preserves_tool_group_header() {
    use crate::appearance::AppearanceConfig;
    use crate::scrollback::block::RenderBlock;
    use crate::scrollback::types::DisplayMode;
    crate::appearance::cache::set_show_thinking_blocks(true);
    let mut app = test_app_with_agent();
    let agent = app.agents.get_mut(&AgentId(0)).expect("agent 0");
    let mut appearance = AppearanceConfig::default();
    appearance.scrollback.display.group_max_visible = 3;
    agent.scrollback.set_appearance(appearance);
    let think_id = agent
        .scrollback
        .push_block(RenderBlock::thinking("hidden-thinking-body"));
    if let Some(e) = agent.scrollback.get_by_id_mut(think_id) {
        e.set_display_mode(DisplayMode::Collapsed);
    }
    for i in 0..6 {
        agent
            .scrollback
            .push_block(RenderBlock::tool_call(format!("Tool{i}"), "info", true));
    }
    agent.scrollback.prepare_layout(80, 40);
    let think_idx = 0usize;
    assert!(
        agent
            .scrollback
            .entry(think_idx)
            .is_some_and(|e| e.block.is_thinking())
    );
    let layouts_on = agent
        .scrollback
        .get_cached_entry_layouts()
        .expect("layout cache");
    assert_eq!(layouts_on[think_idx].height, 1);
    assert_eq!(layouts_on[think_idx].group_header_count, 3);
    let _ = dispatch(Action::SetShowThinkingBlocks(false), &mut app);
    let agent = app.agents.get_mut(&AgentId(0)).expect("agent 0");
    agent.scrollback.prepare_layout(80, 40);
    let layouts = agent
        .scrollback
        .get_cached_entry_layouts()
        .expect("layout cache after hide")
        .to_vec();
    assert_eq!(layouts[think_idx].height, 0);
    assert_eq!(layouts[think_idx].group_header_count, 0);
    assert!(!layouts[think_idx].group_collapse_header);
    let header_idx = 1usize;
    assert!(
        agent
            .scrollback
            .entry(header_idx)
            .is_some_and(|e| e.block.is_tool_call())
    );
    assert_eq!(layouts[header_idx].height, 1);
    assert_eq!(layouts[header_idx].group_header_count, 2);
    assert_eq!(layouts[2].height, 0);
    assert_eq!(layouts[3].height, 0);
    for (i, layout) in layouts.iter().enumerate().take(7).skip(4) {
        assert!(layout.height > 0, "tail tool {i} visible");
    }
    crate::appearance::cache::set_show_thinking_blocks(false);
}
/// Hidden thinking interspersed in a collapsed tool run stays transparent:
/// the run must still truncate to `group_max_visible` instead of splitting
/// in two and over-rendering the group.
#[test]
fn set_show_thinking_blocks_off_truncates_across_interspersed_thinking() {
    use crate::appearance::AppearanceConfig;
    use crate::scrollback::block::RenderBlock;
    use crate::scrollback::types::DisplayMode;
    crate::appearance::cache::set_show_thinking_blocks(true);
    let mut app = test_app_with_agent();
    let agent = app.agents.get_mut(&AgentId(0)).expect("agent 0");
    let mut appearance = AppearanceConfig::default();
    appearance.scrollback.display.group_max_visible = 3;
    agent.scrollback.set_appearance(appearance);
    for i in 0..2 {
        agent
            .scrollback
            .push_block(RenderBlock::tool_call(format!("Tool{i}"), "info", true));
    }
    let think_id = agent
        .scrollback
        .push_block(RenderBlock::thinking("hidden-thinking-body"));
    if let Some(e) = agent.scrollback.get_by_id_mut(think_id) {
        e.set_display_mode(DisplayMode::Collapsed);
    }
    for i in 2..6 {
        agent
            .scrollback
            .push_block(RenderBlock::tool_call(format!("Tool{i}"), "info", true));
    }
    agent.scrollback.prepare_layout(80, 40);
    let think_idx = 2usize;
    assert!(
        agent
            .scrollback
            .entry(think_idx)
            .is_some_and(|e| e.block.is_thinking())
    );
    let _ = dispatch(Action::SetShowThinkingBlocks(false), &mut app);
    let agent = app.agents.get_mut(&AgentId(0)).expect("agent 0");
    agent.scrollback.prepare_layout(80, 40);
    let layouts = agent
        .scrollback
        .get_cached_entry_layouts()
        .expect("layout cache after hide")
        .to_vec();
    assert_eq!(layouts[think_idx].height, 0);
    assert_eq!(layouts[think_idx].group_header_count, 0);
    assert!(!layouts[think_idx].group_collapse_header);
    assert_eq!(layouts[0].height, 1);
    assert_eq!(layouts[0].group_header_count, 2);
    assert_eq!(layouts[1].height, 0);
    assert_eq!(layouts[3].height, 0);
    for (i, layout) in layouts.iter().enumerate().take(7).skip(4) {
        assert!(layout.height > 0, "tail tool {i} visible");
    }
    crate::appearance::cache::set_show_thinking_blocks(false);
}
/// Flipping thinking visibility reshapes verb-group runs (shown finished
/// thoughts claim into folds; hidden ones stay transparent), so the flip must
/// drop manual group expansions like the `group_tool_verbs` flip does: a
/// stale id must not reopen the reshaped run expanded.
#[test]
fn set_show_thinking_blocks_flip_reshapes_verb_runs_and_resets_expansion() {
    use crate::scrollback::block::RenderBlock;
    use crate::scrollback::types::DisplayMode;
    crate::appearance::cache::set_group_tool_verbs(true);
    crate::appearance::cache::set_show_thinking_blocks(true);
    let mut app = test_app_with_agent();
    {
        let sb = &mut app.agents.get_mut(&AgentId(0)).unwrap().scrollback;
        let think_id = sb.push_block(RenderBlock::thinking("plan"));
        if let Some(e) = sb.get_by_id_mut(think_id) {
            e.set_display_mode(DisplayMode::Collapsed);
        }
        sb.push_block(RenderBlock::read("a.rs", None));
        sb.push_block(RenderBlock::read("b.rs", None));
        sb.prepare_layout(80, 40);
        let layouts = sb.get_cached_entry_layouts().unwrap();
        assert!(layouts[0].verb_group_header, "thought anchors the run");
        assert_eq!(layouts[1].height, 0);
        sb.set_selected(Some(0));
        assert!(sb.toggle_group_expansion());
        sb.prepare_layout(80, 40);
        let layouts = sb.get_cached_entry_layouts().unwrap();
        assert!(layouts[0].group_collapse_header, "expanded slot armed");
    }
    let _ = dispatch(Action::SetShowThinkingBlocks(false), &mut app);
    {
        let sb = &mut app.agents.get_mut(&AgentId(0)).unwrap().scrollback;
        sb.prepare_layout(80, 40);
        let layouts = sb.get_cached_entry_layouts().unwrap();
        assert_eq!(layouts[0].height, 0, "hidden thought takes no rows");
        assert!(layouts[1].verb_group_header, "run re-anchors on the tool");
        assert!(!layouts[1].group_collapse_header);
        assert_eq!(layouts[2].height, 0);
    }
    let _ = dispatch(Action::SetShowThinkingBlocks(true), &mut app);
    {
        let sb = &mut app.agents.get_mut(&AgentId(0)).unwrap().scrollback;
        sb.prepare_layout(80, 40);
        let layouts = sb.get_cached_entry_layouts().unwrap();
        assert!(layouts[0].verb_group_header, "thought re-anchors the run");
        assert!(
            !layouts[0].group_collapse_header,
            "flip must reset the stale expansion"
        );
        assert_eq!(layouts[1].height, 0);
    }
    crate::appearance::cache::set_group_tool_verbs(true);
    crate::appearance::cache::set_show_thinking_blocks(true);
}
/// `Action::SetRespectManualFolds` applies to `app.appearance` AND every
/// agent's scrollback, emits one `Effect::PersistSetting`, is idempotent,
/// and rolls back.
#[test]
fn set_respect_manual_folds_applies_persists_and_rolls_back() {
    let mut app = test_app_with_agent();
    insert_placeholder_agent(&mut app, AgentId(1));
    assert!(!app.appearance.scrollback.scroll.respect_manual_folds);
    let effects = dispatch(Action::SetRespectManualFolds(true), &mut app);
    assert!(
        matches!(
            effects.as_slice(),
            [Effect::PersistSetting {
                key: "respect_manual_folds",
                value: crate::settings::SettingValue::Bool(true),
                rollback_value: crate::settings::SettingValue::Bool(false),
            }]
        ),
        "expected exactly one PersistSetting effect, got {effects:?}",
    );
    assert!(app.appearance.scrollback.scroll.respect_manual_folds);
    for (id, agent) in &app.agents {
        assert!(
            agent
                .scrollback
                .appearance()
                .scrollback
                .scroll
                .respect_manual_folds,
            "agent {id:?} scrollback appearance must pick up the toggle live",
        );
    }
    let effects = dispatch(Action::SetRespectManualFolds(true), &mut app);
    assert!(effects.is_empty(), "redundant set must be a no-op");
    let _ = apply_setting_rollback(
        &mut app,
        "respect_manual_folds",
        &crate::settings::SettingValue::Bool(false),
    );
    assert!(
        !app.appearance.scrollback.scroll.respect_manual_folds,
        "rollback must restore app.appearance",
    );
    assert!(
        !app.agents[&AgentId(0)]
            .scrollback
            .appearance()
            .scrollback
            .scroll
            .respect_manual_folds,
        "rollback must restore agent scrollback appearance",
    );
}
/// `Action::SetRenderMermaid` updates the process-wide cache, emits exactly
/// one `Effect::PersistSetting`, toasts on change, and is idempotent.
#[test]
fn set_render_mermaid_persists_and_is_idempotent() {
    use crate::appearance::RenderMermaid;
    crate::appearance::cache::set_render_mermaid(RenderMermaid::Auto);
    let mut app = test_app_with_agent();
    let effects = dispatch(Action::SetRenderMermaid(RenderMermaid::Off), &mut app);
    assert_eq!(
        crate::appearance::cache::load_render_mermaid(),
        RenderMermaid::Off,
        "cache mirror must reflect the new value",
    );
    assert_eq!(
        effects.len(),
        1,
        "SHELL-owned setter must emit one Effect::PersistSetting, got {effects:?}",
    );
    assert!(
        matches!(
            &effects[0],
            Effect::PersistSetting {
                key: "render_mermaid",
                value: crate::settings::SettingValue::Enum("off"),
                rollback_value: crate::settings::SettingValue::Enum("auto"),
            }
        ),
        "unexpected effect: {:?}",
        effects[0],
    );
    assert!(read_toast(&app).contains("Mermaid"));
    app.agents.get_mut(&AgentId(0)).unwrap().toast = None;
    let again = dispatch(Action::SetRenderMermaid(RenderMermaid::Off), &mut app);
    assert!(
        again.is_empty(),
        "idempotent re-dispatch must emit no Effect"
    );
    assert!(
        app.agents[&AgentId(0)].toast.is_none(),
        "idempotent re-dispatch must not toast",
    );
    assert_eq!(
        crate::appearance::cache::load_render_mermaid(),
        RenderMermaid::Off,
    );
}
/// `apply_setting_rollback` restores the `render_mermaid` cache mirror.
#[test]
fn set_render_mermaid_rollback_restores_cache() {
    use crate::appearance::RenderMermaid;
    crate::appearance::cache::set_render_mermaid(RenderMermaid::Auto);
    let mut app = test_app_with_agent();
    let _ = dispatch(Action::SetRenderMermaid(RenderMermaid::Off), &mut app);
    assert_eq!(
        crate::appearance::cache::load_render_mermaid(),
        RenderMermaid::Off,
    );
    let _ = apply_setting_rollback(
        &mut app,
        "render_mermaid",
        &crate::settings::SettingValue::Enum("auto"),
    );
    assert_eq!(
        crate::appearance::cache::load_render_mermaid(),
        RenderMermaid::Auto,
        "rollback must restore the cache mirror",
    );
}
/// `Action::SetScrollSpeed` updates the process-wide cache,
/// recomputes `app.scroll_config`, and emits `Effect::PersistSetting`.
/// Clamps to `[1, 100]`.
#[test]
fn set_scroll_speed_updates_cache_and_clamps() {
    crate::appearance::cache::set_scroll_speed(50);
    let mut app = test_app_with_agent();
    let effects = dispatch(Action::SetScrollSpeed(75), &mut app);
    assert_eq!(
        effects.len(),
        1,
        "SHELL-owned setter must emit exactly one Effect::PersistSetting, got {effects:?}",
    );
    assert!(
        matches!(
            &effects[0],
            Effect::PersistSetting {
                key: "scroll_speed",
                value: crate::settings::SettingValue::Int(75),
                rollback_value: crate::settings::SettingValue::Int(50),
            }
        ),
        "unexpected effect: {:?}",
        effects[0],
    );
    assert_eq!(crate::appearance::cache::load_scroll_speed(), 75);
    let _ = dispatch(Action::SetScrollSpeed(250), &mut app);
    assert_eq!(crate::appearance::cache::load_scroll_speed(), 100);
    let _ = dispatch(Action::SetScrollSpeed(-5), &mut app);
    assert_eq!(crate::appearance::cache::load_scroll_speed(), 1);
}
/// `Action::SetScrollSpeed` with the current value is a no-op.
#[test]
fn set_scroll_speed_idempotent() {
    crate::appearance::cache::set_scroll_speed(42);
    let mut app = test_app_with_agent();
    app.agents.get_mut(&AgentId(0)).unwrap().toast = None;
    let effects = dispatch(Action::SetScrollSpeed(42), &mut app);
    assert!(effects.is_empty());
    assert!(
        app.agents[&AgentId(0)].toast.is_none(),
        "redundant set must not re-toast",
    );
}
/// `Action::SetScrollSpeed` rollback restores the cache.
#[test]
fn set_scroll_speed_rollback_restores_state() {
    crate::appearance::cache::set_scroll_speed(50);
    let mut app = test_app_with_agent();
    let _ = dispatch(Action::SetScrollSpeed(80), &mut app);
    assert_eq!(crate::appearance::cache::load_scroll_speed(), 80);
    let _ = apply_setting_rollback(
        &mut app,
        "scroll_speed",
        &crate::settings::SettingValue::Int(50),
    );
    assert_eq!(
        crate::appearance::cache::load_scroll_speed(),
        50,
        "rollback must restore cache",
    );
}
/// `Action::SetScrollMode` updates the process-wide cache, rebuilds
/// `app.scroll_config` with the forced mode, and emits `Effect::PersistSetting`.
#[test]
fn set_scroll_mode_updates_cache_and_scroll_config() {
    crate::appearance::cache::set_scroll_mode(crate::appearance::ScrollMode::Auto);
    let mut app = test_app_with_agent();
    let effects = dispatch(
        Action::SetScrollMode(crate::appearance::ScrollMode::Wheel),
        &mut app,
    );
    assert!(
        matches!(
            effects.as_slice(),
            [Effect::PersistSetting {
                key: "scroll_mode",
                value: crate::settings::SettingValue::Enum("wheel"),
                rollback_value: crate::settings::SettingValue::Enum("auto"),
            }]
        ),
        "unexpected effects: {effects:?}",
    );
    assert_eq!(
        crate::appearance::cache::load_scroll_mode(),
        crate::appearance::ScrollMode::Wheel
    );
    assert!(
        format!("{:?}", app.scroll_config).contains("mode: Wheel"),
        "scroll_config must rebuild with the forced mode: {:?}",
        app.scroll_config,
    );
    let effects = dispatch(
        Action::SetScrollMode(crate::appearance::ScrollMode::Wheel),
        &mut app,
    );
    assert!(effects.is_empty());
}
/// `Action::SetInvertScroll` updates the cache and flips the rebuilt
/// config's direction bit.
#[test]
fn set_invert_scroll_updates_cache_and_scroll_config() {
    crate::appearance::cache::set_invert_scroll(false);
    let mut app = test_app_with_agent();
    let effects = dispatch(Action::SetInvertScroll(true), &mut app);
    assert!(
        matches!(
            effects.as_slice(),
            [Effect::PersistSetting {
                key: "invert_scroll",
                value: crate::settings::SettingValue::Bool(true),
                rollback_value: crate::settings::SettingValue::Bool(false),
            }]
        ),
        "unexpected effects: {effects:?}",
    );
    assert!(crate::appearance::cache::load_invert_scroll());
    assert!(
        format!("{:?}", app.scroll_config).contains("invert_direction: true"),
        "scroll_config must rebuild inverted: {:?}",
        app.scroll_config,
    );
    let _ = apply_setting_rollback(
        &mut app,
        "invert_scroll",
        &crate::settings::SettingValue::Bool(false),
    );
    assert!(!crate::appearance::cache::load_invert_scroll());
    assert!(format!("{:?}", app.scroll_config).contains("invert_direction: false"));
}
/// `Action::SetScrollLines` updates the cache (clamped to `[1, 10]`) and
/// rebuilds the config with BOTH per-tick values overridden.
#[test]
fn set_scroll_lines_updates_cache_and_clamps() {
    crate::appearance::cache::set_scroll_lines(3);
    let mut app = test_app_with_agent();
    let effects = dispatch(Action::SetScrollLines(5), &mut app);
    assert!(
        matches!(
            effects.as_slice(),
            [Effect::PersistSetting {
                key: "scroll_lines",
                value: crate::settings::SettingValue::Int(5),
                rollback_value: crate::settings::SettingValue::Int(3),
            }]
        ),
        "unexpected effects: {effects:?}",
    );
    assert_eq!(crate::appearance::cache::load_scroll_lines(), Some(5));
    let debug = format!("{:?}", app.scroll_config);
    assert!(
        debug.contains("wheel_lines_per_tick: 5") && debug.contains("trackpad_lines_per_tick: 5"),
        "one knob must override both per-tick values: {debug}",
    );
    let _ = dispatch(Action::SetScrollLines(99), &mut app);
    assert_eq!(crate::appearance::cache::load_scroll_lines(), Some(10));
    let _ = dispatch(Action::SetScrollLines(-2), &mut app);
    assert_eq!(crate::appearance::cache::load_scroll_lines(), Some(1));
}
/// A NON-permission setting rollback must NOT touch the active agent's
/// per-session `auto_mode`. Only the `permission_mode` rollback arm syncs it;
/// otherwise the per-session flag drifts from the (possibly stale) global UI
/// mirror on multi-agent setups.
#[test]
fn non_permission_rollback_preserves_session_auto_mode() {
    use crate::settings::SettingValue;
    let mut app = test_app_with_agent();
    app.agents.get_mut(&AgentId(0)).unwrap().session.auto_mode = true;
    app.current_ui.permission_mode = Some("ask".into());
    apply_setting_rollback(&mut app, "show_tips", &SettingValue::Bool(true));
    assert!(
        app.agents[&AgentId(0)].session.is_auto(),
        "non-permission rollback must not clobber the per-session auto flag"
    );
}
/// Rollback path: a `SettingPersistFailed` for `permission_mode`
/// reverts `agent.session.yolo_mode` + `app.default_yolo` +
/// `app.current_ui.permission_mode` via `set_yolo_mode_inner`.
/// MUST NOT re-emit any effects (would loop on persistent disk
/// failure).
#[test]
fn rollback_permission_mode_reverts_state_no_effect() {
    use crate::settings::SettingValue;
    let mut app = test_app_with_agent();
    let _ = dispatch(Action::SetYoloMode(true), &mut app);
    assert!(app.agents[&AgentId(0)].session.is_yolo());
    assert_eq!(
        app.current_ui.permission_mode.as_deref(),
        Some("always-approve")
    );
    let effects = dispatch(
        Action::TaskComplete(TaskResult::SettingPersistFailed {
            key: "permission_mode",
            rollback_value: SettingValue::Enum("ask"),
            error: "permission denied".into(),
        }),
        &mut app,
    );
    assert!(
        effects.is_empty(),
        "rollback path MUST NOT emit any Effect (would loop on persistent failure)",
    );
    assert!(
        !app.agents[&AgentId(0)].session.is_yolo(),
        "session.yolo_mode must revert via apply_setting_rollback"
    );
    assert!(!app.default_yolo);
    assert_eq!(app.current_ui.permission_mode.as_deref(), Some("ask"));
    let toast = app.agents[&AgentId(0)]
        .toast
        .as_ref()
        .map(|(s, _)| s.clone())
        .expect("failure toast must be set");
    assert_eq!(
        toast, "\u{2717} Could not save permission_mode: permission denied",
        "rollback toast must follow the exact `✗ Could not save {{key}}: {{error}}` format \
             — drift here would diverge from other rollback toasts",
    );
}
/// The reset-dispatch test asserts the typed Action lineage. The
/// reset path dispatches `Action::SetPermissionMode(Ask)` (the
/// registered default) rather than `Action::SetYoloMode(false)`.
/// Both emit `Effect::PersistPermissionMode` so the `has_persist`
/// assertion in the parent test holds; this test pins the typed
/// Action shape directly so a future refactor that drops the
/// `SetPermissionMode` mapping in `action_for_reset` is caught.
#[test]
fn action_for_reset_permission_mode_dispatches_set_permission_mode_for_each_canonical() {
    use crate::app::actions::PermissionModeKind;
    use crate::settings::SettingValue;
    match action_for_reset("permission_mode", &SettingValue::Enum("ask")) {
        Some(Action::SetPermissionMode(PermissionModeKind::Ask)) => {}
        other => {
            panic!(
                "action_for_reset(permission_mode, 'ask') must produce \
                 Action::SetPermissionMode(Ask), got {other:?}"
            )
        }
    }
    match action_for_reset("permission_mode", &SettingValue::Enum("always-approve")) {
        Some(Action::SetPermissionMode(PermissionModeKind::AlwaysApprove)) => {}
        other => {
            panic!(
                "action_for_reset(permission_mode, 'always-approve') must produce \
                 Action::SetPermissionMode(AlwaysApprove), got {other:?}"
            )
        }
    }
    match action_for_reset("permission_mode", &SettingValue::Enum("default")) {
        Some(Action::SetPermissionMode(PermissionModeKind::Default)) => {}
        other => {
            panic!(
                "action_for_reset(permission_mode, 'default') must produce \
                 Action::SetPermissionMode(Default), got {other:?}"
            )
        }
    }
    assert!(action_for_reset("permission_mode", &SettingValue::Enum("bogus")).is_none());
}
/// Slash-command-while-modal-open refresh.
/// `dispatch_cycle_mode` is the entry point for both Shift+Tab
/// and the `/plan` / `/cycle-mode` slash commands. When the
/// settings modal is open and a cycle lands, the modal's
/// `pager_snapshot.plan_mode_active` must refresh to reflect
/// the new effective state — otherwise the indicator stays
/// stale until the next setter dispatch. Mirror of
/// `set_plan_mode_refreshes_open_modal_pager_snapshot`.
#[test]
fn dispatch_cycle_mode_refreshes_open_modal_snapshot() {
    use crate::views::modal::ActiveModal;
    let mut app = test_app_with_agent();
    let _ = dispatch(Action::OpenSettings, &mut app);
    let agent = app.agents.get(&AgentId(0)).unwrap();
    let Some(ActiveModal::Settings { state }) = &agent.active_modal else {
        panic!("expected Settings modal after OpenSettings dispatch")
    };
    assert!(
        !state.pager_snapshot.plan_mode_active,
        "snapshot at open should be false (agent default)",
    );
    let _ = dispatch(Action::CycleMode, &mut app);
    let agent = app.agents.get(&AgentId(0)).unwrap();
    let Some(ActiveModal::Settings { state }) = &agent.active_modal else {
        panic!("Settings modal must remain open across CycleMode")
    };
    assert!(
        state.pager_snapshot.plan_mode_active,
        "snapshot must be refreshed to true after CycleMode (Normal → Plan) — \
             without refresh_open_settings_modals the indicator would stay stale",
    );
    let cur_value =
        crate::settings::current_value_for("plan_mode", &state.ui_snapshot, &state.pager_snapshot)
            .expect("plan_mode must resolve");
    assert_eq!(
        cur_value,
        crate::settings::SettingValue::Enum("on"),
        "current_value_for must read the refreshed snapshot",
    );
}
/// `dispatch(Action::SetTheme("kigiday"), &mut app)` emits
/// exactly one `Effect::PersistSetting`, mutates
/// `app.current_ui.theme`, fires a toast, and toggles AUTO_MODE
/// off (kind is concrete).
///
/// Note: we persist `kigiday` (a non-truecolor theme) here
/// because `Effect::PersistSetting`'s payload is `&'static str`
/// from the registry's canonical table — the persisted CANONICAL
/// is what we're asserting, NOT the live theme cache (which
/// `clamp_to_terminal` might fold to KigiNight in non-truecolor
/// test environments). Persist + canonical contract is the test
/// invariant; the cache contract is exercised separately by the
/// `*_applies_when_*` tests.
#[test]
fn set_theme_emits_persist_setting_with_correct_payload() {
    use crate::settings::SettingValue;
    with_theme_test_env(|| {
        let mut app = test_app_with_agent();
        assert_eq!(app.current_ui.theme, None);
        crate::theme::cache::set(crate::theme::ThemeKind::KigiNight);
        let effects = dispatch(Action::SetTheme("kigiday".into()), &mut app);
        assert_eq!(effects.len(), 1);
        match &effects[0] {
            Effect::PersistSetting {
                key,
                value,
                rollback_value,
            } => {
                assert_eq!(*key, "theme");
                assert_eq!(*value, SettingValue::Enum("kigiday"));
                assert_eq!(*rollback_value, SettingValue::Enum("kiginight"));
            }
            other => panic!("expected PersistSetting, got {other:?}"),
        }
        assert_eq!(app.current_ui.theme.as_deref(), Some("kigiday"));
        assert!(
            !crate::theme::cache::is_auto_mode(),
            "concrete theme commit must disable AUTO_MODE",
        );
    });
}
/// Same payload contract as `set_theme_emits_persist_setting_with_correct_payload`
/// for the auto-dark sibling. Uses `kigiday` to avoid the
/// `clamp_to_terminal` ambiguity in non-truecolor test envs;
/// `apply_kind` doesn't fire here anyway (parent theme is not
/// auto by default) but using a non-truecolor canonical keeps
/// the test robust to environment differences.
#[test]
fn set_auto_dark_theme_emits_persist_setting_with_correct_payload() {
    use crate::settings::SettingValue;
    with_theme_test_env(|| {
        let mut app = test_app_with_agent();
        let effects = dispatch(Action::SetAutoDarkTheme("kigiday".into()), &mut app);
        assert_eq!(effects.len(), 1);
        match &effects[0] {
            Effect::PersistSetting {
                key,
                value,
                rollback_value,
            } => {
                assert_eq!(*key, "auto_dark_theme");
                assert_eq!(*value, SettingValue::Enum("kigiday"));
                assert_eq!(*rollback_value, SettingValue::Enum("kiginight"));
            }
            other => panic!("expected PersistSetting, got {other:?}"),
        }
        assert_eq!(app.current_ui.auto_dark_theme.as_deref(), Some("kigiday"));
    });
}
#[test]
fn set_auto_light_theme_emits_persist_setting_with_correct_payload() {
    use crate::settings::SettingValue;
    with_theme_test_env(|| {
        let mut app = test_app_with_agent();
        let effects = dispatch(Action::SetAutoLightTheme("kiginight".into()), &mut app);
        assert_eq!(effects.len(), 1);
        match &effects[0] {
            Effect::PersistSetting {
                key,
                value,
                rollback_value,
            } => {
                assert_eq!(*key, "auto_light_theme");
                assert_eq!(*value, SettingValue::Enum("kiginight"));
                assert_eq!(*rollback_value, SettingValue::Enum("kigiday"));
            }
            other => panic!("expected PersistSetting, got {other:?}"),
        }
        assert_eq!(
            app.current_ui.auto_light_theme.as_deref(),
            Some("kiginight"),
        );
    });
}
/// PREVIEW Actions emit ZERO `Effect::PersistSetting` and do NOT
/// mutate `app.current_ui.theme`. This is the fix for
/// per-keystroke disk write race.
#[test]
fn preview_theme_emits_no_persist_effect_and_no_current_ui_mutation() {
    with_theme_test_env(|| {
        let mut app = test_app_with_agent();
        let initial = app.current_ui.theme.clone();
        let effects = dispatch(Action::PreviewTheme("tokyonight".into()), &mut app);
        let persist_count = effects
            .iter()
            .filter(|e| matches!(e, Effect::PersistSetting { .. }))
            .count();
        assert_eq!(
            persist_count, 0,
            "preview must NOT emit PersistSetting, got {persist_count} (effects: {effects:?})",
        );
        assert_eq!(
            app.current_ui.theme, initial,
            "preview must NOT mutate app.current_ui.theme",
        );
    });
}
#[test]
fn preview_auto_dark_theme_emits_no_persist_and_no_current_ui_mutation() {
    with_theme_test_env(|| {
        let mut app = test_app_with_agent();
        let initial = app.current_ui.auto_dark_theme.clone();
        let effects = dispatch(Action::PreviewAutoDarkTheme("tokyonight".into()), &mut app);
        assert!(effects.is_empty());
        assert_eq!(app.current_ui.auto_dark_theme, initial);
    });
}
#[test]
fn preview_auto_light_theme_emits_no_persist_and_no_current_ui_mutation() {
    with_theme_test_env(|| {
        let mut app = test_app_with_agent();
        let initial = app.current_ui.auto_light_theme.clone();
        let effects = dispatch(
            Action::PreviewAutoLightTheme("rosepine-moon".into()),
            &mut app,
        );
        assert!(effects.is_empty());
        assert_eq!(app.current_ui.auto_light_theme, initial);
    });
}
/// Auto-theme commit applies the live theme **only** when
/// `theme="auto"` AND the system is in the matching mode.
///
/// Scenario: `theme="kiginight"` (concrete) + system=Dark. User
/// commits `auto_dark_theme="kigiday"`. The setting is dormant
/// (parent theme is concrete, not auto), so the live display
/// must stay on KigiNight even though we're committing a
/// different theme. Uses `kigiday` to avoid `clamp_to_terminal`
/// ambiguity in non-truecolor envs.
#[test]
fn set_auto_dark_theme_does_not_apply_when_theme_is_not_auto() {
    with_theme_test_env(|| {
        crate::theme::system_appearance::set_mock(Some(
            crate::theme::system_appearance::SystemAppearance::Dark,
        ));
        let mut app = test_app_with_agent();
        let _ = dispatch(Action::SetTheme("kiginight".into()), &mut app);
        assert_eq!(
            crate::theme::cache::current_kind(),
            crate::theme::ThemeKind::KigiNight,
        );
        let _ = dispatch(Action::SetAutoDarkTheme("kigiday".into()), &mut app);
        assert_eq!(
            crate::theme::cache::current_kind(),
            crate::theme::ThemeKind::KigiNight,
            "auto_dark_theme commit must NOT change live display when theme is not auto",
        );
        assert_eq!(app.current_ui.auto_dark_theme.as_deref(), Some("kigiday"));
    });
}
/// Auto-theme commit DOES apply the live theme when both
/// (a) parent theme = auto AND (b) system matches.
///
/// Uses `KigiDay` (non-truecolor-requiring) for the dark-mode
/// fixture: the test environment's color detection may not report
/// truecolor support, and `Theme::apply_kind` clamps
/// truecolor-only themes (TokyoNight, RosePineMoon) down to
/// KigiNight. Using a non-truecolor theme avoids the clamp
/// uncertainty. The "live apply" contract is what we're testing
/// — the specific theme picked is incidental.
#[test]
fn set_auto_dark_theme_applies_when_theme_is_auto_and_system_is_dark() {
    with_theme_test_env(|| {
        crate::theme::system_appearance::set_mock(Some(
            crate::theme::system_appearance::SystemAppearance::Dark,
        ));
        let mut app = test_app_with_agent();
        let _ = dispatch(Action::SetTheme("auto".into()), &mut app);
        assert!(crate::theme::cache::is_auto_mode());
        assert_eq!(
            crate::theme::cache::current_kind(),
            crate::theme::ThemeKind::KigiNight,
        );
        let _ = dispatch(Action::SetAutoDarkTheme("kigiday".into()), &mut app);
        assert_eq!(
            crate::theme::cache::current_kind(),
            crate::theme::ThemeKind::KigiDay,
            "auto_dark_theme commit must update live display when theme=auto + system=Dark",
        );
    });
}
/// Auto-theme commit does NOT apply when system is in the
/// non-matching mode (auto_dark_theme + system=Light).
/// Uses `kiginight` for the auto_dark_theme
/// value to avoid `clamp_to_terminal` ambiguity (we want a
/// concrete kind that's clearly different from KigiDay, the
/// active resolved theme).
#[test]
fn set_auto_dark_theme_does_not_apply_when_system_is_light() {
    with_theme_test_env(|| {
        crate::theme::system_appearance::set_mock(Some(
            crate::theme::system_appearance::SystemAppearance::Light,
        ));
        let mut app = test_app_with_agent();
        let _ = dispatch(Action::SetTheme("auto".into()), &mut app);
        assert_eq!(
            crate::theme::cache::current_kind(),
            crate::theme::ThemeKind::KigiDay,
        );
        let _ = dispatch(Action::SetAutoDarkTheme("kiginight".into()), &mut app);
        assert_eq!(
            crate::theme::cache::current_kind(),
            crate::theme::ThemeKind::KigiDay,
            "auto_dark_theme commit must NOT change live display when system=Light",
        );
        assert_eq!(app.current_ui.auto_dark_theme.as_deref(), Some("kiginight"),);
    });
}
/// Symmetric to the dark test: `set_auto_light_theme` applies only
/// when theme=auto + system=Light. Uses a non-truecolor theme
/// (`kiginight`) for the same clamp reason as the dark variant.
#[test]
fn set_auto_light_theme_applies_when_theme_is_auto_and_system_is_light() {
    with_theme_test_env(|| {
        crate::theme::system_appearance::set_mock(Some(
            crate::theme::system_appearance::SystemAppearance::Light,
        ));
        let mut app = test_app_with_agent();
        let _ = dispatch(Action::SetTheme("auto".into()), &mut app);
        assert_eq!(
            crate::theme::cache::current_kind(),
            crate::theme::ThemeKind::KigiDay,
        );
        let _ = dispatch(Action::SetAutoLightTheme("kiginight".into()), &mut app);
        assert_eq!(
            crate::theme::cache::current_kind(),
            crate::theme::ThemeKind::KigiNight,
            "auto_light_theme must update display when theme=auto + system=Light",
        );
    });
}
/// `Action::SetTheme` with an unknown theme name returns an empty
/// Effect vec AND does not mutate `app.current_ui.theme`.
/// Defense-in-depth at the dispatcher (the registry's choice
/// table is the primary protection).
#[test]
fn set_theme_unknown_name_is_no_op() {
    with_theme_test_env(|| {
        let mut app = test_app_with_agent();
        let initial = app.current_ui.theme.clone();
        let effects = dispatch(Action::SetTheme("nonexistent_theme".into()), &mut app);
        assert!(effects.is_empty(), "unknown name must produce no effects");
        assert_eq!(
            app.current_ui.theme, initial,
            "unknown name must not mutate app.current_ui.theme",
        );
    });
}
#[test]
fn set_auto_dark_theme_unknown_name_is_no_op() {
    with_theme_test_env(|| {
        let mut app = test_app_with_agent();
        let initial = app.current_ui.auto_dark_theme.clone();
        let effects = dispatch(Action::SetAutoDarkTheme("nonexistent".into()), &mut app);
        assert!(effects.is_empty());
        assert_eq!(app.current_ui.auto_dark_theme, initial);
    });
}
#[test]
fn set_auto_light_theme_unknown_name_is_no_op() {
    with_theme_test_env(|| {
        let mut app = test_app_with_agent();
        let initial = app.current_ui.auto_light_theme.clone();
        let effects = dispatch(Action::SetAutoLightTheme("nonexistent".into()), &mut app);
        assert!(effects.is_empty());
        assert_eq!(app.current_ui.auto_light_theme, initial);
    });
}
/// `Action::SetAutoDarkTheme("auto")` is rejected — would cause a
/// circular reference (mirrors `load_auto_theme_config`'s filter
/// at the disk-read layer).
#[test]
fn set_auto_dark_theme_rejects_auto_value() {
    with_theme_test_env(|| {
        let mut app = test_app_with_agent();
        let initial = app.current_ui.auto_dark_theme.clone();
        let effects = dispatch(Action::SetAutoDarkTheme("auto".into()), &mut app);
        assert!(
            effects.is_empty(),
            "auto value must be rejected (circular reference prevention)",
        );
        assert_eq!(app.current_ui.auto_dark_theme, initial);
    });
}
#[test]
fn set_auto_light_theme_rejects_auto_value() {
    with_theme_test_env(|| {
        let mut app = test_app_with_agent();
        let initial = app.current_ui.auto_light_theme.clone();
        let effects = dispatch(Action::SetAutoLightTheme("auto".into()), &mut app);
        assert!(effects.is_empty());
        assert_eq!(app.current_ui.auto_light_theme, initial);
    });
}
/// Toast format for theme commits uses the DISPLAY name, not the
/// snake_case canonical.
#[test]
fn set_theme_toast_format_uses_display_name() {
    with_theme_test_env(|| {
        let mut app = test_app_with_agent();
        let _ = dispatch(Action::SetTheme("kigiday".into()), &mut app);
        let toast = read_toast(&app);
        assert!(
            toast.contains("Theme"),
            "toast must contain label, got: {toast:?}",
        );
        assert!(
            toast.contains("Kigi Day"),
            "toast must use display name `Kigi Day`, not canonical `kigiday`, got: {toast:?}",
        );
        assert!(toast.contains('\u{2713}'), "toast must contain the ✓ glyph");
    });
}
#[test]
fn set_auto_dark_theme_toast_format_uses_display_name() {
    with_theme_test_env(|| {
        let mut app = test_app_with_agent();
        let _ = dispatch(Action::SetAutoDarkTheme("kigiday".into()), &mut app);
        let toast = read_toast(&app);
        assert!(toast.contains("Auto dark theme"));
        assert!(toast.contains("Kigi Day"));
        assert!(toast.contains('\u{2713}'));
    });
}
#[test]
fn set_auto_light_theme_toast_format_uses_display_name() {
    with_theme_test_env(|| {
        let mut app = test_app_with_agent();
        let _ = dispatch(Action::SetAutoLightTheme("kiginight".into()), &mut app);
        let toast = read_toast(&app);
        assert!(toast.contains("Auto light theme"));
        assert!(toast.contains("Kigi Night"));
    });
}
/// `apply_setting_rollback` for theme keys: a failed persist
/// reverts `app.current_ui.theme` AND the live cache (mirror of
/// `rollback_known_key_reverts_cache_and_no_effect`).
///
/// Uses non-truecolor themes (`kigiday` ↔ `kiginight`) to avoid
/// the `clamp_to_terminal` interaction in test environments that
/// don't report truecolor support.
#[test]
fn rollback_theme_reverts_current_ui_and_cache() {
    use crate::settings::SettingValue;
    with_theme_test_env(|| {
        let mut app = test_app_with_agent();
        let _ = dispatch(Action::SetTheme("kigiday".into()), &mut app);
        assert_eq!(app.current_ui.theme.as_deref(), Some("kigiday"));
        assert_eq!(
            crate::theme::cache::current_kind(),
            crate::theme::ThemeKind::KigiDay,
        );
        let _ = dispatch(
            Action::TaskComplete(TaskResult::SettingPersistFailed {
                key: "theme",
                rollback_value: SettingValue::Enum("kiginight"),
                error: "disk full".into(),
            }),
            &mut app,
        );
        assert_eq!(
            app.current_ui.theme.as_deref(),
            Some("kiginight"),
            "rollback must update app.current_ui.theme",
        );
        assert_eq!(
            crate::theme::cache::current_kind(),
            crate::theme::ThemeKind::KigiNight,
            "rollback must update the live theme cache too",
        );
    });
}
#[test]
fn rollback_auto_dark_theme_reverts_current_ui() {
    use crate::settings::SettingValue;
    with_theme_test_env(|| {
        let mut app = test_app_with_agent();
        let _ = dispatch(Action::SetAutoDarkTheme("kigiday".into()), &mut app);
        assert_eq!(app.current_ui.auto_dark_theme.as_deref(), Some("kigiday"));
        let _ = dispatch(
            Action::TaskComplete(TaskResult::SettingPersistFailed {
                key: "auto_dark_theme",
                rollback_value: SettingValue::Enum("kiginight"),
                error: "disk full".into(),
            }),
            &mut app,
        );
        assert_eq!(app.current_ui.auto_dark_theme.as_deref(), Some("kiginight"),);
    });
}
#[test]
fn rollback_auto_light_theme_reverts_current_ui() {
    use crate::settings::SettingValue;
    with_theme_test_env(|| {
        let mut app = test_app_with_agent();
        let _ = dispatch(Action::SetAutoLightTheme("kiginight".into()), &mut app);
        assert_eq!(
            app.current_ui.auto_light_theme.as_deref(),
            Some("kiginight"),
        );
        let _ = dispatch(
            Action::TaskComplete(TaskResult::SettingPersistFailed {
                key: "auto_light_theme",
                rollback_value: SettingValue::Enum("kigiday"),
                error: "disk full".into(),
            }),
            &mut app,
        );
        assert_eq!(app.current_ui.auto_light_theme.as_deref(), Some("kigiday"));
    });
}
/// Edge case — if the rollback value is
/// `"auto"` (corrupted hand-edit), `apply_setting_rollback` clears
/// `app.current_ui.auto_dark_theme` to `None` (matches the
/// `load_auto_theme_config` disk-read filter). Without this
/// special case, `set_auto_dark_theme_inner` would no-op and
/// leave the in-memory cache stuck at the (failed-to-persist)
/// new value.
#[test]
fn rollback_auto_dark_theme_with_auto_value_clears_to_none() {
    use crate::settings::SettingValue;
    with_theme_test_env(|| {
        let mut app = test_app_with_agent();
        let _ = dispatch(Action::SetAutoDarkTheme("kigiday".into()), &mut app);
        assert_eq!(app.current_ui.auto_dark_theme.as_deref(), Some("kigiday"));
        let _ = dispatch(
            Action::TaskComplete(TaskResult::SettingPersistFailed {
                key: "auto_dark_theme",
                rollback_value: SettingValue::Enum("auto"),
                error: "disk full".into(),
            }),
            &mut app,
        );
        assert_eq!(
            app.current_ui.auto_dark_theme, None,
            "rolling back to `auto` must clear the in-memory override (it's invalid; \
                 the disk-read filter would also drop it)",
        );
    });
}
#[test]
fn rollback_auto_light_theme_with_auto_value_clears_to_none() {
    use crate::settings::SettingValue;
    with_theme_test_env(|| {
        let mut app = test_app_with_agent();
        let _ = dispatch(Action::SetAutoLightTheme("kiginight".into()), &mut app);
        assert_eq!(
            app.current_ui.auto_light_theme.as_deref(),
            Some("kiginight"),
        );
        let _ = dispatch(
            Action::TaskComplete(TaskResult::SettingPersistFailed {
                key: "auto_light_theme",
                rollback_value: SettingValue::Enum("auto"),
                error: "disk full".into(),
            }),
            &mut app,
        );
        assert_eq!(app.current_ui.auto_light_theme, None);
    });
}
/// Setter refreshes the open settings modal
/// snapshot so the indicator reflects the new value before the
/// shell's CurrentModeUpdate round-trip. Mirrors
/// `set_multiline_mode_refreshes_open_modal_pager_snapshot`.
#[test]
fn set_plan_mode_refreshes_open_modal_pager_snapshot() {
    use crate::views::modal::ActiveModal;
    let mut app = test_app_with_agent();
    let _ = dispatch(Action::OpenSettings, &mut app);
    let agent = app.agents.get(&AgentId(0)).unwrap();
    let Some(ActiveModal::Settings { state }) = &agent.active_modal else {
        panic!("expected Settings modal after OpenSettings dispatch")
    };
    assert!(
        !state.pager_snapshot.plan_mode_active,
        "snapshot at open should be false (agent default)",
    );
    let _ = dispatch(
        Action::SetPlanMode(crate::app::actions::PlanModeKind::On),
        &mut app,
    );
    let agent = app.agents.get(&AgentId(0)).unwrap();
    let Some(ActiveModal::Settings { state }) = &agent.active_modal else {
        panic!("Settings modal must remain open across the dispatch")
    };
    assert!(
        state.pager_snapshot.plan_mode_active,
        "snapshot must be refreshed to true after SetPlanMode(On) — \
             without refresh_open_settings_modals the indicator would stay stale",
    );
    let cur_value =
        crate::settings::current_value_for("plan_mode", &state.ui_snapshot, &state.pager_snapshot)
            .expect("plan_mode must resolve");
    assert_eq!(
        cur_value,
        crate::settings::SettingValue::Enum("on"),
        "current_value_for must read the refreshed snapshot",
    );
}
#[test]
fn new_session_inherits_switched_default_model_for_welcome() {
    use crate::acp::model_state::ModelState;
    use std::sync::Arc;
    let mut app = test_app_with_agent();
    let mk = |slug: &str, name: &str| {
        let id = acp::ModelId::new(Arc::from(slug));
        (id.clone(), acp::ModelInfo::new(id, name.to_string()))
    };
    let (id_a, info_a) = mk("model-a", "Model A");
    let (id_b, info_b) = mk("model-b", "Model B");
    let mut models = ModelState::default();
    models.available.insert(id_a.clone(), info_a);
    models.available.insert(id_b.clone(), info_b);
    models.current = Some(id_a.clone());
    app.models = models.clone();
    app.agents.get_mut(&AgentId(0)).unwrap().session.models = models;
    assert!(set_default_model_inner(&mut app, &id_b));
    assert_eq!(
        app.models.current.as_ref(),
        Some(&id_b),
        "the switch must update the app-level default new sessions clone"
    );
    let _ = dispatch(Action::NewSession, &mut app);
    let new_agent = app.agents.get(&AgentId(1)).expect("new session created");
    assert_eq!(
        new_agent.session.models.current_model_name().as_deref(),
        Some("Model B"),
        "the new session — and its committed welcome card — must show the \
             switched model, not the previous default"
    );
}
/// Without config, the action is unregistered — Ctrl+R on scrollback does
/// nothing for mouse reporting (Ctrl+R is unbound on the prompt).
#[test]
fn mouse_reporting_toggle_inactive_without_config() {
    use crate::actions::{ActionId, ActionRegistry, When};
    let reg_off = ActionRegistry::defaults();
    let reg_on = ActionRegistry::defaults_with_config(true);
    let ctrl_r = crossterm::event::KeyEvent::new(
        crossterm::event::KeyCode::Char('r'),
        crossterm::event::KeyModifiers::CONTROL,
    );
    assert!(reg_off.find(ActionId::ToggleMouseCapture).is_none());
    assert_eq!(
        reg_off.lookup(&ctrl_r, When::ScrollbackFocused),
        None,
        "scrollback Ctrl+R must not toggle mouse without config"
    );
    assert_eq!(
        reg_off.lookup(&ctrl_r, When::PromptFocused),
        None,
        "Ctrl+R is deliberately unbound on the prompt"
    );
    assert!(reg_on.find(ActionId::ToggleMouseCapture).is_some());
    assert_eq!(
        reg_on.lookup(&ctrl_r, When::ScrollbackFocused),
        Some(ActionId::ToggleMouseCapture),
    );
    assert_eq!(
        reg_on.lookup(&ctrl_r, When::PromptFocused),
        None,
        "prompt Ctrl+R stays unbound even when the toggle is enabled"
    );
}
/// Config on: toggle off sets process atomic + sticky banner that survives
/// transient toasts and tick/keypress dismissal of transients.
#[serial_test::serial(MOUSE_CAPTURE_ENABLED)]
#[test]
fn mouse_reporting_toggle_off_sticky_persists_after_transient_toast() {
    reset_mouse_capture_enabled(true);
    assert!(mouse_capture_is_enabled());
    let mut app = test_app_with_agent();
    let id = AgentId(0);
    app.registry = crate::actions::ActionRegistry::defaults_with_config(true);
    let _ = dispatch(Action::ToggleMouseCapture, &mut app);
    assert!(
        !mouse_capture_is_enabled(),
        "toggle off must clear process-wide capture flag"
    );
    let parent = app.agents.get(&id).unwrap();
    assert_eq!(
        parent.sticky_toast.as_deref(),
        Some(MOUSE_OFF_STICKY),
        "sticky banner set on parent"
    );
    {
        let agent = app.agents.get_mut(&id).unwrap();
        agent.show_toast("Copied!");
        assert_eq!(
            agent.toast.as_ref().map(|(m, _)| m.as_str()),
            Some("Copied!"),
            "transient wins while active"
        );
        assert_eq!(agent.sticky_toast.as_deref(), Some(MOUSE_OFF_STICKY));
        if let Some((_, ref mut ticks)) = agent.toast {
            *ticks = 0;
        }
        assert!(agent.tick_toast());
        assert!(agent.toast.is_none());
        assert_eq!(
            agent.sticky_toast.as_deref(),
            Some(MOUSE_OFF_STICKY),
            "sticky remains after transient expires"
        );
        agent.show_toast("temp");
        agent.toast = None;
        assert_eq!(agent.sticky_toast.as_deref(), Some(MOUSE_OFF_STICKY));
    }
    let _ = dispatch(Action::ToggleMouseCapture, &mut app);
    assert!(mouse_capture_is_enabled());
    let parent = app.agents.get(&id).unwrap();
    assert!(parent.sticky_toast.is_none());
    assert_eq!(
        parent.toast.as_ref().map(|(m, _)| m.as_str()),
        Some("Mouse reporting on"),
    );
    reset_mouse_capture_enabled(true);
}
