The PRD's first acceptance gate now holds: grep -RinE '\bx\.ai\b|grok' crates/ --include='*.rs' → 0 matches (exempt: NOTICE and third-party license archives, README provenance, and the required 'Based on Grok Build Open Source' attribution, now sourced from version_attribution.txt). Wire-visible renames (both sides in this repo, changed in lockstep): - Auth method id 'grok.com' → 'kimi-code' (AuthMethodKind::KimiCode). - Every x.ai/* and _x.ai/* ACP ext method and meta key → kigi/* / _kigi/* (~200 names; grokShell → kigiShell). Session-file replay keeps a read-side alias for the legacy '_x.ai/session/update' method so existing updates.jsonl histories load; writes emit only the new name (both directions test-pinned). - Agent types grok-build* → kigi* with a documented legacy-prefix alias at resolution time so persisted sessions keep resolving. - ToolNamespace/BuiltinAgentName GrokBuild* → Kigi* (wire snake_case kigi/kigi_concise/kigi_hashline; schema regenerated); grok_build implementation dirs renamed to kigi*. - x-grok-* headers → x-kigi-*, __GROK_* sentinels → __KIGI_*, themes grokday/groknight → kigiday/kiginight (old persisted values fall back to the default theme), web_fetch allowlist xAI hosts → kimi.com + moonshot platforms, changelog CDN → this repo, grok-build changelog archives deleted. - BYOK default endpoint removed: [endpoints] api_base_url is now truly optional with NO default — consumers fail fast with the flag name when unset (no silent x.ai egress). Mock harnesses inject it explicitly. - System-prompt identity fixed: 'released by xAI' → 'an unofficial community CLI for Kimi' (template + regenerated encrypted form). Also repaired pre-existing grok-era test debt found by the sweep: the stale trace_classify default-model pin, the grok-pager UA label test, pty-harness stale-binary reuse and non-hermetic moonshot routing (a PTY test could previously reach the real api.moonshot.cn), and the outdated oauth fixture scope key. Gates: §9 grep 0; fmt clean; workspace check/clippy 0/0 (-D warnings); FULL cargo test --workspace: 234 suites, 21,961 passed, 0 failed; deny advisories ok.
3308 lines
127 KiB
Rust
3308 lines
127 KiB
Rust
//! 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);
|
|
}
|