//! Plan, yolo, auto, and permission mode transitions and toasts. use super::ctx::with_active_agent; use super::queue::maybe_drain_queue; use super::session::lifecycle::skip_picker_and_create_session; use super::settings::ui::{refresh_open_settings_modals, save_success_toast}; use crate::app::actions::Effect; use crate::app::app_view::{ActiveView, AppView}; use agent_client_protocol as acp; /// Show the current plan: if a plan file exists, open it in the preview /// overlay popover. If no plan has been written yet, show a toast. /// /// Delegates to `AgentView::show_plan_preview()` which reads the plan file /// from `~/.kigi/sessions///plan.md`. pub(super) fn dispatch_show_plan(app: &mut AppView) -> Vec { with_active_agent(app, |agent| { if agent.plan_approval_view.is_some() { agent.reopen_plan_approval(); } else { agent.show_plan_preview(); } }); vec![] } /// Enter plan mode via `/plan`. /// /// When not in plan mode: emits `SetSessionMode` (or `SetModeThenPrompt` /// if a description is provided). When already in plan mode: no-op with toast. /// Use `/view-plan` to open the current saved plan preview. /// /// When a description is present, the mode switch and prompt send must be /// ordered: the mode switch ACP call must complete before the prompt is /// dispatched. `SetModeThenPrompt` bundles both into a single spawned task /// to guarantee this ordering. pub(super) fn dispatch_enter_plan_mode( app: &mut AppView, description: Option, ) -> Vec { let ActiveView::Agent(id) = app.active_view else { return vec![]; }; let Some(agent) = app.agents.get_mut(&id) else { return vec![]; }; let in_plan = agent.plan_mode_pending.unwrap_or(agent.plan_mode_active); if in_plan { app.show_toast("Already in plan mode. Use /view-plan to view the current plan."); return vec![]; } let agent = app.agents.get_mut(&id).unwrap(); let Some(session_id) = agent.session.session_id.clone() else { agent.show_toast("No active session"); return vec![]; }; // Set optimistic pending state (same pattern as dispatch_cycle_mode). agent.plan_mode_pending = Some(true); tracing::info!("Plan mode entered via /plan slash command"); let mode_id = acp::SessionModeId::new("plan"); if let Some(desc) = description { // Enqueue and drain: maybe_drain_queue does all synchronous turn // setup (scrollback, start_turn, prompt_id) and returns a SendPrompt. // We combine it with the mode switch into a single sequential effect // so the mode switch completes before the prompt is sent. // The description is a plain prompt: capture composer-recognized // tokens like the normal submit path (offsets recomputed against // `desc` since the leading `/plan ` was stripped). let skill_token_ranges = agent .prompt .slash_controller .recognized_token_ranges(&desc, &agent.session.models); agent .session .enqueue_prompt_with_skill_tokens(desc, skill_token_ranges); let drain = maybe_drain_queue(agent); let mut effects = Vec::with_capacity(1); for eff in drain { match eff { Effect::SendPrompt { agent_id, text, prompt_id, skill_token_ranges, .. } => { effects.push(Effect::SetModeThenPrompt { session_id: session_id.clone(), mode_id: mode_id.clone(), agent_id, text, prompt_id, skill_token_ranges, }); } other => effects.push(other), } } // If drain was empty (not idle), just emit the mode switch — the // prompt stays queued and will drain naturally when the agent idles. if effects.is_empty() { effects.push(Effect::SetSessionMode { session_id, mode_id, }); } effects } else { vec![Effect::SetSessionMode { session_id, mode_id, }] } } /// Set plan mode (on / off). PAGER-owned + ACP-mediated, per-session. /// /// Optimistic flow: captures effective state (`pending.or(active)`), /// sets `plan_mode_pending`, refreshes modals, toasts, then emits /// `Effect::SetSessionMode`. Shell confirms via `CurrentModeUpdate`. /// /// No explicit rollback — `SetSessionMode` has no failure surface. /// If the ACP transport drops, `plan_mode_pending` stays set until /// the next `CurrentModeUpdate` or session restart. /// /// Idempotent: same value toasts but skips the ACP round-trip. pub(super) fn set_plan_mode( app: &mut AppView, kind: crate::app::actions::PlanModeKind, ) -> Vec { let ActiveView::Agent(id) = app.active_view else { return vec![]; }; let Some(agent) = app.agents.get_mut(&id) else { return vec![]; }; let Some(session_id) = agent.session.session_id.clone() else { agent.show_toast("No active session"); return vec![]; }; // Effective state: prefer optimistic pending over confirmed // active. Mirrors `dispatch_cycle_mode`'s `in_plan` read so // rapid toggles don't double-send. let prev = agent.plan_mode_pending.unwrap_or(agent.plan_mode_active); let new = kind.to_bool(); // Idempotent: toast but skip the ACP round-trip. if prev == new { app.show_toast(&plan_mode_toast(kind)); return vec![]; } // Optimistic mutation: pager-side pending flag, then UI feedback, // then effect. The shell's `CurrentModeUpdate` broadcast will // confirm + clear `plan_mode_pending` via `detect_plan_mode_change`. agent.plan_mode_pending = Some(new); refresh_open_settings_modals(app); app.show_toast(&plan_mode_toast(kind)); tracing::info!( target: "settings", key = "plan_mode", value = new, "setting changed", ); // OFF targets `SessionMode::Default`, not the user's prior mode. // If the user was in `Ask` (shell-injection only), that preference // is silently dropped. See `PLAN_MODE_CHOICES` in `settings/defs.rs`. let mode_id = acp::SessionModeId::new(if new { kigi_tools::types::SessionMode::Plan.as_id() } else { kigi_tools::types::SessionMode::Default.as_id() }); vec![Effect::SetSessionMode { session_id, mode_id, }] } /// Format the `Plan mode` toast. Non-destructive in both directions /// (unlike YOLO), so both ON and OFF use the uniform ✓ glyph. /// Uses lowercase "on"/"off" via `save_success_toast`. fn plan_mode_toast(kind: crate::app::actions::PlanModeKind) -> String { save_success_toast("Plan mode", kind.to_bool()) } /// The single gate for client paths that ENABLE always-approve: `Some(reason)` /// iff `enabling` and the pin (`app.yolo_policy_block`) is set. Every enabling /// path routes through here (or [`refuse_if_yolo_locked`]) so new paths stay /// gated by default; callers must NOT persist on a refusal. pub(super) fn yolo_enable_blocked(app: &AppView, enabling: bool) -> Option<&'static str> { if enabling { app.yolo_policy_block } else { None } } /// `Vec` wrapper for the persisting setters: on a refusal, toast and /// return `Some(vec![])` (no persist); `None` means proceed. fn refuse_if_yolo_locked(app: &mut AppView, enabling: bool) -> Option> { let warning = yolo_enable_blocked(app, enabling)?; app.show_toast(warning); Some(vec![]) } /// Canonical "auto wins only when yolo is off" precedence — the single source /// of truth for the yolo-over-auto rule applied at every reconnect / seed / meta /// site. Callers pass the already-resolved auto signal (a per-session flag or a /// `permission_mode == Some("auto")` test). pub(crate) fn effective_auto(yolo: bool, auto: bool) -> bool { !yolo && auto } /// When the auto gate is off, force the displayed permission mode off Auto and /// clear every agent's per-session auto flag, so the UI / Shift+Tab cycle / /// settings snapshot and each tab's badge never show Auto while the feature is /// disabled. Shared by the startup reconcile and the mid-session kill-switch. /// Clearing every agent (not just when the global mirror still reads "auto") /// matters because `switch_to_agent` re-anchors the mirror to the active tab. pub(crate) fn downgrade_displayed_auto_if_gated(app: &mut AppView) { if app.auto_mode_gate { return; } for agent in app.agents.values_mut() { agent.session.auto_mode = false; } if app.current_ui.permission_mode.as_deref() == Some("auto") { app.current_ui.permission_mode = Some("ask".into()); } } /// Whether a newly created session should start with the Auto display flag set: /// the gate is on, the current UI mode is Auto, and yolo is not winning. Mirrors /// the canonical `auto && !yolo` precedence used on the wire (`ClientCapabilities` /// / `SessionFlags`). The `auto_mode_gate` check is defense-in-depth so a stale /// `current_ui == "auto"` can never seed a new session into Auto when gated off. pub(super) fn inherit_auto_mode(app: &AppView) -> bool { app.auto_mode_gate && effective_auto( app.default_yolo, app.current_ui.permission_mode.as_deref() == Some("auto"), ) } /// Keep the active session's `auto_mode` display flag in lockstep with the /// applied canonical permission mode. The canonical (`app.current_ui /// .permission_mode`) is the single value every mode-change path finalizes — /// the cycle, the settings setter, and the rollback all write it — so deriving /// the flag from it (and clearing it under yolo, which wins) keeps the prompt /// "auto" indicator correct regardless of which seam applied the mode. pub(super) fn sync_active_auto_flag(app: &mut AppView) { let is_auto = app.current_ui.permission_mode.as_deref() == Some("auto"); if let ActiveView::Agent(id) = app.active_view && let Some(agent) = app.agents.get_mut(&id) { agent.session.auto_mode = effective_auto(agent.session.is_yolo(), is_auto); } // Keep `/auto` feature-gate visibility in lockstep across slash surfaces. app.sync_permission_mode_slash_gate(); } /// State-only `permission_mode` (YOLO) mutation; also called from rollback. /// Flips to ON are refused while the pin is set. pub(super) fn set_yolo_mode_inner(app: &mut AppView, new: bool) { if yolo_enable_blocked(app, new).is_some() { tracing::warn!("always-approve enable blocked by managed policy"); return; } // Global mirrors update unconditionally (even if the user navigated // away from the agent mid-rollback). Per-agent state is gated below. app.default_yolo = new; app.permission_mode_from_soft_default = false; // Write-only mirror — see fn doc-comment. app.current_ui.permission_mode = Some(if new { "always-approve" } else { "ask" }.to_string()); let ActiveView::Agent(id) = app.active_view else { return; }; let Some(agent) = app.agents.get_mut(&id) else { return; }; let previous_state = agent.session.is_yolo(); // Drain ordering invariant: flag flip BEFORE the drain (see fn // doc-comment). Do NOT reorder these without re-reading the // contract. agent.session.yolo_mode = new; if new { // YOLO ON: auto-approve all queued permissions. Drain runs // even on idempotent re-dispatch. Prefers `AllowOnce`; falls // back to `Cancelled` (never `AllowAlways`). agent.last_permission_click = None; for perm in agent.permission_queue.drain(..) { if let Some(allow) = perm .options .iter() .find(|o| o.kind == acp::PermissionOptionKind::AllowOnce) { perm.request .response_tx .send(Ok(acp::RequestPermissionResponse::new( acp::RequestPermissionOutcome::Selected( acp::SelectedPermissionOutcome::new(allow.option_id.clone()), ), ))) .ok(); } else { perm.request .response_tx .send(Ok(acp::RequestPermissionResponse::new( acp::RequestPermissionOutcome::Cancelled, ))) .ok(); } } // Restore stashed prompt since queue is now empty. if let Some(stashed) = agent.permission_stashed_prompt.take() { agent.prompt.restore(stashed); } } // Telemetry + tracing guarded on real state change only. if previous_state != new { tracing::info!(target: "settings", key = "permission_mode", value = new, "setting changed"); } } /// Set YOLO (`permission_mode`). SHELL-owned, emits /// `Effect::PersistPermissionMode` with rollback. The drain runs /// unconditionally on YOLO=ON (even duplicate dispatches) because /// a permission could arrive between dispatches. fn capture_prev_permission_canonical(app: &AppView, prev_yolo: bool) -> &'static str { if prev_yolo { "always-approve" } else { match app.current_ui.permission_mode.as_deref() { Some("default") => "default", Some("auto") => "auto", _ => "ask", } } } pub(super) fn set_yolo_mode(app: &mut AppView, new: bool) -> Vec { // Managed policy pins always-approve off — no state change, no persist. if let Some(blocked) = refuse_if_yolo_locked(app, new) { return blocked; } let ActiveView::Agent(id) = app.active_view else { return vec![]; }; // Capture LIVE yolo + plan state and session_id atomically for rollback. let (prev_yolo, session_id, effective_plan) = app .agents .get(&id) .map(|a| { ( a.session.is_yolo(), a.session.session_id.clone(), a.plan_mode_pending.unwrap_or(a.plan_mode_active), ) }) .unwrap_or((false, None, false)); let prev_canonical = capture_prev_permission_canonical(app, prev_yolo); set_yolo_mode_inner(app, new); // Refresh modal snapshots so the indicator reflects the new value. refresh_open_settings_modals(app); // Toggling yolo always lands on ask/always-approve (never auto); keep the // per-session auto display flag in sync (clears it). sync_active_auto_flag(app); // Toast on every save. YOLO ON gets a weightier visual; under an active // plan mode, say the plan edit gate stays binding — "all tool actions // auto-run" would overpromise while the shell rejects non-plan-file edits. if new && effective_plan { app.show_toast(YOLO_ON_UNDER_PLAN_TOAST); } else { app.show_toast(&yolo_toast(new)); } // Forward write is always "ask" or "always-approve" (bool entry // point). Rollback uses `prev_canonical` with LIVE precedence. let canonical: &'static str = if new { "always-approve" } else { "ask" }; vec![Effect::PersistPermissionMode { canonical, session_id, persist: crate::app::actions::PermissionModePersist::WithRollback(prev_canonical), }] } /// Set permission mode by typed kind. Entry point from the settings /// modal. Mirrors `set_yolo_mode` but preserves the canonical string /// (the inner collapses "default" onto "ask"; this setter restores /// the distinction by overriding `app.current_ui.permission_mode` /// after the inner call). Rollback uses LIVE-precedence canonical. pub(super) fn set_permission_mode( app: &mut AppView, kind: crate::app::actions::PermissionModeKind, ) -> Vec { // Feature gate: a commit to Auto is inert when the auto permission-mode // feature is disabled. Reading `app.auto_mode_gate` here (the same source // the Shift+Tab cycle uses) keeps the settings modal and the cycle in // lockstep — both degrade Auto → Ask when the gate is off. let kind = if matches!(kind, crate::app::actions::PermissionModeKind::Auto) && !app.auto_mode_gate { crate::app::actions::PermissionModeKind::Ask } else { kind }; // Managed policy pins always-approve off — keep the modal on live state. if let Some(blocked) = refuse_if_yolo_locked(app, kind.is_always_approve()) { refresh_open_settings_modals(app); return blocked; } let ActiveView::Agent(id) = app.active_view else { return vec![]; }; // Capture LIVE yolo + plan state and session_id atomically for rollback. let (prev_yolo, session_id, effective_plan) = app .agents .get(&id) .map(|a| { ( a.session.is_yolo(), a.session.session_id.clone(), a.plan_mode_pending.unwrap_or(a.plan_mode_active), ) }) .unwrap_or((false, None, false)); let prev_canonical = capture_prev_permission_canonical(app, prev_yolo); // State mutation via shared inner. We overwrite the canonical // below for the Default case. Inner clears the soft-default latch. set_yolo_mode_inner(app, kind.is_always_approve()); // Restore the "default" distinction the inner's bool-projection // collapses. No-op for `AlwaysApprove` and `Ask`. app.current_ui.permission_mode = Some(kind.as_canonical().to_string()); // Refresh modal so its snapshot reflects the overridden canonical. refresh_open_settings_modals(app); // Keep the per-session auto display flag in sync with the applied canonical // (`kind` was already degraded to Ask when the gate is off, so a remaining // Auto here means the gate passed). sync_active_auto_flag(app); // Toast on every save (plan-aware for AlwaysApprove, mirroring // `set_yolo_mode` — the plan edit gate stays binding under yolo). if kind.is_always_approve() && effective_plan { app.show_toast(YOLO_ON_UNDER_PLAN_TOAST); } else { app.show_toast(&permission_mode_toast(kind)); } vec![Effect::PersistPermissionMode { canonical: kind.as_canonical(), session_id, persist: crate::app::actions::PermissionModePersist::WithRollback(prev_canonical), }] } /// Build the toast for a `permission_mode` commit. `AlwaysApprove` /// reuses `yolo_toast(true)` (destructive). `Ask` and `Default` get /// dedicated "Permission mode: ..." toasts matching the picker brand. pub(super) fn permission_mode_toast(kind: crate::app::actions::PermissionModeKind) -> String { use crate::app::actions::PermissionModeKind; match kind { PermissionModeKind::AlwaysApprove => yolo_toast(true), PermissionModeKind::Auto => "\u{2713} Permission mode: Auto (classifier)".to_string(), PermissionModeKind::Ask => "\u{2713} Permission mode: Ask".to_string(), PermissionModeKind::Default => "\u{2713} Permission mode: Default".to_string(), } } /// YOLO-ON toast when plan mode is active: always-approve arms the permission /// fast path, but the shell's plan-mode gate still rejects non-plan-file /// edits, so the standard "all tool actions auto-run" would overpromise. pub(super) const YOLO_ON_UNDER_PLAN_TOAST: &str = "\u{26A0} Always-approve ON: plan mode still blocks file edits until you exit plan mode"; /// Build the YOLO toast — ⚠ on ON (destructive), ✓ on OFF (safe default). fn yolo_toast(new: bool) -> String { if new { // Warning glyph + consequence — only post-commit feedback. "\u{26A0} Always-approve ON: all tool actions auto-run".to_string() } else { // OFF restores safe default — uniform ✓ glyph. save_success_toast("Always-approve", false) } } /// Toggle YOLO mode (Ctrl+O keybinding path). Delegates to the /// registry-driven `set_yolo_mode` so permission-queue draining, /// telemetry, and persistence all flow through a single code path. pub(super) fn dispatch_toggle_yolo(app: &mut AppView) -> Vec { let ActiveView::Agent(id) = app.active_view else { return vec![]; }; let Some(agent) = app.agents.get(&id) else { return vec![]; }; let new = !agent.session.yolo_mode; set_yolo_mode(app, new) } /// Shift+Tab mode cycle from the agent chat view: the shared cycle body plus /// plan-nudge acceptance telemetry (the nudge advertises this chord). The /// dashboard peek calls [`dispatch_cycle_mode_and_sync`] instead, so a peeked /// agent — whose prompt the user is not looking at — never attributes an accept /// and never collapses Auto/Always-Approve for the nudge jump. pub(super) fn dispatch_cycle_mode(app: &mut AppView) -> Vec { // Capture the pre-cycle nudge visibility + plan state so only a transition // into Plan taken while the nudge is on screen attributes as an acceptance; // a disabled/absent nudge never emits. let (nudge_showing, in_plan_before) = active_agent_plan_nudge_state(app); // Tip copy promises one Shift+Tab → Plan; collapse Auto/Always-Approve to // ask first so the ring's Normal→Plan arm is the sole Plan entry. let mut effects = collapse_to_ask_for_nudge_jump(app).unwrap_or_default(); effects.extend(dispatch_cycle_mode_and_sync(app)); // Re-read only `in_plan`, via the same mut agent handle used to retire the // nudge: entering Plan with the nudge up is an acceptance. if nudge_showing && !in_plan_before && let ActiveView::Agent(id) = app.active_view && let Some(agent) = app.agents.get_mut(&id) && agent.plan_mode_pending.unwrap_or(agent.plan_mode_active) { // Retire the now-stale nudge so one impression maps to at most one // acceptance — a full mode loop back to Plan within the ~3s TTL would // otherwise re-emit — unifying with the undo/image tips' clear-on-accept. agent .ephemeral_tip .clear(crate::tips::plan_nudge::PLAN_NUDGE_KEY); } effects } /// When the plan nudge is showing and the active agent is in Auto or /// Always-Approve, collapse permission to ask (no banner / no Plan effects) /// so the subsequent ring step is Normal→Plan. Returns `None` when the ring /// should run alone (Normal, absent nudge, already-in-plan, or no session). /// Agent-view only — peek never calls this. fn collapse_to_ask_for_nudge_jump(app: &mut AppView) -> Option> { let ActiveView::Agent(id) = app.active_view else { return None; }; let agent = app.agents.get(&id)?; if agent.ephemeral_tip.current_key() != Some(crate::tips::plan_nudge::PLAN_NUDGE_KEY) { return None; } let in_plan = agent.plan_mode_pending.unwrap_or(agent.plan_mode_active); if in_plan { return None; } let in_yolo = agent.session.is_yolo(); let in_auto = agent.session.is_auto(); // Normal → Plan is already a single ring step; only collapse Auto / yolo. if !in_yolo && !in_auto { return None; } let session_id = agent.session.session_id.clone()?; if in_yolo { set_yolo_mode_inner(app, false); } app.current_ui.permission_mode = Some("ask".into()); sync_active_auto_flag(app); tracing::info!("Mode cycle: collapse to ask for plan nudge jump"); Some(vec![Effect::PersistPermissionMode { canonical: "ask", session_id: Some(session_id), persist: crate::app::actions::PermissionModePersist::BestEffort, }]) } /// The Shift+Tab cycle body shared by the agent view and the dashboard peek: /// apply the mode, then keep the per-session `auto_mode` display flag in sync /// with the freshly written canonical mode — covering every arm (including the /// pre-session and policy-pin early returns) without per-arm edits. Deliberately /// telemetry-free: the dashboard peek reuses it so it can't attribute a /// plan-nudge acceptance for an agent the user isn't viewing. pub(super) fn dispatch_cycle_mode_and_sync(app: &mut AppView) -> Vec { app.permission_mode_from_soft_default = false; let effects = dispatch_cycle_mode_inner(app); sync_active_auto_flag(app); effects } /// The active agent's `(plan nudge visible, optimistically in plan mode)`, or /// `(false, false)` with no active agent. Lets [`dispatch_cycle_mode`] attribute /// a shift+tab that turns plan mode on while the nudge shows as an acceptance. pub(super) fn active_agent_plan_nudge_state(app: &AppView) -> (bool, bool) { let ActiveView::Agent(id) = app.active_view else { return (false, false); }; match app.agents.get(&id) { Some(agent) => ( agent.ephemeral_tip.current_key() == Some(crate::tips::plan_nudge::PLAN_NUDGE_KEY), agent.plan_mode_pending.unwrap_or(agent.plan_mode_active), ), None => (false, false), } } /// Cycle session mode: Normal → Plan → Always-Approve → Normal. /// /// Uses `plan_mode_pending` (optimistic) when available, falling back to /// `plan_mode_active` (confirmed by ACP). This prevents double-sends when /// the user presses Shift+Tab faster than the ACP round-trip. fn dispatch_cycle_mode_inner(app: &mut AppView) -> Vec { let ActiveView::Agent(id) = app.active_view else { return vec![]; }; // Capture the pin before borrowing `agent`: the "→ Always-Approve" arms are // yolo-enabling, but a `yolo_enable_blocked(app, _)` call would conflict with // the live `&mut agent`. This is the same predicate (enabling = true here). let yolo_locked = app.yolo_policy_block; // Feature gate (default ON): when the auto permission mode is disabled, the // Shift+Tab cycle skips Auto entirely (legacy Normal→Plan→Always-Approve→ // Normal), so Auto is never reachable from the cycle. Resolved once at // startup into `app.auto_mode_gate`. let auto_gate = app.auto_mode_gate; let Some(agent) = app.agents.get_mut(&id) else { return vec![]; }; // Per-session (symmetric with the `in_yolo` reads below), not the global UI // mirror, so the cycle and the prompt "auto" indicator agree per agent. let in_auto = agent.session.is_auto(); let Some(session_id) = agent.session.session_id.clone() else { // No session yet (Shift+Tab forwarded from the welcome screen or a // fresh tab): cycle the mode locally and stash the ACP push in // `deferred_session_mode` — consumed by the `SessionCreated` // handlers, same mechanism as the dashboard's staged plan mode. // Cycle: Normal → Plan → Auto → Always-Approve → Normal (Auto skipped // when always-approve is the only remaining arm under a yolo pin). // Each arm yields the canonical permission mode to persist (`None` // when it is untouched, i.e. Normal → Plan); see the push below. let in_plan = agent.plan_mode_pending.unwrap_or(agent.plan_mode_active); let in_yolo = agent.session.is_yolo(); let persist_canonical: Option<&'static str> = match (in_plan, in_auto, in_yolo) { // Normal → Plan (false, false, false) => { agent.plan_mode_pending = Some(true); agent.deferred_session_mode = Some(kigi_tools::types::SessionMode::Plan); agent.show_mode_switch_banner("Plan"); tracing::info!("Mode cycle (pre-session): Normal → Plan"); None } // Plan → Auto (or Plan → Always-Approve when the auto feature is // gated off, matching the legacy Normal→Plan→Always-Approve cycle). (true, false, false) => { agent.plan_mode_pending = Some(false); agent.deferred_session_mode = None; if auto_gate { // Clear any launch-seeded yolo so the created session isn't // started in yolo while the UI shows Auto (SessionFlags reads // default_yolo at CreateSession). agent.session.yolo_mode = false; app.default_yolo = false; app.current_ui.permission_mode = Some("auto".into()); agent.show_mode_switch_banner("Auto"); tracing::info!("Mode cycle (pre-session): Plan → Auto"); Some("auto") } else if let Some(warning) = yolo_locked { app.current_ui.permission_mode = Some("ask".into()); agent.session.yolo_mode = false; app.default_yolo = false; agent.show_toast(warning); agent.show_mode_switch_banner("Normal"); tracing::info!("Mode cycle (pre-session): Plan → Normal (auto gated, policy)"); Some("ask") } else { agent.session.yolo_mode = true; app.default_yolo = true; app.current_ui.permission_mode = Some("always-approve".into()); agent.show_mode_switch_banner("Always-Approve"); tracing::info!("Mode cycle (pre-session): Plan → Always-Approve (auto gated)"); Some("always-approve") } } // Auto → Always-Approve (or Normal if pinned) (false, true, false) => { if let Some(warning) = yolo_locked { app.current_ui.permission_mode = Some("ask".into()); agent.session.yolo_mode = false; app.default_yolo = false; agent.show_toast(warning); agent.show_mode_switch_banner("Normal"); tracing::info!("Mode cycle (pre-session): Auto → Normal (policy)"); Some("ask") } else { agent.session.yolo_mode = true; app.default_yolo = true; app.current_ui.permission_mode = Some("always-approve".into()); agent.show_mode_switch_banner("Always-Approve"); tracing::info!("Mode cycle (pre-session): Auto → Always-Approve"); Some("always-approve") } } // Always-Approve → Normal (false, _, true) => { agent.session.yolo_mode = false; app.default_yolo = false; app.current_ui.permission_mode = Some("ask".into()); agent.show_mode_switch_banner("Normal"); tracing::info!("Mode cycle (pre-session): Always-Approve → Normal"); Some("ask") } // Plan + Auto → Auto (exit plan, keep the classifier), matching the // with-session `(true, true, false, …)` arm. Every other plan+weird // state (notably Plan+yolo) resets to Normal, matching the // with-session catch-all — both paths MUST agree on the same input. // Clear stale yolo so enforcement matches the displayed mode. (true, _, _) => { agent.plan_mode_pending = Some(false); agent.deferred_session_mode = None; agent.session.yolo_mode = false; app.default_yolo = false; if auto_gate && in_auto && !in_yolo { app.current_ui.permission_mode = Some("auto".into()); agent.show_mode_switch_banner("Auto"); tracing::info!("Mode cycle (pre-session): Plan+Auto → Auto"); Some("auto") } else { app.current_ui.permission_mode = Some("ask".into()); agent.show_mode_switch_banner("Normal"); tracing::info!("Mode cycle (pre-session): Plan(*) → Normal"); Some("ask") } } }; refresh_open_settings_modals(app); let mut effects = Vec::new(); // Persist the displayed mode to disk like the with-session arms do — // otherwise a restart re-reads the stale launch value (e.g. cycling // Always-Approve off pre-session still relaunched in yolo). // `session_id: None` skips the ACP yolo_mode_changed push (nothing to // notify yet; the created session takes its mode from the explicit // `_meta` seeds — see `SessionFlags::to_meta`). if let Some(canonical) = persist_canonical { effects.push(Effect::PersistPermissionMode { canonical, session_id: None, persist: crate::app::actions::PermissionModePersist::BestEffort, }); } effects.extend(skip_picker_and_create_session(app, id)); return effects; }; // Effective plan state: prefer optimistic pending over confirmed active. let in_plan = agent.plan_mode_pending.unwrap_or(agent.plan_mode_active); let in_yolo = agent.session.is_yolo(); match (in_plan, in_auto, in_yolo) { // Normal → Plan (false, false, false) => { agent.plan_mode_pending = Some(true); agent.show_mode_switch_banner("Plan"); refresh_open_settings_modals(app); tracing::info!("Mode cycle: Normal → Plan"); vec![Effect::SetSessionMode { session_id, mode_id: acp::SessionModeId::new(kigi_tools::types::SessionMode::Plan.as_id()), }] } // Plan → Auto (classifier mode; exit plan, not always-approve). // When the auto feature is gated off, Plan → Always-Approve (skip Auto), // matching the legacy cycle and respecting the yolo policy pin. (true, false, false) => { agent.plan_mode_pending = Some(false); if !auto_gate { if let Some(warning) = yolo_locked { set_yolo_mode_inner(app, false); app.current_ui.permission_mode = Some("ask".into()); refresh_open_settings_modals(app); if let Some(a) = app.agents.get_mut(&id) { a.show_toast(warning); a.show_mode_switch_banner("Normal"); } tracing::info!( "Mode cycle: Plan → Normal (auto gated, always-approve blocked by policy)" ); // Exit Plan on the agent too; a policy pin must not strand the session in Plan. return vec![ Effect::SetSessionMode { session_id: session_id.clone(), mode_id: acp::SessionModeId::new( kigi_tools::types::SessionMode::Default.as_id(), ), }, Effect::PersistPermissionMode { canonical: "ask", session_id: Some(session_id), persist: crate::app::actions::PermissionModePersist::BestEffort, }, ]; } set_yolo_mode_inner(app, true); app.current_ui.permission_mode = Some("always-approve".into()); refresh_open_settings_modals(app); if let Some(a) = app.agents.get_mut(&id) { a.show_mode_switch_banner("Always-Approve"); } tracing::info!("Mode cycle: Plan → Always-Approve (auto gated)"); return vec![ Effect::SetSessionMode { session_id: session_id.clone(), mode_id: acp::SessionModeId::new( kigi_tools::types::SessionMode::Default.as_id(), ), }, Effect::PersistPermissionMode { canonical: "always-approve", session_id: Some(session_id), persist: crate::app::actions::PermissionModePersist::BestEffort, }, ]; } set_yolo_mode_inner(app, false); app.current_ui.permission_mode = Some("auto".into()); refresh_open_settings_modals(app); if let Some(a) = app.agents.get_mut(&id) { a.show_mode_switch_banner("Auto"); } tracing::info!("Mode cycle: Plan → Auto"); vec![ Effect::SetSessionMode { session_id: session_id.clone(), mode_id: acp::SessionModeId::new( kigi_tools::types::SessionMode::Default.as_id(), ), }, Effect::PersistPermissionMode { canonical: "auto", session_id: Some(session_id), persist: crate::app::actions::PermissionModePersist::BestEffort, }, ] } // Auto → Always-Approve (or Normal when policy pins yolo off) (false, true, false) => { if let Some(warning) = yolo_locked { set_yolo_mode_inner(app, false); app.current_ui.permission_mode = Some("ask".into()); refresh_open_settings_modals(app); if let Some(a) = app.agents.get_mut(&id) { a.show_toast(warning); a.show_mode_switch_banner("Normal"); } tracing::info!("Mode cycle: Auto → Normal (always-approve blocked by policy)"); return vec![Effect::PersistPermissionMode { canonical: "ask", session_id: Some(session_id), persist: crate::app::actions::PermissionModePersist::BestEffort, }]; } set_yolo_mode_inner(app, true); app.current_ui.permission_mode = Some("always-approve".into()); refresh_open_settings_modals(app); if let Some(a) = app.agents.get_mut(&id) { a.show_mode_switch_banner("Always-Approve"); } tracing::info!("Mode cycle: Auto → Always-Approve"); vec![Effect::PersistPermissionMode { canonical: "always-approve", session_id: Some(session_id), persist: crate::app::actions::PermissionModePersist::BestEffort, }] } // Always-Approve → Normal (false, _, true) => { set_yolo_mode_inner(app, false); app.current_ui.permission_mode = Some("ask".into()); refresh_open_settings_modals(app); if let Some(a) = app.agents.get_mut(&id) { a.show_mode_switch_banner("Normal"); } tracing::info!("Mode cycle: Always-Approve → Normal"); vec![Effect::PersistPermissionMode { canonical: "ask", session_id: Some(session_id), persist: crate::app::actions::PermissionModePersist::BestEffort, }] } // Plan + Auto → Auto: exit plan but keep the classifier. Without this // explicit arm the state falls to `_` and would reset to Normal/ask. (true, true, false) => { agent.plan_mode_pending = Some(false); app.current_ui.permission_mode = Some("auto".into()); refresh_open_settings_modals(app); if let Some(a) = app.agents.get_mut(&id) { a.show_mode_switch_banner("Auto"); } tracing::info!("Mode cycle: Plan+Auto → Auto (exit plan, keep classifier)"); vec![ Effect::SetSessionMode { session_id: session_id.clone(), mode_id: acp::SessionModeId::new( kigi_tools::types::SessionMode::Default.as_id(), ), }, Effect::PersistPermissionMode { canonical: "auto", session_id: Some(session_id), persist: crate::app::actions::PermissionModePersist::BestEffort, }, ] } // Any other combination → reset to Normal. // YOLO inner only called when actually in YOLO (avoids // spurious telemetry). _ => { agent.plan_mode_pending = Some(false); // NLL releases the `agent` borrow after the assignment // above; `set_yolo_mode_inner(app, …)` can reborrow below. if in_yolo { set_yolo_mode_inner(app, false); } app.current_ui.permission_mode = Some("ask".into()); refresh_open_settings_modals(app); if let Some(a) = app.agents.get_mut(&id) { a.show_mode_switch_banner("Normal"); } tracing::info!("Mode cycle: mixed state → Normal"); let mut effects = vec![]; if in_plan { effects.push(Effect::SetSessionMode { session_id: session_id.clone(), mode_id: acp::SessionModeId::new( kigi_tools::types::SessionMode::Default.as_id(), ), }); } if in_yolo || in_auto { effects.push(Effect::PersistPermissionMode { canonical: "ask", session_id: Some(session_id), persist: crate::app::actions::PermissionModePersist::BestEffort, }); } effects } } }