Files
Kigi-CLI/crates/codegen/kigi-tui/src/app/dispatch/modes.rs
T
ZacharyZhang-NY d6c20fc13f M0: compilable skeleton — Kigi 0.1.0 fork surgery
Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.

Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
  kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
  ptyctl, ptyctl-cli, third_party/ unchanged; proto package
  xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
  KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
  (templates re-encrypted)

Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
  trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
  module & dc_log, heap-profile uploader, auth-diagnostics uploader,
  session-analytics halves of feedback; local zero-egress observability
  preserved in new kigi-log crate (unified log, --debug firehose,
  subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
  direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
  relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
  ~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
  kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
  session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
  shell util

Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
  https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
  https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
  Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted

Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
  workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
  all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
  exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
  insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean

Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
  (new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
  fast-worktree); RSS measurement tests serialized via serial_test

Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
  notices sustained; kigi-tools ported-code notices extended; README,
  CONTRIBUTING, SECURITY, AGENTS.md rewritten

Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
2026-07-17 05:31:01 -04:00

962 lines
41 KiB
Rust

//! 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/<urlencoded_cwd>/<session_id>/plan.md`.
pub(super) fn dispatch_show_plan(app: &mut AppView) -> Vec<Effect> {
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<String>,
) -> Vec<Effect> {
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<Effect> {
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<Effect>` 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<Vec<Effect>> {
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<Effect> {
// 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<Effect> {
// 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<Effect> {
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<Effect> {
// 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<Vec<Effect>> {
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<Effect> {
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<Effect> {
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
}
}
}