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).
962 lines
41 KiB
Rust
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
|
|
}
|
|
}
|
|
}
|