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).
This commit is contained in:
2026-07-17 05:31:01 -04:00
commit d6c20fc13f
2612 changed files with 1353757 additions and 0 deletions
@@ -0,0 +1,434 @@
//! Login, logout, account switching, and auth-code submission dispatchers.
use super::ctx::{restore_auth_return_view, show_welcome};
use super::queue::maybe_drain_queue;
use super::router::dispatch;
use super::session::lifecycle::{clear_startup_actions, drain_startup_actions};
use crate::app::actions::{Action, Effect};
use crate::app::agent::AgentId;
use crate::app::agent_view::AgentView;
use crate::app::app_view::{ActiveView, AppView, AuthMode, AuthState};
use crate::scrollback::block::RenderBlock;
use crate::scrollback::blocks::SessionEvent;
// ---------------------------------------------------------------------------
// Auth dispatch
// ---------------------------------------------------------------------------
/// `/logout` -- ask the shell to clear auth, then return to the login screen.
pub(super) fn dispatch_logout(_app: &mut AppView) -> Vec<Effect> {
vec![Effect::Logout]
}
/// Ensure `login_method_id` is populated from stored auth methods.
/// On the eager-auth path (cached token), login_method_id is never set
/// because the user skipped the login screen.
///
/// Does **not** invent `grok.com` when no interactive method is advertised
/// (e.g. `preferred_method=api_key` with no key — empty `auth_methods`).
/// Callers already surface "No login method available" when this leaves
/// `login_method_id` unset.
pub(super) fn ensure_login_method(app: &mut AppView) {
if app.login_method_id.is_some() {
return;
}
let (label, method_id, start_mode) =
crate::acp::find_interactive_login_method(&app.auth_methods);
if let Some(id) = method_id {
app.login_label = label;
app.login_method_id = Some(id);
app.auth_start_mode = match start_mode {
crate::acp::AuthStartMode::Pending => AuthMode::Pending,
crate::acp::AuthStartMode::Command => AuthMode::Command,
};
}
// No interactive method: leave login_method_id unset (fail-closed).
}
/// Error when no interactive login method is available (empty auth_methods,
/// e.g. `preferred_method=api_key` with no credentials). Prefer the shell's
/// pin-unavailable copy when the list is empty.
fn no_login_method_error(app: &AppView) -> String {
if app.auth_methods.is_empty() {
kigi_shell::agent::auth_method::PREFERRED_API_KEY_UNAVAILABLE.to_string()
} else {
"No login method available".to_string()
}
}
/// Log out, then start a new login flow in a single sequential task.
pub(super) fn dispatch_switch_account(app: &mut AppView) -> Vec<Effect> {
ensure_login_method(app);
let Some(method_id) = app.login_method_id.clone() else {
app.auth_state = AuthState::Pending {
error: Some(no_login_method_error(app)),
};
return vec![];
};
let request_seq = app.next_auth_request_seq;
app.next_auth_request_seq += 1;
app.auth_code_input.clear();
app.auth_state = AuthState::Authenticating {
request_seq,
handle: None,
auth_url: None,
mode: app.auth_start_mode,
};
vec![
Effect::SwitchAccount {
request_seq,
method_id,
use_oauth: app.auth_use_oauth,
},
Effect::PollAuthUrl { request_seq },
]
}
/// Scan the trailing run of session-event / system blocks for a
/// [`SessionEvent::ReAuthRequired`] prompt. Used by the `PromptResponse`
/// handler to suppress the redundant "Turn failed" block after a 401 — the
/// re-auth prompt is pushed by the `RetryState` handler, which runs first.
pub(super) fn scrollback_has_recent_reauth_prompt(
scrollback: &crate::scrollback::state::ScrollbackState,
) -> bool {
use crate::scrollback::block::RenderBlock;
for idx in (0..scrollback.len()).rev() {
match scrollback.entry(idx).map(|e| &e.block) {
Some(RenderBlock::SessionEvent(ev)) => {
if matches!(ev.event, SessionEvent::ReAuthRequired) {
return true;
}
}
// Tolerate interleaved system messages in the trailing run.
Some(RenderBlock::System(_)) => {}
// Stop at the first substantive block: any re-auth prompt for
// this turn lives in the trailing events pushed just before the
// PromptResponse arrived.
_ => break,
}
}
false
}
/// True if the trailing run of session/system blocks contains a terminal
/// context-overflow block ([`SessionEvent::ContextTooLarge`] or `CompactionFailed`).
/// Lets `PromptResponse` suppress the redundant `TurnFailed`, mirroring reauth.
pub(super) fn scrollback_has_recent_context_too_large(
scrollback: &crate::scrollback::state::ScrollbackState,
) -> bool {
use crate::scrollback::block::RenderBlock;
for idx in (0..scrollback.len()).rev() {
match scrollback.entry(idx).map(|e| &e.block) {
Some(RenderBlock::SessionEvent(ev)) => {
if matches!(
ev.event,
SessionEvent::ContextTooLarge | SessionEvent::CompactionFailed { .. }
) {
return true;
}
}
// Tolerate interleaved system messages in the trailing run.
Some(RenderBlock::System(_)) => {}
// Stop at the first substantive block.
_ => break,
}
}
false
}
/// Strip the trailing run of auth-error blocks — the `ReAuthRequired`
/// prompt plus any stale `RetryFailed` / `TurnFailed` — from an agent's
/// scrollback. Called after a successful mid-session re-auth so the prompt
/// disappears once the user returns to the session. Mirrors the
/// credit-limit upsell's stale-block strip.
pub(super) fn strip_trailing_auth_error_blocks(agent: &mut AgentView) {
use crate::scrollback::block::RenderBlock;
let mut to_remove = Vec::new();
for idx in (0..agent.scrollback.len()).rev() {
match agent.scrollback.entry(idx).map(|e| &e.block) {
Some(RenderBlock::SessionEvent(ev))
if matches!(
&ev.event,
SessionEvent::ReAuthRequired
| SessionEvent::RetryFailed { .. }
| SessionEvent::TurnFailed { .. }
) =>
{
to_remove.push(idx);
}
// Skip over other trailing session-event / system blocks.
Some(RenderBlock::SessionEvent(_) | RenderBlock::System(_)) => continue,
// Stop at the first substantive block.
_ => break,
}
}
for idx in to_remove {
agent.scrollback.remove_from(idx);
}
}
/// Start an interactive login flow. Triggered by pressing 'l' on the
/// welcome screen or by the `/login` slash command.
///
/// When invoked mid-session (the active view is an agent/dashboard rather
/// than the welcome screen), the auth UI — including the external auth
/// provider's sign-in URL and status — is only rendered by the welcome
/// view. We therefore stash the caller's view in `auth_return_view` and
/// switch to `Welcome` so the flow is actually visible; the prior view is
/// restored once auth completes or is cancelled. Without this, `/login`
/// with an external auth provider configured appeared to do nothing.
pub(super) fn dispatch_login(app: &mut AppView) -> Vec<Effect> {
ensure_login_method(app);
let Some(method_id) = app.login_method_id.clone() else {
app.auth_state = AuthState::Pending {
error: Some(no_login_method_error(app)),
};
return vec![];
};
// Surface the auth UI when triggered from inside a session. `show_welcome`
// resets ephemeral state here, covering the AuthComplete / cancel-login
// fallbacks too (`auth_return_view` is only ever set here).
if !matches!(app.active_view, ActiveView::Welcome) {
app.auth_return_view = Some(app.active_view);
show_welcome(app);
}
let request_seq = app.next_auth_request_seq;
app.next_auth_request_seq += 1;
app.auth_code_input.clear();
app.auth_state = AuthState::Authenticating {
request_seq,
handle: None,
auth_url: None,
mode: app.auth_start_mode,
};
vec![
Effect::Authenticate {
request_seq,
method_id,
use_oauth: app.auth_use_oauth,
force_interactive: true,
},
Effect::PollAuthUrl { request_seq },
]
}
/// Cancel a login that was started from inside a session and restore the
/// caller's view. Only meaningful when `auth_return_view` is set (a
/// mid-session `/login` or 401 re-auth prompt). Any in-flight auth task is
/// left to finish in the background — its `AuthComplete`/`AuthFailed`
/// result is ignored because we move `auth_state` out of `Authenticating`
/// here (the request-seq/state guard in those handlers drops stale results)
/// and bump the seq so a fresh login does not collide.
pub(super) fn dispatch_cancel_login(app: &mut AppView) -> Vec<Effect> {
let Some(return_view) = app.auth_return_view.take() else {
return vec![];
};
app.next_auth_request_seq += 1;
app.auth_state = AuthState::Done;
app.auth_show_raw_url = false;
app.auth_code_input.clear();
restore_auth_return_view(app, return_view);
// The user bailed out of re-auth — drop stashed prompts and strip the
// stale re-auth prompt from scrollback (on all agents: the login may
// have been started from the dashboard). Clearing the stash alone is
// not enough: a leftover `ReAuthRequired` block would let a later
// `PromptResponse` re-detect it via `scrollback_has_recent_reauth_prompt`
// and re-stash the prompt, so a subsequent unrelated login could
// silently resubmit it. Mirrors the strip in the `AuthComplete` path.
for agent in app.agents.values_mut() {
agent.reauth_stashed_prompt = None;
strip_trailing_auth_error_blocks(agent);
}
vec![]
}
/// User submitted a manually-pasted auth token in loopback mode.
pub(super) fn dispatch_submit_auth_code(app: &mut AppView, code: String) -> Vec<Effect> {
let request_seq = match &app.auth_state {
AuthState::Authenticating { request_seq, .. } => *request_seq,
_ => return vec![],
};
vec![Effect::SubmitAuthCode { request_seq, code }]
}
// TaskResult handlers.
pub(super) fn handle_auth_complete(
app: &mut AppView,
request_seq: u64,
meta: Option<serde_json::Value>,
) -> Vec<Effect> {
if let AuthState::Authenticating {
request_seq: current_seq,
..
} = &app.auth_state
&& *current_seq == request_seq
{
if let Some(meta_val) = meta.as_ref()
&& let Ok(auth_meta) =
serde_json::from_value::<kigi_shell::auth::AuthMeta>(meta_val.clone())
{
app.apply_auth_meta(&auth_meta);
}
app.auth_state = AuthState::Done;
app.auth_show_raw_url = false;
app.welcome_prompt_focused = !app.is_access_blocked();
app.auth_code_input.clear();
// Mid-session re-auth (`/login` or a 401 prompt): restore the
// view the user was on instead of running the startup
// load-session flow. The session state lives in `app.agents`,
// independent of `active_view`, so it is preserved across the
// auth detour.
if let Some(return_view) = app.auth_return_view.take() {
restore_auth_return_view(app, return_view);
// Mid-session re-auth returns to the existing session, NOT
// the startup flow, so discard any deferred startup stash
// (e.g. an incidental `Ctrl+N` pressed during /login that the
// chokepoint deferred) rather than leaving it to fire later.
clear_startup_actions(app);
// Re-auth succeeded — hide the now-stale re-auth prompt
// (and any trailing error blocks) so the user returns to
// a clean session. Mirrors the credit-limit upsell's
// stale-block strip.
// Auth is global, so handle every agent (the login may
// have been started from the dashboard, not the agent
// that 401'd).
let mut retry_effects = Vec::new();
for agent in app.agents.values_mut() {
strip_trailing_auth_error_blocks(agent);
// Auto-resubmit the prompt that failed on the expired
// login so the user doesn't have to retype it. The
// user couldn't have queued another prompt during the
// auth detour, so a plain front-enqueue + drain is safe.
if let Some(prompt) = agent.reauth_stashed_prompt.take() {
agent.scrollback.push_block(RenderBlock::system(
"Re-authenticated. Retrying\u{2026}".to_string(),
));
agent.session.enqueue_in_flight_prompt_front(prompt);
retry_effects.extend(maybe_drain_queue(agent));
}
}
let mut effects = dispatch(Action::RequestBundleStatus, app);
if app.usage_visible {
effects.push(Effect::FetchAppBilling);
}
effects.extend(retry_effects);
return effects;
}
// status only; shell auto-syncs post-auth
let mut effects = dispatch(Action::RequestBundleStatus, app);
// Start auto-checking subscription if gated.
// Check immediately (don't wait 5s) then schedule the timer.
if !app.has_access() {
app.paywall_check_started = Some(std::time::Instant::now());
effects.push(Effect::CheckSubscription { verify: None });
effects.push(Effect::SchedulePaywallCheck);
}
// Fetch billing so the welcome screen can show a credit warning.
if app.usage_visible {
effects.push(Effect::FetchAppBilling);
}
// Fetch changelog (mirrors startup path for interactive login).
effects.push(Effect::FetchChangelog);
// ZDR-blocked users stay on the welcome screen — discard any
// deferred startup (they cannot start a session).
if app.is_zdr_blocked() {
clear_startup_actions(app);
return effects;
}
// Replay deferred session startup once BOTH gates are open. Auth
// is now Done, so `session_startup_allowed()` here means "is trust
// also resolved?" -- if trust is still Pending its question renders
// next and its answer drains instead. Same predicate the trust
// handlers use, so the deferred startup runs exactly once after
// whichever gate resolves last.
if app.session_startup_allowed() {
effects.extend(drain_startup_actions(app));
}
return effects;
}
vec![]
}
pub(super) fn handle_auth_url_ready(
app: &mut AppView,
request_seq: u64,
auth_url: Option<String>,
external: bool,
mode: Option<String>,
) -> Vec<Effect> {
if let AuthState::Authenticating {
request_seq: current_seq,
auth_url: current_url,
mode: current_mode,
..
} = &mut app.auth_state
&& *current_seq == request_seq
{
*current_url = auth_url;
// Prefer `mode`; fall back to `external` for older agents. An
// old-agent device login lands on Loopback (harmless paste box;
// the background poll still completes).
*current_mode = match mode.as_deref() {
Some("device") => AuthMode::Device,
Some("command") => AuthMode::Command,
Some("loopback") => AuthMode::Loopback,
_ if external => AuthMode::Command,
_ => AuthMode::Loopback,
};
}
vec![]
}
pub(super) fn handle_mcp_auth_trigger_done(
app: &mut AppView,
agent_id: AgentId,
server_name: String,
result: Result<(), String>,
) -> Vec<Effect> {
let Some(agent) = app.agents.get_mut(&agent_id) else {
return vec![];
};
if let Some(ref mut modal) = agent.extensions_modal {
modal.pending_action = None;
modal.pending_entry_index = None;
if let Err(e) = result {
// String-match heuristic: directive vs name-embedded vs generic.
// Brittle if the shell ever quotes a name shape that doesn't
// match `server_name` here — replace with a structured
// discriminator on McpAuthTriggerResponse if that happens.
let msg = if e.starts_with("To authenticate") {
format!("{server_name}: {e}")
} else if e.contains(&server_name) {
format!("Auth failed: {e}")
} else {
format!("{server_name} auth failed: {e}")
};
modal.modal_message = Some(crate::views::extensions_modal::ModalMessage::Error(msg));
return vec![];
}
}
// No toast on success: the row transition from the FetchMcpsList
// refresh below is the confirmation.
let Some(session_id) = agent.session.session_id.clone() else {
return vec![];
};
vec![Effect::FetchMcpsList {
agent_id,
session_id,
cache: false,
}]
}
@@ -0,0 +1,543 @@
//! Subscription tier checks, credit-limit upsells, and auto-topup handling.
use super::queue::maybe_drain_queue;
use crate::app::actions::Effect;
use crate::app::agent::AgentId;
use crate::app::agent_view::AgentView;
use crate::app::app_view::AppView;
use crate::scrollback::block::RenderBlock;
use std::time::Duration;
/// How long the pager auto-checks subscription status before stopping.
/// After this, the user can still manually check via the [Refresh] button.
pub(super) const PAYWALL_AUTO_CHECK_TIMEOUT: Duration = Duration::from_secs(10 * 60);
/// Whether the user is at the highest subscription tier (SuperGrok Heavy).
///
/// Returns `true` only when `subscription_tier` **positively matches** a
/// known max-tier identifier. When the tier is unknown (`None`) or any
/// other value, returns `false` — the user gets the Q&A modal so lower-
/// tier users always see the upgrade option.
pub(super) fn is_max_tier(subscription_tier: Option<&str>) -> bool {
let Some(t) = subscription_tier else {
return false; // Unknown — default to Q&A.
};
// Normalize: lowercase + spaces→underscores to match both JWT-derived
// keys ("supergrok_heavy") and CCP display names ("SuperGrok Heavy").
t.to_ascii_lowercase().replace(' ', "_") == "supergrok_heavy"
}
/// URL for upgrading the subscription tier.
pub(crate) const UPSELL_URL_UPGRADE: &str = "https://grok.com/supergrok?referrer=grok-build";
/// URL for managing pay-as-you-go / on-demand spending / purchasing credits.
pub(crate) const UPSELL_URL_PAYG: &str = "https://grok.com?_s=usage";
/// Billing mode for credit-limit upsell copy.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum CreditLimitUpsellMode {
/// Unified usage pool — suggest purchasing prepaid credits.
UnifiedCredits,
/// Legacy on-demand / PAYG (`enabled` = on-demand cap already active).
LegacyPayg { enabled: bool },
}
/// Resolve upsell copy mode from credits config.
///
/// Prefers explicit `is_unified_billing_user` (`Option` — do not treat a
/// missing field as legacy). Positive `pay_as_you_go` (on-demand cap &gt; 0)
/// only selects legacy when the unified flag is absent. Unknown defaults to
/// unified (buy credits) so pool users never get “enable on-demand” wrongly.
pub(super) fn credit_limit_upsell_mode(
balance: Option<&crate::views::credit_bar::CreditBalance>,
) -> CreditLimitUpsellMode {
match balance {
Some(b) if b.is_unified_billing_user == Some(true) => CreditLimitUpsellMode::UnifiedCredits,
Some(b) if b.is_unified_billing_user == Some(false) => CreditLimitUpsellMode::LegacyPayg {
enabled: b.pay_as_you_go,
},
// Flag absent: only treat as legacy PAYG when we have a positive
// on-demand cap (pay_as_you_go is derived from cap &gt; 0).
Some(b) if b.pay_as_you_go => CreditLimitUpsellMode::LegacyPayg { enabled: true },
_ => CreditLimitUpsellMode::UnifiedCredits,
}
}
/// Whether an API / retry error is a credit-limit / spend-block denial.
///
/// - **402** Payment Required — always credit/spend block on this surface
/// (Build pool and IC spend blocks); no message filter.
/// - **403** — only when the body contains "run out of credits" (legacy IC
/// spend wording); other 403s (content-safety, ZDR, …) are excluded.
pub(crate) fn is_credit_limit_error(http_status: Option<u16>, message: &str) -> bool {
let m = message.to_ascii_lowercase();
let legacy = m.contains("run out of credits");
match http_status {
Some(402) => true,
Some(403) if legacy => true,
// Retry notifications embed "status 402" / "status 403" in the body
// without a separate status field.
None | Some(_) => m.contains("status 402") || (m.contains("status 403") && legacy),
}
}
/// Well-known error code CCP returns (HTTP 429, flat body
/// `{"code": "...", "error": "..."}`) when a free-tier user exhausts the
/// free usage quota. Kept in sync with the shared well-known error code
/// `SUBSCRIPTION_FREE_USAGE_EXHAUSTED`. sampling-types' `parse_error_bytes` prepends the flat
/// `code` to the flattened message, so the code reaches the pager embedded
/// in `RetryState::Exhausted.reason` and the -32003 error's data string.
pub(crate) const FREE_USAGE_EXHAUSTED_ERROR_CODE: &str = "subscription:free-usage-exhausted";
/// Whether a rate-limit error is the free-usage-quota exhaustion (paywall)
/// rather than transient throttling. Text-sniff on the flattened message,
/// same precedent as [`is_credit_limit_error`].
pub(crate) fn is_free_usage_exhausted_error(reason: &str) -> bool {
reason.contains(FREE_USAGE_EXHAUSTED_ERROR_CODE)
}
/// Whether a rate-limited (-32003) ACP error is the free-usage exhaustion.
/// `data` may be a bare string or the `{message, promptUsage?}` object
/// `attach_prompt_usage` produces — always read via the shared detail helper.
pub(crate) fn acp_error_is_free_usage_exhausted(err: &agent_client_protocol::Error) -> bool {
err.data
.as_ref()
.and_then(kigi_shell::sampling::error::error_detail_from_data)
.as_deref()
.is_some_and(is_free_usage_exhausted_error)
}
/// User-facing message for free-usage exhaustion. Shown by headless mode and
/// `format_acp_error` in place of auth-aware rate-limit copy. Deliberately
/// promises no reset duration — the quota window is backend-config-driven.
pub(crate) const FREE_USAGE_USER_MESSAGE: &str = "You\u{2019}ve reached your free Grok Build usage limit for now. Get SuperGrok for much higher limits, or try again later: https://grok.com/supergrok?referrer=grok-build";
/// Open the credit-limit upsell on the given agent.
///
/// **`max_tier = false`** (default): shows the Q&A question modal with
/// two options ("Upgrade tier" + buy-credits or PAYG). Each option's `id`
/// carries the target URL so the submit handler is position-independent.
///
/// **`max_tier = true`** (positively identified as SuperGrok Heavy):
/// pushes an inline scrollback card (`CreditLimitBlock`) with a single
/// continue action. No Q&A modal — the user can't upgrade further.
pub(super) fn open_credit_limit_upsell(
agent: &mut AgentView,
mode: CreditLimitUpsellMode,
max_tier: bool,
) {
use crate::scrollback::blocks::CreditLimitCardAction;
let (heading, upgrade_tier_desc, secondary_label, secondary_desc, card_action): (
&str,
&str,
&str,
&str,
CreditLimitCardAction,
) = match mode {
CreditLimitUpsellMode::UnifiedCredits => (
"You hit your weekly limit.",
"Upgrade to a higher tier for more usage",
"Buy more credits",
"Purchase credits to keep using Grok Build",
CreditLimitCardAction::PurchaseCredits,
),
CreditLimitUpsellMode::LegacyPayg { enabled: true } => (
"You\u{2019}ve hit your spending cap.",
"Upgrade to a higher tier for more credits",
"Increase limit",
"Raise your pay-as-you-go spending cap",
CreditLimitCardAction::IncreasePaygLimit,
),
CreditLimitUpsellMode::LegacyPayg { enabled: false } => (
"You\u{2019}ve hit the credit limit for your plan.",
"Upgrade to a higher tier for more credits",
"Pay as you go",
"Enable pay-as-you-go credits for on-demand usage",
CreditLimitCardAction::EnablePayg,
),
};
// ── Max tier: inline scrollback card ─────────────────────────
if max_tier {
use crate::scrollback::block::RenderBlock;
agent.scrollback.push_block(RenderBlock::credit_limit_card(
heading,
card_action,
UPSELL_URL_PAYG,
));
return;
}
// ── Default: Q&A question modal with two options ────────────────
use crate::views::question_view::{LocalQuestionKind, QuestionViewState};
use kigi_tools::implementations::grok_build::ask_user_question::{Question, QuestionOption};
if agent.question_view.is_some() {
return;
}
let question = Question {
question: heading.into(),
options: vec![
QuestionOption {
label: "Upgrade tier".into(),
description: upgrade_tier_desc.into(),
preview: None,
id: Some(UPSELL_URL_UPGRADE.into()),
},
QuestionOption {
label: secondary_label.into(),
description: secondary_desc.into(),
preview: None,
id: Some(UPSELL_URL_PAYG.into()),
},
],
multi_select: Some(false),
id: None,
};
let stashed = agent.prompt.stash();
let state = QuestionViewState::new(
format!("credit-limit-upsell-{}", uuid::Uuid::new_v4()),
vec![question],
stashed,
)
.with_local_kind(LocalQuestionKind::CreditLimitUpsell)
.with_no_freeform();
agent.question_view = Some(state);
agent.prompt.set_text("");
}
/// Open the free-usage paywall on the given agent: a Q&A modal in the
/// [`open_credit_limit_upsell`] style with two upgrade options. Each
/// option's `id` carries its target URL so the submit handler is
/// position-independent.
///
/// Driver-only by construction (called from the PromptResponse handler,
/// which viewers never receive).
pub(super) fn open_free_usage_upsell(agent: &mut AgentView) {
open_supergrok_upsell(agent, UpsellReason::FreeUsageLimit);
}
/// Open the SuperGrok upsell for a tier-restricted slash command
/// (`/usage`, `/imagine`, …). Returns whether the modal opened (`false`
/// when another question modal is already up) so the caller can decide
/// whether to consume the input that triggered it.
pub(super) fn open_restricted_command_upsell(agent: &mut AgentView) -> bool {
open_supergrok_upsell(agent, UpsellReason::RestrictedCommand)
}
/// Which situation opened the SuperGrok upsell modal. Controls the heading.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum UpsellReason {
/// Free-usage quota exhausted (429 paywall).
FreeUsageLimit,
/// A tier-restricted slash command was invoked.
RestrictedCommand,
}
/// Shared builder behind [`open_free_usage_upsell`] /
/// [`open_restricted_command_upsell`]: a Q&A modal in the
/// [`open_credit_limit_upsell`] style. Upgrade options carry their target
/// URL in the option `id` (position-independent submit handling).
fn open_supergrok_upsell(agent: &mut AgentView, reason: UpsellReason) -> bool {
use crate::views::question_view::{LocalQuestionKind, QuestionViewState};
use kigi_tools::implementations::grok_build::ask_user_question::{Question, QuestionOption};
// Never displace an already-open question modal. Callers that consume
// input on open must check this `false` and keep the input instead.
if agent.question_view.is_some() {
return false;
}
let (heading, modal_id_prefix) = match reason {
UpsellReason::FreeUsageLimit => ("You hit your free usage limit.", "free-usage-upsell"),
UpsellReason::RestrictedCommand => (
"Unlock all features with SuperGrok.",
"restricted-command-upsell",
),
};
let options = vec![
QuestionOption {
label: "Upgrade to SuperGrok".into(),
description: "For everyday coding and productivity tasks".into(),
preview: None,
id: Some(UPSELL_URL_UPGRADE.into()),
},
QuestionOption {
label: "Upgrade to SuperGrok Heavy".into(),
description: "Get the most out of Grok Build. Highest usage limits.".into(),
preview: None,
// No Heavy-specific URL exists; the /supergrok page lists
// both plans, so both upgrade options land there.
id: Some(UPSELL_URL_UPGRADE.into()),
},
];
let question = Question {
question: heading.into(),
options,
multi_select: Some(false),
id: None,
};
let stashed = agent.prompt.stash();
let state = QuestionViewState::new(
format!("{modal_id_prefix}-{}", uuid::Uuid::new_v4()),
vec![question],
stashed,
)
.with_local_kind(LocalQuestionKind::FreeUsageUpsell)
.with_no_freeform();
agent.question_view = Some(state);
agent.prompt.set_text("");
true
}
/// Apply an [`AutoTopupFetch`] outcome to a cached `auto_topup` slot: `Resolved`
/// sets it, `Cleared` resets it to "unknown" (no credits), and `Unchanged` keeps
/// the last-known-good value (the fetch failed).
pub(super) fn apply_auto_topup(
slot: &mut Option<crate::views::credit_bar::AutoTopupInfo>,
fetch: &crate::views::credit_bar::AutoTopupFetch,
) {
use crate::views::credit_bar::AutoTopupFetch;
match fetch {
AutoTopupFetch::Resolved(rule) => *slot = Some(rule.clone()),
AutoTopupFetch::Cleared => *slot = None,
AutoTopupFetch::Unchanged => {}
}
}
// TaskResult handlers.
pub(super) fn handle_billing_fetched(
app: &mut AppView,
agent_id: AgentId,
balance: Option<crate::views::credit_bar::CreditBalance>,
silent: bool,
subscription_tier: Option<String>,
autotopup: crate::views::credit_bar::AutoTopupFetch,
) -> Vec<Effect> {
// Parse/transport failures route to `BillingError`, so a `None`
// balance here means the response carried no billing config. Clear
// the cached balance + polling so the status bar agrees with the
// "No billing data available." message rather than showing a stale
// value.
app.credit_balance = balance.clone();
// `Resolved` updates the cached rule, `Cleared` resets it to unknown
// (no credits), `Unchanged` keeps the last-known-good (fetch failed).
apply_auto_topup(&mut app.auto_topup, &autotopup);
app.billing_poll_wanted = balance
.as_ref()
.map(|b| b.usage_pct >= 99.0)
.unwrap_or(false);
if let Some(tier) = subscription_tier {
app.subscription_tier = Some(tier);
}
// Render the `/usage` summary from the now-current cached rule.
let summary_topup = app.auto_topup.clone();
if let Some(agent) = app.agents.get_mut(&agent_id) {
// Gateway/chat-kind: do not attach Build coding credits.
let mut topup = agent.auto_topup.clone();
apply_auto_topup(&mut topup, &autotopup);
agent.apply_credit_balance(balance.clone(), topup);
if !silent && !agent.chat_kind {
let msg = match &balance {
Some(bal) => {
crate::views::credit_bar::format_usage_summary(bal, summary_topup.as_ref())
}
None => "No billing data available.".to_string(),
};
agent.scrollback.push_block(RenderBlock::System(
crate::scrollback::blocks::SystemMessageBlock::new(msg),
));
}
}
vec![]
}
pub(super) fn handle_gate_refreshed(
app: &mut AppView,
settings: Option<kigi_shell::util::config::RemoteSettings>,
) -> Vec<Effect> {
let Some(rs) = settings else {
return vec![];
};
app.usage_billing_redirect_url = rs.usage_billing_redirect_url.clone();
if let Some(secs) = rs.subscription_watch_interval_secs {
app.subscription_watch_interval_secs = Some(secs);
}
match AppView::gate_from_settings(&rs) {
Some(gate) => app.impose_gate(gate),
None => app.lift_gate(),
}
}
/// `x.ai/auth/check_subscription` completed. Meta is authoritative
/// (`apply_auth_meta` also drops any deferred gate). A failed check only
/// promotes the deferred gate it was verifying (`verify` generation);
/// generic watch/focus/paywall-chain failures never touch it.
pub(super) fn handle_check_subscription_complete(
app: &mut AppView,
verify: Option<u64>,
meta: Option<serde_json::Value>,
) -> Vec<Effect> {
let was_blocked = !app.has_access();
let applied = match meta {
Some(meta_val) => {
match serde_json::from_value::<kigi_shell::auth::AuthMeta>(meta_val) {
Ok(auth_meta) => {
app.apply_auth_meta(&auth_meta);
true
}
Err(e) => {
// Shell sent meta we can't decode — a protocol bug, not
// a transient failure. The check result is lost, so a
// verify deferral falls through to promotion below.
crate::unified_log::error(
"subscription.check.meta_parse_failed",
None,
Some(serde_json::json!({
"verify": verify,
"error": e.to_string(),
})),
);
false
}
}
}
// meta: None = shell reports "not authenticated" or the check RPC
// failed (already logged as subscription.check.rpc_failed).
None => false,
};
if !applied && let Some(generation) = verify {
app.promote_deferred_gate(generation, "check_failed");
}
crate::unified_log::info(
"subscription.check.complete",
None,
Some(serde_json::json!({
"verify": verify,
"meta_applied": applied,
"was_blocked": was_blocked,
"gated": !app.has_access(),
"tier": app.subscription_tier,
})),
);
maybe_start_paywall_chain(app, was_blocked)
}
/// Safety net for a hung verification check: show the still-pending
/// deferred gate (err on blocking).
pub(super) fn handle_gate_verify_timeout(app: &mut AppView, generation: u64) -> Vec<Effect> {
let was_blocked = !app.has_access();
app.promote_deferred_gate(generation, "verify_timeout");
maybe_start_paywall_chain(app, was_blocked)
}
/// Arm the 5s paywall auto-check chain on an ungated→gated transition, so a
/// paywall shown by verify-before-paywall self-lifts exactly like the
/// login-path one. Guarded so steady-state paywall-poller responses and
/// repeated checks can't fan out extra timers.
fn maybe_start_paywall_chain(app: &mut AppView, was_blocked: bool) -> Vec<Effect> {
if !was_blocked && !app.has_access() && app.paywall_check_started.is_none() {
app.paywall_check_started = Some(std::time::Instant::now());
return vec![Effect::SchedulePaywallCheck];
}
vec![]
}
pub(super) fn handle_credit_limit_recheck_complete(
app: &mut AppView,
agent_id: AgentId,
meta: Option<serde_json::Value>,
) -> Vec<Effect> {
let old_tier = app.subscription_tier.clone();
if let Some(meta_val) = meta
&& let Ok(auth_meta) = serde_json::from_value::<kigi_shell::auth::AuthMeta>(meta_val)
{
app.apply_auth_meta(&auth_meta);
}
let tier_changed = app.subscription_tier != old_tier && app.subscription_tier.is_some();
let Some(agent) = app.agents.get_mut(&agent_id) else {
return vec![];
};
// If the user already submitted another prompt while the
// recheck was in flight, don't retry the stashed one — they've
// moved on. The tier update (above) still takes effect.
let user_moved_on = !agent.session.state.is_idle() || !agent.session.pending_prompts.is_empty();
if tier_changed && !user_moved_on {
if let Some(prompt) = agent.credit_limit_stashed_prompt.take() {
let tier_name = app.subscription_tier.as_deref().unwrap_or("a higher tier");
agent.scrollback.push_block(RenderBlock::system(format!(
"Subscription upgraded to {tier_name}. Retrying\u{2026}"
)));
agent.session.enqueue_in_flight_prompt_front(prompt);
}
} else if !user_moved_on {
let balance = agent
.credit_balance
.as_ref()
.or(app.credit_balance.as_ref());
let mode = credit_limit_upsell_mode(balance);
let max_tier = is_max_tier(app.subscription_tier.as_deref());
open_credit_limit_upsell(agent, mode, max_tier);
}
// Either way, drop the stashed prompt.
agent.credit_limit_stashed_prompt = None;
let mut effects = maybe_drain_queue(agent);
effects.push(Effect::FetchBilling {
agent_id,
silent: true,
});
effects
}
// Action handlers.
pub(super) fn dispatch_open_supergrok_url(app: &mut AppView) -> Vec<Effect> {
let url = app
.gate
.as_ref()
.and_then(|g| g.url.as_deref())
.unwrap_or("https://grok.com/supergrok?referrer=grok-build");
// Funnel attribution: tag CLI-originated SuperGrok upsell clicks
// with `referrer=grok-build`, matching the OAuth consent flow and
// x.ai/cli marketing links. Applied even when the URL came from
// remote settings's `gate_url`, so we don't depend on the remote flag
// being correctly configured. If the URL already specifies a
// referrer it's left alone.
let url = crate::app::link_opener::ensure_query_param(url, "referrer", "grok-build");
crate::app::link_opener::open_url(&url);
vec![]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn free_usage_dual_read_string_and_wrapped_object_data() {
let free = "subscription:free-usage-exhausted quota hit";
let string_err = agent_client_protocol::Error::new(-32003, "Rate limited").data(free);
assert!(acp_error_is_free_usage_exhausted(&string_err));
// attach_prompt_usage wraps string data as {"message": ..., "promptUsage": ...}.
let wrapped =
agent_client_protocol::Error::new(-32003, "Rate limited").data(serde_json::json!({
"message": free,
"promptUsage": { "inputTokens": 1, "outputTokens": 0, "numTurns": 1 }
}));
assert!(acp_error_is_free_usage_exhausted(&wrapped));
assert!(!wrapped.data.as_ref().unwrap().is_string());
let other = agent_client_protocol::Error::new(-32003, "Rate limited").data("throttled");
assert!(!acp_error_is_free_usage_exhausted(&other));
}
}
@@ -0,0 +1,256 @@
//! Active-agent lookup and view-context helpers shared across dispatch modules.
use crate::app::agent::AgentId;
use crate::app::agent_view::AgentView;
use crate::app::app_view::{ActiveView, AppView};
use crate::scrollback::state::ScrollbackState;
use agent_client_protocol as acp;
/// The active agent's root session id, if any. Used to scope server-queue
/// edit Effects to the foregrounded session.
pub(super) fn active_agent_session_id(app: &AppView) -> Option<acp::SessionId> {
let ActiveView::Agent(id) = app.active_view else {
return None;
};
app.agents.get(&id)?.session.session_id.clone()
}
/// Apply a closure to the active agent (if any).
///
/// When a subagent view is active, resolves to the **child** view so
/// actions like SelectNext, GotoBottom, etc. target the visible view.
pub(super) fn with_active_agent(app: &mut AppView, f: impl FnOnce(&mut AgentView)) {
if let ActiveView::Agent(id) = app.active_view
&& let Some(agent) = app.agents.get_mut(&id)
{
if let Some(child_sid) = agent.active_subagent.clone()
&& let Some(child) = agent.subagent_views.get_mut(&child_sid)
{
f(child);
return;
}
f(agent);
}
}
/// Get a shared reference to the active agent view (if any).
pub(super) fn get_active_agent(app: &AppView) -> Option<&AgentView> {
if let ActiveView::Agent(id) = app.active_view
&& let Some(agent) = app.agents.get(&id)
{
if let Some(ref child_sid) = agent.active_subagent
&& let Some(child) = agent.subagent_views.get(child_sid)
{
return Some(child);
}
return Some(agent);
}
None
}
/// Get a mutable reference to the active agent view (if any).
pub(super) fn get_active_agent_mut(app: &mut AppView) -> Option<&mut AgentView> {
if let ActiveView::Agent(id) = app.active_view
&& let Some(agent) = app.agents.get_mut(&id)
{
if let Some(child_sid) = agent.active_subagent.clone()
&& agent.subagent_views.contains_key(&child_sid)
{
return agent.subagent_views.get_mut(&child_sid).map(|b| &mut **b);
}
return Some(agent);
}
None
}
/// Apply a closure to the active agent's scrollback (if any).
///
/// Resolves through `active_subagent` — see [`with_active_agent`].
pub(super) fn with_scrollback(app: &mut AppView, f: impl FnOnce(&mut ScrollbackState)) {
with_active_agent(app, |agent| f(&mut agent.scrollback));
}
/// Navigate the scrollback and clear any persistent text selection.
///
/// Used by navigation actions (j/k/g/G/PageUp/PageDown/Ctrl-D/Ctrl-U) where
/// scrolling away from the selected region should dismiss the highlight.
pub(super) fn navigate_clearing_selection(app: &mut AppView, f: impl FnOnce(&mut ScrollbackState)) {
with_active_agent(app, |agent| {
agent.persistent_text_selection = None;
agent.table_selection_geometry = None;
agent.selection_created_at = None;
agent.highlighted_link_idx = None;
f(&mut agent.scrollback);
});
}
/// Synchronize the sleep inhibitor with the aggregate agent state.
///
/// Inhibits idle sleep when any agent is busy; releases when all are idle.
/// Called after every `AgentState` transition in dispatch.
pub(super) fn sync_sleep_inhibitor(app: &AppView) {
let any_busy = app.agents.values().any(|a| !a.session.state.is_idle());
if any_busy {
app.notification_service.sleep_inhibitor.inhibit();
} else {
app.notification_service.sleep_inhibitor.release();
}
}
pub(super) fn reseed_tip_for_new_session(app: &mut AppView) {
if !matches!(app.active_view, ActiveView::Agent(_)) || app.tips.is_empty() {
return;
}
let kigi_home = kigi_tools::util::kigi_home::kigi_home();
app.tip = kigi_shell::util::tips::pick_and_advance(&app.tips, &kigi_home);
}
/// Switch to the welcome screen. Use for every return-to-welcome transition.
pub(super) fn show_welcome(app: &mut AppView) {
app.active_view = ActiveView::Welcome;
}
/// Restore the view a mid-session auth flow launched from, falling back to the
/// welcome screen (via `show_welcome`) when the original agent is gone. Shared by
/// cancel-login and AuthComplete so they can't diverge.
pub(super) fn restore_auth_return_view(app: &mut AppView, return_view: ActiveView) {
match return_view {
ActiveView::Agent(id) if app.agents.contains_key(&id) => {
app.active_view = ActiveView::Agent(id)
}
ActiveView::AgentDashboard => {
app.active_view = ActiveView::AgentDashboard;
}
_ => show_welcome(app),
}
}
/// Why a switch from one [`ActiveView::Agent`] to another is happening.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SwitchCause {
/// Triggered by `/fork` (post-resolution) creating + switching to a
/// child.
Fork,
/// Triggered by `/new` (fresh agent).
New,
/// Triggered by `/resume` (resuming a prior session) and the
/// welcome-screen session picker.
Load,
/// Triggered by the agent picker (dashboard attach / switch).
Picker,
// `SwitchCause::Dashboard` was added
// for the dashboard attach path but the earlier popup overlay
// never reaches `switch_to_agent`, so the variant was dead. YAGNI —
// any future caller can re-add it. The dashboard's attach path
// sets `DashboardState::attached_agent` directly.
}
/// Surface a launch-blocked `--yolo` once on the first agent view (the TUI owns
/// the terminal, so stderr is gone); idempotent via `.take()`. Dashboard flows
/// that bypass [`switch_to_agent`] call it directly.
pub(super) fn surface_yolo_launch_block_notice(app: &mut AppView, target: AgentId) {
if let Some(warning) = app.yolo_launch_block_notice.take()
&& let Some(agent) = app.agents.get_mut(&target)
{
agent
.scrollback
.push_block(crate::scrollback::block::RenderBlock::system(
warning.to_string(),
));
agent.show_toast(warning);
}
surface_screen_mode_switch_hint(app, target);
}
/// Surface a one-shot switch-back toast after a screen-mode relaunch (fullscreen only).
pub(super) fn surface_screen_mode_switch_hint(app: &mut AppView, target: AgentId) {
if let Some(hint) = app.screen_mode_switch_hint.take()
&& !app.screen_mode.is_minimal()
&& let Some(agent) = app.agents.get_mut(&target)
{
agent.show_toast(hint);
}
}
/// Switch the active agent — the primary funnel for assigning `ActiveView::Agent`
/// (new, resume, picker, fork); also fires [`surface_yolo_launch_block_notice`].
/// No-op if `target` is unknown or already active. Dashboard-first flows that
/// assign `Agent` directly must call the notice themselves.
pub(crate) fn switch_to_agent(app: &mut AppView, target: AgentId, _cause: SwitchCause) {
// Structural backstop for the auth + folder-trust session gate. This is the
// single funnel every FRESH-agent creator routes through (New/Load/Fork —
// `Picker` switches to an already-created, post-gate agent), so asserting the
// gate here makes "no session is created while `TrustState::Pending`" a
// property of the flow rather than of each call site: any future creator
// that forgets the deferring chokepoint gate trips this in debug/tests. The
// deferring chokepoints (`dispatch_new_session`/`_worktree_session`/
// `_load_session_inner`) stash+return BEFORE reaching here, so this never
// fires on the reachable gated paths. (`dispatch_project_selected` re-creates
// an already-active, post-gate agent without switching, so it is exempt.)
// `_cause` stays underscored so it isn't flagged unused once `debug_assert!`
// compiles out in release.
debug_assert!(
matches!(_cause, SwitchCause::Picker) || app.session_startup_allowed(),
"session creation via {_cause:?} requires the startup gate open (auth + folder trust)"
);
if !app.agents.contains_key(&target) {
return;
}
if matches!(app.active_view, ActiveView::Agent(current) if current == target) {
return;
}
app.active_view = ActiveView::Agent(target);
// Re-anchor the global permission-mode mirror to the now-active agent so the
// cycle's `sync_active_auto_flag` (which derives from the global) can't copy a
// different agent's stale Auto/Always-Approve onto this one. Per-session
// yolo/auto are the source of truth; the global is a write-only mirror.
if let Some(agent) = app.agents.get(&target) {
let (is_yolo, is_auto) = (agent.session.is_yolo(), agent.session.is_auto());
let reanchor = if is_yolo {
Some("always-approve")
} else if is_auto && app.auto_mode_gate {
// Gate-aware: never re-anchor the global mirror to "auto" when the
// feature gate is off, even if a stale per-session `auto_mode`
// survived (defense-in-depth with the settings kill-switch fan-out).
Some("auto")
} else if matches!(
app.current_ui.permission_mode.as_deref(),
Some("always-approve") | Some("auto")
) {
// Non-yolo/non-auto agent: clear a stale yolo/auto mirror left by a
// different agent; preserve an existing ask/default distinction.
Some("ask")
} else {
None
};
if let Some(c) = reanchor {
app.current_ui.permission_mode = Some(c.to_string());
}
}
// Seed the auto feature gate on the (possibly new) active agent's slash
// registry.
app.sync_permission_mode_slash_gate();
surface_yolo_launch_block_notice(app, target);
}
pub(super) fn find_agent_id_by_session_id(
agents: &indexmap::IndexMap<AgentId, AgentView>,
session_id: &str,
) -> Option<AgentId> {
agents.iter().find_map(|(id, a)| {
a.session
.session_id
.as_ref()
.is_some_and(|sid| &*sid.0 == session_id)
.then_some(*id)
})
}
/// Root session match (for async kill-result routing off the active view).
pub(super) fn find_agent_by_session_id<'a>(
agents: &'a mut indexmap::IndexMap<AgentId, AgentView>,
session_id: &str,
) -> Option<&'a mut AgentView> {
let id = find_agent_id_by_session_id(agents, session_id)?;
agents.get_mut(&id)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,124 @@
//! Claude session import dispatchers.
use crate::app::actions::Effect;
use crate::app::app_view::AppView;
/// Open the interactive Claude-import modal on the welcome screen.
///
/// Scans for importable items. If empty, shows a brief startup warning and
/// marks dismissed. Otherwise stores modal state on AppView so welcome
/// rendering shows the modal.
pub(super) fn dispatch_import_claude(app: &mut AppView) -> Vec<Effect> {
let cwd = app.cwd.clone();
let plan = kigi_shell::claude_import::scan_importable_settings(&cwd);
if plan.is_empty() {
kigi_shell::claude_import_state::mark_dismissed(&cwd);
// Always write the [claude_compat] imported = true marker so the user's
// opt-in is recorded even on an empty plan.
if let Err(e) = kigi_shell::claude_import::mark_claude_imported() {
tracing::warn!(error = %e, "Failed to write Claude import marker");
}
app.has_claude_import = false;
app.startup_warnings
.retain(|w| !w.message.contains("Claude settings"));
app.startup_warnings.push(crate::startup::StartupWarning {
severity: crate::startup::WarningSeverity::Info,
message: "No Claude settings found to import.".into(),
action: None,
});
return vec![];
}
app.import_claude_modal =
Some(crate::views::import_claude_modal::ImportClaudeModalState::new(plan, cwd));
vec![]
}
/// Apply the user's selection from the import modal and close it.
pub(super) fn dispatch_import_claude_confirm(app: &mut AppView) -> Vec<Effect> {
let Some(modal) = app.import_claude_modal.take() else {
return vec![];
};
let cwd = modal.cwd.clone();
let total_in_modal = modal.total_count();
let filtered = modal.filtered_plan();
let selected_count = filtered.global_items.len() + filtered.project_items.len();
let mut summary = if selected_count == 0 {
"No items selected.".to_string()
} else {
filtered.summary(&cwd).trim_end().to_string()
};
if selected_count > 0 {
match kigi_shell::claude_import::apply_import(&filtered, &cwd) {
Ok(result) => {
summary.push_str(&format!(
"\nImported {} of {} setting(s).",
result.total(),
total_in_modal
));
for path in &result.modified_files {
summary.push_str(&format!("\n Updated: {}", path));
}
}
Err(e) => {
app.startup_warnings.push(crate::startup::StartupWarning {
severity: crate::startup::WarningSeverity::Warning,
message: format!("Failed to import Claude settings: {}", e),
action: None,
});
return vec![];
}
}
}
// Mark current Claude state as seen so the startup warning won't re-fire
// for the same content. Skipped items remain importable via re-running
// the slash command.
kigi_shell::claude_import_state::mark_imported(&cwd);
if let Err(e) = kigi_shell::claude_import::mark_claude_imported() {
tracing::warn!(error = %e, "Failed to write Claude import marker");
}
app.has_claude_import = false;
app.startup_warnings
.retain(|w| !w.message.contains("Claude settings"));
app.startup_warnings.push(crate::startup::StartupWarning {
severity: crate::startup::WarningSeverity::Info,
message: summary,
action: None,
});
vec![]
}
/// Cancel the import modal without applying anything.
pub(super) fn dispatch_import_claude_cancel(app: &mut AppView) -> Vec<Effect> {
app.import_claude_modal = None;
vec![]
}
/// Hide the Claude-import menu row by recording the current `.claude/`
/// content hash. The startup detection compares the saved hash on next
/// launch — if it matches (no new Claude content), the menu stays hidden.
pub(super) fn dispatch_dismiss_claude_import(app: &mut AppView) -> Vec<Effect> {
let cwd = app.cwd.clone();
// Record the current `.claude/` content hash so the welcome menu row
// doesn't reappear next session unless the content actually changes.
kigi_shell::claude_import_state::mark_dismissed(&cwd);
// Also set the [claude_compat] imported = true marker so runtime
// fallback paths (perms, env, MCP servers, hooks, plugins) stop
// reading .claude/ and ~/.claude.json. Dismiss = "I've decided I want
// nothing from .claude/", so don't keep silently reading it at runtime.
if let Err(e) = kigi_shell::claude_import::mark_claude_imported() {
tracing::warn!(error = %e, "Failed to write Claude import marker on dismiss");
}
app.has_claude_import = false;
// Reset the welcome menu selection: removing a row shifts indices, so a
// stale selection (e.g. user had `Worktree mode` highlighted at index 1)
// would now point to a different row.
app.welcome_menu_index = None;
app.startup_warnings
.retain(|w| !w.message.contains("Claude settings"));
vec![]
}
@@ -0,0 +1,368 @@
//! Mid-turn interjection dispatch: optimistic local echo, the
//! `x.ai/interject` effect, and prompt-history recording. Split out of
//! `dispatch.rs` verbatim (pure code motion).
use crate::app::actions::Effect;
use crate::app::agent_view::AgentView;
use crate::app::app_view::{ActiveView, AppView};
use crate::scrollback::block::RenderBlock;
/// Send a mid-turn interjection. Pushes a standard user prompt block locally
/// for instant feedback, records the text in prompt history, clears the
/// prompt, and fires the `x.ai/interject` ext method carrying a client-minted
/// id.
///
/// The shell broadcasts `x.ai/session/interjection` to every attached pane so
/// other clients viewing the same session render it too (multi-client /
/// dashboard mode). Our own broadcast echoes back carrying the same id; the id
/// is recorded in `self_interjection_ids` so `handle_interjection` drops the
/// echo instead of rendering a duplicate. Other panes lack the id and render
/// it. (Optimistic-echo + reconcile-by-id, mirroring the shared prompt queue.)
pub(super) fn dispatch_interject(
app: &mut AppView,
text: String,
images: Vec<crate::prompt_images::PastedImage>,
) -> Vec<Effect> {
let ActiveView::Agent(id) = app.active_view else {
return vec![];
};
let Some(agent) = app.agents.get_mut(&id) else {
return vec![];
};
// Submitting an interjection retires any edit-contextual ephemeral tip —
// even when there is no active session, matching the prompt/bash/
// feedback/remember paths.
agent.ephemeral_tip.clear_on_submit();
let Some(session_id) = agent.session.session_id.clone() else {
agent.show_toast("No active session");
return vec![];
};
record_interject_prompt_history(agent, &text);
// Push a standard user prompt block locally for instant feedback, and
// record its id so the broadcast echo (`x.ai/session/interjection`) is
// deduped instead of rendering a second copy on this pane.
let interjection_id = uuid::Uuid::new_v4().to_string();
agent.self_interjection_ids.insert(interjection_id.clone());
agent
.scrollback
.push_block(RenderBlock::interjection_prompt(&text));
// Interjecting into a parked wait continues the turn below this block —
// the withheld "Worked for …" marker must not fire late beneath it.
agent.suppress_parked_marker_on_interject();
// The composer is NOT touched here: the producer that consumed composer
// text (the InterjectPrompt registry arm) clears it at the call site;
// every other producer (Send now, edit-interject, plan review comments)
// carries non-composer text and must keep the user's draft/stash.
agent.show_toast("Interjection sent");
// Image-bearing interjection: build text + image content blocks via the
// same helper as the queued-prompt drain path (orphan-placeholder
// recovery, allowlist, size cap). Text-only stays on the legacy wire.
let blocks = if images.is_empty() {
None
} else {
Some(crate::prompt_images::build_content_blocks_with_workspace(
text.clone(),
images,
Some(std::path::Path::new(&agent.session.cwd)),
))
};
vec![Effect::SendInterject {
agent_id: id,
session_id,
text,
interjection_id,
blocks,
}]
}
/// Cancel-and-send: send `text` (+ images) as a fresh `sendNow` prompt so the
/// shell cancels the running turn and runs it next. The user block paints at
/// dispatch (the arm hides the queue echo; the adoption reuses the block).
pub(super) fn dispatch_send_prompt_now(
app: &mut AppView,
text: String,
images: Vec<crate::prompt_images::PastedImage>,
) -> Vec<Effect> {
let ActiveView::Agent(id) = app.active_view else {
return vec![];
};
let reconnect_pending = app.reconnect_pending;
let Some(agent) = app.agents.get_mut(&id) else {
return vec![];
};
// Mid-outage guard (mirrors the plain prompt path): the producers already
// consumed the payload (composer text / queue row), so requeue it locally
// instead of firing into a dead channel and losing the message.
if reconnect_pending {
let queue_id = agent.session.next_queue_id;
agent.session.next_queue_id += 1;
agent
.session
.pending_prompts
.push_front(crate::app::agent::QueuedPrompt {
images,
..crate::app::agent::QueuedPrompt::plain(
queue_id,
&text,
crate::app::agent::QueueEntryKind::Prompt,
)
});
agent.show_toast("Reconnecting, please wait...");
return vec![];
}
// Submitting retires any edit-contextual ephemeral tip.
agent.ephemeral_tip.clear_on_submit();
let Some(session_id) = agent.session.session_id.clone() else {
agent.show_toast("No active session");
return vec![];
};
record_interject_prompt_history(agent, &text);
let prompt_id = uuid::Uuid::new_v4().to_string();
// Self-originated: the ACP gate must treat this prompt's deltas as ours.
agent.note_self_originated_prompt(&prompt_id);
// Expect the shell's send-now cancel so the turn-end rails suppress its
// marker — only when the shell will actually cancel (goal turns promote
// without cancelling; a stale arm would mute a later real cancel marker).
if agent.expects_send_now_cancel() {
agent.arm_send_now_expectation(prompt_id.clone());
// The arm hides the queue echo pushed below — paint the block now.
super::queue::push_send_now_user_block(agent, &prompt_id, "prompt", &text, false);
}
agent.suppress_parked_marker_on_interject();
let blocks = crate::prompt_images::build_content_blocks_with_workspace(
text.clone(),
images,
Some(std::path::Path::new(&agent.session.cwd)),
);
// Optimistic queue-pane echo, reconciled by the shell's queue broadcast.
let sid_str = session_id.0.to_string();
super::queue::push_server_queue_echo(app, id, &sid_str, &prompt_id, &text, "prompt");
crate::unified_log::info(
"prompt.send_now",
Some(&sid_str),
Some(serde_json::json!({ "len": text.len(), "prompt_id": prompt_id })),
);
vec![Effect::SendPromptNow {
agent_id: id,
session_id,
blocks,
prompt_id,
}]
}
/// Record an interjection in prompt history (Ctrl+R finds interjections).
/// Shared by `dispatch_interject` and the edited-queued-interject arm — the
/// user typed both, so both must be recallable.
pub(super) fn record_interject_prompt_history(agent: &mut AgentView, text: &str) {
let trimmed_key = text.trim().to_string();
if trimmed_key.is_empty() {
return;
}
agent
.session
.prompt_history
.retain(|p| p.trim() != trimmed_key);
agent.session.prompt_history.insert(0, text.to_string());
if agent.session.prompt_history.len() > 200 {
agent.session.prompt_history.truncate(200);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::actions::Action;
use crate::app::agent::AgentId;
use crate::app::dispatch::router::dispatch;
use crate::app::dispatch::tests::test_app_with_agent;
use agent_client_protocol as acp;
/// Composer-clear ownership: dispatch NEVER touches the composer. The
/// only composer-text producer (the InterjectPrompt registry arm) clears
/// it at the call site; every other producer (Send now, edit-interject,
/// plan review comments) carries non-composer text whose draft/stash
/// must survive dispatch — even when it happens to equal the interjected
/// text (provenance is not inferred by value equality).
#[test]
fn interject_dispatch_never_touches_the_composer() {
let mut app = test_app_with_agent();
let id = AgentId(0);
// Unrelated draft survives a plain interject.
app.agents
.get_mut(&id)
.unwrap()
.prompt
.set_text("stashed draft");
let effects = dispatch(
Action::Interject {
text: "edited body".into(),
images: vec![],
},
&mut app,
);
assert!(matches!(effects.as_slice(), [Effect::SendInterject { .. }]));
assert_eq!(app.agents.get(&id).unwrap().prompt.text(), "stashed draft");
// Edited-queued interject: fire-and-forget, composer untouched.
let effects = dispatch(
Action::QueueInterjectShared {
id: "p1".into(),
expected_version: 1,
new_text: Some("edited body".into()),
},
&mut app,
);
assert!(matches!(
effects.as_slice(),
[Effect::QueueInterject { .. }]
));
assert_eq!(app.agents.get(&id).unwrap().prompt.text(), "stashed draft");
// Even a composer that equals the interjected text is preserved —
// the InterjectPrompt arm already cleared it for the composer path.
app.agents.get_mut(&id).unwrap().prompt.set_text("send me");
let _ = dispatch(
Action::Interject {
text: "send me".into(),
images: vec![],
},
&mut app,
);
assert_eq!(app.agents.get(&id).unwrap().prompt.text(), "send me");
}
/// Interjecting is a submit: it retires the active ephemeral tip.
#[test]
fn interject_clears_active_ephemeral_tip() {
let mut app = test_app_with_agent();
let id = AgentId(0);
let agent = app.agents.get_mut(&id).unwrap();
let _ = agent.ephemeral_tip.show(
crate::tips::EphemeralTip::new("t", ratatui::text::Line::from("hint")),
&mut std::collections::HashMap::new(),
);
assert!(agent.ephemeral_tip.is_active());
let _ = dispatch(
Action::Interject {
text: "mid-turn note".into(),
images: vec![],
},
&mut app,
);
assert!(
!app.agents.get(&id).unwrap().ephemeral_tip.is_active(),
"interject submit must clear the tip"
);
}
/// A no-session interject still retires the tip: the clear now runs before
/// the "No active session" early return, matching the other submit paths.
#[test]
fn interject_without_session_still_clears_ephemeral_tip() {
let mut app = test_app_with_agent();
let id = AgentId(0);
let agent = app.agents.get_mut(&id).unwrap();
agent.session.session_id = None;
let _ = agent.ephemeral_tip.show(
crate::tips::EphemeralTip::new("t", ratatui::text::Line::from("hint")),
&mut std::collections::HashMap::new(),
);
assert!(agent.ephemeral_tip.is_active());
let effects = dispatch(
Action::Interject {
text: "mid-turn note".into(),
images: vec![],
},
&mut app,
);
let agent = app.agents.get(&id).unwrap();
assert!(
!agent.ephemeral_tip.is_active(),
"no-session interject must still clear the tip"
);
assert!(
effects.is_empty(),
"no-session interject dispatches no effects"
);
assert_eq!(
agent.toast.as_ref().map(|(m, _)| m.as_str()),
Some("No active session"),
"no-session interject takes the 'No active session' path"
);
}
/// Image-bearing interject builds structured blocks (Text first with the
/// placeholder intact, then one Image block); no-image stays legacy
/// (`blocks: None`) so the wire shape is byte-identical.
#[test]
fn interject_with_images_builds_blocks_text_first() {
let mut app = test_app_with_agent();
let mut img = crate::prompt_images::from_clipboard_data(&crate::clipboard::ImageData {
data: vec![1, 2, 3],
mime_type: "image/png".into(),
});
img.display_number = 1;
let effects = dispatch(
Action::Interject {
text: "look at [Image #1] please".into(),
images: vec![img],
},
&mut app,
);
match effects.as_slice() {
[
Effect::SendInterject {
text,
blocks: Some(blocks),
..
},
] => {
assert_eq!(text, "look at [Image #1] please");
assert_eq!(blocks.len(), 2);
match &blocks[0] {
acp::ContentBlock::Text(tb) => {
assert!(tb.text.contains("[Image #1]"), "got {:?}", tb.text)
}
other => panic!("expected Text first, got {other:?}"),
}
assert!(matches!(&blocks[1], acp::ContentBlock::Image(_)));
}
other => panic!("expected SendInterject with blocks, got {other:?}"),
}
let effects = dispatch(
Action::Interject {
text: "plain".into(),
images: vec![],
},
&mut app,
);
assert!(matches!(
effects.as_slice(),
[Effect::SendInterject { blocks: None, .. }]
));
}
}
@@ -0,0 +1,83 @@
//! `/jump` picker dispatchers: pure client-side turn navigation.
use crate::app::actions::Effect;
use crate::app::app_view::{ActiveView, AppView};
use crate::scrollback::entry::EntryId;
use crate::views::jump::{JumpRestore, JumpState};
pub(super) fn dispatch_jump_show_picker(app: &mut AppView) -> Vec<Effect> {
let ActiveView::Agent(id) = app.active_view else {
return vec![];
};
let Some(agent) = app.agents.get_mut(&id) else {
return vec![];
};
// Refuse if another prompt overlay owns the input slot (rewind, inline-edit,
// /btw, or a pending permission/question/cancel-turn/plan overlay) — an
// opened picker would be hidden but still eat input.
if agent.jump_slot_taken() {
return vec![];
}
let entries = agent.scrollback.timeline_entries();
if entries.len() < 2 {
app.show_toast("Nothing to jump to yet");
return vec![];
}
let restore = JumpRestore {
bookmark: agent.scrollback.capture_scroll_bookmark(),
selected: agent.scrollback.selected(),
follow_mode: agent.scrollback.is_follow_mode(),
};
// Open on the turn currently at the viewport top (rows are oldest-first,
// so the row index is the turn index).
let selected = agent
.scrollback
.active_turn_for_viewport()
.unwrap_or(entries.len() - 1)
.min(entries.len() - 1);
let preview_id = entries[selected].prompt_entry_id;
agent.jump_state = Some(JumpState {
entries,
selected,
restore,
});
// Same top anchor that cursor moves preview and Enter lands on.
if let Some(idx) = agent.scrollback.index_of_id(preview_id) {
agent.scrollback.scroll_to_entry_top(idx);
}
vec![]
}
pub(super) fn dispatch_jump_picker_select(app: &mut AppView, prompt_id: EntryId) -> 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(js) = agent.jump_state.take() else {
return vec![];
};
// The stable id resolves at the boundary; it fails only if the prompt was
// removed (async clear/rewind) while the picker was open. Restore the
// captured viewport so a failed jump never strands the transcript at the
// last preview scroll.
if !agent.scrollback.jump_to_entry(prompt_id) {
agent.restore_jump_viewport(js.restore);
}
vec![]
}
pub(super) fn dispatch_jump_dismiss(app: &mut AppView) -> Vec<Effect> {
let ActiveView::Agent(id) = app.active_view else {
return vec![];
};
let Some(agent) = app.agents.get_mut(&id) else {
return vec![];
};
agent.dismiss_jump_picker();
vec![]
}
@@ -0,0 +1,64 @@
//! Synchronous state dispatch: [`Action`](crate::app::actions::Action) → state mutations + [`Effect`](crate::app::actions::Effect)s.
//!
//! This is the core business logic of the application. It takes an action,
//! mutates application state, and returns a list of async effects to execute.
//!
//! **Invariants:**
//! - This module never touches the terminal, network, or filesystem.
//! - All mutations are synchronous and deterministic.
//! - Async work is described as [`Effect`](crate::app::actions::Effect) values, not executed.
//! - This makes dispatch fully testable without tokio or a terminal.
//!
//! Imports in this tree use at most one `super::` hop (absolute `crate::` paths
//! otherwise); tests/ shares a fixture prelude via `use super::*;`.
mod auth;
mod billing;
mod ctx;
mod dashboard;
mod import_claude;
mod interject;
mod jump;
mod modes;
mod notes;
mod permissions;
mod prompt;
mod queue;
mod rewind;
mod router;
mod session;
mod settings;
mod status;
mod task_result;
mod transcript;
mod turn;
pub(crate) use billing::{
FREE_USAGE_USER_MESSAGE, UPSELL_URL_PAYG, UPSELL_URL_UPGRADE,
acp_error_is_free_usage_exhausted, is_credit_limit_error, is_free_usage_exhausted_error,
};
pub(crate) use modes::{downgrade_displayed_auto_if_gated, effective_auto};
pub(crate) use notes::{recap_unavailable_toast, scrollback_has_user_messages};
pub(crate) use permissions::resolve_permission_queue_transition;
pub(crate) use prompt::dispatch_initial_prompt;
pub(in crate::app) use prompt::show_small_screen_tip;
pub(super) use queue::{
apply_turn_start_shim, arm_send_now_and_paint, maybe_drain_queue, shim_renders_own_user_block,
};
pub(in crate::app) use rewind::{find_user_prompt_entry_for_shell_index, shell_prompt_index_at};
pub(crate) use router::dispatch;
pub(crate) use settings::ui::refresh_open_settings_modals;
pub(crate) use status::commit_minimal_update_notice;
pub(crate) use turn::reconcile_overdue_turn_ends;
// Test-only consumers (cfg(test) mods elsewhere in the crate); a plain
// re-export trips -D unused-imports in the lib build.
#[cfg(test)]
pub(crate) use ctx::{SwitchCause, switch_to_agent};
#[cfg(test)]
pub(crate) use settings::ui::{ROLLBACK_NO_ARM_TOAST, build_pager_snapshot};
#[cfg(test)]
pub(crate) use turn::TURN_END_RECONCILE_GRACE;
#[cfg(test)]
mod tests;
@@ -0,0 +1,961 @@
//! 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
}
}
}
@@ -0,0 +1,464 @@
//! Feedback, remember-note, btw, and recap dispatchers.
use super::ctx::with_active_agent;
use crate::app::actions::Effect;
use crate::app::agent::AgentId;
use crate::app::agent_view::{AgentView, PromptInputMode};
use crate::app::app_view::{ActiveView, AppView};
use crate::scrollback::block::RenderBlock;
use crate::scrollback::blocks::{SessionEvent, ToolCallBlock};
use std::sync::atomic::{AtomicU64, Ordering};
/// Monotonic counter for correlating async rewrite responses with the modal
/// that requested them. Prevents stale results from populating a different
/// note's review modal when the user closes and re-opens quickly.
static REWRITE_NONCE: AtomicU64 = AtomicU64::new(0);
fn next_rewrite_nonce() -> u64 {
REWRITE_NONCE.fetch_add(1, Ordering::Relaxed)
}
/// Enter feedback mode: visual change to prompt bar (teal accent, pencil prefix).
/// No side effects — the user types feedback text and presses Enter to send.
pub(super) fn dispatch_enter_feedback_mode(app: &mut AppView) -> Vec<Effect> {
with_active_agent(app, |agent| {
agent.prompt_input_mode = PromptInputMode::Feedback;
agent.prompt.set_text("");
});
vec![]
}
/// Enter remember mode: visual change to prompt bar (remember accent, `#` prefix).
/// No side effects — the user types a memory note and presses Enter to send.
pub(super) fn dispatch_enter_remember_mode(app: &mut AppView) -> Vec<Effect> {
with_active_agent(app, |agent| {
agent.prompt_input_mode = PromptInputMode::Remember;
agent.prompt.set_text("");
});
vec![]
}
/// Send feedback text to the server. Shows a thank-you message immediately
/// and fires the HTTP POST as a background effect.
pub(super) fn dispatch_send_feedback(app: &mut AppView, text: String) -> Vec<Effect> {
let ActiveView::Agent(id) = app.active_view else {
return vec![];
};
let Some(agent) = app.agents.get_mut(&id) else {
return vec![];
};
agent.prompt_input_mode = PromptInputMode::Normal;
agent.prompt.set_text("");
// Submitting feedback retires any edit-contextual ephemeral tip.
agent.ephemeral_tip.clear_on_submit();
let trimmed = text.trim().to_string();
if trimmed.is_empty() {
agent.scrollback.push_block(RenderBlock::system(
"Please provide feedback text.".to_string(),
));
return vec![];
}
let Some(session_id) = agent.session.session_id.clone() else {
agent
.scrollback
.push_block(RenderBlock::system("No active session.".to_string()));
return vec![];
};
agent.scrollback.push_block(RenderBlock::system(
"Thanks for the feedback! The Grok Build team is on it.".to_string(),
));
vec![Effect::SendFeedback {
agent_id: id,
session_id,
feedback_text: trimmed,
}]
}
/// Send a raw remember note for LLM-powered rewriting via `x.ai/memory/rewrite`.
/// Clears remember mode and prompts the LLM to reformat the note with session
/// context. Falls back to direct `SaveMemoryNote` when no session is available.
pub(super) fn dispatch_send_remember_note(app: &mut AppView, text: String) -> Vec<Effect> {
use crate::views::modal::ActiveModal;
let ActiveView::Agent(id) = app.active_view else {
return vec![];
};
let Some(agent) = app.agents.get_mut(&id) else {
return vec![];
};
agent.prompt_input_mode = PromptInputMode::Normal;
agent.prompt.set_text("");
// Submitting a memory note retires any edit-contextual ephemeral tip.
agent.ephemeral_tip.clear_on_submit();
let trimmed = text.trim().to_string();
if trimmed.is_empty() {
agent.scrollback.push_block(RenderBlock::system(
"Please provide a memory note.".to_string(),
));
return vec![];
}
let cwd = agent.session.cwd.clone();
let Some(session_id) = agent.session.session_id.clone() else {
// No session — open modal with raw content only (no LLM rewrite).
agent.active_modal = Some(ActiveModal::RememberNoteReview {
raw_content: trimmed.clone(),
enhanced_content: None, // no session → no LLM rewrite, Tab disabled
showing_enhanced: false,
scroll: 0,
window: crate::views::modal_window::ModalWindowState::new(),
cached_lines: None,
cwd,
agent_id: id,
rewrite_nonce: 0, // no rewrite in flight, nonce unused
});
return vec![];
};
// Open modal with raw content, LLM rewrite in flight.
let nonce = next_rewrite_nonce();
agent.active_modal = Some(ActiveModal::RememberNoteReview {
raw_content: trimmed.clone(),
enhanced_content: None,
showing_enhanced: false,
scroll: 0,
window: crate::views::modal_window::ModalWindowState::new(),
cached_lines: None,
cwd: cwd.clone(),
agent_id: id,
rewrite_nonce: nonce,
});
let context_summary = extract_session_context(agent);
vec![Effect::RewriteMemoryNote {
agent_id: id,
session_id,
raw_text: trimmed,
context_summary,
nonce,
}]
}
/// Save the currently displayed remember note from the review modal.
pub(super) fn dispatch_save_remember_note_from_modal(app: &mut AppView) -> Vec<Effect> {
use crate::views::modal::ActiveModal;
let ActiveView::Agent(id) = app.active_view else {
return vec![];
};
let Some(agent) = app.agents.get_mut(&id) else {
return vec![];
};
let (content, cwd) = if let Some(ActiveModal::RememberNoteReview {
ref raw_content,
ref enhanced_content,
showing_enhanced,
ref cwd,
..
}) = agent.active_modal
{
let text = if showing_enhanced {
enhanced_content.as_deref().unwrap_or(raw_content)
} else {
raw_content
};
(text.trim().to_string(), cwd.clone())
} else {
return vec![];
};
agent.active_modal = None;
agent
.scrollback
.push_block(RenderBlock::system("Saving memory note...".to_string()));
vec![Effect::SaveMemoryNote {
agent_id: id,
text: content,
cwd,
}]
}
/// Extract session context for the LLM memory rewrite request.
///
/// Walks scrollback in reverse, collecting:
/// - Last 5 user prompts
/// - File paths from recent tool calls (Read, Edit, ListDir)
/// - CWD and git branch
fn extract_session_context(agent: &AgentView) -> String {
let mut user_prompts: Vec<String> = Vec::new();
let mut file_paths: Vec<String> = Vec::new();
// Walk scrollback entries in reverse to collect recent context.
let len = agent.scrollback.len();
for i in (0..len).rev() {
let Some(entry) = agent.scrollback.entry(i) else {
continue;
};
match &entry.block {
RenderBlock::UserPrompt(prompt) => {
if user_prompts.len() < 5 {
let text = if prompt.text.len() > 200 {
let end = prompt
.text
.char_indices()
.map(|(i, _)| i)
.take_while(|&i| i <= 200)
.last()
.unwrap_or(0);
format!("{}...", &prompt.text[..end])
} else {
prompt.text.clone()
};
user_prompts.push(text);
}
}
RenderBlock::ToolCall(tc) if file_paths.len() < 20 => match tc {
ToolCallBlock::Read(b) => {
file_paths.push(b.path.clone());
}
ToolCallBlock::Edit(b) => {
file_paths.push(b.path.clone());
}
ToolCallBlock::ListDir(b) => {
file_paths.push(b.path.clone());
}
_ => {}
},
_ => {}
}
// Stop early once we have enough context.
if user_prompts.len() >= 5 && file_paths.len() >= 20 {
break;
}
}
let mut parts: Vec<String> = Vec::new();
// CWD
parts.push(format!("CWD: {}", agent.session.cwd.display()));
// Git branch
if let Some(ref branch) = agent.current_branch {
parts.push(format!("Branch: {branch}"));
}
// Recent prompts (chronological order)
if !user_prompts.is_empty() {
user_prompts.reverse();
parts.push("Recent prompts:".to_string());
for p in &user_prompts {
parts.push(format!("- {p}"));
}
}
// Recent file paths (deduplicated, preserving first-seen order)
if !file_paths.is_empty() {
let mut seen = std::collections::HashSet::new();
file_paths.retain(|p| seen.insert(p.clone()));
parts.push("Recent files:".to_string());
for p in &file_paths {
parts.push(format!("- {p}"));
}
}
parts.join("\n")
}
/// Send a /btw side question. Bypasses the prompt queue — works even while
/// the agent is mid-turn. Fires an ACP ext method and shows a loading overlay.
pub(super) fn dispatch_send_btw(app: &mut AppView, question: 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 Some(session_id) = agent.session.session_id.clone() else {
agent.show_toast("No active session");
return vec![];
};
agent.prompt.set_text("");
agent.btw_state = Some(crate::views::btw_overlay::BtwOverlayState::Loading {
question: question.clone(),
});
// Prompt keeps focus while the answer is in flight (panel focuses on Done).
agent.btw_focused = false;
vec![Effect::SendBtw {
agent_id: id,
session_id,
question,
}]
}
/// Toast when a manual `/recap` produces no summary. Empty sessions get a clear
/// empty-state message; anything else (model failure, empty summary, etc.) keeps
/// the generic failure toast.
pub(crate) fn recap_unavailable_toast(has_user_messages: bool) -> &'static str {
if has_user_messages {
"Couldn't generate recap"
} else {
"No messages yet"
}
}
/// Whether scrollback already has a user prompt. Scans entries (not
/// `turn_count`) so it stays correct during `begin_batch`/`end_batch` session
/// load, when `push` defers `rebuild_turns` and `turn_count` can stay 0 while
/// replayed prompts are already present.
pub(crate) fn scrollback_has_user_messages(
scrollback: &crate::scrollback::state::ScrollbackState,
) -> bool {
scrollback
.iter_entries()
.any(|(_, entry)| entry.block.is_user_prompt())
}
/// Request a session recap. Bypasses the prompt queue — works even while the
/// agent is mid-turn. Fires the `x.ai/recap` ext method; the recap arrives
/// asynchronously as a `SessionRecap` notification (rendered in scrollback).
///
/// `auto` is `false` for an explicit `/recap` and `true` for the automatic
/// return-from-away recap. For the manual path we clear the prompt and, when
/// no session exists yet, surface a toast; the auto path is best-effort and
/// silently no-ops without an active session.
pub(super) fn dispatch_send_recap(app: &mut AppView, auto: bool) -> Vec<Effect> {
let ActiveView::Agent(id) = app.active_view else {
return vec![];
};
let Some(agent) = app.agents.get_mut(&id) else {
return vec![];
};
// Shell is authoritative (remote settings / config / env). Skip client requests
// entirely when the feature is off so we never hit `x.ai/recap`.
if !app.session_recap_available {
if !auto {
agent.show_toast("Session recap is not enabled");
}
return vec![];
}
let Some(session_id) = agent.session.session_id.clone() else {
if !auto {
agent.show_toast("No active session");
}
return vec![];
};
if !auto {
agent.prompt.set_text("");
// Nothing to summarize yet — show a clear empty-state toast instead of
// a spinner that ends in "Couldn't generate recap".
//
// Skip the short-circuit while session replay is still loading (prompts
// may not have arrived yet). Prefer an entry scan over `turn_count()`
// so mid-batch resume (deferred `rebuild_turns`) still sees history.
if !agent.session.loading_replay && !scrollback_has_user_messages(&agent.scrollback) {
agent.show_toast(recap_unavailable_toast(false));
return vec![];
}
// Show an immediate loading block with the animated "running" sidebar so
// the user has feedback that a recap is being generated. The
// `SessionRecap` handler fills this entry in and stops the animation.
// Reuse an existing in-flight loading block instead of stacking spinners
// when `/recap` is pressed repeatedly.
let already_loading = agent.pending_recap_entry.is_some_and(|eid| {
agent
.scrollback
.get_by_id(eid)
.is_some_and(|entry| entry.is_running)
});
if !already_loading {
let entry_id =
agent
.scrollback
.push(crate::scrollback::entry::ScrollbackEntry::running(
RenderBlock::session_event(SessionEvent::Recap {
summary: String::new(),
auto: false,
}),
));
agent.pending_recap_entry = Some(entry_id);
}
} else {
// Retry backoff only — do not consume the away period on dispatch.
// The shell often no-ops auto recap until ≥3 min since the last main
// turn; mark_recap_shown runs when any SessionRecap arrives (auto or
// manual `/recap`).
app.notification_service
.focus_tracker
.note_auto_recap_attempt();
}
vec![Effect::SendRecap { session_id, auto }]
}
// TaskResult handlers.
pub(super) fn handle_memory_note_saved(
app: &mut AppView,
agent_id: AgentId,
result: Result<(), String>,
) -> Vec<Effect> {
if let Some(agent) = app.agents.get_mut(&agent_id) {
match result {
Ok(()) => {
agent
.scrollback
.push_block(crate::scrollback::block::RenderBlock::system(format!(
"Memory saved to {}",
crate::util::display_user_grok_path("memory/MEMORY.md")
)));
}
Err(error) => {
agent
.scrollback
.push_block(crate::scrollback::block::RenderBlock::system(format!(
"Couldn't save memory note: {error}"
)));
}
}
}
vec![]
}
pub(super) fn handle_btw_response(
app: &mut AppView,
agent_id: AgentId,
result: Result<String, String>,
) -> Vec<Effect> {
if let Some(agent) = app.agents.get_mut(&agent_id) {
use crate::views::btw_overlay::BtwOverlayState;
let question = match &agent.btw_state {
Some(BtwOverlayState::Loading { question }) => question.clone(),
_ => String::new(),
};
match result {
Ok(response) => {
// Answer arrived: show it (until Esc) and focus the panel
// so Up/Down scroll it until the user returns to the prompt.
agent.btw_state = Some(BtwOverlayState::done(question, response));
agent.btw_focused = true;
}
Err(error) => {
// Error stays until Esc; nothing to scroll, keep prompt focus.
agent.btw_state = Some(BtwOverlayState::Error { question, error });
agent.btw_focused = false;
}
}
}
vec![]
}
@@ -0,0 +1,274 @@
//! Permission request selection, follow-up, cancellation, and queue draining.
use super::modes::set_yolo_mode;
use crate::app::actions::Effect;
use crate::app::agent_view::AgentView;
use crate::app::app_view::{ActiveView, AppView};
use agent_client_protocol as acp;
// ---------------------------------------------------------------------------
// Permission dispatch
// ---------------------------------------------------------------------------
/// Handle permission option selection (AllowOnce, AllowAlways, RejectAlways).
///
/// Pops the front request, sends the response, and handles queue transitions
/// (prompt restore on empty, prompt clear on next-front).
///
/// Special case for [`kigi_workspace::permission::ENABLE_ALWAYS_APPROVE_OPTION_ID`]:
/// when the user picks the prepended "Yes, and don't ask again for anything"
/// option, this dispatcher (a) sends the standard `Selected` response so the
/// in-flight request is allowed once (the shell's `map_selected_outcome`
/// resolves the id to `PromptOutcome::AllowOnce`), then (b) reuses the
/// existing `set_yolo_mode(true)` flow to flip the local YOLO state, drain
/// any remaining queued permissions, persist `[ui] permission_mode =
/// "always-approve"` to `~/.kigi/config.toml`, and fire the
/// `x.ai/yolo_mode_changed` ACP notification. See the option-id constant
/// doc-comment for the full client/shell split. Under a managed-policy
/// pin step (b) is refused with a toast — the request is still allowed once.
pub(super) fn dispatch_permission_select(
app: &mut AppView,
option_id: acp::PermissionOptionId,
) -> 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(perm) = agent.permission_queue.pop_front() else {
return vec![];
};
// Detect the "enable always-approve mode" id BEFORE moving option_id
// into the response. Cheap str compare on the `Arc<str>` interior.
let enable_always_approve =
option_id.0.as_ref() == kigi_workspace::permission::ENABLE_ALWAYS_APPROVE_OPTION_ID;
// Remember the user's choice (by option kind) so the next prompt's cursor
// sticks to it. Allow-flavored choices only — a rejection must not steer a
// later prompt's cursor onto a reject row. Also skip the two options that
// aren't per-prompt choices:
// - the global always-approve (YOLO) option flips global auto-approve, so
// there will be no subsequent prompt to land on;
// - "allow all edits during this session" is edit-scoped (kind
// `AllowAlways`) — letting it stick would steer an unrelated later
// prompt onto its "always allow this command" row, escalating scope.
let steers_next_cursor = !enable_always_approve
&& option_id.0.as_ref() != kigi_workspace::permission::ALLOW_EDITS_SESSION_OPTION_ID;
if steers_next_cursor
&& let Some(kind) = perm
.options
.iter()
.find(|o| o.option_id == option_id)
.map(|o| o.kind)
&& matches!(
kind,
acp::PermissionOptionKind::AllowOnce | acp::PermissionOptionKind::AllowAlways
)
{
crate::appearance::permission_cursor::set_last_used_permission(
crate::appearance::permission_cursor::DefaultSelectedPermission::from_kind(&kind),
);
}
// Build response meta. MCP and bash flows are mutually exclusive at
// the per-request level; check MCP first because it owns the
// `allow-always-mcp` option id and the bash branch is the existing
// fallback.
let meta = if let Some(scope) = perm
.mcp_scope
.as_ref()
.filter(|_| option_id.0.as_ref() == "allow-always-mcp")
{
let selection = match scope.selected {
crate::views::permission_view::McpScope::Tool => {
kigi_workspace::permission::McpScopeSelection::Tool {
tool_name: scope.tool_name.clone(),
}
}
crate::views::permission_view::McpScope::Server => match &scope.server_prefix {
Some(prefix) => kigi_workspace::permission::McpScopeSelection::Server {
server: prefix.clone(),
},
// Defensive: render path should disable Server when no prefix.
None => kigi_workspace::permission::McpScopeSelection::Tool {
tool_name: scope.tool_name.clone(),
},
},
};
serde_json::to_value(selection)
.ok()
.and_then(|v| v.as_object().cloned())
} else if let Some(ref h) = perm.bash_highlights
&& perm.bash_selection_count > 0
{
let parts: Vec<String> = h.highlighted_words[..perm.bash_selection_count].to_vec();
serde_json::to_value(kigi_workspace::permission::BashCommandSelectedTerms {
command_parts: parts,
})
.ok()
.and_then(|v| v.as_object().cloned())
} else {
None
};
perm.request
.response_tx
.send(Ok(acp::RequestPermissionResponse::new(
acp::RequestPermissionOutcome::Selected(acp::SelectedPermissionOutcome::new(option_id)),
)
.meta(meta)))
.ok();
// Queue transition: restore prompt if queue is now empty, clear if next-front.
resolve_permission_queue_transition(agent);
// "Enable always-approve" side effect: flip YOLO + persist + notify.
// Reuses the existing `set_yolo_mode` pipeline so telemetry, queue
// drain, toast, modal refresh, config persistence, and ACP
// notification all flow through one well-tested code path.
//
// Idempotency: if YOLO is already on, the pager auto-approves in
// `handle_permission_request` before the panel is shown, so the
// user couldn't have selected this option. The `is_yolo()` guard
// is defensive — a redundant call would re-emit the toast and a
// duplicate `PersistPermissionMode` effect, but is otherwise safe.
if enable_always_approve {
let already_on = app
.agents
.get(&id)
.map(|a| a.session.is_yolo())
.unwrap_or(false);
if !already_on {
return set_yolo_mode(app, true);
}
}
vec![]
}
/// Handle permission followup message (RejectOnce with user-typed text).
pub(super) fn dispatch_permission_followup(app: &mut AppView, text: 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 Some(perm) = agent.permission_queue.pop_front() else {
return vec![];
};
// Find the RejectOnce option.
let option_id = perm
.options
.iter()
.find(|o| o.kind == acp::PermissionOptionKind::RejectOnce)
.map(|o| o.option_id.clone());
let Some(option_id) = option_id else {
// No RejectOnce option — cancel instead.
perm.request
.response_tx
.send(Ok(acp::RequestPermissionResponse::new(
acp::RequestPermissionOutcome::Cancelled,
)))
.ok();
resolve_permission_queue_transition(agent);
return vec![];
};
// Include followup message in meta.
let meta = if !text.trim().is_empty() {
serde_json::json!({
"followup_message": text,
})
.as_object()
.cloned()
} else {
None
};
perm.request
.response_tx
.send(Ok(acp::RequestPermissionResponse::new(
acp::RequestPermissionOutcome::Selected(acp::SelectedPermissionOutcome::new(option_id)),
)
.meta(meta)))
.ok();
resolve_permission_queue_transition(agent);
vec![]
}
/// Handle permission cancel (Ctrl-C / Esc — cancels front request only).
pub(super) fn dispatch_permission_cancel(app: &mut AppView) -> 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(perm) = agent.permission_queue.pop_front() else {
return vec![];
};
perm.request
.response_tx
.send(Ok(acp::RequestPermissionResponse::new(
acp::RequestPermissionOutcome::Cancelled,
)))
.ok();
resolve_permission_queue_transition(agent);
vec![]
}
/// Drain all queued permission requests, sending `Cancelled` to each.
///
/// Called on turn-end and turn-cancel. After draining, restores the stashed
/// prompt text (if any). This is distinct from `dispatch_permission_cancel`
/// which cancels only the front request.
pub(super) fn drain_permission_queue(agent: &mut AgentView) {
agent.last_permission_click = None;
if agent.permission_queue.is_empty() {
return;
}
for perm in agent.permission_queue.drain(..) {
perm.request
.response_tx
.send(Ok(acp::RequestPermissionResponse::new(
acp::RequestPermissionOutcome::Cancelled,
)))
.ok();
}
// Queue is now empty — restore stashed prompt.
if let Some(stashed) = agent.permission_stashed_prompt.take() {
agent.prompt.restore(stashed);
}
}
/// Handle queue transition after resolving (select/followup/cancel) the front
/// permission request.
///
/// - Queue now empty → restore stashed prompt text.
/// - Queue still has items → clear prompt text (for next followup input)
/// and reset next front's focus to Options.
pub(crate) fn resolve_permission_queue_transition(agent: &mut AgentView) {
agent.last_permission_click = None;
if agent.permission_queue.is_empty() {
// Restore original prompt.
if let Some(stashed) = agent.permission_stashed_prompt.take() {
agent.prompt.restore(stashed);
}
} else {
// Clear any followup text from the just-resolved permission so it
// doesn't leak into the next permission's UI.
agent.prompt.set_text("");
// Reset next front's focus to Options.
if let Some(next) = agent.permission_queue.front_mut() {
next.focus = crate::views::permission_view::PermissionFocus::Options;
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,867 @@
//! Conversation rewind dispatchers and prompt-entry lookup helpers.
use crate::app::actions::Effect;
use crate::app::agent::AgentId;
use crate::app::app_view::{ActiveView, AppView};
use crate::scrollback::block::RenderBlock;
use crate::scrollback::state::ScrollbackState;
use crate::views::prompt_widget::{PromptWidget, StashedPrompt};
/// User prompt that participates in the shell's prompt numbering.
/// Interjections render as user prompts but the shell never numbers them,
/// so counting them would skew the positional prompt↔entry mapping.
///
/// Known approximation: an interjection the shell converted into its own
/// `interject-fallback-` turn IS shell-numbered, but its live block (rendered
/// from the interjection broadcast) is flagged `is_interjection` and carries
/// no index, so the positional fallback under-counts around it until a
/// resume replays it as an indexed prompt. The primary path (explicit
/// `prompt_index` matches) is unaffected.
fn is_indexed_user_prompt(block: &RenderBlock) -> bool {
matches!(block, RenderBlock::UserPrompt(b) if !b.is_interjection)
}
fn stash_prompt(prompt: &mut PromptWidget) -> Option<StashedPrompt> {
if prompt.text().is_empty() {
None
} else {
Some(prompt.stash())
}
}
pub(in crate::app) fn shell_prompt_index_at(
scrollback: &ScrollbackState,
entry_idx: usize,
) -> Option<usize> {
for idx in (0..=entry_idx).rev() {
if let Some(e) = scrollback.get(idx)
&& let RenderBlock::UserPrompt(ref block) = e.block
{
// A mid-turn interjection belongs to the enclosing turn — keep
// walking back to that turn's starting prompt.
if block.is_interjection {
continue;
}
if let Some(pi) = block.prompt_index {
return Some(pi);
}
let count = (0..=idx)
.filter(|&i| {
scrollback
.get(i)
.is_some_and(|e2| is_indexed_user_prompt(&e2.block))
})
.count();
return if count > 0 { Some(count - 1) } else { None };
}
}
None
}
pub(in crate::app) fn find_user_prompt_entry_for_shell_index(
scrollback: &ScrollbackState,
target_prompt_index: usize,
) -> Option<usize> {
for idx in (0..scrollback.len()).rev() {
if let Some(entry) = scrollback.get(idx)
&& let RenderBlock::UserPrompt(ref block) = entry.block
&& block.prompt_index == Some(target_prompt_index)
{
return Some(idx);
}
}
let mut count = 0usize;
for idx in 0..scrollback.len() {
if let Some(e) = scrollback.get(idx)
&& is_indexed_user_prompt(&e.block)
{
if count == target_prompt_index {
return Some(idx);
}
count += 1;
}
}
None
}
pub(super) fn dispatch_rewind(app: &mut AppView) -> 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 {
app.show_toast("No active session");
return vec![];
};
// Rewind takes input priority over the `/jump` picker; close a lingering
// one first so it can't reappear (stale) after rewind finishes.
agent.dismiss_jump_picker();
let selected_idx = agent.scrollback.selected();
let selected_shell_idx =
selected_idx.and_then(|idx| shell_prompt_index_at(&agent.scrollback, idx));
if agent.session.state.is_busy() {
let anchor = agent.scrollback.len().saturating_sub(1);
let draft = stash_prompt(&mut agent.prompt);
agent.rewind_state = Some(crate::views::rewind::RewindState::new_cancel_offer(
anchor,
draft,
selected_shell_idx,
));
return vec![];
}
let draft = stash_prompt(&mut agent.prompt);
agent.rewind_state = Some(crate::views::rewind::RewindState {
phase: crate::views::rewind::RewindPhase::Loading,
anchor_entry_idx: selected_idx.unwrap_or(0),
stashed_draft: draft,
selected_prompt_index: selected_shell_idx,
});
vec![Effect::FetchRewindPoints {
agent_id: id,
session_id,
}]
}
pub(super) fn dispatch_rewind_show_picker(app: &mut AppView) -> 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 {
app.show_toast("No active session");
return vec![];
};
// Rewind takes input priority over the `/jump` picker; close a lingering
// one first so it can't reappear (stale) after rewind finishes.
agent.dismiss_jump_picker();
if agent.session.state.is_busy() {
let anchor = agent.scrollback.len().saturating_sub(1);
let draft = stash_prompt(&mut agent.prompt);
agent.rewind_state = Some(crate::views::rewind::RewindState::new_cancel_offer(
anchor, draft, None,
));
return vec![];
}
let draft = stash_prompt(&mut agent.prompt);
agent.rewind_state = Some(crate::views::rewind::RewindState {
phase: crate::views::rewind::RewindPhase::Loading,
anchor_entry_idx: 0,
stashed_draft: draft,
selected_prompt_index: None,
});
vec![Effect::FetchRewindPoints {
agent_id: id,
session_id,
}]
}
pub(super) fn dispatch_rewind_picker_select(app: &mut AppView, prompt_index: usize) -> Vec<Effect> {
let ActiveView::Agent(id) = app.active_view else {
return vec![];
};
let Some(agent) = app.agents.get_mut(&id) else {
return vec![];
};
let point = agent.rewind_points.as_ref().and_then(
|pts: &Vec<crate::views::rewind::RewindPointInfo>| {
pts.iter().find(|p| p.prompt_index == prompt_index)
},
);
let has_file_changes = point.map(|p| p.has_file_changes).unwrap_or(false);
let anchor = find_user_prompt_entry_for_shell_index(&agent.scrollback, prompt_index);
if let Some(entry_idx) = anchor {
agent.scrollback.set_selected(Some(entry_idx));
}
let draft = agent.rewind_state.take().and_then(|s| s.stashed_draft);
agent.rewind_state = Some(crate::views::rewind::RewindState {
phase: crate::views::rewind::RewindPhase::ModeSelect {
target_prompt_index: prompt_index,
has_file_changes,
// Inline edit-and-resubmit: the conversation rewind is a given,
// so a files-only option makes no sense there.
offer_files_only: agent.inline_edit.is_none(),
active_idx: 0,
},
anchor_entry_idx: anchor.unwrap_or(0),
stashed_draft: draft,
selected_prompt_index: Some(prompt_index),
});
vec![]
}
pub(super) fn dispatch_rewind_cancel_offer(app: &mut AppView) -> 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 {
return vec![];
};
let anchor = agent
.rewind_state
.as_ref()
.map(|s| s.anchor_entry_idx)
.unwrap_or(0);
let selected = agent
.rewind_state
.as_ref()
.and_then(|s| s.selected_prompt_index);
let draft = agent.rewind_state.take().and_then(|s| s.stashed_draft);
agent.rewind_state = Some(crate::views::rewind::RewindState {
phase: crate::views::rewind::RewindPhase::Loading,
anchor_entry_idx: anchor,
stashed_draft: draft,
selected_prompt_index: selected,
});
let mut effects = vec![Effect::CancelTurn {
session_id: session_id.clone(),
cancel_subagents: true,
trigger: None,
// The rewind picker owns history via `handle_rewind`; this pre-cancel
// must not also pop the in-flight prompt.
rewind_if_pristine: false,
}];
effects.push(Effect::FetchRewindPoints {
agent_id: id,
session_id,
});
effects
}
pub(super) fn dispatch_rewind_select_mode(
app: &mut AppView,
mode: crate::views::rewind::RewindMode,
target: usize,
) -> Vec<Effect> {
use crate::views::rewind::{RewindMode, RewindPhase, RewindState};
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 {
return vec![];
};
match mode {
RewindMode::ConversationOnly if target == 0 => {
let anchor = agent
.rewind_state
.as_ref()
.map(|s| s.anchor_entry_idx)
.unwrap_or(0);
let preview = agent
.rewind_points
.as_ref()
.and_then(|pts| pts.iter().find(|p| p.prompt_index == target))
.and_then(|p| p.prompt_preview.clone());
let draft = agent.rewind_state.take().and_then(|s| s.stashed_draft);
agent.rewind_state = Some(RewindState {
phase: RewindPhase::ConversationOnlyConfirm {
target_prompt_index: target,
active_idx: 0,
prompt_preview: preview,
},
anchor_entry_idx: anchor,
stashed_draft: draft,
selected_prompt_index: None,
});
vec![]
}
RewindMode::ConversationOnly => {
let anchor = agent
.rewind_state
.as_ref()
.map(|s| s.anchor_entry_idx)
.unwrap_or(0);
let draft = agent.rewind_state.take().and_then(|s| s.stashed_draft);
agent.rewind_state = Some(RewindState {
phase: RewindPhase::Executing {
target_prompt_index: target,
mode,
},
anchor_entry_idx: anchor,
stashed_draft: draft,
selected_prompt_index: None,
});
stash_inline_resubmit_if_editing(agent);
vec![Effect::RewindExecute {
agent_id: id,
session_id,
target_prompt_index: target,
mode,
}]
}
RewindMode::All | RewindMode::FilesOnly => {
let has_files = agent
.rewind_state
.as_ref()
.and_then(|s| match &s.phase {
RewindPhase::ModeSelect {
has_file_changes, ..
} => Some(*has_file_changes),
_ => None,
})
.unwrap_or(false);
let anchor = agent
.rewind_state
.as_ref()
.map(|s| s.anchor_entry_idx)
.unwrap_or(0);
let draft = agent.rewind_state.take().and_then(|s| s.stashed_draft);
if !has_files {
agent.rewind_state = Some(RewindState {
phase: RewindPhase::Executing {
target_prompt_index: target,
mode,
},
anchor_entry_idx: anchor,
stashed_draft: draft,
selected_prompt_index: None,
});
stash_inline_resubmit_if_editing(agent);
vec![Effect::RewindExecute {
agent_id: id,
session_id,
target_prompt_index: target,
mode,
}]
} else {
agent.rewind_state = Some(RewindState {
phase: RewindPhase::Previewing {
target_prompt_index: target,
mode,
},
anchor_entry_idx: anchor,
stashed_draft: draft,
selected_prompt_index: None,
});
vec![Effect::RewindPreview {
agent_id: id,
session_id,
target_prompt_index: target,
mode,
}]
}
}
}
}
pub(super) fn dispatch_rewind_confirm(
app: &mut AppView,
target: usize,
mode: crate::views::rewind::RewindMode,
) -> 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 {
return vec![];
};
let anchor = agent
.rewind_state
.as_ref()
.map(|s| s.anchor_entry_idx)
.unwrap_or(0);
let draft = agent.rewind_state.take().and_then(|s| s.stashed_draft);
agent.rewind_state = Some(crate::views::rewind::RewindState {
phase: crate::views::rewind::RewindPhase::Executing {
target_prompt_index: target,
mode,
},
anchor_entry_idx: anchor,
stashed_draft: draft,
selected_prompt_index: None,
});
stash_inline_resubmit_if_editing(agent);
vec![Effect::RewindExecute {
agent_id: id,
session_id,
target_prompt_index: target,
mode,
}]
}
pub(super) fn dispatch_rewind_conversation_only_confirm(
app: &mut AppView,
target: usize,
) -> Vec<Effect> {
dispatch_rewind_confirm(
app,
target,
crate::views::rewind::RewindMode::ConversationOnly,
)
}
pub(super) fn dispatch_rewind_dismiss(app: &mut AppView) -> Vec<Effect> {
let ActiveView::Agent(id) = app.active_view else {
return vec![];
};
let Some(agent) = app.agents.get_mut(&id) else {
return vec![];
};
let draft = agent.rewind_state.take().and_then(|s| s.stashed_draft);
if let Some(d) = draft {
agent.prompt.restore(d);
}
agent.rewind_points = None;
vec![]
}
pub(super) fn dispatch_rewind_back_to_mode_select(app: &mut AppView) -> Vec<Effect> {
let ActiveView::Agent(id) = app.active_view else {
return vec![];
};
let Some(agent) = app.agents.get_mut(&id) else {
return vec![];
};
if let Some(ref state) = agent.rewind_state {
let anchor = state.anchor_entry_idx;
let sel_pi = state.selected_prompt_index;
let draft = agent.rewind_state.take().and_then(|s| s.stashed_draft);
let (target, has_file_changes) = agent
.rewind_points
.as_ref()
.and_then(|pts| {
sel_pi
.and_then(|pi| pts.iter().find(|p| p.prompt_index == pi))
.or_else(|| pts.iter().max_by_key(|p| p.prompt_index))
})
.map(|p| (p.prompt_index, p.has_file_changes))
.unwrap_or((0, false));
agent.rewind_state = Some(crate::views::rewind::RewindState {
phase: crate::views::rewind::RewindPhase::ModeSelect {
target_prompt_index: target,
has_file_changes,
// Re-derive the inline context: while the inline editor is
// open the files-only row stays hidden on the way back too.
offer_files_only: agent.inline_edit.is_none(),
active_idx: 0,
},
anchor_entry_idx: anchor,
stashed_draft: draft,
selected_prompt_index: sel_pi,
});
}
vec![]
}
pub(super) fn dispatch_rewind_dismiss_error(app: &mut AppView) -> Vec<Effect> {
dispatch_rewind_dismiss(app)
}
/// The single place the inline-edit resubmit gets armed: called right
/// before every `Effect::RewindExecute` emission in the rewind flow. If the
/// inline editor is open, the (trimmed) edited text is stashed for
/// `dispatch_rewind_success` to resubmit after the rewind lands. Dismiss /
/// error / empty-points paths never arm it, so they need no clearing — the
/// editor simply stays open there.
fn stash_inline_resubmit_if_editing(agent: &mut crate::app::agent_view::AgentView) {
if let Some(ref edit) = agent.inline_edit {
agent.pending_inline_resubmit = Some(edit.textarea.text().trim().to_string());
}
}
/// Submit an inline edit: enter the exact same rewind flow as `/rewind`,
/// pre-targeted at the edited prompt (points fetch → ModeSelect with the
/// file-revert question → optional preview/confirm → execute; cancel-offer
/// first when a turn is running). The editor stays open behind the rewind
/// overlays; `stash_inline_resubmit_if_editing` arms the resubmit only when
/// a rewind actually executes, and `dispatch_rewind_success` sends the
/// edited text from the rewound point.
pub(super) fn dispatch_inline_edit_submit(app: &mut AppView) -> 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 {
app.show_toast("No active session");
return vec![];
};
let Some(edit) = agent.inline_edit.as_ref() else {
return vec![];
};
// Unchanged/empty edits have nothing to submit: just close the editor.
let text = edit.textarea.text().trim().to_string();
if text.is_empty() || text == edit.original.trim() {
agent.exit_inline_edit();
return vec![];
}
let target = edit.prompt_index;
let anchor = agent
.scrollback
.index_of_id(edit.entry_id)
.or_else(|| agent.scrollback.selected())
.unwrap_or(0);
let draft = stash_prompt(&mut agent.prompt);
if agent.session.state.is_busy() {
// Mid-turn submit: the same cancel-offer `/rewind` raises, over the
// still-open editor. Confirm cancels the turn and re-enters the
// flow; dismiss returns to the editor.
agent.rewind_state = Some(crate::views::rewind::RewindState::new_cancel_offer(
anchor,
draft,
Some(target),
));
return vec![];
}
agent.rewind_state = Some(crate::views::rewind::RewindState {
phase: crate::views::rewind::RewindPhase::Loading,
anchor_entry_idx: anchor,
stashed_draft: draft,
selected_prompt_index: Some(target),
});
vec![Effect::FetchRewindPoints {
agent_id: id,
session_id,
}]
}
pub(super) fn dispatch_rewind_success(
app: &mut AppView,
agent_id: crate::app::agent::AgentId,
response: crate::views::rewind::RewindResponse,
) -> Vec<Effect> {
let Some(agent) = app.agents.get_mut(&agent_id) else {
return vec![];
};
// Inline-edit resubmit text; taken unconditionally so a failed rewind
// drops it.
let inline_resubmit = agent.pending_inline_resubmit.take();
if !response.success {
let err = response.error.unwrap_or_else(|| "unknown error".into());
let anchor = agent
.rewind_state
.as_ref()
.map(|s| s.anchor_entry_idx)
.unwrap_or(0);
let draft = agent.rewind_state.take().and_then(|s| s.stashed_draft);
agent.rewind_state = Some(crate::views::rewind::RewindState {
phase: crate::views::rewind::RewindPhase::Error { message: err },
anchor_entry_idx: anchor,
stashed_draft: draft,
selected_prompt_index: None,
});
// Note: the inline editor (if any) stays open — dismissing the
// error returns to editing.
return vec![];
}
// The rewind went through: the inline editor's job is done. Close it
// before the truncation below removes its entry.
if inline_resubmit.is_some() {
agent.inline_edit = None;
agent.scrollback.set_inline_edit_height(None);
}
let mode_str = response.mode.as_deref().unwrap_or("all");
let target = response.target_prompt_index;
let is_files_only = mode_str == "files_only";
let stashed_draft = agent.rewind_state.take().and_then(|s| s.stashed_draft);
if !is_files_only {
let target_idx = find_user_prompt_entry_for_shell_index(&agent.scrollback, target);
if let Some(anchor_idx) = target_idx {
let removed = agent.scrollback.remove_from(anchor_idx);
// Explicit drop BEFORE the purge: the rewound tail (entries +
// their render caches — potentially most of a long transcript)
// must be freed for the release below to return its pages.
drop(removed);
crate::memory_release::release_retained_memory_with("rewind-truncate");
}
}
// An inline resubmit skips the confirmation — the edited prompt
// re-appearing at the same spot is self-explanatory. (Files-only keeps
// it: nothing is resubmitted there, so the revert needs its signal.)
if inline_resubmit.is_none() || is_files_only {
let msg = match mode_str {
"conversation_only" => "Reverted conversation",
"files_only" => "Reverted file changes",
_ => "Reverted conversation and file changes",
};
if app.screen_mode.is_minimal() {
// Minimal has no toast surface and can't erase committed lines, so the confirmation stays in scrollback there.
agent
.scrollback
.push_block(RenderBlock::system(msg.to_string()));
} else {
agent.show_toast(msg);
}
}
if let Some(ref text) = inline_resubmit
&& is_files_only
{
// Files-only: no conversation rewind happened, so there is nothing
// to resubmit from — surface the edited text in the composer
// instead of silently dropping the edit.
agent.prompt.set_text(text);
} else if inline_resubmit.is_some() {
// Restore the full draft before a non-consuming resubmit.
if let Some(draft) = stashed_draft {
agent.prompt.restore(draft);
}
} else if let Some(ref prompt_text) = response.prompt_text
&& !is_files_only
{
agent.prompt.set_text(prompt_text);
} else if let Some(draft) = stashed_draft {
agent.prompt.restore(draft);
}
if !is_files_only {
agent.set_active_pane(crate::app::agent_view::ActivePane::Prompt, false);
}
agent.rewind_points = None;
agent.scrollback.goto_bottom();
if let Some(text) = inline_resubmit
&& !is_files_only
{
if app.active_view == ActiveView::Agent(agent_id) {
// Resubmit from the rewound point; `consume_input=false` keeps
// the composer draft, `literal=true` sends slash-lookalike text
// as a prompt (the transcript is already truncated — running it
// as a command would swallow the resubmit).
return super::prompt::dispatch_send_prompt_inner(
app, text, /* consume_input */ false, /* literal */ true,
/* is_follow_up */ false,
);
}
// View switched mid-rewind: fall back to prefilling that composer,
// appending so an existing draft isn't clobbered.
if let Some(agent) = app.agents.get_mut(&agent_id) {
if agent.prompt.text().trim().is_empty() {
agent.prompt.set_text(&text);
} else {
agent.prompt.append_text(&format!("\n{text}"));
}
}
}
vec![]
}
// TaskResult handlers.
pub(super) fn handle_rewind_points_loaded(
app: &mut AppView,
agent_id: AgentId,
points: Vec<crate::views::rewind::RewindPointInfo>,
) -> Vec<Effect> {
let Some(agent) = app.agents.get_mut(&agent_id) else {
return vec![];
};
agent.rewind_points = Some(points.clone());
let desired_target = agent
.rewind_state
.as_ref()
.and_then(|s| s.selected_prompt_index);
let stashed = agent.rewind_state.take().and_then(|s| s.stashed_draft);
if points.is_empty() {
if let Some(stashed) = stashed {
agent.prompt.restore(stashed);
}
app.show_toast("No undoable prompts");
return vec![];
}
if let Some(dt) = desired_target {
let resolved = points
.iter()
.find(|p| p.prompt_index == dt)
.or_else(|| points.iter().max_by_key(|p| p.prompt_index))
.cloned();
if let Some(point) = resolved {
let target = point.prompt_index;
let has_file_changes = point.has_file_changes;
let anchor = find_user_prompt_entry_for_shell_index(&agent.scrollback, target);
let draft = stashed.or_else(|| stash_prompt(&mut agent.prompt));
if let Some(entry_idx) = anchor {
agent.scrollback.set_selected(Some(entry_idx));
}
agent.rewind_state = Some(crate::views::rewind::RewindState::new_mode_select(
anchor.unwrap_or(0),
target,
has_file_changes,
// Inline edit-and-resubmit: the conversation rewind is a
// given — hide the "File changes only" row entirely.
agent.inline_edit.is_none(),
draft,
));
}
} else {
let mut sorted = points.clone();
sorted.sort_by_key(|e| std::cmp::Reverse(e.prompt_index));
let draft = stashed.or_else(|| stash_prompt(&mut agent.prompt));
let initial_anchor = sorted
.first()
.map(|p| {
find_user_prompt_entry_for_shell_index(&agent.scrollback, p.prompt_index)
.unwrap_or(0)
})
.unwrap_or(0);
agent.rewind_state = Some(crate::views::rewind::RewindState {
phase: crate::views::rewind::RewindPhase::Picker {
points: sorted,
selected: 0,
},
anchor_entry_idx: initial_anchor,
stashed_draft: draft,
selected_prompt_index: None,
});
agent.scrollback.scroll_to_entry_center(initial_anchor);
}
vec![]
}
pub(super) fn handle_rewind_preview_complete(
app: &mut AppView,
agent_id: AgentId,
response: crate::views::rewind::RewindResponse,
target_prompt_index: usize,
mode: crate::views::rewind::RewindMode,
) -> Vec<Effect> {
let Some(agent) = app.agents.get_mut(&agent_id) else {
return vec![];
};
if response.error.is_some() && response.clean_files.is_empty() && response.conflicts.is_empty()
{
let err = response.error.unwrap_or_default();
let anchor = agent
.rewind_state
.as_ref()
.map(|s| s.anchor_entry_idx)
.unwrap_or(0);
let draft = agent.rewind_state.take().and_then(|s| s.stashed_draft);
agent.rewind_state = Some(crate::views::rewind::RewindState {
phase: crate::views::rewind::RewindPhase::Error { message: err },
anchor_entry_idx: anchor,
stashed_draft: draft,
selected_prompt_index: None,
});
return vec![];
}
let conflicts: Vec<_> = response
.conflicts
.iter()
.map(crate::views::rewind::ConflictDisplay::from_conflict)
.collect();
let anchor = agent
.rewind_state
.as_ref()
.map(|s| s.anchor_entry_idx)
.unwrap_or(0);
let preview = agent
.rewind_points
.as_ref()
.and_then(|pts| pts.iter().find(|p| p.prompt_index == target_prompt_index))
.and_then(|p| p.prompt_preview.clone());
let draft = agent.rewind_state.take().and_then(|s| s.stashed_draft);
agent.rewind_state = Some(crate::views::rewind::RewindState {
phase: crate::views::rewind::RewindPhase::Confirm {
target_prompt_index,
mode,
clean_files: response.clean_files,
conflicts,
active_idx: 0,
prompt_preview: preview,
},
anchor_entry_idx: anchor,
stashed_draft: draft,
selected_prompt_index: None,
});
vec![]
}
pub(super) fn handle_rewind_preview_failed(
app: &mut AppView,
agent_id: AgentId,
error: String,
) -> Vec<Effect> {
let Some(agent) = app.agents.get_mut(&agent_id) else {
return vec![];
};
let anchor = agent
.rewind_state
.as_ref()
.map(|s| s.anchor_entry_idx)
.unwrap_or(0);
let draft = agent.rewind_state.take().and_then(|s| s.stashed_draft);
agent.rewind_state = Some(crate::views::rewind::RewindState {
phase: crate::views::rewind::RewindPhase::Error { message: error },
anchor_entry_idx: anchor,
stashed_draft: draft,
selected_prompt_index: None,
});
vec![]
}
pub(super) fn handle_rewind_execute_failed(
app: &mut AppView,
agent_id: AgentId,
error: String,
) -> Vec<Effect> {
let Some(agent) = app.agents.get_mut(&agent_id) else {
return vec![];
};
// A pending inline resubmit dies with its rewind; the editor itself
// stays open so dismissing the error returns to editing.
agent.pending_inline_resubmit = None;
let anchor = agent
.rewind_state
.as_ref()
.map(|s| s.anchor_entry_idx)
.unwrap_or(0);
let draft = agent.rewind_state.take().and_then(|s| s.stashed_draft);
agent.rewind_state = Some(crate::views::rewind::RewindState {
phase: crate::views::rewind::RewindPhase::Error { message: error },
anchor_entry_idx: anchor,
stashed_draft: draft,
selected_prompt_index: None,
});
vec![]
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,410 @@
use crate::app::actions::Effect;
use crate::app::app_view::{AppView, SessionPickerEntry};
use crate::app::dispatch::ctx::get_active_agent_mut;
use crate::app::effects::ConversationsPartial;
use crate::views::modal::ActiveModal;
use crate::views::picker::PickerState;
use crate::views::session_picker::{
PickerSelectionAnchor, SessionPickerLanes, SessionPickerPendingNotice, SourceFilter,
capture_picker_selection, effective_filter_query, repo_name_from_cwd, restore_picker_selection,
};
type SearchHit = kigi_shell::extensions::session_search::SearchSessionHit;
struct PickerSurface<'a> {
entries: &'a mut Option<Vec<SessionPickerEntry>>,
loading: &'a mut bool,
lanes: &'a mut SessionPickerLanes,
state: &'a mut PickerState,
content_results: &'a mut Option<Vec<SearchHit>>,
content_loading: &'a mut bool,
entries_query: &'a mut Option<String>,
source_filter: SourceFilter,
grouped: bool,
current_repo: String,
}
impl PickerSurface<'_> {
fn capture_selection(&self) -> PickerSelectionAnchor {
capture_picker_selection(
self.entries.as_deref(),
self.content_results.as_deref(),
self.state,
effective_filter_query(&self.state.query, self.entries_query.as_deref()),
self.grouped,
*self.content_loading,
self.source_filter,
Some(&self.current_repo),
)
}
fn restore_selection(&mut self, anchor: PickerSelectionAnchor) {
let filter_query =
effective_filter_query(&self.state.query, self.entries_query.as_deref()).to_owned();
restore_picker_selection(
anchor,
self.entries.as_deref(),
self.content_results.as_deref(),
self.state,
&filter_query,
self.grouped,
*self.content_loading,
self.source_filter,
Some(&self.current_repo),
);
self.state.expanded.clear();
}
fn native_loaded(
&mut self,
sessions: Vec<SessionPickerEntry>,
query: Option<String>,
chat_mode: bool,
empty_notice: String,
partial_notice: Option<&'static str>,
) -> Option<String> {
let anchor = self.capture_selection();
let is_search = query.is_some();
*self.loading = false;
if is_search {
*self.content_loading = false;
}
*self.entries_query = query;
if chat_mode {
*self.entries = (!sessions.is_empty()).then_some(sessions);
} else {
crate::app::foreign_sessions::replace_native_entries(self.entries, sessions);
}
if is_search && self.entries.is_none() {
*self.entries = Some(Vec::new());
}
let notice = if self.entries.is_none() && !is_search {
if self.lanes.foreign_loading {
self.lanes.pending_notice = Some(SessionPickerPendingNotice::Empty(empty_notice));
None
} else {
self.lanes.pending_notice = None;
Some(empty_notice)
}
} else {
self.lanes.pending_notice = None;
if chat_mode {
partial_notice.map(str::to_owned)
} else {
None
}
};
self.restore_selection(anchor);
notice
}
fn native_failed(
&mut self,
error_notice: String,
is_search: bool,
chat_mode: bool,
) -> Option<String> {
let anchor = self.capture_selection();
*self.loading = false;
if is_search {
*self.content_loading = false;
}
if chat_mode {
*self.entries = None;
} else {
crate::app::foreign_sessions::replace_native_entries(self.entries, Vec::new());
}
*self.entries_query = None;
let notice = if self.lanes.foreign_loading {
self.lanes.pending_notice = Some(SessionPickerPendingNotice::Error(error_notice));
None
} else {
self.lanes.pending_notice = None;
Some(error_notice)
};
self.restore_selection(anchor);
notice
}
fn foreign_loaded(&mut self, scanned: Vec<SessionPickerEntry>) -> Option<String> {
let anchor = self.capture_selection();
crate::app::foreign_sessions::replace_foreign_entries(self.entries, scanned);
self.lanes.foreign_loading = false;
let notice = self.lanes.take_ready_notice(self.entries.is_some());
self.restore_selection(anchor);
notice
}
}
pub(in crate::app::dispatch) fn dispatch_fetch_session_list(app: &mut AppView) -> Vec<Effect> {
app.session_picker_detail_generation += 1;
app.session_picker_loading = true;
app.session_picker_entries = None;
app.session_picker_state.selected = 0;
app.session_picker_state.query.clear();
app.session_picker_state.query_cursor = 0;
app.session_picker_state.search_active = false;
app.session_picker_state.expanded.clear();
app.session_picker_content_results = None;
app.session_picker_content_loading = false;
app.session_picker_entries_query = None;
if app.chat_mode {
app.session_picker_list_seq += 1;
}
app.foreign_session_scan_seq += 1;
let foreign_seq = app.foreign_session_scan_seq;
let mut effects = vec![Effect::FetchSessionList {
query: None,
seq: app.session_picker_list_seq,
}];
let foreign_effect = if app.chat_mode {
app.foreign_scan_coordinator.begin_request(foreign_seq);
None
} else {
let kigi_home = kigi_tools::util::kigi_home::kigi_home();
crate::app::foreign_sessions::scan_effect(
&app.cwd,
app.foreign_session_compat,
&kigi_home,
app.foreign_scan_coordinator.clone(),
foreign_seq,
)
};
let foreign_loading = foreign_effect.is_some();
let mut modal_lanes_set = false;
if let Some(agent) = get_active_agent_mut(app)
&& let Some(ActiveModal::SessionPicker { lanes, .. }) = agent.active_modal.as_mut()
{
lanes.foreign_loading = foreign_loading;
lanes.pending_notice = None;
modal_lanes_set = true;
}
app.session_picker_lanes.foreign_loading = foreign_loading && !modal_lanes_set;
app.session_picker_lanes.pending_notice = None;
effects.extend(foreign_effect);
effects
}
pub(in crate::app::dispatch) fn handle_session_list_loaded(
app: &mut AppView,
sessions: Vec<SessionPickerEntry>,
partial: Option<ConversationsPartial>,
seq: u64,
query: Option<String>,
) -> Vec<Effect> {
if seq != app.session_picker_list_seq {
return vec![];
}
app.session_picker_detail_generation += 1;
if let Some(partial) = partial {
crate::unified_log::warn(
"session.list.partial",
None,
Some(serde_json::json!({ "reason": format!("{partial:?}") })),
);
}
let empty_notice = partial.map_or_else(
|| "No sessions found for this directory".to_owned(),
|partial| partial.picker_notice().to_owned(),
);
let partial_notice = partial.map(ConversationsPartial::picker_notice);
let chat_mode = app.chat_mode;
let mut sessions = Some(sessions);
let mut notice = None;
if let Some(agent) = get_active_agent_mut(app) {
let current_repo = repo_name_from_cwd(&agent.session.cwd.to_string_lossy());
if let Some(ActiveModal::SessionPicker {
entries,
loading,
lanes,
state,
content_results,
content_loading,
entries_query,
source_filter,
..
}) = agent.active_modal.as_mut()
{
notice = PickerSurface {
entries,
loading,
lanes,
state,
content_results,
content_loading,
entries_query,
source_filter: *source_filter,
grouped: true,
current_repo,
}
.native_loaded(
sessions.take().unwrap_or_default(),
query.clone(),
chat_mode,
empty_notice.clone(),
partial_notice,
);
}
}
if let Some(sessions) = sessions {
let current_repo = repo_name_from_cwd(&app.cwd.to_string_lossy());
notice = PickerSurface {
entries: &mut app.session_picker_entries,
loading: &mut app.session_picker_loading,
lanes: &mut app.session_picker_lanes,
state: &mut app.session_picker_state,
content_results: &mut app.session_picker_content_results,
content_loading: &mut app.session_picker_content_loading,
entries_query: &mut app.session_picker_entries_query,
source_filter: app.session_picker_source_filter,
grouped: app.session_picker_grouped,
current_repo,
}
.native_loaded(sessions, query, chat_mode, empty_notice, partial_notice);
}
if let Some(notice) = notice {
app.show_toast(&notice);
}
vec![]
}
pub(in crate::app::dispatch) fn handle_session_list_failed(
app: &mut AppView,
error: String,
seq: u64,
query: Option<String>,
) -> Vec<Effect> {
if seq != app.session_picker_list_seq {
return vec![];
}
app.session_picker_detail_generation += 1;
tracing::warn!(error = %error, "session list fetch failed");
let error_notice = format!("Couldn't load sessions: {error}");
let is_search = query.is_some();
let chat_mode = app.chat_mode;
let mut handled = false;
let mut notice = None;
if let Some(agent) = get_active_agent_mut(app) {
let current_repo = repo_name_from_cwd(&agent.session.cwd.to_string_lossy());
if let Some(ActiveModal::SessionPicker {
entries,
loading,
lanes,
state,
content_results,
content_loading,
entries_query,
source_filter,
..
}) = agent.active_modal.as_mut()
{
notice = PickerSurface {
entries,
loading,
lanes,
state,
content_results,
content_loading,
entries_query,
source_filter: *source_filter,
grouped: true,
current_repo,
}
.native_failed(error_notice.clone(), is_search, chat_mode);
handled = true;
}
}
if !handled {
let current_repo = repo_name_from_cwd(&app.cwd.to_string_lossy());
notice = PickerSurface {
entries: &mut app.session_picker_entries,
loading: &mut app.session_picker_loading,
lanes: &mut app.session_picker_lanes,
state: &mut app.session_picker_state,
content_results: &mut app.session_picker_content_results,
content_loading: &mut app.session_picker_content_loading,
entries_query: &mut app.session_picker_entries_query,
source_filter: app.session_picker_source_filter,
grouped: app.session_picker_grouped,
current_repo,
}
.native_failed(error_notice, is_search, chat_mode);
}
if let Some(notice) = notice {
app.show_toast(&notice);
}
vec![]
}
pub(in crate::app::dispatch) fn handle_foreign_sessions_scanned(
app: &mut AppView,
scanned: Vec<SessionPickerEntry>,
seq: u64,
) -> Vec<Effect> {
if app.chat_mode || seq != app.foreign_session_scan_seq {
return vec![];
}
app.session_picker_detail_generation += 1;
let mut scanned = Some(scanned);
let mut notice = None;
let mut handled = false;
if let Some(agent) = get_active_agent_mut(app) {
let current_repo = repo_name_from_cwd(&agent.session.cwd.to_string_lossy());
if let Some(ActiveModal::SessionPicker {
entries,
loading,
lanes,
state,
content_results,
content_loading,
entries_query,
source_filter,
..
}) = agent.active_modal.as_mut()
&& lanes.foreign_loading
{
handled = true;
notice = PickerSurface {
entries,
loading,
lanes,
state,
content_results,
content_loading,
entries_query,
source_filter: *source_filter,
grouped: true,
current_repo,
}
.foreign_loaded(scanned.take().unwrap_or_default());
}
}
if !handled && app.session_picker_lanes.foreign_loading {
let current_repo = repo_name_from_cwd(&app.cwd.to_string_lossy());
notice = PickerSurface {
entries: &mut app.session_picker_entries,
loading: &mut app.session_picker_loading,
lanes: &mut app.session_picker_lanes,
state: &mut app.session_picker_state,
content_results: &mut app.session_picker_content_results,
content_loading: &mut app.session_picker_content_loading,
entries_query: &mut app.session_picker_entries_query,
source_filter: app.session_picker_source_filter,
grouped: app.session_picker_grouped,
current_repo,
}
.foreign_loaded(scanned.unwrap_or_default());
}
if let Some(notice) = notice {
app.show_toast(&notice);
}
vec![]
}
pub(in crate::app::dispatch) fn invalidate_foreign_picker(app: &mut AppView) {
app.foreign_session_scan_seq += 1;
app.foreign_scan_coordinator
.begin_request(app.foreign_session_scan_seq);
app.session_picker_lanes = Default::default();
app.session_picker_detail_generation += 1;
}
@@ -0,0 +1,615 @@
//! Fork and project-selection dispatchers and fork placeholder builders.
use super::lifecycle::{dispatch_new_session_inner_with_id, refuse_chat_mode_build_agent};
use crate::acp::tracker::AcpUpdateTracker;
use crate::app::actions::Effect;
use crate::app::agent::{AgentCommand, AgentId, AgentSession, AgentState};
use crate::app::agent_view::{AgentView, McpInitProgress};
use crate::app::app_view::{ActiveView, AppView};
use crate::app::dispatch::ctx::{SwitchCause, switch_to_agent};
use crate::app::dispatch::modes::inherit_auto_mode;
use crate::app::dispatch::prompt::{
consume_chat_kind, dispatch_send_prompt, supersede_open_reload_window,
};
use crate::scrollback::block::RenderBlock;
use crate::scrollback::blocks::SessionEvent;
use crate::scrollback::state::ScrollbackState;
use agent_client_protocol as acp;
use std::time::Instant;
/// Top-level `/fork` dispatcher. Resolves the worktree decision: an
/// explicit `--worktree` / `--no-worktree` flag short-circuits to
/// [`dispatch_fork_resolved`]. When no flag is given and a persisted
/// `fork_worktree_mode` preference is set (`Always` / `Never`), the
/// popup is skipped and the corresponding path is taken directly. The
/// `Ask` default opens the [`open_fork_question`] modal so the user is
/// asked.
///
/// When the parent session's working directory is **not** inside a git
/// repository (indicated by the absence of a `git_head_changed`
/// notification — `current_branch` is `None`):
/// - `--worktree` is rejected with a toast (nothing to create a worktree from).
/// - No flag (regardless of `fork_worktree_mode`): the worktree question
/// is skipped and the fork proceeds with `worktree = false`.
///
/// Note: if the notification has not arrived yet (rare — user forks
/// before the shell sends `git_head_changed`), the fallback to
/// `worktree = false` is safe and the worktree can be created manually
/// afterwards.
///
/// Two failure surfaces:
/// - Active view is not an agent: toast and return.
/// - Active agent has no `session_id` (still being created): toast and
/// return. Both rejections are deliberate -- queueing the fork until
/// `SessionLoaded` would require persisting `ForkArgs` across the
/// `TaskResult` and is deferred to v2.
pub(in crate::app::dispatch) fn dispatch_fork(
app: &mut AppView,
args: crate::slash::commands::fork::ForkArgs,
) -> Vec<Effect> {
let ActiveView::Agent(parent_id) = app.active_view else {
app.show_toast("/fork only works inside a session");
return vec![];
};
let (has_session, in_git_repo) = app
.agents
.get(&parent_id)
.map(|a| (a.session.session_id.is_some(), a.current_branch.is_some()))
.unwrap_or((false, false));
if !has_session {
app.show_toast("Cannot fork: session is still being created");
return vec![];
}
match args.worktree_override {
Some(true) if !in_git_repo => {
app.show_toast("Cannot create worktree: not in a git repository");
vec![]
}
Some(worktree) => dispatch_fork_resolved(app, worktree, args.directive),
None => {
if in_git_repo {
use crate::app::app_view::WorktreeMode;
match app.fork_worktree_mode {
WorktreeMode::Always => dispatch_fork_resolved(app, true, args.directive),
WorktreeMode::Never => dispatch_fork_resolved(app, false, args.directive),
WorktreeMode::Ask => open_fork_question(app, args.directive),
}
} else {
dispatch_fork_resolved(app, false, args.directive)
}
}
}
}
/// If `persist_mode` is `Some`, write `mode` into `*field` and append
/// a [`Effect::PersistWorktreeMode`] to `effects` with the given
/// `config_key`.
pub(in crate::app::dispatch) fn apply_persist_worktree_mode(
field: &mut crate::app::app_view::WorktreeMode,
effects: &mut Vec<Effect>,
persist_mode: Option<crate::app::app_view::WorktreeMode>,
config_key: &'static str,
) {
if let Some(mode) = persist_mode {
*field = mode;
effects.push(Effect::PersistWorktreeMode { mode, config_key });
}
}
/// Build the two persistence options shared by the fork and new-session
/// worktree question modals ("Always worktree" / "Never worktree").
pub(super) fn worktree_persist_options()
-> [kigi_tools::implementations::grok_build::ask_user_question::QuestionOption; 2] {
use kigi_tools::implementations::grok_build::ask_user_question::QuestionOption;
[
QuestionOption {
label: "Always worktree".into(),
description: "Use worktree and stop asking (reset in config.toml)".into(),
preview: None,
id: None,
},
QuestionOption {
label: "Never worktree".into(),
description: "Skip worktree and stop asking (reset in config.toml)".into(),
preview: None,
id: None,
},
]
}
/// Open the local worktree question modal on the active agent. Refuses
/// if a question (ACP or local) is already on screen, surfacing a toast
/// instead -- the modal-collision protocol.
fn open_fork_question(app: &mut AppView, directive: Option<String>) -> Vec<Effect> {
use crate::views::question_view::{LocalQuestionKind, QuestionViewState};
use kigi_tools::implementations::grok_build::ask_user_question::{Question, QuestionOption};
let ActiveView::Agent(id) = app.active_view else {
return vec![];
};
let Some(agent) = app.agents.get_mut(&id) else {
return vec![];
};
if agent.question_view.is_some() {
app.show_toast("Finish answering the current question first");
return vec![];
}
let mut options = vec![
QuestionOption {
label: "Yes".into(),
description: "Fork in a new isolated git worktree".into(),
preview: None,
id: None,
},
QuestionOption {
label: "No".into(),
description: "Fork in the current cwd".into(),
preview: None,
id: None,
},
];
options.extend(worktree_persist_options());
let question = Question {
question: "Run this fork in an isolated git worktree?".into(),
id: None,
options,
multi_select: Some(false),
};
let agent = app.agents.get_mut(&id).expect("agent present (re-borrow)");
let stashed = agent.prompt.stash();
let state = QuestionViewState::new(
format!("fork-{}", uuid::Uuid::new_v4()),
vec![question],
stashed,
)
.with_local_kind(LocalQuestionKind::Fork { directive });
agent.question_view = Some(state);
agent.prompt.set_text("");
vec![]
}
/// Construct the placeholder agent, push discoverability markers, flip
/// the discovery gate, switch to the new agent, and emit the appropriate
/// fork effect (worktree or no-worktree path).
///
/// `worktree == true` reuses the existing
/// [`Effect::CreateWorktreeSession`] pipeline (with `load_session_id`
/// set to the parent session id). `worktree == false` emits the new
/// [`Effect::ForkSession`] which calls `x.ai/session/fork` directly.
pub(in crate::app::dispatch) fn dispatch_fork_resolved(
app: &mut AppView,
worktree: bool,
directive: Option<String>,
) -> Vec<Effect> {
let ActiveView::Agent(parent_id) = app.active_view else {
return vec![];
};
let Some(parent) = app.agents.get(&parent_id) else {
return vec![];
};
let Some(parent_session_id) = parent.session.session_id.clone() else {
app.show_toast("Cannot fork: session not yet created");
return vec![];
};
let parent_cwd = parent.session.cwd.clone();
let parent_is_worktree = parent.session.is_worktree;
let new_id = AgentId(app.next_agent_id);
app.next_agent_id += 1;
let new_agent = build_fork_placeholder(app, new_id, parent_id, &parent_cwd, worktree);
let parent_marker = match directive.as_deref() {
Some(d) => format!("Forked: {d}"),
None => "Forked".to_string(),
};
let parent_chat_kind = parent.chat_kind || app.chat_mode;
app.agents.insert(new_id, new_agent);
{
let agent = app
.agents
.get_mut(&new_id)
.expect("just-inserted agent missing");
agent.prompt.set_compact(app.appearance.prompt.compact);
agent.prompt.adopt_slash_mru(app.slash_mru.clone());
agent
.prompt
.set_contextual_hints(app.contextual_hints.undo, app.contextual_hints.plan_mode);
agent.set_session_recap_available(app.session_recap_available);
agent.apply_app_scoped_gates(
app.sharing_enabled,
app.usage_visible,
app.chat_mode,
app.screen_mode,
&app.tier_restricted_commands,
);
agent.chat_kind = parent_chat_kind;
agent.apply_credit_balance(app.credit_balance.clone(), app.auto_topup.clone());
agent
.prompt
.slash_controller
.registry_mut()
.set_plugins_visible(!app.appearance.disable_plugins);
agent.pending_fork_banner = Some(crate::app::agent_view::PendingForkBanner {
parent_sid: parent_session_id.0.to_string(),
worktree,
});
if worktree {
agent
.scrollback
.push_block(RenderBlock::system("Creating worktree\u{2026}".to_string()));
}
agent.pending_first_prompt = directive;
}
if let Some(parent_mut) = app.agents.get_mut(&parent_id) {
parent_mut
.scrollback
.push_block(RenderBlock::system(parent_marker));
}
switch_to_agent(app, new_id, SwitchCause::Fork);
if worktree {
vec![Effect::CreateWorktreeSession {
agent_id: new_id,
load_session_id: Some(parent_session_id.0.to_string()),
label: None,
git_ref: None,
model_id: None,
preferred_session_id: None,
chat_kind: parent_chat_kind,
}]
} else {
vec![Effect::ForkSession {
agent_id: new_id,
parent_session_id,
parent_cwd,
parent_is_worktree,
new_session_id: None,
}]
}
}
pub(in crate::app::dispatch) fn open_project_question(
app: &mut AppView,
prompt_text: String,
) -> Vec<Effect> {
use crate::views::question_view::{LocalQuestionKind, QuestionViewState};
let ActiveView::Agent(id) = app.active_view else {
return vec![];
};
let Some(agent) = app.agents.get_mut(&id) else {
return vec![];
};
if agent.question_view.is_some() {
return vec![];
}
let recent_dirs = tokio::task::block_in_place(|| {
tokio::runtime::Handle::current()
.block_on(crate::project_picker::sources::collect_recent_dirs(10))
});
let pq = crate::project_picker::build_project_question(&recent_dirs, &app.cwd);
if pq.resolved_paths.len() <= 1 {
return dispatch_project_selected(app, app.cwd.clone(), prompt_text, false);
}
let stashed = agent.prompt.stash();
let state = QuestionViewState::new(
format!("project-select-{}", uuid::Uuid::new_v4()),
vec![pq.question],
stashed,
)
.with_local_kind(LocalQuestionKind::ProjectSelect {
resolved_paths: pq.resolved_paths,
original_cwd: app.cwd.clone(),
stashed_prompt: prompt_text,
dont_ask_index: pq.dont_ask_index,
});
let Some(agent) = app.agents.get_mut(&id) else {
return vec![];
};
agent.question_view = Some(state);
agent.prompt.set_text("");
crate::unified_log::info("project_picker.opened", None, None);
vec![]
}
pub(in crate::app::dispatch) fn dispatch_project_selected(
app: &mut AppView,
path: std::path::PathBuf,
stashed_prompt: String,
disable_picker: bool,
) -> Vec<Effect> {
crate::unified_log::info(
"project_picker.selected",
None,
Some(serde_json::json!(
{ "path" : path.display().to_string(), "prompt_len" : stashed_prompt
.len(), "disable_picker" : disable_picker }
)),
);
app.mark_project_picker_done();
let mut effects = Vec::new();
if disable_picker {
app.project_picker_disabled = true;
app.show_toast("Won't ask about project directory again (reset in config.toml)");
effects.push(Effect::PersistProjectPickerDisabled { disabled: true });
}
let path = if path.is_dir() {
path
} else {
app.show_toast("Directory not found, continuing in current directory");
app.cwd.clone()
};
app.cwd = path.clone();
crate::git_info::populate_from_cwd_async(path.clone());
effects.push(Effect::SetWorkingDir { path: path.clone() });
let ActiveView::Agent(id) = app.active_view else {
effects.extend(dispatch_send_prompt(app, stashed_prompt));
return effects;
};
if let Some(agent) = app.agents.get_mut(&id) {
let changed = agent.session.cwd != path;
agent.session.cwd = path.clone();
if changed {
let display = crate::project_picker::sources::display_path(&path);
agent.show_toast(&format!("Updated working directory to {display}"));
}
}
if let Some(agent) = app.agents.get_mut(&id) {
agent.mcp_init_progress = Some(McpInitProgress {
total: 0,
connected: 0,
started_at: Instant::now(),
});
agent.session.prompt_history_loading = true;
}
let preferred_session_id = app.deferred_startup.preferred_session_id.take();
let chat_kind = consume_chat_kind(app);
if let Some(agent) = app.agents.get_mut(&id) {
agent.chat_kind = chat_kind;
agent.apply_credit_balance(app.credit_balance.clone(), app.auto_topup.clone());
}
effects.push(Effect::CreateSession {
agent_id: id,
cwd: path,
model_id: None,
preferred_session_id,
chat_kind,
});
effects.extend(dispatch_send_prompt(app, stashed_prompt));
effects
}
/// Build the placeholder [`AgentView`] for a fork. Centralises the
/// `AgentSession`/spinner construction shared by both worktree and
/// no-worktree branches so the parallel struct literal does not drift.
fn build_fork_placeholder(
app: &AppView,
new_id: AgentId,
parent_id: AgentId,
parent_cwd: &std::path::Path,
worktree: bool,
) -> AgentView {
let mut scrollback = ScrollbackState::new();
scrollback.set_appearance(app.appearance.clone());
let mut agent = AgentView::new(
AgentSession {
id: new_id,
acp_tx: app.acp_tx.clone(),
session_id: None,
models: app.models.clone(),
state: AgentState::Idle,
tracker: AcpUpdateTracker::new(),
cwd: parent_cwd.to_path_buf(),
is_worktree: false,
forked_from: Some(parent_id),
pending_prompts: std::collections::VecDeque::new(),
next_queue_id: 0,
yolo_mode: app.default_yolo,
auto_mode: inherit_auto_mode(app),
prompt_history: Vec::new(),
prompt_history_loading: false,
loading_replay: false,
restore_degree: None,
rate_limited: false,
model_incompatible: false,
credit_limit_blocked: false,
free_usage_blocked: false,
available_commands: app.bootstrap_acp_commands.clone(),
available_commands_generation: 1,
available_tools: None,
model_switch_pending: false,
user_model_preference: None,
deferred_model_switch: app.deferred_model_switch_from_cli(),
bg_tasks: std::collections::BTreeMap::new(),
bg_tool_call_to_task: std::collections::HashMap::new(),
scheduled_tasks: std::collections::HashMap::new(),
in_flight_prompt: None,
current_prompt_id: None,
created_via_new: false,
},
scrollback,
);
let cmd = if worktree {
AgentCommand::CreateWorktree
} else {
AgentCommand::ForkSession
};
agent.session.start_command(cmd);
agent.turn_started_at = Some(Instant::now());
agent
}
/// Build the discoverability banner for the child agent. Includes the
/// child's session id, the full parent session id, and — when
/// `switch_hint` names a command (the caller's
/// [`crate::views::dashboard::session_switch_hint_command`]: `/dashboard`
/// normally, `/resume` in minimal mode where the dashboard is refused) —
/// a session-switch tip so the user knows how to switch back. No-worktree
/// case appends the dim continuation `(both agents share cwd)`.
///
/// Called in `TaskResult::SessionLoaded` (not at dispatch time) because
/// the child's session id is not known until the backend responds.
pub(in crate::app::dispatch) fn build_child_fork_marker(
session_id: &str,
parent_sid: &str,
worktree: bool,
switch_hint: Option<&str>,
) -> String {
let header = if let Some(cmd) = switch_hint {
format!(
"Session {session_id} (forked from {parent_sid}) \u{2014} use {cmd} to switch between sessions",
)
} else {
format!("Session {session_id} (forked from {parent_sid})")
};
if worktree {
header
} else {
format!("{header}\n (both agents share cwd)")
}
}
pub(in crate::app::dispatch) fn dispatch_startup_fork_session(
app: &mut AppView,
parent_session_id: String,
parent_cwd: Option<std::path::PathBuf>,
new_session_id: Option<String>,
) -> Vec<Effect> {
if !app.session_startup_allowed() {
app.deferred_startup.session =
Some(crate::app::session_startup::DeferredSessionStartup::Fork {
parent_session_id,
parent_cwd,
new_session_id,
});
return vec![];
}
let (_agent_id, mut effects) = dispatch_new_session_inner_with_id(app, None);
let agent_id = app
.agents
.keys()
.next_back()
.copied()
.expect("fork placeholder agent");
effects.retain(|e| !matches!(e, Effect::CreateSession { .. }));
let cwd = parent_cwd.unwrap_or_else(|| app.cwd.clone());
let parent_is_worktree =
crate::app::session_startup::parent_session_is_worktree(&parent_session_id, &cwd);
effects.push(Effect::ForkSession {
agent_id,
parent_session_id: acp::SessionId::new(parent_session_id),
parent_cwd: cwd,
parent_is_worktree,
new_session_id,
});
effects
}
#[allow(clippy::too_many_arguments)]
pub(in crate::app::dispatch) fn handle_worktree_forked(
app: &mut AppView,
agent_id: AgentId,
session_id: acp::SessionId,
worktree_path: std::path::PathBuf,
session_cwd: std::path::PathBuf,
code_restored: bool,
restore_summary: Option<String>,
restore_degree: Option<kigi_workspace::session::git::RestoreDegree>,
) -> Vec<Effect> {
let session_id_str = session_id.0.to_string();
let pending_entry = std::mem::take(&mut app.deferred_startup.pending_chat);
let agent_entry = app.agents.get(&agent_id).is_some_and(|a| a.chat_kind);
let conversation_entry = pending_entry || agent_entry;
if crate::app::session_startup::chat_mode_refuses_local_build_load(
app.chat_mode,
conversation_entry,
&session_id_str,
&app.cwd,
) {
refuse_chat_mode_build_agent(app, agent_id);
return vec![];
}
if let Some(agent) = app.agents.get_mut(&agent_id) {
supersede_open_reload_window(agent, agent_id, "WorktreeForked");
agent.session.finish_command();
agent.mark_turn_finished();
agent.bind_session_id(session_id);
agent.scrollback.begin_batch();
agent.begin_replay_window();
agent.session.restore_degree = restore_degree;
agent.session.cwd = session_cwd.clone();
agent.session.is_worktree = true;
app.restore_code = None;
agent.prompt.file_search.retarget(&session_cwd);
agent.scrollback.push_block(RenderBlock::system(format!(
"Worktree ready: {}",
worktree_path.display()
)));
match (code_restored, restore_summary.as_deref()) {
(true, Some(s)) => {
agent
.scrollback
.push_block(RenderBlock::system(format!("\u{2713} Code restored: {s}")));
}
(false, Some(s)) => {
agent.scrollback.push_block(RenderBlock::system(format!(
"\u{26A0} Code restore failed: {s}"
)));
}
_ => {}
}
let effective_chat = conversation_entry || app.chat_mode;
agent.chat_kind = effective_chat;
agent.apply_credit_balance(app.credit_balance.clone(), app.auto_topup.clone());
return vec![Effect::LoadSession {
agent_id,
session_id: session_id_str,
session_cwd: Some(session_cwd),
chat_kind: conversation_entry,
}];
}
vec![]
}
pub(in crate::app::dispatch) fn handle_fork_session_ready(
app: &mut AppView,
agent_id: AgentId,
new_session_id: acp::SessionId,
cwd: std::path::PathBuf,
) -> Vec<Effect> {
let session_id_str = new_session_id.0.to_string();
let pending_entry = std::mem::take(&mut app.deferred_startup.pending_chat);
let agent_entry = app.agents.get(&agent_id).is_some_and(|a| a.chat_kind);
let conversation_entry = pending_entry || agent_entry;
if crate::app::session_startup::chat_mode_refuses_local_build_load(
app.chat_mode,
conversation_entry,
&session_id_str,
&app.cwd,
) {
refuse_chat_mode_build_agent(app, agent_id);
return vec![];
}
if let Some(agent) = app.agents.get_mut(&agent_id) {
supersede_open_reload_window(agent, agent_id, "ForkSessionReady");
agent.session.finish_command();
agent.mark_turn_finished();
agent.bind_session_id(new_session_id);
agent.scrollback.begin_batch();
agent.begin_replay_window();
agent.session.cwd = cwd.clone();
let effective_chat = conversation_entry || app.chat_mode;
agent.chat_kind = effective_chat;
return vec![Effect::LoadSession {
agent_id,
session_id: session_id_str,
session_cwd: Some(cwd),
chat_kind: conversation_entry,
}];
}
vec![]
}
pub(in crate::app::dispatch) fn handle_fork_session_failed(
app: &mut AppView,
agent_id: AgentId,
error: String,
) -> Vec<Effect> {
tracing::error!(agent = ? agent_id, error = % error, "Fork session failed");
if let Some(agent) = app.agents.get_mut(&agent_id) {
agent.pending_extensions_fetch = false;
agent.session.finish_command();
let elapsed = agent.turn_elapsed();
agent.mark_turn_finished();
agent.pending_first_prompt = None;
agent.pending_fork_banner = None;
agent
.scrollback
.push_block(RenderBlock::session_event(SessionEvent::TurnFailed {
error,
elapsed,
}));
}
vec![]
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,7 @@
//! Session lifecycle, loading, picking, modal, and fork dispatchers.
pub(in crate::app::dispatch) mod foreign;
pub(in crate::app::dispatch) mod fork;
pub(in crate::app::dispatch) mod lifecycle;
pub(in crate::app::dispatch) mod load;
pub(in crate::app::dispatch) mod modal;
@@ -0,0 +1,95 @@
//! Session rename / close helpers (shared with the dashboard).
//!
//! The `/sessions` picker modal was removed; rename-via-slash and
//! dashboard close still use these dispatchers.
use crate::app::actions::Effect;
use crate::app::agent::AgentId;
use crate::app::app_view::{ActiveView, AppView};
use crate::app::dispatch::ctx::{SwitchCause, show_welcome, switch_to_agent};
use crate::app::dispatch::task_result::unregister_session_effect;
/// Remove an agent and clean up all references to it:
/// `forked_from` pointers on surviving agents.
pub(in crate::app::dispatch) fn remove_agent_and_cleanup(app: &mut AppView, agent_id: AgentId) {
let removed = app.agents.shift_remove(&agent_id);
for agent in app.agents.values_mut() {
if agent.session.forked_from == Some(agent_id) {
agent.session.forked_from = None;
}
}
if removed.is_some() {
drop(removed);
crate::memory_release::release_retained_memory_with("agent-close");
}
}
/// Close (drop from this pager's in-memory list) the given agent.
///
/// Order matters:
/// 1. Refuse to close the only alive agent (toast "Cannot close the
/// only session -- use /home to exit"). The user has nothing to
/// fall back to inside the agent shell.
/// 2. If the closed agent is currently active, switch first to a
/// surviving peer (parent via `forked_from` if alive, else the
/// first surviving entry) using `SwitchCause::Picker`. If no peer
/// survives, fall back to Welcome (already covered by case 1 --
/// this is a defensive belt).
/// 3. Drop the agent from `app.agents` (`shift_remove` to preserve
/// insertion order on every other entry) and clear `forked_from`
/// references on surviving agents so dangling parent pointers
/// cannot resurface.
pub(in crate::app::dispatch) fn dispatch_sessions_confirm_close(
app: &mut AppView,
closed_id: AgentId,
) -> Vec<Effect> {
if !app.agents.contains_key(&closed_id) {
return vec![];
}
if app.agents.len() == 1 {
app.show_toast("Cannot close the only session -- use /home to exit");
return vec![];
}
if matches!(app.active_view, ActiveView::Agent(id) if id == closed_id) {
let parent = app
.agents
.get(&closed_id)
.and_then(|a| a.session.forked_from)
.filter(|p| app.agents.contains_key(p));
let fallback = parent.or_else(|| app.agents.keys().copied().find(|id| *id != closed_id));
if let Some(target) = fallback {
switch_to_agent(app, target, SwitchCause::Picker);
} else {
show_welcome(app);
}
}
let effects = unregister_session_effect(
app.agents
.get(&closed_id)
.and_then(|a| a.session.session_id.clone()),
);
remove_agent_and_cleanup(app, closed_id);
effects
}
/// Rename the current session via x.ai/session/rename.
///
/// Produces Effect::RenameSession which spawns an async ACP ext request.
/// On completion, TaskResult::RenameSessionComplete shows the result.
pub(in crate::app::dispatch) fn dispatch_rename_session(
app: &mut AppView,
title: 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 Some(session_id) = agent.session.session_id.clone() else {
return vec![];
};
agent.display_name = Some(title.clone());
vec![Effect::RenameSession {
agent_id: id,
session_id,
title,
cwd: agent.session.cwd.clone(),
}]
}
@@ -0,0 +1,4 @@
//! Settings setters and settings UI dispatchers.
pub(in crate::app::dispatch) mod setters;
pub(in crate::app::dispatch) mod ui;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,508 @@
//! Session status, sharing, privacy, usage, and info dispatchers.
use super::ctx::get_active_agent;
use super::settings::ui::refresh_open_settings_modals;
use crate::app::actions::Effect;
use crate::app::agent::AgentId;
use crate::app::agent_view::AgentView;
use crate::app::app_view::{ActiveView, AppView};
use crate::notifications::{NotificationEvent, NotificationEventKind};
use crate::scrollback::block::RenderBlock;
/// Toggle YOLO mode (auto-approve all permissions).
///
/// When turning ON: auto-approve all currently queued permissions and
/// restore the stashed prompt. Future incoming permissions will be
/// auto-approved in `handle_permission_request`.
///
/// Share the current session via a public URL.
///
/// Produces Effect::ShareSession which spawns an async ACP ext request.
/// On completion, TaskResult::ShareSessionComplete shows the URL in scrollback.
pub(super) fn dispatch_share_session(app: &mut AppView) -> Vec<Effect> {
if !app.sharing_enabled {
app.show_toast("Sharing is disabled");
return 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 {
// No active session — error should have been caught by slash command,
// but guard here just in case.
return vec![];
};
vec![Effect::ShareSession {
agent_id: id,
session_id,
}]
}
/// Show session info: fetch via x.ai/session/info and display in scrollback.
///
/// Produces Effect::ShowSessionInfo which spawns an async ACP ext request.
/// On completion, TaskResult::SessionInfoComplete shows the formatted info.
pub(super) fn dispatch_show_session_info(app: &mut AppView) -> 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 {
// No active session — error should have been caught by slash command,
// but guard here just in case.
return vec![];
};
vec![Effect::ShowSessionInfo {
agent_id: id,
session_id,
show_resolved_model: app.show_resolved_model,
}]
}
/// Show privacy and data retention status as a system message in scrollback.
///
/// Three-state display: Enterprise ZDR, coding data sharing opted out,
/// or opted in. Labels align with `CODING_DATA_SHARING_CHOICES` in
/// `settings/defs.rs` and the `coding_data_sharing_toast` format.
pub(super) fn dispatch_show_privacy_info(app: &mut AppView) -> Vec<Effect> {
let mut lines = Vec::new();
if app.is_zdr {
// Enterprise ZDR -- the team has disabled retention entirely.
lines.push(" Zero Data Retention: enabled");
lines.push(" Your data is not retained or used for training (ZDR enabled).");
} else if app.coding_data_retention_opt_out {
// Coding data sharing opted out -- matches desktop's "Privacy mode" state.
lines.push(" Privacy: privacy mode");
lines.push(" Your code data will not be trained on or used to improve the product.");
lines.push("");
lines.push(" Use /privacy opt-in to share data and help improve the product.");
} else {
// Coding data sharing opted in -- matches desktop's "Share data" state.
lines.push(" Privacy: share data");
lines.push(" Usage and code data may be used by SpaceXAI to improve the product.");
lines.push("");
lines.push(" Use /privacy opt-out to enable privacy mode.");
}
lines.push("");
lines.push(" Learn more: https://x.ai/legal");
let text = lines.join("\n");
push_system_to_any_agent(app, &text);
vec![]
}
/// State-only mutation for `coding_data_sharing`. SHELL-owned.
pub(super) fn set_coding_data_sharing_inner(app: &mut AppView, opted_in: bool) {
app.coding_data_retention_opt_out = !opted_in;
}
/// Set coding-data-sharing preference. SHELL-owned, auth-metadata-backed
/// (persists via ACP ext-request, NOT `~/.kigi/config.toml`).
pub(super) fn set_coding_data_sharing(app: &mut AppView, opted_in: bool) -> Vec<Effect> {
// ── Guard 1: Enterprise ZDR ──────────────────────────────────────
if app.is_zdr {
app.show_toast("\u{2717} Cannot change: Zero Data Retention enabled");
return vec![];
}
// ── Guard 2: Non-admin team member ───────────────────────────────
if app.team_name.is_some() {
let is_admin = app
.team_role
.as_deref()
.is_some_and(|r| r.eq_ignore_ascii_case("admin"));
if !is_admin {
app.show_toast("\u{2717} Data sharing is controlled by your team admin");
return vec![];
}
}
// ── Guard 3: an agent must exist to thread the ACP call through ──
let agent_id = match app.active_view {
crate::app::app_view::ActiveView::Agent(id) => id,
_ => match app.agents.keys().next().copied() {
Some(id) => id,
None => {
tracing::warn!(
target: "settings",
key = "coding_data_sharing",
opted_in,
"set_coding_data_sharing called with no agents — unreachable in \
practice; returning empty (no toast: app.show_toast would no-op)",
);
return vec![];
}
},
};
let prev = !app.coding_data_retention_opt_out;
// ── Idempotent path: toast but skip the ACP round-trip. ──────────
if prev == opted_in {
app.show_toast(&coding_data_sharing_toast(opted_in));
return vec![];
}
// ── Optimistic mutation: state, then UI feedback, then effect. ───
set_coding_data_sharing_inner(app, opted_in);
refresh_open_settings_modals(app);
app.show_toast(&coding_data_sharing_toast(opted_in));
tracing::info!(
target: "settings",
key = "coding_data_sharing",
opted_in,
"setting changed",
);
vec![Effect::SetCodingDataSharing {
agent_id,
opted_in,
rollback_to_opted_in: prev,
}]
}
/// Format the `Coding data sharing` toast. Asymmetric: opt-in
/// (privacy-degrading) uses ⚠ + consequence text; opt-out (safe
/// default) uses ✓. Uses display names from the registry catalog.
pub(super) fn coding_data_sharing_toast(opted_in: bool) -> String {
let display = display_for_coding_data_sharing_canonical(opted_in);
if opted_in {
// Privacy-degrading: warn glyph + spelled-out consequence.
format!(
"\u{26A0} Coding data sharing: {display} \u{2014} code samples may be retained \
for training"
)
} else {
// Safe default — uniform ✓ glyph.
format!("\u{2713} Coding data sharing: {display}")
}
}
/// Display string for the canonical bool. Keep aligned with
/// `CODING_DATA_SHARING_CHOICES` in `settings/defs.rs`.
fn display_for_coding_data_sharing_canonical(opted_in: bool) -> &'static str {
if opted_in { "Opt in" } else { "Opt out" }
}
/// Scrub an untrusted error string for toast display. Substitutes a
/// generic placeholder when the input exceeds 120 chars or contains
/// control / bidi-override characters (prevents escape-sequence
/// injection and visual spoofing). Full error stays in tracing logs.
pub(super) fn scrub_error_for_toast(error: &str) -> String {
const MAX_TOAST_ERROR_LEN: usize = 120;
if error.len() > MAX_TOAST_ERROR_LEN
|| error
.chars()
.any(crate::render::line_utils::is_unsafe_display_char)
{
"server error (see logs for details)".to_string()
} else {
error.to_string()
}
}
/// Push a system message to the active agent's scrollback, or to any available
/// agent if on the welcome screen.
fn push_system_to_any_agent(app: &mut AppView, msg: &str) {
let block = crate::scrollback::block::RenderBlock::system(msg.to_string());
if let ActiveView::Agent(id) = app.active_view
&& let Some(agent) = app.agents.get_mut(&id)
{
agent.scrollback.push_block(block);
return;
}
if let Some(agent) = app.agents.values_mut().next() {
agent.scrollback.push_block(block);
}
}
/// Show context info: fetch via x.ai/session/info and display rich breakdown.
///
/// Produces Effect::ShowContextInfo which spawns an async ACP ext request.
/// On completion, TaskResult::ContextInfoComplete shows the formatted info.
pub(super) fn dispatch_show_context_info(app: &mut AppView) -> 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 {
return vec![];
};
vec![Effect::ShowContextInfo {
agent_id: id,
session_id,
}]
}
/// Show credit usage: fetch billing data and display inline.
///
/// When the remote settings `grok_build_usage_redirect_url` flag is set (delivered via
/// RemoteSettings, targeted at personal-team users), skip the backend fetch and
/// just point the user at that URL instead. This is a kill switch for the
/// personal-team billing path while it is unreliable.
pub(super) fn dispatch_show_usage(app: &mut AppView) -> Vec<Effect> {
let ActiveView::Agent(id) = app.active_view else {
return vec![];
};
if let Some(url) = app.usage_billing_redirect_url.clone() {
if let Some(agent) = app.agents.get_mut(&id) {
agent.scrollback.push_block(RenderBlock::System(
crate::scrollback::blocks::SystemMessageBlock::new(format!(
"Please check your usage on {url}"
)),
));
}
return vec![];
}
// Non-silent fetch: the effect also pulls the auto top-up rule so the
// summary can render usage, prepaid credits, and auto top-up together.
vec![Effect::FetchBilling {
agent_id: id,
silent: false,
}]
}
/// Commit a one-line "update available" notice into the active agent's
/// scrollback. Minimal mode has no welcome screen (the full TUI's update
/// surface), so the background update check's result is shown here instead
/// No-op when there is no active agent.
pub(crate) fn commit_minimal_update_notice(app: &mut AppView, latest_version: &str) {
if let ActiveView::Agent(id) = app.active_view
&& let Some(agent) = app.agents.get_mut(&id)
{
agent.scrollback.push_block(RenderBlock::system(format!(
"Update available: v{latest_version} — restart to apply."
)));
}
}
/// `/queue` — commit a read-only list of the queued prompts as a system block.
/// The text is built by [`crate::app::status_blocks::queue_block_text`]; this
/// just resolves the active agent and pushes it. Works in every render mode; the
/// primary inspection surface in minimal, which has no interactive `QueuePane`.
pub(super) fn dispatch_show_queue(app: &mut AppView) -> Vec<Effect> {
if let ActiveView::Agent(id) = app.active_view
&& let Some(agent) = app.agents.get_mut(&id)
{
let text = crate::app::status_blocks::queue_block_text(agent);
agent.scrollback.push_block(RenderBlock::system(text));
}
vec![]
}
/// `/tasks` — commit a read-only list of background tasks, subagents, and
/// scheduled (`/loop`) tasks as a system block. The text is built by
/// [`crate::app::status_blocks::tasks_block_text`]; this just resolves the
/// active agent and pushes it. Works in every render mode; the primary snapshot
/// surface in minimal, which has no interactive `TasksPane`.
pub(super) fn dispatch_show_tasks(app: &mut AppView) -> Vec<Effect> {
if let ActiveView::Agent(id) = app.active_view
&& let Some(agent) = app.agents.get_mut(&id)
{
let text = crate::app::status_blocks::tasks_block_text(agent);
agent.scrollback.push_block(RenderBlock::system(text));
}
vec![]
}
/// Open the hidden `/gboom` easter egg as a modal over the active agent
/// view. Requires a graphics-capable terminal (kitty protocol or iTerm2);
/// otherwise a toast explains why nothing happened. On session-less
/// surfaces (dashboard, welcome) this is a silent no-op.
///
/// Targets the top-level agent view (where the prompt lives), not a
/// focused subagent view: the modal's tick/draw plumbing runs on the
/// top-level view, mirroring the video viewer.
pub(super) fn dispatch_open_gboom(app: &mut AppView) -> Vec<Effect> {
use crate::terminal::image::{GraphicsProtocol, detect_graphics_protocol};
let ActiveView::Agent(id) = app.active_view else {
return vec![];
};
let Some(agent) = app.agents.get_mut(&id) else {
return vec![];
};
if detect_graphics_protocol() == GraphicsProtocol::None {
agent.show_toast(
"No demons here \u{2014} GBOOM needs a graphics-capable terminal \
(kitty, Ghostty, WezTerm, iTerm2)",
);
return vec![];
}
// Close other media modals: they share the kitty placement id. Drop the
// image viewer's in-flight loader too (its close path clears both —
// a leaked rx would mis-feed the next image viewer's poll loop).
agent.image_viewer = None;
agent.image_load_rx = None;
agent.video_viewer = None;
agent.gboom = Some(crate::gboom::GboomState::new());
vec![]
}
/// Emit a `SessionReady` notification for the given agent.
///
/// Takes `&NotificationService` separately from `&AgentView` to avoid
/// borrow-checker conflicts when `agent` is borrowed from `app.agents`.
pub(super) fn notify_session_ready(
notification_service: &crate::notifications::NotificationService,
agent: &AgentView,
) {
notification_service.notify(NotificationEvent {
kind: NotificationEventKind::SessionReady,
title: "Grok".into(),
body: NotificationEventKind::SessionReady.as_str().into(),
session_id: agent.session.session_id.as_ref().map(|s| s.0.to_string()),
});
}
// TaskResult handlers.
pub(super) fn handle_coding_data_sharing_updated(
app: &mut AppView,
agent_id: AgentId,
opted_in: bool,
) -> Vec<Effect> {
// Re-anchor mirror to server-confirmed value (defense-in-
// depth against server reshaping the boolean). `agent_id`
// discarded — privacy is app-level, not per-agent.
set_coding_data_sharing_inner(app, opted_in);
refresh_open_settings_modals(app);
// Re-toast on confirmation. Without this, a slow ACP
// round-trip would leave the user with only the
// optimistic toast (already faded) and no
// server-confirmed feedback.
app.show_toast(&coding_data_sharing_toast(opted_in));
tracing::info!(
target: "settings",
key = "coding_data_sharing",
?agent_id,
opted_in,
"ACP update confirmed; mirror re-anchored",
);
vec![]
}
pub(super) fn handle_coding_data_sharing_failed(
app: &mut AppView,
agent_id: AgentId,
error: String,
rollback_to_opted_in: bool,
) -> Vec<Effect> {
// Revert optimistic mutation: inner → refresh → toast.
//
// `agent_id` discarded — privacy is global.
set_coding_data_sharing_inner(app, rollback_to_opted_in);
refresh_open_settings_modals(app);
// Scrub long/unsafe error strings before toasting.
let scrubbed = scrub_error_for_toast(&error);
app.show_toast(&format!(
"\u{2717} Couldn't update coding data sharing: {scrubbed}"
));
tracing::warn!(
target: "settings",
key = "coding_data_sharing",
?agent_id,
rollback_to_opted_in,
%error,
"ACP update failed; reverted optimistic mutation",
);
vec![]
}
pub(super) fn handle_context_info_complete(
app: &mut AppView,
agent_id: AgentId,
info: Box<kigi_shell::session::SessionInfoResponse>,
) -> Vec<Effect> {
if let Some(agent) = app.agents.get_mut(&agent_id) {
let model = info.data.model.as_deref().unwrap_or("unknown").to_string();
// Take ownership of the snapshot once, hand a clone to the
// agent's running counters, then move the original into the
// scrollback block (which keeps it for theme-reactive
// re-rendering). This still costs one clone but reads as
// "the agent needs a copy" rather than "the block needs a
// copy", which matches the lifetime story.
let snapshot = info.data.context;
agent.apply_full_context_info(snapshot.clone());
agent
.scrollback
.push_block(crate::scrollback::block::RenderBlock::context_info(
snapshot, model,
));
}
vec![]
}
// Action handlers.
pub(super) fn dispatch_copy_session_id(app: &mut AppView, index: usize) -> Vec<Effect> {
use crate::views::modal::ActiveModal;
// Try agent modal first, then fall back to app fields (welcome screen).
let id = get_active_agent(app)
.and_then(|agent| {
if let Some(ActiveModal::SessionPicker {
entries: Some(ref e),
..
}) = agent.active_modal
{
e.get(index).map(|entry| entry.id.clone())
} else {
None
}
})
.or_else(|| {
app.session_picker_entries
.as_ref()
.and_then(|s| s.get(index))
.map(|e| e.id.clone())
});
if let Some(id) = id {
let r = crate::clipboard::copy_text(&id);
app.show_toast(r.message);
}
vec![]
}
pub(super) fn dispatch_show_release_notes(
app: &mut AppView,
title: String,
content: String,
) -> Vec<Effect> {
match app.active_view {
ActiveView::Agent(id) => {
if let Some(agent) = app.agents.get_mut(&id) {
agent.active_modal = Some(crate::views::modal::ActiveModal::DocViewer {
title,
content,
scroll: 0,
window: crate::views::modal_window::ModalWindowState::new(),
cached_lines: None,
previous_palette: None,
standalone: true,
});
}
}
ActiveView::Welcome => {
app.welcome_doc_viewer = Some(crate::views::modal::ActiveModal::DocViewer {
title,
content,
scroll: 0,
window: crate::views::modal_window::ModalWindowState::new(),
cached_lines: None,
previous_palette: None,
standalone: true,
});
}
_ => {}
}
vec![]
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,320 @@
//! Tests for login, logout, account switching, and auth-code dispatchers.
use super::*;
// ── agent-bound kinds (bash) ─────────
/// A bash command typed while a turn is RUNNING takes the
/// server-authoritative immediate path (Effect + optimistic echo, no local
/// queue entry).
#[test]
fn bash_while_running_is_server_authoritative() {
let mut app = test_app_with_agent();
let id = AgentId(0);
app.agents.get_mut(&id).unwrap().session.state = AgentState::TurnRunning;
let effects = dispatch(Action::SendBashCommand("ls -la".into()), &mut app);
let pid = match &effects[0] {
Effect::SendBashCommand {
command, prompt_id, ..
} => {
assert_eq!(command, "ls -la");
prompt_id.clone()
}
other => panic!("expected immediate SendBashCommand, got {other:?}"),
};
// Not in the local queue.
assert_eq!(app.agents[&id].session.queue_len(), 0);
// Optimistic echo present with kind="bash".
let q = app
.shared_prompt_queue("test-session")
.expect("echo present");
assert_eq!(q.len(), 1);
assert_eq!(q[0].id, pid);
assert_eq!(q[0].kind, "bash");
assert_eq!(q[0].text, "ls -la");
}
#[test]
fn auth_complete_triggers_bundle_status_fetch() {
let mut app = test_app();
app.auth_state = AuthState::Authenticating {
request_seq: 1,
handle: None,
auth_url: None,
mode: AuthMode::Pending,
};
let effects = dispatch(
Action::TaskComplete(TaskResult::AuthComplete {
request_seq: 1,
meta: None,
}),
&mut app,
);
assert!(matches!(app.auth_state, AuthState::Done));
// Pager only refreshes the on-disk catalog snapshot; the actual
// bundle download now runs inside the shell post-auth.
assert!(
effects
.iter()
.any(|e| matches!(e, Effect::FetchBundleStatus))
);
}
#[test]
fn auth_complete_with_deferred_load_also_fetches_status() {
let mut app = test_app();
app.auth_state = AuthState::Authenticating {
request_seq: 1,
handle: None,
auth_url: None,
mode: AuthMode::Pending,
};
app.deferred_startup.session =
Some(crate::app::session_startup::DeferredSessionStartup::Load {
session_id: "test-session".into(),
session_cwd: None,
chat_kind: false,
});
let effects = dispatch(
Action::TaskComplete(TaskResult::AuthComplete {
request_seq: 1,
meta: None,
}),
&mut app,
);
assert!(
effects
.iter()
.any(|e| matches!(e, Effect::FetchBundleStatus))
);
assert!(
effects
.iter()
.any(|e| matches!(e, Effect::LoadSession { .. }))
);
assert!(app.deferred_startup.session.is_none());
}
/// `/login` from the welcome screen (startup / logged-out) must NOT
/// stash a return view — the normal login-then-load flow is preserved.
#[test]
fn login_from_welcome_does_not_stash_return_view() {
let mut app = test_app();
assert_eq!(app.active_view, ActiveView::Welcome);
dispatch(Action::Login, &mut app);
assert_eq!(app.active_view, ActiveView::Welcome);
assert_eq!(app.auth_return_view, None);
}
/// A second auth-failed turn with no rewindable prompt
/// (`in_flight_prompt == None`) must not clobber the stash from an
/// earlier 401.
#[test]
fn second_auth_failure_does_not_clobber_reauth_stash() {
use crate::scrollback::block::RenderBlock;
let mut app = test_app_with_agent();
let id = AgentId(0);
{
let agent = app.agents.get_mut(&id).unwrap();
agent.reauth_stashed_prompt = Some(crate::app::agent::InFlightPrompt {
text: "first prompt".into(),
images: Vec::new(),
scrollback_entry: crate::scrollback::EntryId::new(0),
chip_elements: Vec::new(),
});
agent
.scrollback
.push_block(RenderBlock::session_event(SessionEvent::ReAuthRequired));
agent.session.state = AgentState::TurnRunning;
agent.turn_started_at = Some(std::time::Instant::now());
agent.session.in_flight_prompt = None;
}
dispatch(
Action::TaskComplete(TaskResult::PromptResponse {
agent_id: id,
result: Err("Unauthorized (401)".to_string()),
http_status: Some(401),
prompt_id: None,
}),
&mut app,
);
assert_eq!(
app.agents[&id]
.reauth_stashed_prompt
.as_ref()
.map(|prompt| prompt.text.as_str()),
Some("first prompt"),
"a None in_flight_prompt must not wipe an earlier stash"
);
}
/// Cancelling a mid-session re-auth drops the stashed prompt so it is
/// not silently resubmitted on a later, unrelated login.
#[test]
fn cancel_login_drops_reauth_stashed_prompt() {
let mut app = test_app_with_agent();
let id = AgentId(0);
app.agents.get_mut(&id).unwrap().reauth_stashed_prompt =
Some(crate::app::agent::InFlightPrompt {
text: "stale".into(),
images: Vec::new(),
scrollback_entry: crate::scrollback::EntryId::new(0),
chip_elements: Vec::new(),
});
dispatch(Action::Login, &mut app);
dispatch(Action::CancelLogin, &mut app);
assert!(
app.agents[&id].reauth_stashed_prompt.is_none(),
"cancelling re-auth must drop the stashed prompt"
);
}
/// Cancelling a mid-session re-auth strips the stale `ReAuthRequired`
/// prompt from scrollback so a later `PromptResponse` cannot re-detect
/// it and re-stash the prompt for silent resubmission.
#[test]
fn cancel_login_strips_reauth_prompt_from_scrollback() {
use crate::scrollback::block::RenderBlock;
let mut app = test_app_with_agent();
let id = AgentId(0);
{
let agent = app.agents.get_mut(&id).unwrap();
agent.reauth_stashed_prompt = Some(crate::app::agent::InFlightPrompt {
text: "stale".into(),
images: Vec::new(),
scrollback_entry: crate::scrollback::EntryId::new(0),
chip_elements: Vec::new(),
});
agent
.scrollback
.push_block(RenderBlock::session_event(SessionEvent::ReAuthRequired));
}
dispatch(Action::Login, &mut app);
dispatch(Action::CancelLogin, &mut app);
let sb = &app.agents[&id].scrollback;
let has_reauth = (0..sb.len()).any(|i| {
matches!(
sb.entry(i).map(|e| &e.block),
Some(RenderBlock::SessionEvent(ev)) if matches!(ev.event, SessionEvent::ReAuthRequired)
)
});
assert!(
!has_reauth,
"cancelling re-auth must strip the stale re-auth prompt from scrollback"
);
}
/// Empty `auth_methods` (preferred_method pin unavailable) must not invent
/// `grok.com` or start an OIDC flow the agent did not advertise.
#[test]
fn login_with_empty_auth_methods_fails_closed() {
let mut app = test_app_with_agent();
app.auth_methods.clear();
app.login_method_id = None;
let effects = dispatch(Action::Login, &mut app);
assert!(
effects.is_empty(),
"must not start Authenticate without an advertised method"
);
assert_eq!(
app.active_view,
ActiveView::Agent(AgentId(0)),
"must stay on the session view"
);
assert!(
matches!(
&app.auth_state,
AuthState::Pending { error: Some(msg) }
if msg.contains("preferred_method=api_key")
),
"must surface pin-unavailable error, got {:?}",
app.auth_state
);
assert!(app.login_method_id.is_none());
}
/// Cancelling a mid-session login returns to the session rather than
/// quitting the app, and clears the stashed view + auth state.
#[test]
fn cancel_login_restores_view() {
let mut app = test_app_with_agent();
dispatch(Action::Login, &mut app);
assert_eq!(app.active_view, ActiveView::Welcome);
let effects = dispatch(Action::CancelLogin, &mut app);
assert!(effects.is_empty(), "cancel is pure state, no effects");
assert_eq!(app.active_view, ActiveView::Agent(AgentId(0)));
assert_eq!(app.auth_return_view, None);
assert!(matches!(app.auth_state, AuthState::Done));
}
/// `CancelLogin` outside a mid-session login is a no-op (must not move
/// off the welcome screen or panic).
#[test]
fn cancel_login_noop_without_stashed_view() {
let mut app = test_app();
let effects = dispatch(Action::CancelLogin, &mut app);
assert!(effects.is_empty());
assert_eq!(app.active_view, ActiveView::Welcome);
assert_eq!(app.auth_return_view, None);
}
#[test]
fn auth_complete_extracts_show_resolved_model_from_meta() {
let mut app = test_app();
app.auth_state = AuthState::Authenticating {
request_seq: 1,
handle: None,
auth_url: None,
mode: AuthMode::Pending,
};
assert!(app.show_resolved_model);
dispatch(
Action::TaskComplete(TaskResult::AuthComplete {
request_seq: 1,
meta: Some(serde_json::json!({ "show_resolved_model": false })),
}),
&mut app,
);
assert!(!app.show_resolved_model);
}
#[test]
fn auth_complete_preserves_show_resolved_model_when_absent() {
let mut app = test_app();
app.show_resolved_model = false;
app.auth_state = AuthState::Authenticating {
request_seq: 1,
handle: None,
auth_url: None,
mode: AuthMode::Pending,
};
dispatch(
Action::TaskComplete(TaskResult::AuthComplete {
request_seq: 1,
meta: Some(serde_json::to_value(kigi_shell::auth::AuthMeta::default()).unwrap()),
}),
&mut app,
);
assert!(!app.show_resolved_model);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,414 @@
//! Tests for the `/jump` picker dispatchers.
use super::*;
fn push_turns(app: &mut AppView, id: AgentId, n: usize) {
let agent = app.agents.get_mut(&id).unwrap();
for i in 0..n {
agent
.scrollback
.push_block(RenderBlock::user_prompt(format!("question {i}")));
let tall = (0..8)
.map(|p| format!("answer {i} para {p}"))
.collect::<Vec<_>>()
.join("\n\n");
agent
.scrollback
.push_block(RenderBlock::agent_message(tall));
}
agent.scrollback.prepare_layout(80, 6);
}
#[test]
fn show_picker_needs_two_turns() {
let mut app = test_app_with_agent();
let id = AgentId(0);
push_turns(&mut app, id, 1);
let effects = dispatch(Action::JumpShowPicker, &mut app);
assert!(effects.is_empty());
assert!(
app.agents[&id].jump_state.is_none(),
"a single turn has nothing to jump to"
);
}
#[test]
fn show_picker_snapshots_viewport_and_opens_on_active_turn() {
let mut app = test_app_with_agent();
let id = AgentId(0);
push_turns(&mut app, id, 3);
app.agents.get_mut(&id).unwrap().scrollback.goto_bottom();
dispatch(Action::JumpShowPicker, &mut app);
let agent = &app.agents[&id];
let js = agent.jump_state.as_ref().expect("picker open");
assert_eq!(js.entries.len(), 3);
assert_eq!(js.entries[0].preview, "question 0");
assert_eq!(js.selected, 2, "opens on the turn at the viewport top");
assert!(
js.restore.bookmark.is_some(),
"captured a viewport bookmark"
);
assert!(js.restore.follow_mode, "goto_bottom left follow on");
}
#[test]
fn show_picker_refused_while_rewind_open() {
let mut app = test_app_with_agent();
let id = AgentId(0);
push_turns(&mut app, id, 3);
app.agents.get_mut(&id).unwrap().rewind_state = Some(
crate::views::rewind::RewindState::new_cancel_offer(0, None, None),
);
dispatch(Action::JumpShowPicker, &mut app);
assert!(app.agents[&id].jump_state.is_none());
}
#[test]
fn show_picker_refused_while_inline_edit_open() {
let mut app = test_app_with_agent();
let id = AgentId(0);
push_turns(&mut app, id, 3);
assert!(
app.agents.get_mut(&id).unwrap().enter_inline_edit(0),
"entered inline edit on the first prompt"
);
dispatch(Action::JumpShowPicker, &mut app);
assert!(
app.agents[&id].jump_state.is_none(),
"picker must not stack on an open inline edit (wheel scroll would leak)"
);
}
#[test]
fn show_picker_refused_while_input_overlay_pending() {
// A pending permission / question / cancel-turn / plan-approval overlay
// suppresses the picker's rendering, so opening one would be invisible but
// still eat wheel/keys — `/jump` must refuse.
let mut app = test_app_with_agent();
let id = AgentId(0);
push_turns(&mut app, id, 3);
app.agents.get_mut(&id).unwrap().cancel_turn_view =
Some(crate::views::modal::CancelTurnViewState {
active_idx: 0,
running_count: 1,
});
dispatch(Action::JumpShowPicker, &mut app);
assert!(
app.agents[&id].jump_state.is_none(),
"/jump must not open behind a pending input overlay"
);
}
#[test]
fn scroll_drops_hidden_jump_picker_behind_input_overlay() {
// If an input overlay arrives (async) after the picker opened, the picker
// is hidden but `jump_state` lingers; a wheel event must drop it instead of
// scrolling a cursor the user can't see (and shifting the transcript).
let mut app = test_app_with_agent();
let id = AgentId(0);
push_turns(&mut app, id, 3);
app.agents.get_mut(&id).unwrap().scrollback.goto_bottom();
dispatch(Action::JumpShowPicker, &mut app);
assert!(app.agents[&id].jump_state.is_some(), "picker opened");
app.agents.get_mut(&id).unwrap().cancel_turn_view =
Some(crate::views::modal::CancelTurnViewState {
active_idx: 0,
running_count: 1,
});
app.agents.get_mut(&id).unwrap().handle_scroll(1, 0, 0);
let agent = &app.agents[&id];
assert!(
agent.jump_state.is_none(),
"a hidden picker is dropped on scroll, not driven"
);
assert!(
agent.cancel_turn_view.is_some(),
"the suppressing overlay is untouched"
);
}
#[test]
fn key_drops_hidden_jump_picker_behind_input_overlay() {
// The key-path mirror: with an input overlay pending (and, as here, the
// scrollback pane focused so the pane-gated cancel-turn panel is skipped),
// a key must drop the hidden picker instead of the picker handling it.
use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
let mut app = test_app_with_agent();
let id = AgentId(0);
push_turns(&mut app, id, 3);
dispatch(Action::JumpShowPicker, &mut app);
assert!(app.agents[&id].jump_state.is_some(), "picker opened");
app.agents.get_mut(&id).unwrap().cancel_turn_view =
Some(crate::views::modal::CancelTurnViewState {
active_idx: 0,
running_count: 1,
});
let reg = crate::actions::ActionRegistry::defaults();
let ev = Event::Key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
let _ = app.agents.get_mut(&id).unwrap().handle_input(&ev, &reg);
assert!(
app.agents[&id].jump_state.is_none(),
"a hidden picker is dropped before it can handle keys"
);
}
#[test]
fn ctrl_c_stays_cancellable_with_jump_open() {
use crate::app::agent::AgentState;
use crate::app::app_view::InputOutcome;
use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
// /jump must not swallow the advertised Ctrl+C while a turn is running.
let mut app = test_app_with_agent();
let id = AgentId(0);
push_turns(&mut app, id, 3);
app.agents.get_mut(&id).unwrap().session.state = AgentState::TurnRunning;
dispatch(Action::JumpShowPicker, &mut app);
assert!(app.agents[&id].jump_state.is_some(), "picker opened");
let reg = crate::actions::ActionRegistry::defaults();
let ev = Event::Key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL));
let outcome = app.agents.get_mut(&id).unwrap().handle_input(&ev, &reg);
assert!(
app.agents[&id].jump_state.is_none(),
"Ctrl+C dismissed the picker"
);
assert!(
matches!(outcome, InputOutcome::Action(Action::CancelTurn)),
"and cancelled the running turn, got {outcome:?}"
);
}
#[test]
fn show_picker_refused_while_btw_open() {
// /btw owns the prompt slot; opening /jump behind it would split input.
let mut app = test_app_with_agent();
let id = AgentId(0);
push_turns(&mut app, id, 3);
app.agents.get_mut(&id).unwrap().btw_state = Some(
crate::views::btw_overlay::BtwOverlayState::done("q".into(), "a".into()),
);
dispatch(Action::JumpShowPicker, &mut app);
assert!(
app.agents[&id].jump_state.is_none(),
"/jump must not open behind /btw"
);
}
#[test]
fn session_reload_dismisses_jump_picker() {
// jump_state indexes the pre-reload transcript; a reconnect must drop it.
let mut app = test_app_with_agent();
let id = AgentId(0);
push_turns(&mut app, id, 3);
dispatch(Action::JumpShowPicker, &mut app);
assert!(app.agents[&id].jump_state.is_some(), "picker opened");
app.agents.get_mut(&id).unwrap().begin_session_reload(1);
assert!(
app.agents[&id].jump_state.is_none(),
"reload cleared the picker"
);
}
#[test]
fn picker_select_jumps_and_closes() {
let mut app = test_app_with_agent();
let id = AgentId(0);
push_turns(&mut app, id, 3);
app.agents.get_mut(&id).unwrap().scrollback.goto_bottom();
dispatch(Action::JumpShowPicker, &mut app);
let target_id = app.agents[&id].jump_state.as_ref().unwrap().entries[0].prompt_entry_id;
let target_entry = app.agents[&id].scrollback.index_of_id(target_id).unwrap();
dispatch(Action::JumpPickerSelect(target_id), &mut app);
let agent = &app.agents[&id];
assert!(agent.jump_state.is_none(), "picker closed");
assert_eq!(agent.scrollback.selected(), Some(target_entry));
assert_eq!(agent.scrollback.current_turn(), Some(0));
assert!(!agent.scrollback.is_follow_mode());
}
#[test]
fn picker_select_uses_stable_id_across_removal() {
// The picker carries a stable EntryId, so removing an earlier entry (which
// shifts every positional index) still lands the jump on the intended
// prompt — a positional turn index would target the wrong block.
let mut app = test_app_with_agent();
let id = AgentId(0);
push_turns(&mut app, id, 4);
dispatch(Action::JumpShowPicker, &mut app);
let (first_id, target_id) = {
let entries = &app.agents[&id].jump_state.as_ref().unwrap().entries;
(
entries[0].prompt_entry_id,
entries.last().unwrap().prompt_entry_id,
)
};
// Remove the first turn's prompt, shifting the positional indices.
app.agents
.get_mut(&id)
.unwrap()
.scrollback
.remove_entry(first_id);
dispatch(Action::JumpPickerSelect(target_id), &mut app);
let agent = &app.agents[&id];
assert!(agent.jump_state.is_none(), "picker closed");
let expected = agent
.scrollback
.index_of_id(target_id)
.expect("target prompt still present");
assert_eq!(
agent.scrollback.selected(),
Some(expected),
"stable id lands on the intended prompt even after indices shifted"
);
}
#[test]
fn picker_select_restores_viewport_on_out_of_range_turn() {
// A turn index can go stale if the turn list shrank (async clear/rewind)
// while the picker was open; selecting it must restore the captured
// viewport instead of stranding the transcript at the last preview.
let mut app = test_app_with_agent();
let id = AgentId(0);
push_turns(&mut app, id, 3);
app.agents.get_mut(&id).unwrap().scrollback.goto_bottom();
let at_bottom = app.agents[&id].scrollback.scroll_offset();
dispatch(Action::JumpShowPicker, &mut app);
// Move the preview far from the snapshot so a restore is observable.
{
let agent = app.agents.get_mut(&id).unwrap();
let first_id = agent.jump_state.as_ref().unwrap().entries[0].prompt_entry_id;
let first = agent.scrollback.index_of_id(first_id).unwrap();
agent.scrollback.scroll_to_entry_center(first);
}
assert_ne!(app.agents[&id].scrollback.scroll_offset(), at_bottom);
dispatch(
Action::JumpPickerSelect(crate::scrollback::entry::EntryId::new(999_999)),
&mut app,
);
let agent = &app.agents[&id];
assert!(agent.jump_state.is_none(), "picker closed");
assert_eq!(
agent.scrollback.scroll_offset(),
at_bottom,
"a failed jump restores the captured viewport"
);
assert!(agent.scrollback.is_follow_mode(), "follow restored");
}
#[test]
fn rewind_dismisses_open_jump_picker() {
// The mirror of `show_picker_refused_while_rewind_open`: starting rewind
// while the picker is open must dismiss it (and restore its viewport), so
// the input-shadowed picker can't reappear stale once rewind closes.
let mut app = test_app_with_agent();
let id = AgentId(0);
push_turns(&mut app, id, 3);
app.agents.get_mut(&id).unwrap().scrollback.goto_bottom();
let before_offset = app.agents[&id].scrollback.scroll_offset();
dispatch(Action::JumpShowPicker, &mut app);
// Preview a far turn so the viewport actually moved under the picker.
{
let agent = app.agents.get_mut(&id).unwrap();
let first_id = agent.jump_state.as_ref().unwrap().entries[0].prompt_entry_id;
let first = agent.scrollback.index_of_id(first_id).unwrap();
agent.scrollback.scroll_to_entry_center(first);
}
assert!(app.agents[&id].jump_state.is_some());
assert_ne!(app.agents[&id].scrollback.scroll_offset(), before_offset);
dispatch(Action::Rewind, &mut app);
let agent = &app.agents[&id];
assert!(
agent.jump_state.is_none(),
"rewind dismissed the jump picker"
);
assert!(agent.rewind_state.is_some(), "rewind opened");
assert_eq!(
agent.scrollback.scroll_offset(),
before_offset,
"the jump viewport was restored before rewind took over"
);
}
#[test]
fn inline_edit_dismisses_open_jump_picker() {
// The mirror of `show_picker_refused_while_inline_edit_open`: entering
// inline edit while the picker is open dismisses it so it can't reappear
// stale. (Inline edit re-centers on the edited entry, so only the picker
// teardown is asserted, not the viewport.)
let mut app = test_app_with_agent();
let id = AgentId(0);
push_turns(&mut app, id, 3);
app.agents.get_mut(&id).unwrap().scrollback.goto_bottom();
dispatch(Action::JumpShowPicker, &mut app);
assert!(app.agents[&id].jump_state.is_some());
let entered = app.agents.get_mut(&id).unwrap().enter_inline_edit(0);
assert!(entered, "entered inline edit on the first prompt");
let agent = &app.agents[&id];
assert!(
agent.jump_state.is_none(),
"entering inline edit dismissed the jump picker"
);
assert!(agent.inline_edit.is_some(), "inline edit opened");
}
#[test]
fn dismiss_restores_viewport() {
let mut app = test_app_with_agent();
let id = AgentId(0);
push_turns(&mut app, id, 3);
{
let sb = &mut app.agents.get_mut(&id).unwrap().scrollback;
sb.goto_bottom();
}
let before_offset = app.agents[&id].scrollback.scroll_offset();
let before_selected = app.agents[&id].scrollback.selected();
dispatch(Action::JumpShowPicker, &mut app);
// Preview a far-away turn so the transcript actually moved.
{
let agent = app.agents.get_mut(&id).unwrap();
let first_id = agent.jump_state.as_ref().unwrap().entries[0].prompt_entry_id;
let first = agent.scrollback.index_of_id(first_id).unwrap();
agent.scrollback.scroll_to_entry_center(first);
}
assert_ne!(app.agents[&id].scrollback.scroll_offset(), before_offset);
dispatch(Action::JumpDismiss, &mut app);
let agent = &app.agents[&id];
assert!(agent.jump_state.is_none());
assert_eq!(agent.scrollback.scroll_offset(), before_offset);
assert_eq!(agent.scrollback.selected(), before_selected);
assert!(agent.scrollback.is_follow_mode(), "follow restored");
}
@@ -0,0 +1,911 @@
//! Tests for the dispatch module tree: shared fixtures and per-domain test modules.
mod auth;
mod billing;
mod dashboard;
mod jump;
mod modes;
mod notes;
mod permissions;
mod prompt;
mod rewind;
mod router;
mod session;
mod settings;
mod status;
mod task_result;
mod transcript;
mod turn;
use super::billing::{
CreditLimitUpsellMode, credit_limit_upsell_mode, is_max_tier, open_credit_limit_upsell,
open_free_usage_upsell,
};
use super::ctx::{find_agent_by_session_id, get_active_agent, get_active_agent_mut};
use super::dashboard::{
apply_pending_dispatch_config, dispatch_dashboard_attach, dispatch_dashboard_begin_rename,
dispatch_dashboard_commit_rename, dispatch_dashboard_confirm_worktree,
dispatch_dashboard_create_new_agent_with_detail, dispatch_dashboard_dispatch,
dispatch_dashboard_dispatch_slash, dispatch_dashboard_overlay_cycle,
dispatch_dashboard_overlay_exit, dispatch_dashboard_overlay_stop,
dispatch_dashboard_peek_reply, dispatch_dashboard_permission_followup,
dispatch_dashboard_permission_select, dispatch_dashboard_question_answer,
dispatch_dashboard_stop, dispatch_dashboard_toggle_auto_approve, dispatch_exit_dashboard,
dispatch_open_dashboard, ensure_dashboard_state, resolve_location_input,
};
use super::modes::{
YOLO_ON_UNDER_PLAN_TOAST, active_agent_plan_nudge_state, dispatch_cycle_mode_and_sync,
permission_mode_toast,
};
use super::permissions::drain_permission_queue;
use super::prompt::{
dispatch_send_prompt, dispatch_send_prompt_inner, input_can_trigger_project_picker,
};
use super::session::fork::build_child_fork_marker;
use super::session::lifecycle::{dispatch_new_session_inner, drain_startup_actions, finish_trust};
use super::session::load::{dispatch_load_session_with_restore, reanchor_grouped_selection};
use super::session::modal::{dispatch_rename_session, dispatch_sessions_confirm_close};
use super::settings::setters::set_default_model_inner;
use super::settings::ui::{action_for_reset, apply_setting_rollback};
use super::status::scrub_error_for_toast;
use super::task_result::dispatch_task_result;
use super::*;
use crate::acp::model_state::ModelState;
use crate::acp::tracker::AcpUpdateTracker;
use crate::app::actions::{Action, Effect, SubagentKillOutcome, SwitchModelError, TaskResult};
use crate::app::agent::{AgentId, AgentSession, AgentState};
use crate::app::agent_view::{ActivePane, AgentView, PromptMode};
use crate::app::app_view::{ActiveView, AppView, AuthMode, AuthState, TrustState};
use crate::scrollback::block::RenderBlock;
use crate::scrollback::blocks::{SessionEvent, ToolCallBlock};
use crate::scrollback::state::ScrollbackState;
use agent_client_protocol as acp;
use indexmap::IndexMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Instant;
fn test_app() -> AppView {
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
AppView {
active_view: ActiveView::Welcome,
auth_return_view: None,
agents: IndexMap::new(),
next_agent_id: 0,
models: ModelState::default(),
registry: crate::actions::ActionRegistry::defaults(),
settings_registry: std::sync::Arc::new(crate::settings::SettingsRegistry::defaults()),
current_ui: kigi_shell::agent::config::UiConfig::default(),
cwd: PathBuf::from("/tmp"),
project_picker_shown: true,
project_picker_disabled: false,
cwd_has_git_ancestor: false,
acp_tx: tx,
scratch: crate::scrollback::render::ScratchBuffer::new(),
cursor: crate::render::draw::CursorState::new(),
pending_action: None,
exit_session_pending: None,
scroll_state: crate::input::mouse::MouseScrollState::default(),
scroll_config: crate::input::mouse::ScrollConfig::default(),
appearance: crate::appearance::AppearanceConfig::default(),
notification_service: crate::notifications::NotificationService::new(Default::default()),
pending_notification_escapes: None,
deferred_notification: None,
tracing_rx: None,
changelog_markdown: None,
changelog_bullets: Vec::new(),
tips: Vec::new(),
tip: None,
cli_model_override: None,
cli_effort_token: None,
default_yolo: false,
permission_mode_from_soft_default: true,
auto_mode_gate: true,
yolo_policy_block: None,
yolo_launch_block_notice: None,
screen_mode_switch_hint: None,
require_plan_approval: false,
plan_mode: false,
chat_mode: false,
subagents: false,
ask_user: false,
mouse_captured: true,
new_worktree_dialog: None,
contextual_hints: Default::default(),
remote_contextual_hints: None,
tip_seen_counts: Default::default(),
last_known_terminal_rows: 0,
small_screen_tip_evaluated: false,
clipboard_focus_tip: Default::default(),
new_session_worktree_mode: crate::app::app_view::WorktreeMode::Never,
fork_worktree_mode: crate::app::app_view::WorktreeMode::Ask,
restore_code: None,
agent_override: None,
bootstrap_acp_commands: Vec::new(),
auth_methods: vec![acp::AuthMethod::Agent(acp::AuthMethodAgent::new(
acp::AuthMethodId::new("grok.com"),
"Grok".to_string(),
))],
auth_state: AuthState::Done,
trust_state: TrustState::Done,
login_label: None,
login_method_id: None,
auth_start_mode: AuthMode::Pending,
auth_code_input: String::new(),
next_auth_request_seq: 1,
deferred_startup: Default::default(),
auth_use_oauth: false,
auth_clipboard_copied: false,
team_id: None,
team_name: None,
is_zdr: false,
team_role: None,
coding_data_retention_opt_out: false,
show_tips: None,
auto_update: None,
ask_user_question_timeout_enabled: None,
zdr_access_enabled: false,
usage_billing_redirect_url: None,
access_gate_shown_logged: false,
gate: None,
subscription_tier: None,
paywall_check_started: None,
last_subscription_check_at: None,
subscription_watch_interval_secs: None,
pending_gate_verification: None,
gate_verify_gen: 0,
bundle_state: crate::app::bundle::BundleState::default(),
scroll_debug_hud: crate::views::scroll_debug_hud::ScrollDebugHud::new(),
fps_hud: crate::views::fps_hud::FpsHud::new(),
welcome_prompt: crate::views::prompt_widget::PromptWidget::new(),
slash_mru: std::rc::Rc::new(std::cell::RefCell::new(
crate::slash::mru::SlashMru::new_in_memory(),
)),
welcome_prompt_focused: false,
welcome_tip_typing_dismissed: false,
welcome_menu_index: None,
welcome_menu_rects: Vec::new(),
welcome_show_changelog_action: false,
welcome_import_banner_rect: None,
last_mouse_pos: None,
last_scroll_pos: None,
last_cache_evict_at: None,
welcome_prompt_rect: None,
welcome_auth_url_rect: None,
welcome_on_auth_url: false,
welcome_on_changelog_cta: false,
welcome_auth_fallback_rect: None,
welcome_refresh_rect: None,
welcome_gate_url_rect: None,
welcome_changelog_cta_rect: None,
auth_show_raw_url: false,
auth_mouse_disabled: false,
session_picker_entries: None,
session_picker_loading: false,
session_picker_state: crate::views::picker::PickerState::with_mode(
crate::views::picker::PickerMode::FullScreen,
),
session_picker_source_filter: crate::views::session_picker::SourceFilter::default(),
session_picker_content_results: None,
session_picker_content_loading: false,
session_picker_deep_search_seq: 0,
session_picker_list_seq: 0,
foreign_session_compat: Default::default(),
foreign_session_scan_seq: 0,
foreign_scan_coordinator: Default::default(),
session_picker_lanes: Default::default(),
session_picker_detail_generation: 0,
session_picker_entries_query: None,
welcome_tick: 0,
welcome_shimmer_frame: 0,
startup_warnings: Vec::new(),
is_api_key_auth: false,
pending_update_version: None,
foreign_resume_launch_generation: 0,
foreign_resume_launch: None,
quit_for_update: false,
relaunch: None,
import_claude_modal: None,
welcome_doc_viewer: None,
screen_mode: crate::app::ScreenMode::Inline,
pending_effects: Vec::new(),
pending_editor_path: None,
pending_agents_modal_refresh: None,
pending_pager_path: None,
pending_pager_ansi: false,
minimal_state: crate::minimal_api::MinimalState::default(),
reconnect_pending: false,
show_resolved_model: true,
sharing_enabled: false,
usage_visible: true,
tier_restricted_commands: Vec::new(),
leader_mode: true,
credit_balance: None,
auto_topup: None,
billing_poll_wanted: false,
leader_roster: Vec::new(),
dashboard_local_sessions: Vec::new(),
dashboard_sessions_loading: false,
shared_prompt_queues: std::collections::HashMap::new(),
optimistic_prompt_echoes: std::collections::HashMap::new(),
pending_running_adoptions: std::collections::HashMap::new(),
session_picker_grouped: false,
cancel_rewind_enabled: true,
session_recap_available: false,
dashboard: None,
dashboard_persisted: None,
keyboard_normalizer: crate::input::KeyboardNormalizer::from_terminal_context(),
has_claude_import: false,
}
}
/// Build a default `AgentSession` for
/// tests. Centralises the fixture so new fields on `AgentSession`
/// don't break every test that constructs one by hand. The
/// `acp_tx` is cloned from the test `AppView`; the
/// `deferred_model_switch` is pulled from the `AppView`'s CLI
/// overrides for parity with `dispatch_new_session_inner`.
fn make_test_agent_session(app: &AppView, id: AgentId, sid: &str) -> AgentSession {
AgentSession {
id,
acp_tx: app.acp_tx.clone(),
session_id: Some(sid.to_string().into()),
models: ModelState::default(),
state: AgentState::Idle,
tracker: AcpUpdateTracker::new(),
cwd: PathBuf::from("/tmp"),
is_worktree: false,
forked_from: None,
pending_prompts: std::collections::VecDeque::new(),
next_queue_id: 0,
yolo_mode: false,
auto_mode: false,
prompt_history: Vec::new(),
prompt_history_loading: false,
loading_replay: false,
restore_degree: None,
rate_limited: false,
model_incompatible: false,
credit_limit_blocked: false,
free_usage_blocked: false,
available_commands: Vec::new(),
available_commands_generation: 0,
available_tools: None,
model_switch_pending: false,
user_model_preference: None,
deferred_model_switch: app.deferred_model_switch_from_cli(),
bg_tasks: std::collections::BTreeMap::new(),
bg_tool_call_to_task: std::collections::HashMap::new(),
scheduled_tasks: std::collections::HashMap::new(),
in_flight_prompt: None,
current_prompt_id: None,
created_via_new: false,
}
}
pub(super) fn test_app_with_agent() -> AppView {
let mut app = test_app();
let id = AgentId(0);
let session = make_test_agent_session(&app, id, "test-session");
let mut agent = AgentView::new(session, ScrollbackState::new());
agent.active_pane = ActivePane::Scrollback;
app.agents.insert(id, agent);
app.next_agent_id = 1;
switch_to_agent(&mut app, id, SwitchCause::New);
app
}
/// Give a test agent a generated title so the dashboard renders it.
///
/// The dashboard hides empty (no-real-turn) sessions
/// (`views::dashboard::row::is_empty_top_level`); nav/render tests that
/// rely on their placeholder agents being visible call this to opt in.
fn mark_agent_nonempty(app: &mut AppView, id: AgentId) {
if let Some(a) = app.agents.get_mut(&id) {
a.generated_session_title = Some(format!("Session {}", id.0));
}
}
/// Push a plain prompt directly onto the LOCAL drip-feed queue
/// (`pending_prompts`), bypassing the server-authoritative
/// immediate-send routing. Used by tests that exercise the local
/// `maybe_drain_queue` / editing / `DrainQueue` machinery, which is still
/// the path for image/skill/bash/editing prompts and idle drains.
pub(super) fn enqueue_local(app: &mut AppView, id: AgentId, text: &str) {
app.agents
.get_mut(&id)
.unwrap()
.session
.enqueue_prompt(text.to_string());
}
fn make_test_subagent(child_sid: &str, sa_id: &str) -> crate::app::subagent::SubagentInfo {
crate::app::subagent::SubagentInfo {
subagent_id: Arc::from(sa_id),
child_session_id: Arc::from(child_sid),
description: Arc::from("test subagent"),
subagent_type: Arc::from("general-purpose"),
persona: None,
role: None,
model: None,
context_source: None,
resumed_from: None,
capability_mode: None,
context_normalized: false,
parent_prompt_id: None,
started_at: std::time::Instant::now(),
last_progress_at: std::time::Instant::now(),
finished: false,
status: None,
error: None,
duration_ms: None,
tool_calls: None,
turns: None,
turn_count: None,
tool_call_count: None,
tokens_used: None,
context_window_tokens: None,
context_usage_pct: None,
tools_used: Vec::new(),
error_count: None,
activity_label: None,
is_background: false,
pending_kill: false,
kill_requested_at: None,
scrollback_entry_id: None,
prompt: None,
child_cwd: None,
worktree_path: None,
child_updates_replayed: false,
}
}
fn arm_reconcile(
app: &mut AppView,
id: AgentId,
prompt_id: &str,
stop_reason: &str,
age: std::time::Duration,
) {
arm_reconcile_with_trigger(app, id, prompt_id, stop_reason, None, age);
}
/// [`arm_reconcile`] with an explicit `_meta.cancelTrigger`.
fn arm_reconcile_with_trigger(
app: &mut AppView,
id: AgentId,
prompt_id: &str,
stop_reason: &str,
cancel_trigger: Option<&str>,
age: std::time::Duration,
) {
app.agents.get_mut(&id).unwrap().pending_turn_end_reconcile =
Some(crate::app::agent_view::PendingTurnEnd {
prompt_id: prompt_id.into(),
stop_reason: Some(stop_reason.into()),
agent_result: None,
cancel_trigger: cancel_trigger.map(str::to_string),
received_at: std::time::Instant::now() - age,
});
}
pub(super) fn end_turn() -> Action {
Action::TaskComplete(TaskResult::PromptResponse {
agent_id: AgentId(0),
result: Ok(acp::PromptResponse::new(acp::StopReason::EndTurn)),
http_status: None,
prompt_id: None,
})
}
/// Plant a Build session under the process `kigi_home()` (OnceLock-cached;
/// do not rely on setting `KIGI_SHARE_DIR` mid-process). Caller must remove `sess_dir`.
fn plant_local_build_session(cwd: &std::path::Path, session_id: &str) -> std::path::PathBuf {
let home = kigi_shell::util::kigi_home::kigi_home();
let encoded = kigi_shell::util::kigi_home::encode_cwd_dirname(&cwd.to_string_lossy());
let sess_dir = home.join("sessions").join(encoded).join(session_id);
std::fs::create_dir_all(&sess_dir).expect("plant session dir");
std::fs::write(sess_dir.join("summary.json"), b"{}").expect("plant summary");
sess_dir
}
/// Extract the in-flight auth request sequence, panicking if the auth
/// state is not `Authenticating`.
fn authenticating_seq(app: &AppView) -> u64 {
match app.auth_state {
AuthState::Authenticating { request_seq, .. } => request_seq,
ref other => panic!("expected Authenticating, got {other:?}"),
}
}
/// Extract text from the last system message in an agent's scrollback.
fn last_system_text(app: &AppView, id: AgentId) -> String {
system_text_from_end(app, id, 0)
}
/// Like [`last_system_text`] but takes an offset from the end.
/// `offset = 0` is the last entry, `offset = 1` is second-to-last, etc.
fn system_text_from_end(app: &AppView, id: AgentId, offset: usize) -> String {
let sb = &app.agents[&id].scrollback;
let idx = sb.len() - 1 - offset;
let entry = sb.get(idx).expect("scrollback index out of bounds");
match &entry.block {
RenderBlock::System(sys) => sys.text.clone(),
other => panic!("expected System block at index {idx}, got {other:?}"),
}
}
/// Insert a placeholder agent at `id` so `switch_to_agent` recognises
/// it (the helper's defensive check uses `app.agents.contains_key`).
/// `session_id` and `active_pane` are populated to mirror the
/// existing `test_app_with_agent` setup; these tests do not read
/// either field.
fn insert_placeholder_agent(app: &mut AppView, id: AgentId) {
let mut agent = AgentView::new(
AgentSession {
id,
acp_tx: app.acp_tx.clone(),
session_id: Some("placeholder".into()),
models: ModelState::default(),
state: AgentState::Idle,
tracker: AcpUpdateTracker::new(),
cwd: PathBuf::from("/tmp"),
is_worktree: false,
forked_from: None,
pending_prompts: std::collections::VecDeque::new(),
next_queue_id: 0,
yolo_mode: false,
auto_mode: false,
prompt_history: Vec::new(),
prompt_history_loading: false,
loading_replay: false,
restore_degree: None,
rate_limited: false,
model_incompatible: false,
credit_limit_blocked: false,
free_usage_blocked: false,
available_commands: Vec::new(),
available_commands_generation: 0,
available_tools: None,
model_switch_pending: false,
user_model_preference: None,
deferred_model_switch: None,
bg_tasks: std::collections::BTreeMap::new(),
bg_tool_call_to_task: std::collections::HashMap::new(),
scheduled_tasks: std::collections::HashMap::new(),
in_flight_prompt: None,
current_prompt_id: None,
created_via_new: false,
},
ScrollbackState::new(),
);
agent.active_pane = ActivePane::Scrollback;
app.agents.insert(id, agent);
}
/// Build an app with three agents (ids 0, 1, 2) and `active_view` set
/// to agent 0.
fn three_agent_app() -> AppView {
let mut app = test_app_with_agent();
insert_placeholder_agent(&mut app, AgentId(1));
insert_placeholder_agent(&mut app, AgentId(2));
app
}
use crate::slash::commands::fork::ForkArgs;
fn fork_args(worktree_override: Option<bool>, directive: Option<&str>) -> ForkArgs {
ForkArgs {
worktree_override,
directive: directive.map(String::from),
}
}
/// Build a single-agent app for the `/fork` dispatcher tests.
///
/// Sets `current_branch` to `Some("main")` so the agent appears to be
/// inside a git repo. This is required because `dispatch_fork` skips
/// the worktree question when `current_branch` is `None` (non-git cwd).
fn fork_test_app() -> AppView {
let mut app = test_app_with_agent();
app.agents.get_mut(&AgentId(0)).unwrap().current_branch = Some("main".into());
app
}
/// Build a minimal `AcpArgs<acp::ExtRequest>` for an
/// `x.ai/ask_user_question` ext-method request. Returns the args
/// plus the receiver half of the response oneshot so the test can
/// assert the handler completes the ACP roundtrip.
fn make_ask_user_question_args(
tool_call_id: &str,
) -> (
kigi_acp_lib::AcpArgs<acp::ExtRequest>,
tokio::sync::oneshot::Receiver<kigi_acp_lib::AcpResult<acp::ExtResponse>>,
) {
use kigi_tools::implementations::grok_build::ask_user_question::{
AskUserQuestionExtRequest, Question, QuestionOption,
};
let req = AskUserQuestionExtRequest {
session_id: "test-session".into(),
tool_call_id: tool_call_id.into(),
mode:
kigi_tools::implementations::grok_build::ask_user_question::AskUserQuestionMode::Default,
questions: vec![Question {
question: "ACP-driven question".into(),
options: vec![QuestionOption {
label: "ok".into(),
description: "ok".into(),
preview: None,
id: None,
}],
multi_select: Some(false),
id: None,
}],
};
let (tx, rx) = tokio::sync::oneshot::channel();
let ext = acp::ExtRequest::new(
"x.ai/ask_user_question",
serde_json::value::to_raw_value(&req)
.expect("serialize AskUserQuestionExtRequest")
.into(),
);
(
kigi_acp_lib::AcpArgs {
request: ext,
response_tx: tx,
},
rx,
)
}
fn set_forked_from(app: &mut AppView, child: AgentId, parent: AgentId) {
if let Some(agent) = app.agents.get_mut(&child) {
agent.session.forked_from = Some(parent);
}
}
fn make_bg_task(task_id: &str) -> crate::app::agent::BgTaskState {
crate::app::agent::BgTaskState {
task_id: task_id.into(),
tool_call_id: String::new(),
command: "sleep 99".into(),
description: None,
cwd: String::new(),
output_file: String::new(),
status: crate::app::agent::BgTaskStatus::Running,
start_time: std::time::SystemTime::now(),
end_time: None,
exit_code: None,
signal: None,
stdout: String::new(),
stdout_line_count: 0,
truncated: false,
pending_kill: false,
kill_requested_at: None,
scrollback_entry_id: None,
is_monitor: false,
restored_from_replay: false,
}
}
/// Set up a two-agent app: agent 0 is active with "sess-A",
/// agent 1 is inactive with "sess-B" and a bg task.
fn two_agent_app_with_bg_task() -> AppView {
let mut app = test_app_with_agent();
app.agents[&AgentId(0)].session.session_id = Some(acp::SessionId::new("sess-A"));
let id1 = AgentId(1);
let mut agent1 = AgentView::new(
AgentSession {
id: id1,
acp_tx: app.acp_tx.clone(),
session_id: Some(acp::SessionId::new("sess-B")),
models: ModelState::default(),
state: AgentState::Idle,
tracker: AcpUpdateTracker::new(),
cwd: PathBuf::from("/tmp"),
is_worktree: false,
forked_from: None,
pending_prompts: std::collections::VecDeque::new(),
next_queue_id: 0,
yolo_mode: false,
auto_mode: false,
prompt_history: Vec::new(),
prompt_history_loading: false,
loading_replay: false,
restore_degree: None,
rate_limited: false,
model_incompatible: false,
credit_limit_blocked: false,
free_usage_blocked: false,
available_commands: Vec::new(),
available_commands_generation: 0,
available_tools: None,
model_switch_pending: false,
user_model_preference: None,
deferred_model_switch: None,
bg_tasks: std::collections::BTreeMap::new(),
bg_tool_call_to_task: std::collections::HashMap::new(),
scheduled_tasks: std::collections::HashMap::new(),
in_flight_prompt: None,
current_prompt_id: None,
created_via_new: false,
},
ScrollbackState::new(),
);
let mut task = make_bg_task("task-B-1");
task.pending_kill = true;
task.kill_requested_at = Some(std::time::Instant::now());
agent1.session.bg_tasks.insert("task-B-1".into(), task);
app.agents.insert(id1, agent1);
app.next_agent_id = 2;
assert!(matches!(app.active_view, ActiveView::Agent(AgentId(0))));
app
}
fn project_picker_app() -> AppView {
let mut app = test_app();
app.cwd = PathBuf::from("/tmp");
app.project_picker_shown = false;
app
}
/// Test helper: open Settings then OpenResetConfirm for `key`.
/// Extracted so individual tests don't have to repeat the
/// open-then-open ritual.
fn setup_reset_confirm_open(app: &mut AppView, key: crate::settings::SettingKey) {
use crate::views::modal::ActiveModal;
let _ = dispatch(Action::OpenSettings, app);
let _ = dispatch(Action::OpenResetConfirm { key }, app);
let agent = app.agents.get(&AgentId(0)).expect("agent must exist");
assert!(
matches!(
agent.active_modal,
Some(ActiveModal::ResetSettingsConfirm { .. })
),
"setup_reset_confirm_open: ResetSettingsConfirm must be active",
);
}
fn make_picker_entry(id: &str, cwd: &str) -> crate::app::app_view::SessionPickerEntry {
crate::app::app_view::SessionPickerEntry {
id: id.into(),
summary: id.into(),
updated_at: chrono::Utc::now(),
created_at: chrono::Utc::now(),
cwd: cwd.into(),
hostname: None,
source: "local".into(),
model_id: None,
num_messages: 0,
last_active_at: None,
branch: None,
repo_name: "repo".into(),
worktree_label: None,
card_detail: None,
}
}
fn make_conversation_entry(id: &str) -> crate::app::app_view::SessionPickerEntry {
let mut e = make_picker_entry(id, "");
e.source = "conversation".into();
e
}
/// Open a SessionPicker modal on the active agent seeded with `entries`.
fn open_session_picker_with(
app: &mut AppView,
entries: Vec<crate::app::app_view::SessionPickerEntry>,
) {
use crate::views::modal::ActiveModal;
let agent = get_active_agent_mut(app).expect("active agent");
agent.active_modal = Some(ActiveModal::SessionPicker {
state: crate::views::picker::PickerState::default(),
entries: Some(entries),
loading: false,
lanes: Default::default(),
previous_palette: None,
window: crate::views::modal_window::ModalWindowState::new(),
content_results: None,
content_loading: false,
deep_search_seq: 0,
entries_query: None,
source_filter: crate::views::session_picker::SourceFilter::default(),
pending_delete: None,
});
}
/// Toast strings match the expected format and contain on/off
/// status.
fn read_toast(app: &AppView) -> String {
let agent = app.agents.get(&AgentId(0)).expect("agent must exist");
agent
.toast
.as_ref()
.map(|(s, _)| s.clone())
.expect("toast should be set")
}
/// Helper: enqueue a single permission containing the new
/// "enable-always-approve" option (AllowOnce kind, position 0 —
/// default-selected by the real `enqueue_permission` helper),
/// a regular "opt-allow-once" (AllowOnce kind, position 1), and
/// a "opt-reject-once" (RejectOnce, position 2). Mirrors the
/// option list the shell builds for TUI/Pager/Desktop.
/// Returns the response receiver for the injected permission.
fn enqueue_permission_with_enable_always_approve(
app: &mut AppView,
) -> tokio::sync::oneshot::Receiver<acp::Result<acp::RequestPermissionResponse>> {
use crate::views::permission_view::{PermissionFocus, PermissionViewState};
use std::sync::Arc;
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
let (response_tx, response_rx) = tokio::sync::oneshot::channel();
let request = acp::RequestPermissionRequest::new(
acp::SessionId::new(Arc::from("test-sess")),
acp::ToolCallUpdate::new(
acp::ToolCallId::new(Arc::from("tc-enable-aa-1")),
acp::ToolCallUpdateFields::default(),
),
vec![
acp::PermissionOption::new(
acp::PermissionOptionId::new(Arc::from(
kigi_workspace::permission::ENABLE_ALWAYS_APPROVE_OPTION_ID,
)),
"Yes, and don't ask again for anything",
acp::PermissionOptionKind::AllowOnce,
),
acp::PermissionOption::new(
acp::PermissionOptionId::new(Arc::from("opt-allow-once")),
"Yes, proceed",
acp::PermissionOptionKind::AllowOnce,
),
acp::PermissionOption::new(
acp::PermissionOptionId::new(Arc::from("opt-reject-once")),
"No",
acp::PermissionOptionKind::RejectOnce,
),
],
);
let options = request.options.clone();
agent.permission_queue.push_back(PermissionViewState {
request: kigi_acp_lib::AcpArgs {
request,
response_tx,
},
id: 1,
focus: PermissionFocus::Options,
options,
active_idx: 0,
bash_highlights: None,
bash_selection_count: 0,
bash_command_raw: None,
mcp_scope: None,
title: "test-enable-always-approve".to_string(),
description: vec![],
args_expanded: false,
desc_scroll: 0,
subagent_label: None,
options_area_height: 0,
options_scroll_offset: 0,
});
response_rx
}
const POLICY_WARNING: &str = kigi_workspace::permission::resolution::YOLO_PIN_REASON_REQUIREMENTS;
fn agent_toast(app: &AppView) -> Option<String> {
app.agents[&AgentId(0)]
.toast
.as_ref()
.map(|(s, _)| s.clone())
}
/// Use the `theme_cache::test_lock` to serialize tests that touch
/// the in-memory theme state (single mutable global). Mirrors the
/// pattern used by `theme::cache::tests`.
fn with_theme_test_env(f: impl FnOnce()) {
let _guard = crate::theme::cache::test_lock()
.lock()
.unwrap_or_else(|e| e.into_inner());
crate::theme::cache::reset_for_test();
crate::theme::cache::seed_auto_theme_defaults_for_test();
crate::theme::cache::set(crate::theme::ThemeKind::GrokNight);
crate::theme::system_appearance::clear_mock();
f();
crate::theme::system_appearance::clear_mock();
crate::theme::cache::reset_for_test();
}
fn agent_scrollback_len(app: &AppView) -> usize {
app.agents.get(&AgentId(0)).unwrap().scrollback.len()
}
use crate::scrollback::blocks::UserPromptBlock;
/// Helper: open the dashboard against an existing `app`.
fn open_dashboard(app: &mut AppView) {
let _ = dispatch_open_dashboard(app);
}
/// Display-order list of selectable row ids — the same order
/// `dashboard_neighbor_row` and the renderer walk. Test-only mirror
/// of the row build in `dispatch_dashboard_select`.
fn dashboard_row_order(app: &AppView) -> Vec<crate::views::dashboard::DashboardRowId> {
let d = app.dashboard.as_ref().unwrap();
let home = crate::views::dashboard::render::cached_home();
let roster: &[crate::app::roster::RosterEntry] = if app.leader_mode {
&app.leader_roster
} else {
&app.dashboard_local_sessions
};
let rows = crate::views::dashboard::build_rows_with_roster(
&app.agents,
&d.pinned,
&d.reorder,
None,
d.grouping,
&d.filter,
home,
roster,
);
crate::views::dashboard::render::focusables(
&rows,
d.grouping,
&d.filter,
&d.collapsed_sections,
d.idle_show_all,
d.search_mode,
)
.into_iter()
.filter_map(|f| match f {
crate::views::dashboard::Focusable::Row(id) => Some(id),
crate::views::dashboard::Focusable::Section(_)
| crate::views::dashboard::Focusable::IdleOverflow => None,
})
.collect()
}
/// Build a synthetic `PermissionViewState` with the given id and
/// options. Pushes it to the agent's permission_queue.
///
/// Returns the response receiver so tests can verify
/// the response was actually `send`'d through the oneshot. The
/// previous version dropped the receiver (`_rx`), which let
/// "happy-path" tests assert the queue was popped but masked
/// regressions where the pop happened without the corresponding
/// send.
fn push_synthetic_permission(
agent: &mut crate::app::agent_view::AgentView,
id: usize,
options: Vec<(&str, &str)>,
) -> tokio::sync::oneshot::Receiver<Result<acp::RequestPermissionResponse, acp::Error>> {
use crate::views::permission_view::{PermissionFocus, PermissionViewState};
let (tx, rx) =
tokio::sync::oneshot::channel::<Result<acp::RequestPermissionResponse, acp::Error>>();
let request = kigi_acp_lib::AcpArgs {
request: acp::RequestPermissionRequest::new(
acp::SessionId::new(std::sync::Arc::from("sess-1")),
acp::ToolCallUpdate::new(
acp::ToolCallId::new(std::sync::Arc::from("tc-1")),
acp::ToolCallUpdateFields::default(),
),
options
.iter()
.map(|(oid, name)| {
acp::PermissionOption::new(
acp::PermissionOptionId::new(std::sync::Arc::from(*oid)),
name.to_string(),
if *oid == "reject" {
acp::PermissionOptionKind::RejectOnce
} else {
acp::PermissionOptionKind::AllowOnce
},
)
})
.collect(),
),
response_tx: tx,
};
let opts = request.request.options.clone();
let state = PermissionViewState {
request,
id,
focus: PermissionFocus::Options,
options: opts,
active_idx: 0,
bash_highlights: None,
bash_selection_count: 0,
bash_command_raw: None,
mcp_scope: None,
title: "Test permission".to_string(),
description: Vec::new(),
args_expanded: false,
desc_scroll: 0,
subagent_label: None,
options_area_height: 0,
options_scroll_offset: 0,
};
agent.permission_queue.push_back(state);
rx
}
const MOUSE_OFF_STICKY: &str = crate::app::MOUSE_OFF_HINT_SCROLLBACK;
fn reset_mouse_capture_enabled(on: bool) {
crate::app::MOUSE_CAPTURE_ENABLED.store(on, std::sync::atomic::Ordering::Release);
}
fn mouse_capture_is_enabled() -> bool {
crate::app::MOUSE_CAPTURE_ENABLED.load(std::sync::atomic::Ordering::Acquire)
}
/// Build a minimal `CreditBalance` for billing dispatch tests.
fn test_bal(usage_pct: f64) -> crate::views::credit_bar::CreditBalance {
crate::views::credit_bar::CreditBalance {
usage_pct,
effective_usage_pct: usage_pct,
period_end_display: None,
pay_as_you_go: false,
on_demand_cap_cents: None,
on_demand_used_cents: None,
prepaid_balance_cents: None,
period_type: None,
is_unified_billing_user: None,
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,195 @@
//! Tests for feedback / remember / btw / recap dispatchers.
use super::*;
use crate::app::dispatch::{recap_unavailable_toast, scrollback_has_user_messages};
#[test]
fn recap_unavailable_toast_empty_vs_with_messages() {
assert_eq!(recap_unavailable_toast(false), "No messages yet");
assert_eq!(recap_unavailable_toast(true), "Couldn't generate recap");
}
#[test]
fn manual_recap_with_no_messages_toasts_empty_state_and_skips_request() {
let mut app = test_app_with_agent();
app.session_recap_available = true;
let id = AgentId(0);
{
let agent = app.agents.get_mut(&id).unwrap();
agent.prompt.set_text("/recap");
assert!(!scrollback_has_user_messages(&agent.scrollback));
}
let effects = dispatch(Action::SendRecap { auto: false }, &mut app);
assert!(
effects.is_empty(),
"empty session must not fire x.ai/recap: {effects:?}"
);
let agent = app.agents.get(&id).unwrap();
assert!(agent.pending_recap_entry.is_none(), "no loading spinner");
assert_eq!(
agent.toast.as_ref().map(|(s, _)| s.as_str()),
Some("No messages yet"),
"empty session should say No messages yet, not Couldn't generate recap"
);
assert_eq!(agent.prompt.text(), "", "slash command text is cleared");
}
#[test]
fn manual_recap_with_messages_requests_and_shows_spinner() {
let mut app = test_app_with_agent();
app.session_recap_available = true;
let id = AgentId(0);
{
let agent = app.agents.get_mut(&id).unwrap();
agent
.scrollback
.push_block(RenderBlock::user_prompt("hello"));
assert!(scrollback_has_user_messages(&agent.scrollback));
}
let effects = dispatch(Action::SendRecap { auto: false }, &mut app);
assert!(
matches!(effects.as_slice(), [Effect::SendRecap { auto: false, .. }]),
"expected SendRecap effect, got {effects:?}"
);
let agent = app.agents.get(&id).unwrap();
assert!(
agent.pending_recap_entry.is_some(),
"manual recap shows a loading spinner when there is something to summarize"
);
assert!(agent.toast.is_none());
}
/// Regression: during session/load, scrollback is batched so
/// `turn_count()` stays 0 until `end_batch`, but UserPrompt entries may already
/// be present. Manual `/recap` must still request a recap.
#[test]
fn manual_recap_during_batch_load_with_prompts_still_requests() {
let mut app = test_app_with_agent();
app.session_recap_available = true;
let id = AgentId(0);
{
let agent = app.agents.get_mut(&id).unwrap();
agent.scrollback.begin_batch();
agent
.scrollback
.push_block(RenderBlock::user_prompt("hello from resume"));
// Batched push defers rebuild_turns — turn index is stale, entries aren't.
assert_eq!(agent.scrollback.turn_count(), 0);
assert!(scrollback_has_user_messages(&agent.scrollback));
}
let effects = dispatch(Action::SendRecap { auto: false }, &mut app);
assert!(
matches!(effects.as_slice(), [Effect::SendRecap { auto: false, .. }]),
"batched resume with user prompts must still fire x.ai/recap: {effects:?}"
);
let agent = app.agents.get(&id).unwrap();
assert!(agent.pending_recap_entry.is_some());
assert!(agent.toast.is_none());
// Clean up batch for the test fixture (not required for the assertion).
app.agents.get_mut(&id).unwrap().scrollback.end_batch();
}
/// While session replay is still streaming, don't claim "No messages yet" even
/// if scrollback looks empty — history may arrive on the next notification.
#[test]
fn manual_recap_while_loading_replay_still_requests() {
let mut app = test_app_with_agent();
app.session_recap_available = true;
let id = AgentId(0);
{
let agent = app.agents.get_mut(&id).unwrap();
agent.session.loading_replay = true;
assert!(!scrollback_has_user_messages(&agent.scrollback));
}
let effects = dispatch(Action::SendRecap { auto: false }, &mut app);
assert!(
matches!(effects.as_slice(), [Effect::SendRecap { auto: false, .. }]),
"loading_replay must not short-circuit to No messages yet: {effects:?}"
);
let agent = app.agents.get(&id).unwrap();
assert!(agent.pending_recap_entry.is_some());
assert!(agent.toast.is_none());
}
#[test]
fn recap_request_transport_failure_with_no_turns_uses_empty_toast() {
let mut app = test_app_with_agent();
let id = AgentId(0);
let session_id = app.agents[&id].session.session_id.clone().unwrap();
{
let agent = app.agents.get_mut(&id).unwrap();
let spinner = agent
.scrollback
.push(crate::scrollback::entry::ScrollbackEntry::running(
RenderBlock::session_event(SessionEvent::Recap {
summary: String::new(),
auto: false,
}),
));
agent.pending_recap_entry = Some(spinner);
assert!(!scrollback_has_user_messages(&agent.scrollback));
}
dispatch(
Action::TaskComplete(TaskResult::RecapRequested {
session_id,
auto: false,
error: Some("transport down".into()),
}),
&mut app,
);
let agent = app.agents.get(&id).unwrap();
assert!(agent.pending_recap_entry.is_none());
assert_eq!(
agent.toast.as_ref().map(|(s, _)| s.as_str()),
Some("No messages yet")
);
}
#[test]
fn recap_request_transport_failure_with_turns_uses_generic_toast() {
let mut app = test_app_with_agent();
let id = AgentId(0);
let session_id = app.agents[&id].session.session_id.clone().unwrap();
{
let agent = app.agents.get_mut(&id).unwrap();
agent
.scrollback
.push_block(RenderBlock::user_prompt("hello"));
let spinner = agent
.scrollback
.push(crate::scrollback::entry::ScrollbackEntry::running(
RenderBlock::session_event(SessionEvent::Recap {
summary: String::new(),
auto: false,
}),
));
agent.pending_recap_entry = Some(spinner);
assert!(scrollback_has_user_messages(&agent.scrollback));
}
dispatch(
Action::TaskComplete(TaskResult::RecapRequested {
session_id,
auto: false,
error: Some("transport down".into()),
}),
&mut app,
);
let agent = app.agents.get(&id).unwrap();
assert!(agent.pending_recap_entry.is_none());
assert_eq!(
agent.toast.as_ref().map(|(s, _)| s.as_str()),
Some("Couldn't generate recap")
);
}
@@ -0,0 +1,570 @@
//! Tests for permission request selection, follow-ups, and queue draining.
use super::*;
/// `ConfirmResetSetting
/// { Reset }` on `permission_mode` (the security-critical SHELL Enum)
/// dispatches `Action::SetPermissionMode(PermissionModeKind::Ask)`
/// (the typed Action, per the modal-commit ↔ typed-setter
/// rule) via recursive dispatch. Emits
/// `Effect::PersistPermissionMode` — verifies the recursive
/// dispatch reaches the YOLO pipeline through
/// `set_permission_mode` rather than the legacy `set_yolo_mode`.
#[test]
fn dispatch_confirm_reset_setting_reset_dispatches_set_permission_mode_for_permission_mode() {
use crate::views::modal::ResetSettingsResult;
let mut app = test_app_with_agent();
// Flip yolo on first (default is OFF = "ask").
let _ = dispatch(Action::SetYoloMode(true), &mut app);
assert!(app.agents[&AgentId(0)].session.is_yolo());
setup_reset_confirm_open(&mut app, "permission_mode");
let effects = dispatch(
Action::ConfirmResetSetting {
choice: ResetSettingsResult::Reset,
},
&mut app,
);
// Recursive dispatch into Action::SetYoloMode(false) emits a
// PersistPermissionMode effect.
let has_persist = effects
.iter()
.any(|e| matches!(e, Effect::PersistPermissionMode { .. }));
assert!(
has_persist,
"Reset of permission_mode must emit PersistPermissionMode, got {effects:?}",
);
// Agent's yolo flag is reset to default (off).
assert!(
!app.agents[&AgentId(0)].session.is_yolo(),
"agent.session.yolo_mode must be reset to default (off)",
);
}
/// **Security-critical:** YOLO ON must drain the per-agent
/// `permission_queue` with `AllowOnce` responses. If this drain
/// path regresses (e.g., the setter falls back to `Cancelled`
/// without an `AllowOnce` lookup), the user enables YOLO and
/// their queued permissions silently get rejected.
#[test]
fn set_yolo_mode_on_drains_permission_queue_with_allow_once() {
use crate::views::permission_view::{PermissionFocus, PermissionViewState};
use std::sync::Arc;
let mut app = test_app_with_agent();
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
// Inject a fake queued permission. The drain semantics use
// `find(|o| o.kind == AllowOnce)` so we need ≥1 AllowOnce
// option for the test to exercise the happy path.
let (response_tx, mut response_rx) = tokio::sync::oneshot::channel();
let request = acp::RequestPermissionRequest::new(
acp::SessionId::new(Arc::from("test-sess")),
acp::ToolCallUpdate::new(
acp::ToolCallId::new(Arc::from("tc-1")),
acp::ToolCallUpdateFields::default(),
),
vec![
acp::PermissionOption::new(
acp::PermissionOptionId::new(Arc::from("opt-allow-once")),
"Allow once",
acp::PermissionOptionKind::AllowOnce,
),
acp::PermissionOption::new(
acp::PermissionOptionId::new(Arc::from("opt-reject")),
"Reject",
acp::PermissionOptionKind::RejectOnce,
),
],
);
let options = request.options.clone();
agent.permission_queue.push_back(PermissionViewState {
request: kigi_acp_lib::AcpArgs {
request,
response_tx,
},
id: 1,
focus: PermissionFocus::Options,
options,
active_idx: 0,
bash_highlights: None,
bash_selection_count: 0,
bash_command_raw: None,
mcp_scope: None,
title: "test".to_string(),
description: vec![],
args_expanded: false,
desc_scroll: 0,
subagent_label: None,
options_area_height: 0,
options_scroll_offset: 0,
});
assert_eq!(agent.permission_queue.len(), 1);
let _ = dispatch(Action::SetYoloMode(true), &mut app);
// Queue is drained.
assert!(
app.agents[&AgentId(0)].permission_queue.is_empty(),
"YOLO ON must drain the permission_queue",
);
// Verify the `AllowOnce` response was actually sent (NOT
// `Cancelled`). The drain semantics use `find(|o| o.kind ==
// AllowOnce)` — a regression to `Cancelled` here would
// silently reject every queued permission when the user
// enables YOLO, which is the exact security failure mode
// this test prevents.
match response_rx.try_recv() {
Ok(Ok(acp::RequestPermissionResponse {
outcome:
acp::RequestPermissionOutcome::Selected(acp::SelectedPermissionOutcome {
option_id,
..
}),
..
})) => {
assert_eq!(
option_id,
acp::PermissionOptionId::new(Arc::from("opt-allow-once")),
"the drain must select the AllowOnce option (NOT Cancelled / RejectOnce)",
);
}
other => panic!(
"queue drain must send an `AllowOnce` Selected response, got {other:?} — \
security regression: queued permissions are NOT being auto-approved on YOLO ON",
),
}
}
#[test]
fn permission_select_clears_double_click_tracker_for_next_prompt() {
use crate::views::permission_view::PermissionFocus;
use std::sync::Arc;
let mut app = test_app_with_agent();
let _rx_front = enqueue_permission_with_enable_always_approve(&mut app);
let _rx_next = enqueue_permission_with_enable_always_approve(&mut app);
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
agent.permission_queue.get_mut(1).unwrap().focus = PermissionFocus::FollowupInput;
agent.last_permission_click = Some((Instant::now(), 1));
let _ = dispatch(
Action::PermissionSelect(acp::PermissionOptionId::new(Arc::from("opt-allow-once"))),
&mut app,
);
let agent = &app.agents[&AgentId(0)];
assert_eq!(agent.permission_queue.len(), 1);
assert!(
agent.last_permission_click.is_none(),
"armed click on the resolved prompt must not pair with a click on the next prompt"
);
assert_eq!(
agent.permission_queue.front().unwrap().focus,
PermissionFocus::Options,
"next front must be reset to Options"
);
}
#[test]
fn drain_permission_queue_clears_double_click_tracker() {
let mut app = test_app_with_agent();
let _rx = enqueue_permission_with_enable_always_approve(&mut app);
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
agent.last_permission_click = Some((Instant::now(), 1));
drain_permission_queue(agent);
assert!(agent.permission_queue.is_empty());
assert!(
agent.last_permission_click.is_none(),
"turn-end/turn-cancel drain must invalidate the armed click"
);
}
#[test]
fn set_permission_mode_always_approve_blocked_by_policy_pin() {
use crate::app::actions::PermissionModeKind;
use crate::views::modal::ActiveModal;
let mut app = test_app_with_agent();
app.yolo_policy_block = Some(POLICY_WARNING);
// Open the settings modal so the blocked path's snapshot refresh is
// exercised: the modal must keep showing the live (non-yolo) value.
let _ = dispatch(Action::OpenSettings, &mut app);
let effects = dispatch(
Action::SetPermissionMode(PermissionModeKind::AlwaysApprove),
&mut app,
);
assert!(
effects.is_empty(),
"blocked modal commit must not persist, got {effects:?}",
);
assert!(!app.agents[&AgentId(0)].session.is_yolo());
assert_eq!(
app.current_ui.permission_mode, None,
"canonical mirror must stay untouched"
);
let agent = app.agents.get(&AgentId(0)).unwrap();
let Some(ActiveModal::Settings { state }) = &agent.active_modal else {
panic!("Settings modal must remain open across the blocked dispatch")
};
assert!(
!state.pager_snapshot.yolo_mode,
"modal snapshot must show the live (non-yolo) value after the block",
);
assert_ne!(
state.ui_snapshot.permission_mode.as_deref(),
Some("always-approve"),
"modal canonical must not show the refused mode",
);
assert_eq!(agent_toast(&app).as_deref(), Some(POLICY_WARNING));
// Non-yolo kinds still commit under the pin.
let effects = dispatch(Action::SetPermissionMode(PermissionModeKind::Ask), &mut app);
assert_eq!(effects.len(), 1, "Ask must persist under the pin");
assert_eq!(app.current_ui.permission_mode.as_deref(), Some("ask"));
}
/// SetPermissionMode(Auto) persists auto and does not enable yolo.
#[test]
fn set_permission_mode_auto_persists_without_yolo() {
use crate::app::actions::PermissionModeKind;
let mut app = test_app_with_agent();
let effects = dispatch(
Action::SetPermissionMode(PermissionModeKind::Auto),
&mut app,
);
assert!(!app.agents[&AgentId(0)].session.is_yolo());
assert_eq!(app.current_ui.permission_mode.as_deref(), Some("auto"));
assert!(
effects.iter().any(|e| matches!(
e,
Effect::PersistPermissionMode {
canonical: "auto",
..
}
)),
"expected PersistPermissionMode(auto), got {effects:?}"
);
}
/// Feature gate OFF: a SetPermissionMode(Auto) commit (e.g. from the
/// settings modal) degrades to Ask — same `app.auto_mode_gate` source the
/// Shift+Tab cycle uses, so the two never disagree.
#[test]
fn set_permission_mode_auto_degrades_to_ask_when_gated_off() {
use crate::app::actions::PermissionModeKind;
let mut app = test_app_with_agent();
app.auto_mode_gate = false;
let effects = dispatch(
Action::SetPermissionMode(PermissionModeKind::Auto),
&mut app,
);
assert_eq!(
app.current_ui.permission_mode.as_deref(),
Some("ask"),
"gate OFF: Auto commit must land on Ask, not auto"
);
assert!(
!effects.iter().any(|e| matches!(
e,
Effect::PersistPermissionMode {
canonical: "auto",
..
}
)),
"gate OFF: must not persist 'auto', got {effects:?}"
);
}
/// Rollback with an unknown canonical: defensively defaults to
/// "ask" (the safe fallback — fewer prompts on a corrupt
/// rollback value is worse, more prompts is safer).
///
/// The previous docstring claimed "logs a
/// warning and defaults to 'ask'" — the warning log is fired via
/// `tracing::warn!` in `apply_setting_rollback`'s arm, but the
/// test doesn't capture/assert it. The fix is documentary: the
/// test pins the OBSERVABLE behaviour (state defaults to "ask")
/// and acknowledges that the warn-log is best-effort visibility
/// for developers, not a contract surface the test enforces.
/// `tracing_test::traced_test` capture would be more rigorous
/// but is not currently used in this crate.
#[test]
fn rollback_permission_mode_unknown_canonical_defaults_to_ask() {
use crate::settings::SettingValue;
let mut app = test_app_with_agent();
// Pre-set to true.
let _ = dispatch(Action::SetYoloMode(true), &mut app);
// Garbage canonical rolls back to "ask" (the safe default).
let _ = dispatch(
Action::TaskComplete(TaskResult::SettingPersistFailed {
key: "permission_mode",
rollback_value: SettingValue::Enum("garbage-value"),
error: "test-error".into(),
}),
&mut app,
);
assert!(
!app.agents[&AgentId(0)].session.is_yolo(),
"unknown canonical → safe default (ask = no auto-approve)",
);
assert_eq!(app.current_ui.permission_mode.as_deref(), Some("ask"));
// The failure toast is the standard
// `✗ Could not save permission_mode: …` format. A future
// enhancement could differentiate "schema corruption" from
// "real disk failure" in the toast text, but currently the
// user sees the same wording; pinned here so a future
// divergence is intentional.
}
/// Rollback path refreshes open modal
/// snapshots in the same way the success path does. Mirror of
/// `set_yolo_mode_refreshes_open_modal_snapshots` for the
/// `apply_setting_rollback` entry into `set_yolo_mode_inner`.
/// Without this, a modal that's open when a disk write fails
/// shows a stale "always-approve" indicator after the state
/// has rolled back to "ask".
#[test]
fn rollback_permission_mode_refreshes_open_modal_snapshots() {
use crate::settings::SettingValue;
use crate::views::modal::ActiveModal;
let mut app = test_app_with_agent();
// Pre-set yolo=true via the typed setter so the rollback
// captures real prior state.
let _ = dispatch(Action::SetYoloMode(true), &mut app);
// Open the modal AFTER the optimistic toggle so the open-time
// snapshot reflects yolo=true.
let _ = dispatch(Action::OpenSettings, &mut app);
let agent = app.agents.get(&AgentId(0)).unwrap();
let Some(ActiveModal::Settings { state }) = &agent.active_modal else {
panic!("expected Settings modal");
};
assert!(
state.pager_snapshot.yolo_mode,
"pre-rollback snapshot reflects optimistic state (yolo=true)",
);
// Simulate disk-write failure → rollback to "ask".
let _ = dispatch(
Action::TaskComplete(TaskResult::SettingPersistFailed {
key: "permission_mode",
rollback_value: SettingValue::Enum("ask"),
error: "test-error".into(),
}),
&mut app,
);
// The modal's snapshot MUST refresh to the rolled-back value.
let agent = app.agents.get(&AgentId(0)).unwrap();
let Some(ActiveModal::Settings { state }) = &agent.active_modal else {
panic!("modal must stay open after rollback");
};
assert!(
!state.pager_snapshot.yolo_mode,
"rollback path MUST refresh pager_snapshot.yolo_mode (false after revert)",
);
assert_eq!(
state.ui_snapshot.permission_mode.as_deref(),
Some("ask"),
"rollback path MUST refresh ui_snapshot.permission_mode to 'ask'",
);
}
#[test]
fn set_permission_mode_ask_emits_brand_consistent_toast() {
use crate::app::actions::PermissionModeKind;
let mut app = test_app_with_agent();
// Pre-set to AlwaysApprove so the Ask dispatch is a real
// transition (avoids idempotent fast-path).
let _ = dispatch(Action::SetYoloMode(true), &mut app);
// Clear toast so we observe the Ask dispatch's fresh toast.
app.agents.get_mut(&AgentId(0)).unwrap().toast = None;
let effects = dispatch(Action::SetPermissionMode(PermissionModeKind::Ask), &mut app);
assert!(!app.agents[&AgentId(0)].session.is_yolo());
assert_eq!(app.current_ui.permission_mode.as_deref(), Some("ask"));
// Toast brands as "Permission mode" not
// "Always-approve". Previously the Ask arm reused `yolo_toast(false)`
// which produced "✓ Always-approve: off" — a brand mismatch.
let toast = app.agents[&AgentId(0)]
.toast
.as_ref()
.map(|(s, _)| s.clone())
.expect("toast must be set");
assert_eq!(
toast, "\u{2713} Permission mode: Ask",
"PR 11 R1 G-3 #11: Ask toast must brand as 'Permission mode' not 'Always-approve'",
);
// Effect carries the new canonical + the prior canonical
// (was "always-approve" from the test-setup pre-set).
assert_eq!(effects.len(), 1);
match &effects[0] {
Effect::PersistPermissionMode {
canonical, persist, ..
} => {
assert_eq!(*canonical, "ask");
assert_eq!(
*persist,
crate::app::actions::PermissionModePersist::WithRollback("always-approve"),
"prior canonical was 'always-approve' (pre-set by SetYoloMode(true))",
);
}
other => panic!("expected PersistPermissionMode, got {other:?}"),
}
}
/// Regression test. A `--yolo`
/// startup sets `agent.session.yolo_mode = true` but leaves
/// `app.current_ui.permission_mode` at `None`. Without the
/// LIVE-precedence capture, dispatching `SetPermissionMode(Default)`
/// would produce `WithRollback("ask")` — diverging the pager from
/// the shell on disk failure (the ACP suppress-on-failure gate
/// keeps the shell at YOLO, but the pager would roll back to
/// non-YOLO). This test pins the LIVE-precedence fix.
#[test]
fn set_permission_mode_with_live_yolo_and_no_ui_mirror_rolls_back_to_always_approve() {
use crate::app::actions::PermissionModeKind;
let mut app = test_app_with_agent();
// Simulate `--yolo` startup: agent yolo + default_yolo set,
// but `current_ui.permission_mode = None` (config has no
// `[ui] permission_mode` setting).
app.agents.get_mut(&AgentId(0)).unwrap().session.yolo_mode = true;
app.default_yolo = true;
app.current_ui.permission_mode = None;
let effects = dispatch(
Action::SetPermissionMode(PermissionModeKind::Default),
&mut app,
);
// The dispatch flipped yolo off (Default projects onto
// bool=false) and set the canonical to "default".
assert!(!app.agents[&AgentId(0)].session.is_yolo());
assert_eq!(app.current_ui.permission_mode.as_deref(), Some("default"));
// **Rollback contract.** Rollback must target
// "always-approve" (the LIVE state at dispatch time), NOT
// "ask" (a bool-projected guess from the None mirror).
match &effects[0] {
Effect::PersistPermissionMode { persist, .. } => {
assert_eq!(
*persist,
crate::app::actions::PermissionModePersist::WithRollback("always-approve"),
"PR 11 R1 Security #8: LIVE yolo state must take precedence over the \
None on-disk mirror when computing the rollback canonical \
otherwise a `--yolo` startup + Default-commit + disk-failure diverges \
the pager from the shell",
);
}
other => panic!("expected PersistPermissionMode, got {other:?}"),
}
}
/// `apply_setting_rollback("permission_mode",
/// Enum("default"))` — the rollback arm that preserves the
/// "default" canonical through a failed-persist. The headline
/// architectural contract: rolling back to "default" must NOT
/// collapse onto "ask" via the inner's bool projection.
#[test]
fn rollback_permission_mode_default_canonical_preserves_default() {
use crate::settings::SettingValue;
let mut app = test_app_with_agent();
// Pre-flip to YOLO so the rollback has somewhere to roll
// back FROM.
let _ = dispatch(Action::SetYoloMode(true), &mut app);
assert!(app.agents[&AgentId(0)].session.is_yolo());
assert_eq!(
app.current_ui.permission_mode.as_deref(),
Some("always-approve"),
);
// Simulate disk-write failure with `rollback_value =
// Enum("default")`.
let effects = dispatch(
Action::TaskComplete(TaskResult::SettingPersistFailed {
key: "permission_mode",
rollback_value: SettingValue::Enum("default"),
error: "simulated".into(),
}),
&mut app,
);
// Rollback path MUST NOT re-emit any Effect — that would
// loop on persistent disk failure.
assert!(
effects.is_empty(),
"rollback path must not re-emit Effects, got {effects:?}",
);
// Yolo flipped to false (Default projects onto bool=false).
assert!(
!app.agents[&AgentId(0)].session.is_yolo(),
"Default projects onto yolo=false; agent.session.yolo_mode must flip back",
);
// Canonical preserved as "default" — the headline
// contract. Without the post-inner override in the rollback
// arm, the inner's bool-projection write would leave this
// at "ask".
assert_eq!(
app.current_ui.permission_mode.as_deref(),
Some("default"),
"PR 11 R1 Tests #22: rollback to 'default' canonical must NOT collapse \
onto 'ask' the post-inner override restores the canonical",
);
}
/// Non-empty permission_queue → NeedsInput.
#[test]
fn classify_top_level_permission_queue_non_empty_is_needs_input() {
use crate::views::dashboard::{RowState, classify_top_level};
let mut app = test_app_with_agent();
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
let _rx = push_synthetic_permission(agent, 1, vec![("allow", "Allow")]);
assert_eq!(classify_top_level(agent), RowState::NeedsInput);
}
#[test]
fn permission_select_reject_does_not_steer_sticky_cursor() {
use crate::appearance::permission_cursor::{
DefaultSelectedPermission, last_used_permission, set_last_used_permission,
};
use std::sync::Arc;
let mut app = test_app_with_agent();
let _rx_allow = enqueue_permission_with_enable_always_approve(&mut app);
let _rx_reject = enqueue_permission_with_enable_always_approve(&mut app);
set_last_used_permission(DefaultSelectedPermission::AlwaysAllowAllSessions);
let _ = dispatch(
Action::PermissionSelect(acp::PermissionOptionId::new(Arc::from("opt-allow-once"))),
&mut app,
);
assert_eq!(
last_used_permission(),
DefaultSelectedPermission::AllowOnce,
"allow selection records the sticky cursor target"
);
let _ = dispatch(
Action::PermissionSelect(acp::PermissionOptionId::new(Arc::from("opt-reject-once"))),
&mut app,
);
assert_eq!(
last_used_permission(),
DefaultSelectedPermission::AllowOnce,
"reject selection must not steer the sticky cursor"
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,46 @@
//! Tests for session lifecycle, loading, pickers, modals, forking, and trust.
use super::*;
mod foreign;
mod fork;
mod lifecycle;
mod load;
mod modal;
mod take_deferred;
/// Like [`test_app`] but with `cwd` set to this crate's directory,
/// which lives inside the git repo. Worktree tests require a git
/// ancestor to pass the `has_git_ancestor` pre-check.
fn test_app_git() -> AppView {
let mut app = test_app();
app.cwd = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
app.cwd_has_git_ancestor = true;
app
}
fn count_extension_fetches(effects: &[Effect]) -> usize {
effects
.iter()
.filter(|e| {
matches!(
e,
Effect::FetchHooksList { .. }
| Effect::FetchPluginsList { .. }
| Effect::FetchMcpsList { .. }
| Effect::FetchSkillsList { .. }
)
})
.count()
}
/// Build a single-agent app for the `/new` dispatcher tests.
///
/// Sets `current_branch` to `Some("main")` so the agent appears to be
/// inside a git repo (mirrors `fork_test_app`).
fn new_session_test_app() -> AppView {
let mut app = test_app_with_agent();
app.agents.get_mut(&AgentId(0)).unwrap().current_branch = Some("main".into());
app.cwd_has_git_ancestor = true;
app
}
@@ -0,0 +1,243 @@
//! Tests for session-related modals (extensions, /new worktree question)
//! and session close helpers shared with the dashboard.
use super::*;
#[test]
fn open_extensions_modal_no_session_sets_flag_no_fetches() {
use crate::views::extensions_modal::ExtensionsTab;
let mut app = test_app_with_agent();
let id = AgentId(0);
app.agents.get_mut(&id).unwrap().session.session_id = None;
let effects = dispatch(
Action::OpenExtensionsModal {
tab: ExtensionsTab::Hooks,
},
&mut app,
);
assert_eq!(count_extension_fetches(&effects), 0);
assert!(app.agents[&id].pending_extensions_fetch);
assert!(app.agents[&id].extensions_modal.is_some());
}
#[test]
fn open_extensions_modal_with_session_emits_fetches_no_flag() {
use crate::views::extensions_modal::ExtensionsTab;
let mut app = test_app_with_agent();
let id = AgentId(0);
let effects = dispatch(
Action::OpenExtensionsModal {
tab: ExtensionsTab::Hooks,
},
&mut app,
);
assert_eq!(count_extension_fetches(&effects), 4);
assert!(!app.agents[&id].pending_extensions_fetch);
}
#[test]
fn open_extensions_modal_with_session_resets_stale_flag() {
use crate::views::extensions_modal::ExtensionsTab;
let mut app = test_app_with_agent();
let id = AgentId(0);
app.agents.get_mut(&id).unwrap().pending_extensions_fetch = true;
let effects = dispatch(
Action::OpenExtensionsModal {
tab: ExtensionsTab::Hooks,
},
&mut app,
);
assert_eq!(count_extension_fetches(&effects), 4);
assert!(!app.agents[&id].pending_extensions_fetch);
}
#[test]
fn session_created_with_flag_but_modal_closed_clears_flag_no_fetches() {
let mut app = test_app_with_agent();
let id = AgentId(0);
{
let a = app.agents.get_mut(&id).unwrap();
a.session.session_id = None;
a.pending_extensions_fetch = true;
a.extensions_modal = None;
}
let effects = dispatch(
Action::TaskComplete(TaskResult::SessionCreated {
agent_id: id,
session_id: acp::SessionId::new("s"),
models: None,
}),
&mut app,
);
assert_eq!(count_extension_fetches(&effects), 0);
assert!(!app.agents[&id].pending_extensions_fetch);
}
// ── /new dispatcher tests ─────────────────────────────────────────────
#[test]
fn dispatch_new_session_opens_question_modal_in_git_repo() {
let mut app = new_session_test_app();
app.new_session_worktree_mode = crate::app::app_view::WorktreeMode::Ask;
let effects = dispatch(Action::NewSession, &mut app);
assert!(effects.is_empty(), "no effects until modal answered");
// No new agent yet (creation is deferred until modal answered).
assert_eq!(app.agents.len(), 1);
let qv = app.agents[&AgentId(0)]
.question_view
.as_ref()
.expect("modal must be open");
match qv.local_kind.as_ref().expect("local_kind must be set") {
crate::views::question_view::LocalQuestionKind::NewSession => {}
other => panic!("expected NewSession, got {other:?}"),
}
assert_eq!(
qv.questions[0].options.len(),
4,
"modal must offer exactly 4 options (Yes/No/Always/Never)"
);
let labels: Vec<&str> = qv.questions[0]
.options
.iter()
.map(|o| o.label.as_str())
.collect();
assert_eq!(
labels,
vec!["Yes", "No", "Always worktree", "Never worktree"]
);
}
#[test]
fn dispatch_new_session_skips_modal_in_non_git_repo() {
// current_branch stays None (no git repo) → no modal, straight
// to dispatch_new_session_inner.
let mut app = test_app_with_agent();
let effects = dispatch(Action::NewSession, &mut app);
assert!(
effects
.iter()
.any(|e| matches!(e, Effect::CreateSession { .. })),
"non-git path must emit CreateSession, got {effects:?}"
);
assert!(
app.agents.values().all(|a| a.question_view.is_none()),
"non-git path must not open the modal"
);
}
// ── Session close (shared with dashboard) ─────────────────────────────
#[test]
fn close_inactive_agent_drops_it() {
let mut app = three_agent_app();
let effects = dispatch_sessions_confirm_close(&mut app, AgentId(2));
assert!(
effects
.iter()
.all(|e| matches!(e, Effect::UnregisterActiveSession { .. }))
);
assert!(!app.agents.contains_key(&AgentId(2)));
assert_eq!(app.agents.len(), 2);
}
#[test]
fn close_agent_releases_retained_memory() {
use crate::memory_release::test_support;
test_support::install_counting_hook();
let mut app = three_agent_app();
// Dropping a real AgentView (scrollback + caches + child views) → purge.
let before = test_support::calls();
dispatch_sessions_confirm_close(&mut app, AgentId(2));
assert!(!app.agents.contains_key(&AgentId(2)));
assert_eq!(
test_support::calls(),
before + 1,
"dropping the closed AgentView must purge retained pages"
);
// Closing an unknown agent drops nothing → no purge.
let before = test_support::calls();
dispatch_sessions_confirm_close(&mut app, AgentId(999));
assert_eq!(
test_support::calls(),
before,
"a no-op close must not purge"
);
}
#[test]
fn close_clears_forked_from_on_surviving_children() {
let mut app = three_agent_app();
set_forked_from(&mut app, AgentId(2), AgentId(1));
dispatch_sessions_confirm_close(&mut app, AgentId(1));
assert!(
app.agents[&AgentId(2)].session.forked_from.is_none(),
"stale forked_from pointer must be cleared after parent close"
);
}
#[test]
fn close_only_agent_is_refused_with_toast() {
let mut app = test_app_with_agent();
let agents_before = app.agents.len();
dispatch_sessions_confirm_close(&mut app, AgentId(0));
assert_eq!(
app.agents.len(),
agents_before,
"the only agent must NOT be closed"
);
}
#[test]
fn close_unknown_agent_is_silent_noop() {
let mut app = three_agent_app();
let agents_before = app.agents.len();
dispatch_sessions_confirm_close(&mut app, AgentId(999));
assert_eq!(app.agents.len(), agents_before);
}
#[test]
fn close_only_agent_short_circuits_before_reaching_welcome_fallback() {
let mut app = test_app_with_agent();
assert!(matches!(app.active_view, ActiveView::Agent(id) if id == AgentId(0)));
dispatch_sessions_confirm_close(&mut app, AgentId(0));
assert!(matches!(app.active_view, ActiveView::Agent(id) if id == AgentId(0)));
assert!(app.agents.contains_key(&AgentId(0)));
}
#[test]
fn close_does_not_disturb_unrelated_forked_from_pointers() {
let mut app = three_agent_app();
set_forked_from(&mut app, AgentId(1), AgentId(0));
set_forked_from(&mut app, AgentId(2), AgentId(0));
dispatch_sessions_confirm_close(&mut app, AgentId(1));
assert_eq!(
app.agents[&AgentId(2)].session.forked_from,
Some(AgentId(0)),
"unrelated forked_from must NOT be cleared"
);
}
#[test]
fn extensions_modal_in_non_project_dir_creates_session() {
let mut app = project_picker_app();
dispatch(Action::NewSession, &mut app);
let id = AgentId(0);
let effects = dispatch(
Action::OpenExtensionsModal {
tab: crate::views::extensions_modal::ExtensionsTab::McpServers,
},
&mut app,
);
assert!(
effects
.iter()
.any(|e| matches!(e, Effect::CreateSession { .. })),
"session-less modal open must create the deferred session"
);
assert!(app.agents[&id].pending_extensions_fetch);
}
@@ -0,0 +1,211 @@
use crate::acp::model_state::{EffortTokenError, ModelState};
use crate::app::dispatch::session::lifecycle::{DeferredSwitchOutcome, take_deferred_model_switch};
use agent_client_protocol as acp;
use kigi_shell::sampling::types::ReasoningEffort;
use std::sync::Arc;
fn model_with_support(id: &str, supports: bool) -> (acp::ModelId, acp::ModelInfo) {
let id = acp::ModelId::new(Arc::from(id));
let meta = if supports {
Some(serde_json::json!({
"supportsReasoningEffort": true,
"reasoningEffort": "medium",
"reasoningEfforts": [
{ "id": "deep", "value": "xhigh", "label": "Deep" },
{ "id": "high", "value": "high", "label": "High" },
],
}))
} else {
Some(serde_json::json!({ "reasoningEffort": "medium" }))
};
let info = acp::ModelInfo::new(id.clone(), id.0.to_string())
.meta(meta.and_then(|v| v.as_object().cloned()));
(id, info)
}
fn models_with_current(supports: bool) -> ModelState {
let (id, info) = model_with_support("grok-build", supports);
let mut models = ModelState::default();
models.available.insert(id.clone(), info);
models.current = Some(id);
models.reasoning_effort = Some(ReasoningEffort::Medium);
models
}
#[test]
fn effort_only_resolves_canonical_token() {
let models = models_with_current(true);
let out = take_deferred_model_switch(None, &models, Some("high"));
assert_eq!(
out,
DeferredSwitchOutcome {
switch: Some((models.current.clone().unwrap(), Some(ReasoningEffort::High))),
effort_error: None,
}
);
}
#[test]
fn effort_only_resolves_remapped_menu_id() {
let models = models_with_current(true);
let out = take_deferred_model_switch(None, &models, Some("deep"));
assert_eq!(
out,
DeferredSwitchOutcome {
switch: Some((
models.current.clone().unwrap(),
Some(ReasoningEffort::Xhigh)
)),
effort_error: None,
}
);
}
#[test]
fn effort_only_unsupported_canonical_token_is_unsupported() {
// Gate-first: a canonical token on a model that doesn't support reasoning
// effort surfaces Unsupported (matching `/effort` and headless) rather than
// silently applying an effort the server would drop.
let models = models_with_current(false);
assert_eq!(
take_deferred_model_switch(None, &models, Some("high")),
DeferredSwitchOutcome {
switch: None,
effort_error: Some(EffortTokenError::Unsupported),
}
);
}
#[test]
fn effort_only_unsupported_unknown_token_is_unsupported() {
let models = models_with_current(false);
assert_eq!(
take_deferred_model_switch(None, &models, Some("bogus")),
DeferredSwitchOutcome {
switch: None,
effort_error: Some(EffortTokenError::Unsupported),
}
);
}
#[test]
fn effort_only_skips_when_already_equal() {
let mut models = models_with_current(true);
models.reasoning_effort = Some(ReasoningEffort::High);
assert_eq!(
take_deferred_model_switch(None, &models, Some("high")),
DeferredSwitchOutcome {
switch: None,
effort_error: None,
}
);
}
#[test]
fn effort_only_errors_on_unknown_token() {
let models = models_with_current(true);
assert_eq!(
take_deferred_model_switch(None, &models, Some("bogus")),
DeferredSwitchOutcome {
switch: None,
effort_error: Some(EffortTokenError::UnknownToken {
token: "bogus".into(),
offered: vec!["deep".into(), "high".into()],
}),
}
);
}
#[test]
fn stashed_model_switch_prefers_explicit_stash() {
let models = models_with_current(true);
let other = acp::ModelId::new(Arc::from("other-model"));
let out = take_deferred_model_switch(
Some((other.clone(), Some(ReasoningEffort::Low))),
&models,
Some("high"),
);
assert_eq!(
out,
DeferredSwitchOutcome {
switch: Some((other, Some(ReasoningEffort::Low))),
effort_error: None,
}
);
}
#[test]
fn stashed_model_re_resolves_remap_when_effort_missing() {
let models = models_with_current(true);
let current = models.current.clone().unwrap();
let out = take_deferred_model_switch(Some((current.clone(), None)), &models, Some("deep"));
assert_eq!(
out,
DeferredSwitchOutcome {
switch: Some((current, Some(ReasoningEffort::Xhigh))),
effort_error: None,
}
);
}
#[test]
fn stashed_model_keeps_model_when_token_unresolvable() {
let models = models_with_current(true);
let current = models.current.clone().unwrap();
let out = take_deferred_model_switch(Some((current.clone(), None)), &models, Some("bogus"));
assert_eq!(
out,
DeferredSwitchOutcome {
switch: Some((current, None)),
effort_error: Some(EffortTokenError::UnknownToken {
token: "bogus".into(),
offered: vec!["deep".into(), "high".into()],
}),
}
);
}
#[test]
fn stashed_model_keeps_model_when_unsupported() {
// -m targets a non-reasoning model plus an effort token: keep the model
// switch, drop the effort, and surface Unsupported.
let mut models = models_with_current(true);
let (plain, plain_info) = model_with_support("plain-model", false);
models.available.insert(plain.clone(), plain_info);
let out = take_deferred_model_switch(Some((plain.clone(), None)), &models, Some("high"));
assert_eq!(
out,
DeferredSwitchOutcome {
switch: Some((plain, None)),
effort_error: Some(EffortTokenError::Unsupported),
}
);
}
#[test]
fn effort_only_accepts_max_as_xhigh() {
let models = models_with_current(true);
let out = take_deferred_model_switch(None, &models, Some("max"));
assert_eq!(
out,
DeferredSwitchOutcome {
switch: Some((
models.current.clone().unwrap(),
Some(ReasoningEffort::Xhigh)
)),
effort_error: None,
}
);
}
#[test]
fn effort_only_errors_without_active_model() {
let models = ModelState::default();
assert_eq!(
take_deferred_model_switch(None, &models, Some("high")),
DeferredSwitchOutcome {
switch: None,
effort_error: Some(EffortTokenError::NoActiveModel),
}
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,940 @@
//! Tests for session status, sharing, privacy, and coding-data-sharing dispatchers.
use super::*;
/// Regression (leader-mode turn-end race): when this client is briefly Idle
/// (`is_turn_running() == false`, `current_prompt_id` cleared) but the server
/// still has queued prompts — visible as a non-empty `shared_queue` mirror —
/// a newly-sent prompt must route to the SERVER (immediate-send), NOT be
/// locally drained as a phantom running turn. The failure mode: a
/// `send_route_plain immediate=false is_turn_running=false shared_queue_len=5`
/// path taking `local_drain`, leaving the prompt shown running on the sender
/// while it was actually queued behind the existing entries on the leader and
/// every other client.
#[test]
fn send_while_idle_with_nonempty_shared_queue_routes_to_server() {
let mut app = test_app_with_agent();
let id = AgentId(0);
// Two prompts already queued on the server (as a broadcast would leave
// things): populate the authoritative map AND mirror it into the agent.
app.push_optimistic_prompt_echo("test-session", "q1", "a", "prompt");
app.push_optimistic_prompt_echo("test-session", "q2", "b", "prompt");
{
let snapshot = app.shared_prompt_queue("test-session").cloned().unwrap();
let agent = app.agents.get_mut(&id).unwrap();
// Turn-end window: locally Idle with no current prompt, but the
// server's queue (mirrored from the last broadcast) still has work.
agent.session.state = AgentState::Idle;
agent.session.current_prompt_id = None;
agent.shared_queue = snapshot;
assert!(agent.session.pending_prompts.is_empty());
}
let effects = dispatch(Action::SendPrompt("c".into()), &mut app);
// Routed to the server (immediate-send), keyed by a fresh prompt_id.
let pid = effects
.iter()
.find_map(|e| match e {
Effect::SendPrompt {
text, prompt_id, ..
} if text == "c" => Some(prompt_id.clone()),
_ => None,
})
.unwrap_or_else(|| panic!("expected immediate SendPrompt for 'c', got {effects:?}"));
// Did NOT start a local turn or adopt "c" as the running prompt.
assert!(
!app.agents[&id].session.state.is_turn_running(),
"must not promote 'c' to a local running turn"
);
assert!(
app.agents[&id].session.current_prompt_id.is_none(),
"must not set current_prompt_id locally for a server-queued prompt"
);
// Echoed into the shared queue BEHIND the existing entries (position 3).
let q = app
.shared_prompt_queue("test-session")
.expect("optimistic echo present");
assert_eq!(q.len(), 3, "c queued behind q1, q2");
assert_eq!(q.last().map(|e| e.id.as_str()), Some(pid.as_str()));
assert_eq!(q.last().map(|e| e.text.as_str()), Some("c"));
}
#[test]
fn show_privacy_info_zdr() {
let mut app = test_app_with_agent();
app.is_zdr = true;
let effects = dispatch(Action::ShowPrivacyInfo, &mut app);
assert!(effects.is_empty());
let text = last_system_text(&app, AgentId(0));
assert!(text.contains("Zero Data Retention"));
}
/// `/privacy` info-print uses the desktop-aligned "privacy mode" /
/// "share data" labels from the user's intentional rewrite.
#[test]
fn show_privacy_info_opted_out() {
let mut app = test_app_with_agent();
app.coding_data_retention_opt_out = true;
let effects = dispatch(Action::ShowPrivacyInfo, &mut app);
assert!(effects.is_empty());
let text = last_system_text(&app, AgentId(0));
assert!(
text.contains("Privacy: privacy mode"),
"info-print must use 'Privacy: privacy mode' (desktop-aligned label): {text}",
);
assert!(text.contains("/privacy opt-in"));
}
#[test]
fn show_privacy_info_opted_in() {
let mut app = test_app_with_agent();
app.coding_data_retention_opt_out = false;
let effects = dispatch(Action::ShowPrivacyInfo, &mut app);
assert!(effects.is_empty());
let text = last_system_text(&app, AgentId(0));
assert!(
text.contains("Privacy: share data"),
"info-print must use 'Privacy: share data' (desktop-aligned label): {text}",
);
assert!(text.contains("/privacy opt-out"));
}
/// The info-print uses desktop-aligned labels ("privacy mode" /
/// "share data"). This test pins those labels to catch accidental
/// regressions to the registry's "Opt in" / "Opt out" display
/// strings.
#[test]
fn show_privacy_info_does_not_use_old_desktop_labels() {
// opted-out → "Privacy: privacy mode"
let mut app = test_app_with_agent();
app.coding_data_retention_opt_out = true;
let _ = dispatch(Action::ShowPrivacyInfo, &mut app);
let text = last_system_text(&app, AgentId(0));
assert!(
text.contains("privacy mode"),
"[opted-out] info-print must contain 'privacy mode': {text:?}",
);
// opted-in → "Privacy: share data"
let mut app = test_app_with_agent();
app.coding_data_retention_opt_out = false;
let _ = dispatch(Action::ShowPrivacyInfo, &mut app);
let text = last_system_text(&app, AgentId(0));
assert!(
text.contains("share data"),
"[opted-in] info-print must contain 'share data': {text:?}",
);
}
// ── coding_data_sharing dispatch tests ───
//
// The dispatcher uses **optimistic + rollback + toast**, matching the
// `set_yolo_mode` pattern. These tests pin the contract:
// - Guards (ZDR, non-admin team) toast and short-circuit.
// - Idempotent dispatch toasts but emits no Effect.
// - Optimistic mutation flips `app.coding_data_retention_opt_out`
// BEFORE the Effect is emitted.
// - `Effect::SetCodingDataSharing` carries
// `rollback_to_opted_in = previous_value`.
// - `TaskResult::CodingDataSharingFailed` reverts the optimistic
// mutation; `TaskResult::CodingDataSharingUpdated` re-anchors
// to the server-confirmed value.
/// Idempotent re-dispatch when already opted-in toasts but emits
/// no Effect (avoids a wasted ACP round-trip).
///
/// Toast uses the **display name** ("Opt in", not the
/// snake-case canonical "opt-in") AND the **destructive `⚠`
/// glyph** on the opt-in direction (privacy-degrading).
#[test]
fn set_coding_data_sharing_idempotent_opt_in() {
let mut app = test_app_with_agent();
app.coding_data_retention_opt_out = false; // currently opted-in
let effects = dispatch(Action::SetCodingDataSharing { opted_in: true }, &mut app);
assert!(
effects.is_empty(),
"idempotent re-dispatch must NOT emit Effect"
);
let toast = read_toast(&app);
assert!(
toast.contains("Opt in"),
"toast must show display name 'Opt in' (PR 9 R1, General-3 Issue 6): {toast}",
);
assert!(
!toast.contains("opt-in"),
"toast must NOT use snake-case canonical 'opt-in' — display name only: {toast}",
);
assert!(
toast.contains('\u{26A0}'),
"idempotent opt-in toast uses ⚠ destructive-warning glyph (PR 9 R1, \
General-3 Issue 5): {toast}",
);
// State unchanged.
assert!(
!app.coding_data_retention_opt_out,
"idempotent path must not mutate state",
);
}
/// Idempotent re-dispatch when already opted-out toasts but emits
/// no Effect.
///
/// Opt-out direction uses the **uniform `✓` glyph**
/// (restoring the safe default) and the display name "Opt out".
#[test]
fn set_coding_data_sharing_idempotent_opt_out() {
let mut app = test_app_with_agent();
app.coding_data_retention_opt_out = true; // currently opted-out
let effects = dispatch(Action::SetCodingDataSharing { opted_in: false }, &mut app);
assert!(
effects.is_empty(),
"idempotent re-dispatch must NOT emit Effect"
);
let toast = read_toast(&app);
assert!(
toast.contains("Opt out"),
"toast must show display name 'Opt out': {toast}",
);
assert!(
toast.contains('\u{2713}'),
"idempotent opt-out toast uses ✓ safe-default glyph: {toast}",
);
assert!(
!toast.contains('\u{26A0}'),
"opt-out is the safe direction — must NOT use ⚠: {toast}",
);
// State unchanged.
assert!(
app.coding_data_retention_opt_out,
"idempotent path must not mutate state",
);
}
/// ZDR teams are blocked from toggling. The blocked path
/// toasts (not scrollback) and short-circuits with no Effect.
#[test]
fn set_coding_data_sharing_blocked_by_zdr() {
let mut app = test_app_with_agent();
app.is_zdr = true;
app.coding_data_retention_opt_out = false;
let effects = dispatch(Action::SetCodingDataSharing { opted_in: false }, &mut app);
assert!(effects.is_empty(), "ZDR block must NOT emit Effect");
let toast = read_toast(&app);
assert!(
toast.contains("Zero Data Retention"),
"ZDR toast must surface the policy: {toast}",
);
assert!(
toast.contains('\u{2717}'),
"blocked toast uses ✗ glyph: {toast}"
);
// State unchanged — the user was blocked, the optimistic
// mutation never happened.
assert!(
!app.coding_data_retention_opt_out,
"ZDR block must not mutate state",
);
}
/// ZDR block fires even when the toggle would be a no-op
/// (defense-in-depth: don't quietly accept a same-value toggle
/// from a user the policy says shouldn't be touching this).
#[test]
fn set_coding_data_sharing_blocked_by_zdr_even_if_idempotent() {
let mut app = test_app_with_agent();
app.is_zdr = true;
app.coding_data_retention_opt_out = false;
let effects = dispatch(Action::SetCodingDataSharing { opted_in: true }, &mut app);
assert!(effects.is_empty());
assert!(read_toast(&app).contains("Zero Data Retention"));
}
/// Non-admin team members are blocked from toggling (matches
/// desktop). The blocked path toasts and short-circuits.
#[test]
fn set_coding_data_sharing_blocked_non_admin() {
let mut app = test_app_with_agent();
app.team_name = Some("Acme".into());
app.team_role = Some("Member".into());
app.coding_data_retention_opt_out = false;
let effects = dispatch(Action::SetCodingDataSharing { opted_in: false }, &mut app);
assert!(effects.is_empty());
let toast = read_toast(&app);
assert!(
toast.contains("team admin"),
"non-admin toast must mention team admin: {toast}",
);
}
/// Admin team members CAN toggle. The admin-allowed path produces
/// an Effect carrying the rollback value.
#[test]
fn set_coding_data_sharing_allowed_for_admin() {
let mut app = test_app_with_agent();
app.team_name = Some("Acme".into());
app.team_role = Some("Admin".into());
app.coding_data_retention_opt_out = false; // currently opted-in
let effects = dispatch(Action::SetCodingDataSharing { opted_in: false }, &mut app);
assert_eq!(effects.len(), 1);
match &effects[0] {
Effect::SetCodingDataSharing {
opted_in,
rollback_to_opted_in,
..
} => {
assert!(!*opted_in, "Effect must carry opted_in=false");
assert!(
*rollback_to_opted_in,
"rollback_to_opted_in must capture pre-toggle opt-in=true",
);
}
other => panic!("expected SetCodingDataSharing Effect, got {other:?}"),
}
// Optimistic mutation already applied.
assert!(
app.coding_data_retention_opt_out,
"admin-allowed dispatch must optimistically flip state",
);
}
/// Non-idempotent dispatch emits one Effect AND mutates state
/// optimistically AND toasts.
#[test]
fn set_coding_data_sharing_produces_effect_and_optimistic_mutation() {
let mut app = test_app_with_agent();
app.coding_data_retention_opt_out = false; // currently opted-in
let effects = dispatch(Action::SetCodingDataSharing { opted_in: false }, &mut app);
assert_eq!(effects.len(), 1, "non-idempotent dispatch emits one Effect");
match &effects[0] {
Effect::SetCodingDataSharing {
agent_id,
opted_in,
rollback_to_opted_in,
} => {
assert_eq!(*agent_id, AgentId(0));
assert!(!*opted_in);
assert!(
*rollback_to_opted_in,
"rollback_to_opted_in must be pre-toggle value (true == opted-in)",
);
}
other => panic!("expected SetCodingDataSharing Effect, got {other:?}"),
}
// Optimistic mutation applied.
assert!(
app.coding_data_retention_opt_out,
"dispatch must optimistically mutate state",
);
// Toast on every dispatch (SHELL setter contract).
assert!(app.agents[&AgentId(0)].toast.is_some());
}
/// `TaskResult::CodingDataSharingUpdated` re-anchors state to the
/// server-confirmed value (defense-in-depth) and re-toasts.
#[test]
fn coding_data_sharing_updated_re_anchors_state_and_re_toasts() {
let mut app = test_app_with_agent();
// Simulate post-optimistic state: opted-out.
app.coding_data_retention_opt_out = true;
let id = AgentId(0);
// Server confirms opt-out (same as optimistic).
let effects = dispatch(
Action::TaskComplete(TaskResult::CodingDataSharingUpdated {
agent_id: id,
opted_in: false,
}),
&mut app,
);
assert!(effects.is_empty(), "TaskResult arm must NOT emit Effect");
// State re-anchored (was already true, stays true).
assert!(app.coding_data_retention_opt_out);
// Re-toast on confirmation uses display name + ✓.
let toast = read_toast(&app);
assert!(
toast.contains("Opt out"),
"confirmation toast must use display name 'Opt out': {toast}",
);
assert!(
toast.contains('\u{2713}'),
"opt-out confirmation toast uses ✓: {toast}",
);
}
/// `TaskResult::CodingDataSharingUpdated` corrects the in-memory
/// state if the server reshapes the boolean (e.g. policy
/// override). Pins the defense-in-depth re-anchor contract.
#[test]
fn coding_data_sharing_updated_corrects_state_if_server_disagrees() {
let mut app = test_app_with_agent();
// Optimistic mutation said "opt-out" — but the server
// overrides to "opt-in" (e.g. policy that prevents opt-out).
app.coding_data_retention_opt_out = true;
let id = AgentId(0);
let effects = dispatch(
Action::TaskComplete(TaskResult::CodingDataSharingUpdated {
agent_id: id,
opted_in: true, // server says opted-in
}),
&mut app,
);
assert!(effects.is_empty());
// State corrected to match server.
assert!(
!app.coding_data_retention_opt_out,
"server-confirmed opt-in must overwrite optimistic opt-out",
);
// Server-correction toast uses the destructive ⚠
// pattern for the opt-in direction (the privacy-degrading
// override deserves the warning glyph even if the SERVER, not
// the user, made the call).
let toast = read_toast(&app);
assert!(
toast.contains("Opt in"),
"post-correction toast uses display name 'Opt in': {toast}",
);
assert!(
toast.contains('\u{26A0}'),
"opt-in direction always uses ⚠ glyph, even on server-correction path: {toast}",
);
}
/// `TaskResult::CodingDataSharingFailed` REVERTS the optimistic
/// mutation and surfaces a failure toast. Pins the rollback
/// contract.
///
/// Failure toast uses the standardised "coding data sharing"
/// wording.
#[test]
fn coding_data_sharing_failed_rolls_back_and_toasts_error() {
let mut app = test_app_with_agent();
// Simulate post-optimistic state: user picked opt-out, state
// was flipped, then the ACP call failed. The pre-toggle value
// was opt-in (true), so `rollback_to_opted_in = true`.
app.coding_data_retention_opt_out = true;
let id = AgentId(0);
let effects = dispatch(
Action::TaskComplete(TaskResult::CodingDataSharingFailed {
agent_id: id,
error: "server error".into(),
rollback_to_opted_in: true,
}),
&mut app,
);
assert!(effects.is_empty(), "rollback path must NOT emit Effect");
// State reverted to pre-toggle (opted-in).
assert!(
!app.coding_data_retention_opt_out,
"rollback must revert optimistic mutation",
);
// Failure toast surfaces the error using full label.
let toast = read_toast(&app);
assert!(
toast.contains("coding data sharing"),
"PR 9 R1: failure toast wording standardised to include 'coding data sharing' \
(G2 Issue 2): {toast}",
);
assert!(toast.contains("server error"), "error in toast: {toast}");
assert!(toast.contains('\u{2717}'), "failure toast uses ✗: {toast}");
}
/// `TaskResult::CodingDataSharingFailed` reverts in the OTHER
/// direction too (the pre-toggle state could have been either).
#[test]
fn coding_data_sharing_failed_rolls_back_to_opt_out() {
let mut app = test_app_with_agent();
// Post-optimistic: opted-in (user picked opt-in, server
// failed, pre-toggle was opt-out).
app.coding_data_retention_opt_out = false;
let id = AgentId(0);
let effects = dispatch(
Action::TaskComplete(TaskResult::CodingDataSharingFailed {
agent_id: id,
error: "network timeout".into(),
rollback_to_opted_in: false,
}),
&mut app,
);
assert!(effects.is_empty());
// Reverted to pre-toggle opt-out.
assert!(
app.coding_data_retention_opt_out,
"rollback to opt-out must set state=true",
);
}
/// Optimistic mutation refreshes any open settings modal.
/// Without this refresh, the modal indicator would stay at the
/// pre-toggle value until manual re-render.
#[test]
fn set_coding_data_sharing_refreshes_open_modal_snapshot() {
let mut app = test_app_with_agent();
app.coding_data_retention_opt_out = false;
// Open a settings modal (capture initial snapshot).
let _ = dispatch(Action::OpenSettings, &mut app);
// Verify snapshot reads opted-in.
let agent_id = AgentId(0);
{
let state = match &app.agents[&agent_id].active_modal {
Some(crate::views::modal::ActiveModal::Settings { state }) => state,
_ => panic!("expected Settings modal open after OpenSettings dispatch"),
};
assert!(
!state.pager_snapshot.coding_data_sharing_opt_out,
"initial snapshot must read opt_out=false (opted-in)",
);
}
// Dispatch the toggle.
let _ = dispatch(Action::SetCodingDataSharing { opted_in: false }, &mut app);
// Snapshot now reflects the optimistic mutation.
let state = match &app.agents[&agent_id].active_modal {
Some(crate::views::modal::ActiveModal::Settings { state }) => state,
_ => panic!("Settings modal must still be open after SetCodingDataSharing dispatch"),
};
assert!(
state.pager_snapshot.coding_data_sharing_opt_out,
"snapshot must refresh to reflect opt_out=true (opted-out) after dispatch",
);
}
/// Rollback also refreshes the modal — the user sees the
/// reverted value, not the stale optimistic one.
#[test]
fn coding_data_sharing_failed_refreshes_open_modal_snapshot() {
let mut app = test_app_with_agent();
app.coding_data_retention_opt_out = false;
let _ = dispatch(Action::OpenSettings, &mut app);
// Optimistic flip.
let _ = dispatch(Action::SetCodingDataSharing { opted_in: false }, &mut app);
// ACP failure.
let _ = dispatch(
Action::TaskComplete(TaskResult::CodingDataSharingFailed {
agent_id: AgentId(0),
error: "x".into(),
rollback_to_opted_in: true,
}),
&mut app,
);
let state = match &app.agents[&AgentId(0)].active_modal {
Some(crate::views::modal::ActiveModal::Settings { state }) => state,
_ => panic!("Settings modal must still be open after rollback TaskResult"),
};
assert!(
!state.pager_snapshot.coding_data_sharing_opt_out,
"rollback must refresh snapshot back to opt_out=false (opted-in)",
);
}
// ── coding_data_sharing toast tests ─────────────
/// The opt-in transition
/// uses the **`⚠` destructive-warning glyph** + spelled-out
/// consequence text — mirroring `yolo_toast`'s
/// "Always-approve ON: all tool actions auto-run" pattern. The
/// consequence text is verbatim-pinned because the toast is the
/// only post-commit feedback for a privacy-degrading transition;
/// a future PR that softens the wording silently degrades the
/// safety affordance.
#[test]
fn set_coding_data_sharing_opt_in_renders_destructive_warning_toast() {
let mut app = test_app_with_agent();
app.coding_data_retention_opt_out = true; // currently opted-out
let effects = dispatch(Action::SetCodingDataSharing { opted_in: true }, &mut app);
assert_eq!(effects.len(), 1, "non-idempotent opt-in must emit Effect");
let toast = read_toast(&app);
assert!(
toast.contains('\u{26A0}'),
"opt-in toast MUST use ⚠ glyph (PR 9 R1, General-3 Issue 5 — \
privacy-degrading transition deserves destructive-warning glyph): {toast}",
);
assert!(
!toast.contains('\u{2713}'),
"opt-in toast MUST NOT use the uniform ✓ glyph — that's the \
safe-default toast for opt-out: {toast}",
);
assert!(
toast.contains("Opt in"),
"destructive toast still uses display name 'Opt in': {toast}",
);
// Consequence text pinned: a future PR softening this loses
// the safety affordance.
assert!(
toast.contains("code samples"),
"destructive toast must spell out the consequence \
(mention 'code samples'): {toast}",
);
assert!(
toast.contains("training"),
"destructive toast must spell out the consequence \
(mention 'training'): {toast}",
);
}
/// The opt-out transition uses the
/// uniform `✓` glyph (safe default), NOT the destructive `⚠`.
/// Mirrors `yolo_toast(false)` precedent — restoring the safe
/// default doesn't warrant the heavier visual.
#[test]
fn set_coding_data_sharing_opt_out_renders_safe_default_toast() {
let mut app = test_app_with_agent();
app.coding_data_retention_opt_out = false; // currently opted-in
let _ = dispatch(Action::SetCodingDataSharing { opted_in: false }, &mut app);
let toast = read_toast(&app);
assert!(
toast.contains('\u{2713}'),
"opt-out toast uses ✓ safe-default glyph: {toast}",
);
assert!(
!toast.contains('\u{26A0}'),
"opt-out toast MUST NOT use ⚠ — that's reserved for the privacy-degrading \
direction (PR 9 R1): {toast}",
);
assert!(toast.contains("Opt out"));
}
/// The toast renders
/// the registered `EnumChoice.display` ("Opt in" / "Opt out"),
/// NOT the persisted canonical ("opt-in" / "opt-out"). Mirrors
/// the `set_theme_toast_format_uses_display_name` contract.
/// The display strings here are pinned by the
/// `coding_data_sharing_choices_use_canonical_strings` e2e test
/// (registry side) AND
/// `pr9_coding_data_sharing_choices_use_canonical_strings` (which
/// also pins the display labels via the same EnumChoice
/// entries).
#[test]
fn coding_data_sharing_toast_format_uses_display_name() {
let mut app = test_app_with_agent();
// Opt-in direction.
app.coding_data_retention_opt_out = true;
let _ = dispatch(Action::SetCodingDataSharing { opted_in: true }, &mut app);
let opt_in_toast = read_toast(&app);
assert!(
opt_in_toast.contains("Opt in"),
"opt-in toast uses display 'Opt in', not canonical 'opt-in': {opt_in_toast}",
);
// Clear and test opt-out direction.
app.agents.get_mut(&AgentId(0)).unwrap().toast = None;
app.coding_data_retention_opt_out = false;
let _ = dispatch(Action::SetCodingDataSharing { opted_in: false }, &mut app);
let opt_out_toast = read_toast(&app);
assert!(
opt_out_toast.contains("Opt out"),
"opt-out toast uses display 'Opt out', not canonical 'opt-out': {opt_out_toast}",
);
}
/// The failure toast
/// substitutes a generic placeholder when the error string is
/// too long OR contains control characters / newlines. Pins the
/// scrub contract.
#[test]
fn coding_data_sharing_failed_scrubs_long_error_messages() {
let mut app = test_app_with_agent();
app.coding_data_retention_opt_out = true;
let id = AgentId(0);
// ~500-char error simulating a stack trace / HTML 502 page.
let huge_error = "a".repeat(500);
let _ = dispatch(
Action::TaskComplete(TaskResult::CodingDataSharingFailed {
agent_id: id,
error: huge_error.clone(),
rollback_to_opted_in: false,
}),
&mut app,
);
let toast = read_toast(&app);
assert!(
!toast.contains(&huge_error),
"long error MUST be scrubbed from the toast: {} chars",
toast.len(),
);
assert!(
toast.contains("see logs"),
"scrubbed toast must point at the log for full details: {toast}",
);
}
/// Control characters (CR/LF/NUL)
/// in the error trigger the scrub path even on short strings —
/// preserves the toast's single-line layout.
#[test]
fn coding_data_sharing_failed_scrubs_control_chars_in_error() {
let mut app = test_app_with_agent();
app.coding_data_retention_opt_out = true;
let id = AgentId(0);
// Short message with embedded newlines.
let multiline = "line1\nline2\nline3".to_string();
let _ = dispatch(
Action::TaskComplete(TaskResult::CodingDataSharingFailed {
agent_id: id,
error: multiline.clone(),
rollback_to_opted_in: false,
}),
&mut app,
);
let toast = read_toast(&app);
assert!(
!toast.contains('\n'),
"newlines MUST be scrubbed from the toast (would break single-line layout): \
{toast:?}",
);
assert!(
toast.contains("see logs"),
"control-char-scrubbed toast points at logs: {toast}",
);
}
/// The scrub path preserves short,
/// sanitised error messages verbatim — the typical happy-path
/// shell-side error string stays unscrubbed.
#[test]
fn coding_data_sharing_failed_preserves_short_clean_error_message() {
let mut app = test_app_with_agent();
app.coding_data_retention_opt_out = true;
let id = AgentId(0);
let short_clean = "network timeout".to_string();
let _ = dispatch(
Action::TaskComplete(TaskResult::CodingDataSharingFailed {
agent_id: id,
error: short_clean.clone(),
rollback_to_opted_in: false,
}),
&mut app,
);
let toast = read_toast(&app);
assert!(
toast.contains(&short_clean),
"short clean error must appear verbatim in the toast: {toast}",
);
assert!(
!toast.contains("see logs"),
"short clean error must NOT trigger the scrub fallback: {toast}",
);
}
/// Direct unit test of the `scrub_error_for_toast` helper —
/// pins the threshold and the fallback string against drift.
#[test]
fn scrub_error_for_toast_unit() {
// Empty + short messages pass through.
assert_eq!(scrub_error_for_toast(""), "");
assert_eq!(scrub_error_for_toast("ok"), "ok");
assert_eq!(scrub_error_for_toast("network timeout"), "network timeout");
// At-threshold (120 chars) still passes through.
let len_120 = "x".repeat(120);
assert_eq!(scrub_error_for_toast(&len_120), len_120);
// Over-threshold (121 chars) triggers scrub.
let len_121 = "x".repeat(121);
assert_eq!(
scrub_error_for_toast(&len_121),
"server error (see logs for details)"
);
// Control chars trigger scrub even at short lengths.
assert_eq!(
scrub_error_for_toast("hi\nthere"),
"server error (see logs for details)"
);
assert_eq!(
scrub_error_for_toast("hi\rthere"),
"server error (see logs for details)"
);
// Format-category (Cf) chars also trigger scrub — bidi
// overrides, zero-width joiner / space, BOM. Prevents
// Trojan-Source-style visual spoofing
// where a toast READS as one thing but bytes encode
// another via embedded RIGHT-TO-LEFT-OVERRIDE.
assert_eq!(
scrub_error_for_toast("opt\u{202E}-out"),
"server error (see logs for details)",
"RIGHT-TO-LEFT OVERRIDE (U+202E) must be scrubbed",
);
assert_eq!(
scrub_error_for_toast("opt\u{200B}out"),
"server error (see logs for details)",
"ZERO WIDTH SPACE (U+200B) must be scrubbed",
);
assert_eq!(
scrub_error_for_toast("\u{FEFF}leading BOM"),
"server error (see logs for details)",
"BOM (U+FEFF) must be scrubbed",
);
assert_eq!(
scrub_error_for_toast("zwj\u{200D}joiner"),
"server error (see logs for details)",
"ZERO WIDTH JOINER (U+200D) must be scrubbed",
);
}
/// The no-agent path
/// returns empty cleanly — no toast (the show_toast call would
/// no-op anyway), no panic, no Effect emitted. A "✗ No active
/// session" toast would be dead UX (no agent = no toast surface
/// to render on), so this path emits a tracing::warn! instead.
#[test]
fn set_coding_data_sharing_no_agents_returns_empty_without_panic() {
let mut app = test_app_with_agent();
// Remove every agent so the dispatcher hits the no-agent path.
app.agents.clear();
// Force the view off Agent so the dispatcher falls through to
// app.agents.keys().next() which is now empty.
app.active_view = ActiveView::Welcome;
let effects = dispatch(Action::SetCodingDataSharing { opted_in: false }, &mut app);
assert!(
effects.is_empty(),
"no-agent path must return empty (no Effect to fire)",
);
// State unchanged (we never reach the optimistic mutation).
assert!(
!app.coding_data_retention_opt_out,
"no-agent path must NOT mutate state",
);
}
#[test]
fn dispatch_rename_session_updates_display_name_locally() {
let mut app = test_app_with_agent();
let effects = dispatch_rename_session(&mut app, "renamed via slash".into());
assert_eq!(effects.len(), 1);
assert_eq!(
app.agents[&AgentId(0)].display_name.as_deref(),
Some("renamed via slash"),
"/rename must also update local display_name cache"
);
}
/// `ConfirmResetSetting { choice: Reset }` on a SHARED Bool
/// target restores the Settings modal AND fires the typed
/// `Action::SetCompactMode(default)` via recursive dispatch —
/// the `Effect::PersistSetting` is the externally-observable
/// signal. Also asserts the ui_snapshot was
/// refreshed to the new (post-reset) value (symmetric with the
/// Cancel test's snapshot assertion).
#[test]
fn dispatch_confirm_reset_setting_reset_dispatches_typed_setter_for_shared_bool() {
use crate::settings::SettingValue;
use crate::views::modal::{ActiveModal, ResetSettingsResult};
let mut app = test_app_with_agent();
// Flip compact_mode to true so we can observe the reset back
// to its default (false).
let _ = dispatch(Action::SetCompactMode(true), &mut app);
assert!(app.current_ui.compact_mode);
setup_reset_confirm_open(&mut app, "compact_mode");
let effects = dispatch(
Action::ConfirmResetSetting {
choice: ResetSettingsResult::Reset,
},
&mut app,
);
// Recursive dispatch into Action::SetCompactMode(false) emits
// the persist effect.
assert_eq!(effects.len(), 1);
match &effects[0] {
Effect::PersistSetting { key, value, .. } => {
assert_eq!(*key, "compact_mode");
assert_eq!(value, &SettingValue::Bool(false));
}
other => panic!("expected PersistSetting, got {other:?}"),
}
// In-memory state is reset to the default.
assert!(!app.current_ui.compact_mode);
// Modal is restored AND ui_snapshot reflects the new value
// (symmetric with the Cancel test).
let agent = app.agents.get(&AgentId(0)).expect("agent must exist");
match &agent.active_modal {
Some(ActiveModal::Settings { state }) => {
assert!(
!state.ui_snapshot.compact_mode,
"ui_snapshot must reflect the post-reset value"
);
}
_ => panic!("Reset branch must restore the Settings modal"),
}
}
/// `ConfirmResetSetting { choice: Reset }` on a SHARED Enum
/// target (`theme`) dispatches `Action::SetTheme(default)` via
/// recursive dispatch — verifies the action_for_reset Enum arm.
#[test]
fn dispatch_confirm_reset_setting_reset_dispatches_typed_setter_for_shared_enum() {
use crate::settings::SettingValue;
use crate::views::modal::ResetSettingsResult;
let mut app = test_app_with_agent();
// Flip theme to a non-default first.
let _ = dispatch(Action::SetTheme("tokyonight".to_string()), &mut app);
assert_eq!(app.current_ui.theme.as_deref(), Some("tokyonight"));
setup_reset_confirm_open(&mut app, "theme");
let effects = dispatch(
Action::ConfirmResetSetting {
choice: ResetSettingsResult::Reset,
},
&mut app,
);
// Reset → SetTheme("groknight") (the registered default).
assert_eq!(effects.len(), 1);
match &effects[0] {
Effect::PersistSetting { key, value, .. } => {
assert_eq!(*key, "theme");
assert_eq!(value, &SettingValue::Enum("groknight"));
}
other => panic!("expected PersistSetting, got {other:?}"),
}
assert_eq!(app.current_ui.theme.as_deref(), Some("groknight"));
}
#[test]
fn show_usage_on_welcome_screen_is_noop() {
let mut app = test_app();
let effects = dispatch(Action::ShowUsage, &mut app);
assert!(
effects.is_empty(),
"ShowUsage with no active agent should be a no-op"
);
}
#[test]
fn show_usage_with_redirect_url_shows_link_and_skips_fetch() {
let mut app = test_app_with_agent();
app.usage_billing_redirect_url = Some("https://billing.example.com/me".to_string());
let before = agent_scrollback_len(&app);
let effects = dispatch(Action::ShowUsage, &mut app);
assert!(
effects.is_empty(),
"with a redirect URL set, ShowUsage should not fetch (billing or auto-topup), got: {effects:?}"
);
assert_eq!(
agent_scrollback_len(&app),
before + 1,
"redirect path should push one system message with the billing link"
);
assert!(
last_system_text(&app, AgentId(0)).contains("https://billing.example.com/me"),
"redirect message should use the remote settings-provided URL"
);
}
// ── Minimal update-notice tests ──────────────────────────────────────
#[test]
fn minimal_update_notice_commits_a_system_block() {
let mut app = test_app_with_agent();
let before = agent_scrollback_len(&app);
commit_minimal_update_notice(&mut app, "9.9.9");
assert_eq!(agent_scrollback_len(&app), before + 1);
let text = last_system_text(&app, AgentId(0));
assert!(text.contains("Update available: v9.9.9"), "got: {text:?}");
assert!(text.contains("restart to apply"), "got: {text:?}");
}
#[test]
fn minimal_update_notice_no_active_agent_is_noop() {
let mut app = test_app();
// Must not panic and must not require an agent.
commit_minimal_update_notice(&mut app, "9.9.9");
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,379 @@
//! Tests for the block viewer and transcript dispatchers.
use super::*;
fn make_test_png(width: u32, height: u32) -> Vec<u8> {
use image::{ImageBuffer, Rgba};
let img: ImageBuffer<Rgba<u8>, Vec<u8>> =
ImageBuffer::from_pixel(width, height, Rgba([128, 64, 32, 255]));
let mut buf = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png)
.unwrap();
buf
}
fn make_test_jpeg(width: u32, height: u32) -> Vec<u8> {
use image::{ImageBuffer, Rgb};
let img: ImageBuffer<Rgb<u8>, Vec<u8>> =
ImageBuffer::from_pixel(width, height, Rgb([128, 64, 32]));
let mut buf = Vec::new();
img.write_to(
&mut std::io::Cursor::new(&mut buf),
image::ImageFormat::Jpeg,
)
.unwrap();
buf
}
#[test]
fn open_block_viewer_on_group_header_toggles_group() {
let mut app = test_app_with_agent();
let id = AgentId(0);
{
let agent = app.agents.get_mut(&id).unwrap();
let mut appearance = crate::appearance::AppearanceConfig::default();
appearance.scrollback.display.group_max_visible = 3;
agent.scrollback.set_appearance(appearance);
for i in 0..6 {
agent
.scrollback
.push_block(crate::scrollback::block::RenderBlock::tool_call(
format!("Tool{i}"),
"info",
true,
));
}
agent.scrollback.prepare_layout(80, 40);
agent.scrollback.set_selected(Some(0));
assert!(agent.scrollback.is_selected_group_header());
}
// Enter on the "N more" header expands the group instead of opening
// the hidden first entry in the block viewer.
dispatch(Action::OpenBlockViewer, &mut app);
{
let agent = app.agents.get_mut(&id).unwrap();
assert!(
agent.block_viewer.is_none(),
"viewer must not open on a group header"
);
assert_eq!(
agent.scrollback.selected(),
None,
"expanding a group clears the selection"
);
agent.scrollback.prepare_layout(80, 40);
agent.scrollback.set_selected(Some(0));
assert_eq!(
agent.scrollback.selected_group_header_fold_label(),
Some("collapse"),
"entry 0 should now be the expanded group's collapse header"
);
}
// Enter on the collapse header collapses the group back.
dispatch(Action::OpenBlockViewer, &mut app);
{
let agent = app.agents.get_mut(&id).unwrap();
assert!(agent.block_viewer.is_none());
agent.scrollback.prepare_layout(80, 40);
assert_eq!(
agent.scrollback.selected_group_header_fold_label(),
Some("expand"),
"group should be truncated again ('N more' header)"
);
}
}
#[test]
fn open_block_viewer_opens_grep_search_block() {
use crate::scrollback::blocks::{SearchFileMatch, SearchLineMatch};
let mut app = test_app_with_agent();
let id = AgentId(0);
let agent = app.agents.get_mut(&id).unwrap();
agent.scrollback.push_block(RenderBlock::search(
"fn main",
1,
vec![SearchFileMatch {
path: "src/main.rs".into(),
matches: vec![SearchLineMatch {
line_number: 1,
content: "fn main() {}".into(),
}],
}],
));
agent.scrollback.set_selected(Some(0));
let entry = agent.scrollback.entry(0).unwrap();
assert!(entry.block.has_normal_fullscreen_viewer());
let effects = dispatch(Action::OpenBlockViewer, &mut app);
assert!(effects.is_empty());
let agent = app.agents.get(&id).unwrap();
assert!(agent.block_viewer.is_some());
assert_eq!(
agent.block_viewer.as_ref().unwrap().kind,
crate::views::block_viewer::ViewerKind::Grep
);
}
#[test]
fn open_block_viewer_opens_list_dir_block() {
let mut app = test_app_with_agent();
let id = AgentId(0);
let agent = app.agents.get_mut(&id).unwrap();
agent
.scrollback
.push_block(RenderBlock::list_dir_with_output("/tmp", "a.txt\nb.txt"));
agent.scrollback.set_selected(Some(0));
let entry = agent.scrollback.entry(0).unwrap();
assert!(entry.block.has_normal_fullscreen_viewer());
let effects = dispatch(Action::OpenBlockViewer, &mut app);
assert!(effects.is_empty());
let agent = app.agents.get(&id).unwrap();
assert!(agent.block_viewer.is_some());
assert_eq!(
agent.block_viewer.as_ref().unwrap().kind,
crate::views::block_viewer::ViewerKind::PlainText
);
}
#[test]
fn open_block_viewer_prefers_markdown_viewer_over_image_refs() {
use crate::terminal::image::{GraphicsProtocol, set_protocol_for_test};
let mut app = test_app_with_agent();
let id = AgentId(0);
let dir = tempfile::tempdir().unwrap();
let image_path = dir.path().join("referenced.png");
std::fs::write(&image_path, make_test_png(20, 10)).unwrap();
let agent = app.agents.get_mut(&id).unwrap();
agent
.scrollback
.push_block(RenderBlock::agent_message(format!(
"Here is an image: ![ref]({})",
image_path.display()
)));
agent.scrollback.set_selected(Some(0));
// Need a graphics protocol so the top-level media guard doesn't
// short-circuit before reaching the block viewer.
let _guard = set_protocol_for_test(GraphicsProtocol::Kitty);
let effects = dispatch(Action::OpenBlockViewer, &mut app);
assert!(effects.is_empty());
let agent = app.agents.get(&id).unwrap();
assert!(agent.block_viewer.is_some());
assert!(agent.image_viewer.is_none());
}
#[test]
fn open_block_viewer_uses_markdown_viewer_for_agent_message_with_image_ref() {
use crate::terminal::image::{GraphicsProtocol, set_protocol_for_test};
let mut app = test_app_with_agent();
let id = AgentId(0);
let dir = tempfile::tempdir().unwrap();
let jpg_path = dir.path().join("generated.jpg");
std::fs::write(&jpg_path, make_test_jpeg(20, 10)).unwrap();
let agent = app.agents.get_mut(&id).unwrap();
agent
.scrollback
.push_block(RenderBlock::agent_message(format!(
"![generated]({})",
jpg_path.display()
)));
agent.scrollback.set_selected(Some(0));
let _guard = set_protocol_for_test(GraphicsProtocol::Kitty);
let effects = dispatch(Action::OpenBlockViewer, &mut app);
assert!(effects.is_empty());
let agent = app.agents.get(&id).unwrap();
// Agent messages with image refs now open the normal markdown viewer
// (inline media rendering moved to the tool call block).
assert!(agent.block_viewer.is_some());
}
#[test]
fn open_block_viewer_opens_image_only_blocks_natively() {
use crate::terminal::image::{GraphicsProtocol, set_protocol_for_test};
let mut app = test_app_with_agent();
let id = AgentId(0);
let dir = tempfile::tempdir().unwrap();
let image_path = dir.path().join("referenced.png");
std::fs::write(&image_path, make_test_png(20, 10)).unwrap();
let agent = app.agents.get_mut(&id).unwrap();
agent
.scrollback
.push_block(RenderBlock::ToolCall(ToolCallBlock::Other(
crate::scrollback::blocks::OtherToolCallBlock::new("image_tool", "saved image")
.with_output(format!("Saved image: {}", image_path.display())),
)));
agent.scrollback.set_selected(Some(0));
let entry = agent.scrollback.entry(0).unwrap();
assert!(entry.block.supports_fullscreen());
assert!(!entry.block.has_normal_fullscreen_viewer());
// Pretend the host terminal speaks Kitty graphics so the media
// short-circuit guard (`guard_image_support`) doesn't fire and the
// dispatch reaches the image branch, which opens the file natively
// rather than an in-app viewer.
let _guard = set_protocol_for_test(GraphicsProtocol::Kitty);
let effects = dispatch(Action::OpenBlockViewer, &mut app);
// Generated media now opens in the OS-native viewer (fire-and-forget),
// so neither the in-app block viewer nor image viewer is shown.
assert!(effects.is_empty());
let agent = app.agents.get(&id).unwrap();
assert!(agent.block_viewer.is_none());
assert!(agent.image_viewer.is_none());
}
// -- Plugins tab: group-collapse seeding on PluginsListLoaded --------------
fn plugins_list_response() -> kigi_hooks_plugins_types::PluginsListResponse {
use crate::views::extensions_modal::test_plugin_info;
kigi_hooks_plugins_types::PluginsListResponse {
plugins: vec![
test_plugin_info(
"user-tool",
Some(kigi_hooks_plugins_types::PluginOrigin::UserGrok),
),
test_plugin_info(
"claude-tool",
Some(kigi_hooks_plugins_types::PluginOrigin::UserClaude),
),
],
}
}
fn open_plugins_modal(app: &mut AppView, id: AgentId) {
app.agents.get_mut(&id).unwrap().extensions_modal =
Some(crate::views::extensions_modal::ExtensionsModalState::new(
crate::views::extensions_modal::ExtensionsTab::Plugins,
));
}
fn deliver_plugins_list(app: &mut AppView, id: AgentId) {
dispatch(
Action::TaskComplete(TaskResult::PluginsListLoaded {
agent_id: id,
result: Ok(plugins_list_response()),
}),
app,
);
}
fn plugins_collapsed_keys(app: &AppView, id: AgentId) -> Vec<String> {
let modal = app.agents[&id].extensions_modal.as_ref().unwrap();
let mut keys: Vec<String> = modal.plugins_collapsed_groups.iter().cloned().collect();
keys.sort();
keys
}
#[test]
fn plugins_list_loaded_seeds_all_groups_collapsed_on_first_load() {
use crate::views::extensions_modal::TabDataState;
let mut app = test_app_with_agent();
let id = AgentId(0);
open_plugins_modal(&mut app, id);
deliver_plugins_list(&mut app, id);
assert_eq!(
plugins_collapsed_keys(&app, id),
vec!["origin:user".to_string(), "origin:user-claude".to_string()]
);
let modal = app.agents[&id].extensions_modal.as_ref().unwrap();
match &modal.plugins_data {
TabDataState::Loaded(response) => assert_eq!(response.plugins.len(), 2),
other => panic!("expected Loaded plugins data, got {other:?}"),
}
}
#[test]
fn plugins_list_delivery_seeds_once_then_always_preserves() {
use crate::views::extensions_modal::TabDataState;
let mut app = test_app_with_agent();
let id = AgentId(0);
open_plugins_modal(&mut app, id);
deliver_plugins_list(&mut app, id);
// User expands a group, then the post-action refetch arrives.
app.agents
.get_mut(&id)
.unwrap()
.extensions_modal
.as_mut()
.unwrap()
.plugins_collapsed_groups
.remove("origin:user");
deliver_plugins_list(&mut app, id);
assert_eq!(
plugins_collapsed_keys(&app, id),
vec!["origin:user-claude".to_string()],
"post-action refetch must not re-collapse an expanded group"
);
// Reload sets Loading, but seeding is once-per-modal: still preserves.
app.agents
.get_mut(&id)
.unwrap()
.extensions_modal
.as_mut()
.unwrap()
.plugins_data = TabDataState::Loading;
deliver_plugins_list(&mut app, id);
assert_eq!(
plugins_collapsed_keys(&app, id),
vec!["origin:user-claude".to_string()],
"reload must not re-collapse groups the user expanded"
);
}
#[test]
fn open_block_viewer_skips_image_viewer_when_no_graphics() {
use crate::terminal::image::{GraphicsProtocol, set_protocol_for_test};
let mut app = test_app_with_agent();
let id = AgentId(0);
let dir = tempfile::tempdir().unwrap();
let image_path = dir.path().join("referenced.png");
std::fs::write(&image_path, make_test_png(20, 10)).unwrap();
let agent = app.agents.get_mut(&id).unwrap();
agent
.scrollback
.push_block(RenderBlock::ToolCall(ToolCallBlock::Other(
crate::scrollback::blocks::OtherToolCallBlock::new("image_tool", "saved image")
.with_output(format!("Saved image: {}", image_path.display())),
)));
agent.scrollback.set_selected(Some(0));
// Terminal has no inline-image protocol (e.g. Windows / ConPTY).
// The dispatch should refuse to open the image-viewer modal and
// surface the situation via a toast instead.
let _guard = set_protocol_for_test(GraphicsProtocol::None);
let effects = dispatch(Action::OpenBlockViewer, &mut app);
assert!(effects.is_empty());
let agent = app.agents.get(&id).unwrap();
assert!(agent.block_viewer.is_none());
assert!(
agent.image_viewer.is_none(),
"image_viewer modal should not open on terminals without a graphics protocol"
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,644 @@
//! Transcript export, block copying, viewer/modal, and input-log dump dispatchers.
use super::ctx::with_active_agent;
use super::session::lifecycle::skip_picker_and_create_session;
use crate::app::actions::Effect;
use crate::app::agent::AgentId;
use crate::app::app_view::{ActiveView, AppView};
use crate::scrollback::block::{BlockContent, RenderBlock};
use crate::scrollback::blocks::ToolCallBlock;
use agent_client_protocol as acp;
/// Copy the selected block's content to the system clipboard.
///
/// Respects the block's raw/pretty mode for markdown content.
/// Shows a toast notification on theExtensionsTab
pub(super) fn dispatch_copy_block_content(app: &mut AppView) {
with_active_agent(app, |agent| {
let Some(idx) = agent.scrollback.selected() else {
return;
};
if agent.scrollback.entry_content_hidden_by_group(idx) {
return;
}
let Some(entry) = agent.scrollback.entry(idx) else {
return;
};
// BgTask blocks: copy stdout from central store
let text = if let RenderBlock::BgTask(block) = &entry.block {
let stdout = agent
.session
.bg_tasks
.get(&block.task_id)
.map(|t| t.stdout.clone())
.unwrap_or_default();
if stdout.is_empty() {
None
} else {
Some(stdout)
}
} else {
entry.block.copy_text(entry.raw)
};
if let Some(text) = text
&& !text.is_empty()
{
agent.copy_to_clipboard(&text);
}
});
}
/// Copy the Nth most recent assistant message to the clipboard.
pub(super) fn dispatch_copy_assistant_message(app: &mut AppView, n: usize) {
with_active_agent(app, |agent| {
// Collect agent messages in reverse order (most recent first).
let mut agent_messages: Vec<String> = Vec::new();
for i in (0..agent.scrollback.len()).rev() {
if let Some(entry) = agent.scrollback.entry(i)
&& let RenderBlock::AgentMessage(msg) = &entry.block
{
agent_messages.push(msg.copy_text(false));
}
}
if agent_messages.is_empty() {
agent
.scrollback
.push_block(RenderBlock::system("No assistant messages to copy"));
return;
}
if n > agent_messages.len() {
agent.scrollback.push_block(RenderBlock::system(format!(
"Only {} assistant {} available to copy",
agent_messages.len(),
if agent_messages.len() == 1 {
"message"
} else {
"messages"
}
)));
return;
}
let text = &agent_messages[n - 1];
if text.is_empty() {
agent
.scrollback
.push_block(RenderBlock::system("Assistant message is empty"));
return;
}
let stats = crate::clipboard::clipboard_stats_suffix(text);
agent
.scrollback
.push_block(RenderBlock::system(format!("Copied to clipboard{stats}")));
agent.copy_to_clipboard(text);
});
}
/// Dispatch for the `/export` command.
/// Collects the active (sub)agent's scrollback, renders a clean Markdown transcript,
/// and either writes it to the (expanded) file or copies it to the clipboard using the
/// full route (native + tmux + OSC 52) with appropriate feedback.
pub(super) fn dispatch_export_conversation(
app: &mut AppView,
file_path: Option<std::path::PathBuf>,
) {
with_active_agent(app, |agent| {
let blocks: Vec<_> = (0..agent.scrollback.len())
.filter_map(|i| agent.scrollback.entry(i).map(|e| &e.block))
.collect();
let md = crate::scrollback::export::render_blocks_to_markdown(blocks);
if md.is_empty() {
agent
.scrollback
.push_block(RenderBlock::system("No conversation content to export"));
return;
}
if let Some(p) = file_path {
// All fs logic (tilde, mkdir, write) lives here (single owner, thin command layer).
let expanded =
std::path::PathBuf::from(shellexpand::tilde(&p.to_string_lossy()).as_ref());
if let Some(parent) = expanded.parent()
&& let Err(e) = std::fs::create_dir_all(parent)
{
agent.scrollback.push_block(RenderBlock::system(format!(
"Failed to create directory: {e}"
)));
return;
}
match std::fs::write(&expanded, &md) {
Ok(()) => {
agent.scrollback.push_block(RenderBlock::system(format!(
"Conversation exported to {}",
expanded.display()
)));
}
Err(e) => {
// Do not blindly re-emit a user-supplied path in the error message
// (it may contain secrets or PII); the generic failure is sufficient.
agent
.scrollback
.push_block(RenderBlock::system(format!("Failed to write file: {}", e)));
}
}
} else {
// Clipboard path: stats block (like assistant copy) + route-aware toast
// (like block content copy / selection). Good UX for a potentially large transcript.
let stats = crate::clipboard::clipboard_stats_suffix(&md);
agent.scrollback.push_block(RenderBlock::system(format!(
"Conversation copied to clipboard{stats}"
)));
agent.copy_to_clipboard(&md);
}
});
}
/// Open the full transcript in `$PAGER`.
///
/// **Minimal mode** renders a full-fidelity ANSI transcript — every block
/// fully expanded (reasoning in full, tool output uncapped, diff colors kept)
/// — a full layout + syntax-highlight + ANSI-serialization pass over the whole
/// session. Rendering that inline froze the event loop for seconds on long
/// sessions ("laggy /transcript"), and the block model is `!Send` (syntect's
/// resumable highlighter state lives inside markdown blocks), so it can't be
/// shipped to a worker either. Instead this only ARMS the request; the minimal
/// render loop builds the transcript **incrementally, a time-budgeted slice
/// per frame** (`full_view::pump_transcript`, the same time-sliced amortization
/// pattern other TUIs use for heavy transcript work), then arms `pending_pager_path`
/// for the event loop's suspend-into-`$PAGER`.
///
/// **Other modes** keep the compact markdown export (string concatenation, no
/// layout or highlighting — cheap enough to stay synchronous).
pub(crate) fn dispatch_open_transcript_pager(app: &mut AppView) {
if app.screen_mode.is_minimal() {
crate::minimal_api::request_minimal_transcript(app);
return;
}
let mut md = None;
with_active_agent(app, |agent| {
let blocks: Vec<_> = (0..agent.scrollback.len())
.filter_map(|i| agent.scrollback.entry(i).map(|e| &e.block))
.collect();
let rendered = crate::scrollback::export::render_blocks_to_markdown(blocks);
if !rendered.is_empty() {
md = Some(rendered);
}
});
let Some(content) = md else {
with_active_agent(app, |agent| {
agent.scrollback.push_block(RenderBlock::system(
"No conversation transcript to view yet",
));
});
return;
};
let path = std::env::temp_dir().join(format!("grok-transcript-{}.md", uuid::Uuid::new_v4()));
match std::fs::write(&path, content) {
Ok(()) => {
app.pending_pager_path = Some(path);
app.pending_pager_ansi = false;
}
Err(e) => {
with_active_agent(app, |agent| {
agent.scrollback.push_block(RenderBlock::system(format!(
"Failed to write transcript: {e}"
)));
});
}
}
}
/// Open the fullscreen block viewer for the selected entry.
/// Falls back to the image viewer only for entries without a normal block viewer.
pub(super) fn dispatch_open_block_viewer(app: &mut AppView) {
use crate::views::block_viewer::BlockViewerPane;
with_active_agent(app, |agent| {
let Some(idx) = agent.scrollback.selected() else {
return;
};
let Some(entry) = agent.scrollback.entry(idx) else {
return;
};
// Block has images/media but terminal can't render pixels — toast and bail.
let has_media =
!entry.block.image_references().is_empty() || entry.block.inline_media().is_some();
if has_media && !crate::terminal::image::detect_graphics_protocol().supports_images() {
agent.guard_image_support();
return;
}
if !entry.block.has_normal_fullscreen_viewer() {
// Video: Enter starts inline playback (no modal).
if let Some(first_ref) = entry.block.video_references().first() {
let path = first_ref.path.clone();
agent.start_inline_video_playback(&path);
return;
}
// Image: Enter opens the file in the OS-native viewer.
if let Some(first_ref) = entry.block.image_references().first() {
let path = first_ref.path.clone();
agent.open_media_natively(&path);
}
return;
}
// Try to create a normal viewer for the selected block type.
let viewer = match &entry.block {
RenderBlock::Thinking(_) | RenderBlock::AgentMessage(_) => {
BlockViewerPane::for_markdown(entry.id, entry)
}
RenderBlock::ToolCall(ToolCallBlock::Execute(_)) => {
BlockViewerPane::for_execute(entry.id, entry)
}
RenderBlock::ToolCall(ToolCallBlock::Edit(_)) => {
BlockViewerPane::for_edit(entry.id, entry)
}
RenderBlock::ToolCall(ToolCallBlock::Read(_)) => {
BlockViewerPane::for_read(entry.id, entry)
}
RenderBlock::ToolCall(ToolCallBlock::Search(_)) => {
BlockViewerPane::for_grep(entry.id, entry)
}
RenderBlock::ToolCall(ToolCallBlock::ListDir(_)) => {
BlockViewerPane::for_list_dir(entry.id, entry)
}
RenderBlock::ToolCall(ToolCallBlock::WebFetch(_)) => {
BlockViewerPane::for_web_fetch(entry.id, entry)
}
RenderBlock::ToolCall(ToolCallBlock::WebSearch(_)) => {
BlockViewerPane::for_web_search(entry.id, entry)
}
RenderBlock::ToolCall(ToolCallBlock::IntegrationSearch(_)) => {
BlockViewerPane::for_integration_search(entry.id, entry)
}
RenderBlock::ToolCall(ToolCallBlock::UseTool(_)) => {
BlockViewerPane::for_use_tool(entry.id, entry)
}
RenderBlock::BgTask(block) => {
let stdout = agent
.session
.bg_tasks
.get(&block.task_id)
.map(|t| t.stdout.as_str())
.unwrap_or("");
let is_running = agent
.session
.bg_tasks
.get(&block.task_id)
.is_some_and(|t| t.status == crate::app::agent::BgTaskStatus::Running);
Some(BlockViewerPane::for_bg_task(
entry.id,
&block.task_id,
stdout,
is_running,
))
}
_ => None,
};
if viewer.is_some() {
agent.block_viewer = viewer;
return;
}
// Video: Enter starts inline playback.
if let Some(first_ref) = entry.block.video_references().first() {
let path = first_ref.path.clone();
agent.start_inline_video_playback(&path);
return;
}
// Image: Enter opens the file in the OS-native viewer.
if let Some(first_ref) = entry.block.image_references().first() {
let path = first_ref.path.clone();
agent.open_media_natively(&path);
}
});
}
/// Fetch-set that populates every Extensions-modal tab. Shared by the manual
/// open path, the post-CTA-install auth handoff, and the deferred-fetch
/// session-ready handlers so they can't drift and leave a tab stuck on its
/// initial `Loading` state.
pub(super) fn extensions_modal_tab_fetches(
agent_id: AgentId,
session_id: acp::SessionId,
) -> Vec<Effect> {
vec![
Effect::FetchHooksList {
agent_id,
session_id: session_id.clone(),
},
Effect::FetchPluginsList {
agent_id,
session_id: session_id.clone(),
},
Effect::FetchMcpsList {
agent_id,
session_id: session_id.clone(),
cache: true,
},
Effect::FetchSkillsList {
agent_id,
session_id,
},
]
}
/// Open the hooks/plugins modal on the active agent view and fetch list data.
pub(super) fn dispatch_open_extensions_modal(
app: &mut AppView,
tab: crate::views::extensions_modal::ExtensionsTab,
) -> Vec<Effect> {
use crate::views::extensions_modal::ExtensionsModalState;
let ActiveView::Agent(id) = app.active_view else {
return vec![];
};
let Some(agent) = app.agents.get_mut(&id) else {
return vec![];
};
// Mutual exclusivity: close agents modal when opening extensions.
agent.agents_modal = None;
let mut modal = ExtensionsModalState::new(tab);
modal.session_team_id = app.team_id.clone();
agent.extensions_modal = Some(modal);
let Some(session_id) = agent.session.session_id.clone() else {
// Tabs default to Loading; the fetch fires on SessionCreated. With a
// picker-deferred session nothing else would create one, so do it now.
agent.pending_extensions_fetch = true;
return skip_picker_and_create_session(app, id);
};
agent.pending_extensions_fetch = false;
extensions_modal_tab_fetches(id, session_id)
}
/// Open the agents modal, showing all agent definitions.
pub(super) fn dispatch_open_config_agents_modal(
app: &mut AppView,
initial_tab: Option<crate::views::agents_modal::AgentsTab>,
) -> Vec<Effect> {
use crate::views::agents_modal::{AgentsModalState, load_agent_toggle};
let ActiveView::Agent(id) = app.active_view else {
return vec![];
};
let bundle = app.bundle_state.clone();
let Some(agent) = app.agents.get_mut(&id) else {
return vec![];
};
// Mutual exclusivity with extensions_modal
agent.extensions_modal = None;
let cwd = agent.session.cwd.clone();
let toggle = load_agent_toggle();
let model_agent_type = agent
.session
.models
.current
.as_ref()
.and_then(|id| agent.session.models.available.get(id))
.and_then(model_agent_type_from_info);
let session_id = agent.session.session_id.clone();
let active_agent = agent.session_agent_name.clone();
let mut modal = AgentsModalState::new(
&cwd,
&toggle,
&bundle,
model_agent_type.as_deref(),
active_agent,
);
if let Some(tab) = initial_tab {
modal.active_tab = tab;
}
agent.agents_modal = Some(modal);
if let Some(session_id) = session_id {
return vec![Effect::FetchSessionAgentName {
agent_id: id,
session_id,
}];
}
vec![]
}
/// `agentType` / `agent_type` from a catalog `ModelInfo` meta blob.
fn model_agent_type_from_info(info: &agent_client_protocol::ModelInfo) -> Option<String> {
let meta = info.meta.as_ref()?;
["agentType", "agent_type"]
.into_iter()
.find_map(|key| meta.get(key))
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(str::to_owned)
}
/// Copy the selected block's metadata (e.g., command) to clipboard.
pub(super) fn dispatch_copy_block_meta(app: &mut AppView) {
with_active_agent(app, |agent| {
let Some(idx) = agent.scrollback.selected() else {
return;
};
if agent.scrollback.entry_content_hidden_by_group(idx) {
return;
}
let Some(entry) = agent.scrollback.entry(idx) else {
return;
};
if let Some(text) = entry.block.copy_meta()
&& !text.is_empty()
{
agent.copy_to_clipboard(&text);
}
});
}
/// Dump the input flight recorder to a JSON file for debugging.
/// See `input_log.rs` module docs for lifecycle/removal instructions.
pub(super) fn dispatch_dump_input_log(app: &mut AppView) -> Vec<Effect> {
let ActiveView::Agent(id) = app.active_view else {
return vec![];
};
let Some(agent) = app.agents.get_mut(&id) else {
return vec![];
};
if agent.input_log.entry_count() == 0 {
agent.show_toast("No input events recorded yet.");
return vec![];
}
let time_span_ms = agent.input_log.time_span_ms();
let entries = agent.input_log.snapshot_entries();
let entry_count = entries.len();
let terminal = crate::terminal::terminal_context().diagnostics_snapshot();
let session_id = agent.session.session_id.as_ref().map(|s| s.0.to_string());
let pager_version = crate::client_identity::PAGER_CLIENT_VERSION;
let now = chrono::Utc::now();
let dump = crate::input_log::InputDump {
dumped_at: now.to_rfc3339(),
session_id: session_id.clone(),
pager_version,
terminal,
active_pane: format!("{:?}", agent.active_pane),
textarea_cursor: agent.prompt.cursor(),
textarea_text_len: agent.prompt.text().len(),
textarea_has_selection: agent.prompt.textarea.selection_range().is_some(),
entry_count,
time_span_ms,
entries,
};
let json = match serde_json::to_string_pretty(&dump) {
Ok(j) => j,
Err(e) => {
agent.show_toast(&format!("Failed to serialize input log: {e}"));
return vec![];
}
};
let kigi_home = kigi_tools::util::kigi_home::kigi_home();
let logs_dir = kigi_home.join("logs");
let _ = std::fs::create_dir_all(&logs_dir);
let ts = now.format("%Y%m%d-%H%M%S");
let path = logs_dir.join(format!("input-debug-{ts}.json"));
match std::fs::write(&path, json) {
Ok(()) => {
let display_path = path.display();
agent.show_toast(&format!(
"Input log ({entry_count} events) → {display_path}"
));
crate::unified_log::info(
&format!("input debug dump: {entry_count} events, {time_span_ms}ms span"),
session_id.as_deref(),
None,
);
}
Err(e) => {
agent.show_toast(&format!("Failed to write input log: {e}"));
}
}
vec![]
}
// TaskResult handlers.
pub(super) fn handle_hooks_list_loaded(
app: &mut AppView,
agent_id: AgentId,
result: Result<kigi_hooks_plugins_types::HooksListResponse, String>,
) -> Vec<Effect> {
use crate::views::extensions_modal::TabDataState;
if let Some(agent) = app.agents.get_mut(&agent_id)
&& let Some(ref mut modal) = agent.extensions_modal
{
modal.hooks_data = match result {
Ok(response) => {
// Default all groups to collapsed.
let mut seen = std::collections::HashSet::new();
for hook in &response.hooks {
seen.insert(hook.source_dir.clone());
}
modal.hooks_collapsed_groups = seen;
TabDataState::Loaded(response)
}
Err(e) => TabDataState::Error(e),
};
}
vec![]
}
pub(super) fn handle_plugins_list_loaded(
app: &mut AppView,
agent_id: AgentId,
result: Result<kigi_hooks_plugins_types::PluginsListResponse, String>,
) -> Vec<Effect> {
use crate::views::extensions_modal::TabDataState;
if let Some(agent) = app.agents.get_mut(&agent_id)
&& let Some(ref mut modal) = agent.extensions_modal
{
modal.plugins_data = match result {
Ok(response) => {
modal.seed_plugin_groups_once(&response.plugins);
TabDataState::Loaded(response)
}
Err(e) => TabDataState::Error(e),
};
// Clear pending_action so the UI unblocks as soon as the
// plugins list arrives.
modal.pending_action = None;
modal.pending_entry_index = None;
}
vec![]
}
pub(super) fn handle_mcp_toggle_done(
app: &mut AppView,
agent_id: AgentId,
result: Result<(), String>,
) -> Vec<Effect> {
let Some(agent) = app.agents.get_mut(&agent_id) else {
return vec![];
};
if let Some(ref mut modal) = agent.extensions_modal
&& let Err(e) = result
{
modal.pending_action = None;
modal.pending_entry_index = None;
modal.modal_message = Some(crate::views::extensions_modal::ModalMessage::Error(e));
return vec![];
}
let Some(session_id) = agent.session.session_id.clone() else {
return vec![];
};
vec![Effect::FetchMcpsList {
agent_id,
session_id,
cache: false,
}]
}
pub(super) fn handle_skills_toggle_done(
app: &mut AppView,
agent_id: AgentId,
result: Result<Vec<kigi_tools::implementations::skills::types::SkillInfo>, String>,
) -> Vec<Effect> {
use crate::views::extensions_modal::TabDataState;
if let Some(agent) = app.agents.get_mut(&agent_id)
&& let Some(ref mut modal) = agent.extensions_modal
{
modal.pending_action = None;
modal.pending_entry_index = None;
match result {
Ok(skills) => {
let len = skills.len();
modal.skills_data = TabDataState::Loaded(skills);
if len > 0 && modal.picker_state.selected >= len {
modal.picker_state.selected = len.saturating_sub(1);
}
}
Err(e) => {
modal.modal_message = Some(crate::views::extensions_modal::ModalMessage::Error(e));
}
}
}
// The toggle effect already called x.ai/skills/refresh-baseline
// which triggers the session to reload skills and push an
// AvailableCommandsUpdate notification with the updated list.
vec![]
}
@@ -0,0 +1,576 @@
//! Turn cancellation, task and subagent kills, and overdue turn reconciliation.
use super::ctx::find_agent_by_session_id;
use super::permissions::drain_permission_queue;
use super::queue::{apply_turn_start_shim, maybe_drain_queue};
use crate::app::actions::Effect;
use crate::app::agent::AgentId;
use crate::app::agent_view::ActivePane;
use crate::app::app_view::{ActiveView, AppView};
use crate::scrollback::blocks::SessionEvent;
use std::time::Instant;
/// Map `[ui].cancel_subagents_on_turn_cancel` / in-memory agent preference to
/// `cancel_subagents` for the cancel wire payload. `None` means prompt.
fn effective_cancel_subagents_preference(
agent_pref: Option<bool>,
ui: &kigi_shell::agent::config::UiConfig,
) -> Option<bool> {
agent_pref.or(match ui.cancel_subagents_on_turn_cancel.as_deref() {
Some("always_stop") => Some(true),
Some("always_continue") => Some(false),
_ => None,
})
}
fn cancel_subagents_pref_canonical(stop: bool) -> &'static str {
if stop {
"always_stop"
} else {
"always_continue"
}
}
fn cancel_subagents_pref_canonical_from_ui(
ui: &kigi_shell::agent::config::UiConfig,
) -> &'static str {
match ui.cancel_subagents_on_turn_cancel.as_deref() {
Some("always_stop") => "always_stop",
Some("always_continue") => "always_continue",
_ => "ask",
}
}
/// Apply a global always-stop / always-continue preference to every agent and
/// `app.current_ui` (in-memory only; caller emits `Effect::PersistSetting`).
pub(super) fn apply_cancel_subagents_preference_global(app: &mut AppView, stop: bool) {
let canonical = cancel_subagents_pref_canonical(stop);
app.current_ui.cancel_subagents_on_turn_cancel = Some(canonical.to_string());
for agent in app.agents.values_mut() {
agent.cancel_subagents_preference = Some(stop);
}
}
pub(super) fn dispatch_cancel_turn(app: &mut AppView) -> Vec<Effect> {
let ActiveView::Agent(id) = app.active_view else {
return vec![];
};
let ui_pref = effective_cancel_subagents_preference(None, &app.current_ui);
// Scoped agent borrow: extract decisions, then release before `do_cancel_turn`.
let preferred_cancel_subagents = {
let Some(agent) = app.agents.get_mut(&id) else {
return vec![];
};
let resolved_pref = agent.cancel_subagents_preference.or(ui_pref);
// Retry path: a cancel was already sent (`TurnCancelling`) but the turn
// never resolved — the `session/cancel` notification or the turn-end
// response may have been lost in transit. Re-send instead of silently
// no-opping (cancel is idempotent on the agent), so Ctrl+C / palette
// CancelTurn is never a dead key on a stuck "Cancelling…" spinner.
// Skips the subagent panel — that
// choice was already made (or defaulted) on the first cancel.
if agent.session.state.is_cancelling() {
let Some(session_id) = agent.session.session_id.clone() else {
return vec![];
};
crate::unified_log::info(
"cancel.retry",
Some(&session_id.0),
Some(serde_json::json!({
"current_prompt_id": agent.session.current_prompt_id,
})),
);
// Explicit user cancel supersedes any pending send-now expectation (its marker renders).
agent.clear_send_now_expectation();
return vec![Effect::CancelTurn {
session_id,
cancel_subagents: resolved_pref.unwrap_or(true),
// A fresh gesture (e.g. a second Ctrl+C on a stuck spinner) re-set
// the hint; consume it so the re-sent cancel still carries the trigger.
trigger: agent.cancel_trigger_hint.take(),
// Retry cancel of a stuck turn — no local prompt rewind here.
rewind_if_pristine: false,
}];
}
if !agent.session.state.is_turn_running() {
return vec![];
}
if let Some(stop) = resolved_pref {
Some(stop)
} else {
// Check all running subagents, not just those from the current turn.
// This is broader than the old TUI (which filtered by parent_prompt_id),
// but intentional: subagents kept alive from a previous cancel should
// still prompt the user on the next cancel.
let running_count = agent
.subagent_sessions
.values()
.filter(|s| s.is_running())
.count();
if running_count > 0 && agent.cancel_turn_view.is_none() {
agent.cancel_turn_view = Some(crate::views::modal::CancelTurnViewState {
active_idx: 0,
running_count,
});
// Default focus to the picker so keyboard up/down navigates options
// immediately. Without this, if the user triggered cancel while the
// scrollback pane was focused (e.g. browsing history), the modal
// would open but keystrokes would still go to scrollback — the
// picker was only reachable via mouse hover/click.
if agent.active_pane == ActivePane::Scrollback {
agent.active_pane = ActivePane::Prompt;
}
return vec![];
}
None
}
};
do_cancel_turn(app, preferred_cancel_subagents.unwrap_or(true))
}
pub(super) fn dispatch_cancel_turn_choice(
app: &mut AppView,
choice: crate::views::modal::CancelTurnChoice,
) -> Vec<Effect> {
use crate::views::modal::CancelTurnChoice;
let cancel_subagents = matches!(
choice,
CancelTurnChoice::StopRunning | CancelTurnChoice::AlwaysStop
);
if let ActiveView::Agent(id) = app.active_view
&& let Some(agent) = app.agents.get_mut(&id)
{
agent.cancel_turn_view = None;
agent.cancel_turn_buttons.clear();
}
let mut effects = Vec::new();
match choice {
CancelTurnChoice::AlwaysStop | CancelTurnChoice::AlwaysContinue => {
let stop = matches!(choice, CancelTurnChoice::AlwaysStop);
let prev_canonical = cancel_subagents_pref_canonical_from_ui(&app.current_ui);
let new_canonical = cancel_subagents_pref_canonical(stop);
apply_cancel_subagents_preference_global(app, stop);
if prev_canonical != new_canonical {
tracing::info!(
target: "settings",
key = "cancel_subagents_on_turn_cancel",
value = new_canonical,
"setting changed",
);
effects.push(Effect::PersistSetting {
key: "cancel_subagents_on_turn_cancel",
value: crate::settings::SettingValue::Enum(new_canonical),
rollback_value: crate::settings::SettingValue::Enum(prev_canonical),
});
}
}
// One-shot choices: apply only to this cancel; global/session pref unchanged.
CancelTurnChoice::StopRunning | CancelTurnChoice::ContinueToRun => {}
}
effects.extend(do_cancel_turn(app, cancel_subagents));
effects
}
pub(super) fn do_cancel_turn(app: &mut AppView, cancel_subagents: bool) -> Vec<Effect> {
let ActiveView::Agent(id) = app.active_view else {
return vec![];
};
let Some(agent) = app.agents.get_mut(&id) else {
return vec![];
};
if !agent.session.state.is_turn_running() {
return vec![];
}
// If the server hasn't emitted any activity yet AND there are no other
// queued prompts, "rewind" the prompt back into the input box and remove
// its scrollback block. The cancel notification still flies to the
// server, but the local turn state is reset to Idle immediately so the
// UI looks like the user never hit Send.
//
// Skip rewind when queued prompts exist: restoring the in-flight prompt
// to the input box while the next queued prompt drains would mix two
// user intentions in confusing ways. Fall back to the standard cancel
// flow in that case.
//
// Clearing `current_prompt_id` (via `finish_turn`) is what makes orphan
// chunks/PR for the cancelled turn get dropped by the `promptId` gate
// in acp_handler / PromptResponse handler.
// When a prompt is queued on the server-authoritative shared queue, cancel
// restores the FRONT queued prompt to the input instead (handled after the
// cleanup below). So skip the in-flight rewind in that case — the user wants
// the queued prompt back, not the in-flight one.
//
// Minimal mode prints each committed block once into the terminal's native
// scrollback, and that print can't be "un-printed". A user-prompt block
// commits immediately (it is never `is_running`), so a just-promoted queued
// prompt's block is already in native scrollback by the time the user can
// cancel it. Rewinding then `remove_entry`s it from scrollback *state* while
// the printed copy stays on screen AND restores the text into the input —
// showing the prompt twice (dogfood bug: double-Esc on a queued prompt). Skip
// the rewind when the in-flight block has already committed and fall back to
// the standard cancel. `committed` is always false in alt-screen / inline, so
// this is a no-op outside minimal.
let in_flight_committed = match agent.session.in_flight_prompt.as_ref() {
Some(stashed) => agent.scrollback.is_committed(stashed.scrollback_entry),
None => false,
};
let rewinding = agent.shared_queue.is_empty()
&& app.cancel_rewind_enabled
&& agent.session.in_flight_prompt.is_some()
&& agent.session.pending_prompts.is_empty()
&& !in_flight_committed;
if rewinding && let Some(stashed) = agent.session.in_flight_prompt.take() {
agent.prompt.set_text(&stashed.text);
agent.prompt.restore_chip_elements(&stashed.chip_elements);
agent.prompt.set_images(stashed.images);
agent.prompt.set_cursor(stashed.text.len());
agent.scrollback.remove_entry(stashed.scrollback_entry);
// Full state reset: tracker cleanup + state Idle + clear timing
// fields + clear current_prompt_id.
agent.session.finish_turn(&mut agent.scrollback);
agent.turn_started_at = None;
agent.activity_started_at = None;
agent.last_activity = None;
} else {
agent.session.cancel_turn(&mut agent.scrollback);
}
agent.cancel_turn_view = None;
agent.cancel_turn_buttons.clear();
drain_permission_queue(agent);
if let Some(mut pav) = agent.plan_approval_view.take() {
pav.send_stale_cancel();
agent.plan_next_comment_id = pav.next_comment_id;
agent.prompt.restore(pav.stashed_prompt);
agent.line_viewer = None;
}
let Some(session_id) = agent.session.session_id.clone() else {
return vec![];
};
// Explicit user cancel supersedes any pending send-now expectation (its marker renders).
agent.clear_send_now_expectation();
// Server-authoritative queue: the agent owns the drain. On an interactive
// cancel we only tear down the running turn and let the agent promote the
// FRONT queued prompt as the next turn — its `x.ai/queue/changed`
// rebroadcast (carrying `running_prompt_id`) is the source of truth, and the
// pager adopts it via `handle_queue_changed` / `apply_turn_start_shim`. We
// do NOT pull any queued prompt back into the input or predict the new queue
// order client-side; the user's first queued prompt is what runs next.
vec![Effect::CancelTurn {
session_id,
cancel_subagents,
// Consume the gesture hint set by the key/mouse handler (persists
// through the subagent picker until this final build). `None` for
// non-gesture callers (login/reauth flows).
trigger: agent.cancel_trigger_hint.take(),
// Mirror the local rewind on the wire: when we restored the prompt to
// the composer above, ask the shell to trim its pristine copy too so a
// resend can't pair the kept copy with the new send.
rewind_if_pristine: rewinding,
}]
}
/// Grace window between a driver-side `x.ai/session/prompt_complete`
/// broadcast and that turn's `session/prompt` RPC response, after which
/// [`reconcile_overdue_turn_ends`] finishes the turn from the broadcast. The
/// healthy-path gap is milliseconds (the shell emits the broadcast just
/// before writing the RPC response), so an expiry means the response is
/// genuinely lost, not merely slow.
pub(crate) const TURN_END_RECONCILE_GRACE: std::time::Duration = std::time::Duration::from_secs(2);
/// Finish turns whose end was announced by `x.ai/session/prompt_complete`
/// but whose `session/prompt` RPC response never arrived.
///
/// The RPC response is the driver's only turn-state exit, and it can be lost
/// in leader response routing / reconnect races (the loss left the TUI
/// latched in `TurnCancelling` — Esc dead, prompts piling into a queue
/// that never drains — until a restart). The
/// broadcast is armed in `handle_prompt_complete` and disarmed by a matching
/// `TaskResult::PromptResponse`; whatever is still armed past
/// [`TURN_END_RECONCILE_GRACE`] is reconciled here with the essential subset
/// of the PromptResponse teardown (state, marker, adoption hand-off, queue
/// drain).
///
/// Returns `None` when nothing fired; `Some(effects)` (possibly empty) when
/// at least one agent was reconciled, so the caller forces a redraw.
pub(crate) fn reconcile_overdue_turn_ends(app: &mut AppView) -> Option<Vec<Effect>> {
let overdue: Vec<AgentId> = app
.agents
.iter()
.filter(|(_, a)| {
a.pending_turn_end_reconcile
.as_ref()
.is_some_and(|p| p.received_at.elapsed() >= TURN_END_RECONCILE_GRACE)
})
.map(|(id, _)| *id)
.collect();
if overdue.is_empty() {
return None;
}
let mut fired = false;
let mut effects = Vec::new();
for id in overdue {
// Take the stashed adoption before borrowing the agent (disjoint
// `app` fields; same pattern as the PromptResponse arm).
let pending_adoption = app.pending_running_adoptions.remove(&id);
let Some(agent) = app.agents.get_mut(&id) else {
continue;
};
let Some(pending) = agent.pending_turn_end_reconcile.take() else {
continue;
};
let still_ours =
agent.session.current_prompt_id.as_deref() == Some(pending.prompt_id.as_str());
let busy = agent.session.state.is_turn_running() || agent.session.state.is_cancelling();
if !still_ours || !busy {
// The turn already resolved through the normal path (or a new
// turn was adopted); the marker is stale. Restore the adoption
// for the path that owns it.
if let Some(p) = pending_adoption {
app.pending_running_adoptions.insert(id, p);
}
continue;
}
fired = true;
let was_cancelling = agent.session.state.is_cancelling()
|| pending.stop_reason.as_deref() == Some("cancelled");
// Send-now cancel: suppress the marker (wire `cancelTrigger` wins, else
// the armed expectation). Consumed every reconcile (no stale flag).
let expected_send_now = agent.expect_send_now_cancel.take();
let send_now_cancel = was_cancelling
&& match pending.cancel_trigger.as_deref() {
Some(trigger) => trigger == "send_now",
None => expected_send_now.is_some(),
};
let elapsed = agent.turn_elapsed().unwrap_or_default();
crate::unified_log::warn(
"turn.end_reconciled_from_broadcast",
agent.session.session_id.as_ref().map(|s| s.0.as_ref()),
Some(serde_json::json!({
"prompt_id": pending.prompt_id,
"stop_reason": pending.stop_reason,
"was_cancelling": was_cancelling,
"send_now_cancel": send_now_cancel,
"grace_ms": TURN_END_RECONCILE_GRACE.as_millis() as u64,
})),
);
agent.session.finish_turn(&mut agent.scrollback);
let event = if was_cancelling {
// Send-now cancel renders no marker (the new prompt is the next turn).
(!send_now_cancel).then_some(SessionEvent::TurnCancelled { elapsed })
} else {
match pending.stop_reason.as_deref() {
// Rate limits drive a dedicated driver UX via the retry
// notifications (already delivered); no extra marker.
Some("rate_limit") => None,
Some("error") => Some(SessionEvent::TurnFailed {
error: pending
.agent_result
.clone()
.unwrap_or_else(|| "unknown error".into()),
elapsed: Some(elapsed),
}),
_ => Some(SessionEvent::TurnCompleted {
elapsed: Some(elapsed),
}),
}
};
crate::app::turn_completion::push_turn_terminal_marker(
agent,
event,
Some(pending.prompt_id.as_str()),
false,
);
agent.mark_turn_finished();
agent.activity_started_at = None;
agent.last_activity = None;
drain_permission_queue(agent);
agent.cancel_turn_view = None;
agent.cancel_turn_buttons.clear();
if agent.bash_turn {
agent.bash_turn = false;
agent.scrollback.goto_bottom();
}
agent.cron_task_id = None;
// FIFO handoff (mirrors the PromptResponse arm): adopt the next
// server-authoritative running prompt now that the slot is free.
if let Some(p) = pending_adoption
&& agent.session.current_prompt_id.is_none()
{
if p.prompt_id != pending.prompt_id && agent.should_adopt_running_prompt(&p.prompt_id) {
apply_turn_start_shim(agent, p.prompt_id, p.text, &p.kind);
} else {
agent.discard_pending_adoption_updates(&p.prompt_id);
}
}
effects.extend(maybe_drain_queue(agent));
}
fired.then_some(effects)
}
pub(super) fn dispatch_cancel_scheduled_task(app: &mut AppView, task_id: 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 Some(session_id) = agent.session.session_id.clone() else {
return vec![];
};
// Remove from local state immediately (optimistic).
agent.session.scheduled_tasks.remove(&task_id);
vec![Effect::DeleteScheduledTask {
session_id,
task_id,
}]
}
pub(super) fn dispatch_kill_bg_task(app: &mut AppView, task_id: 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 Some(session_id) = agent.session.session_id.clone() else {
return vec![];
};
// Mark as pending_kill for UI feedback
if let Some(task) = agent.session.bg_tasks.get_mut(&task_id) {
task.pending_kill = true;
task.kill_requested_at = Some(Instant::now());
}
vec![Effect::KillBgTask {
session_id,
task_id,
}]
}
pub(super) fn dispatch_kill_subagent(app: &mut AppView, subagent_id: 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 Some(session_id) = agent.session.session_id.clone() else {
return vec![];
};
// Mark as pending_kill for UI feedback
for info in agent.subagent_sessions.values_mut() {
if info.subagent_id.as_ref() == subagent_id {
info.pending_kill = true;
info.kill_requested_at = Some(Instant::now());
}
}
vec![Effect::KillSubagent {
session_id,
subagent_id,
}]
}
pub(super) fn dispatch_demote_to_background(app: &mut AppView) -> Vec<Effect> {
let ActiveView::Agent(id) = app.active_view else {
return vec![];
};
let Some(agent) = app.agents.get_mut(&id) else {
return vec![];
};
if !agent.session.state.is_turn_running() {
return vec![];
}
let Some(session_id) = agent.session.session_id.clone() else {
return vec![];
};
// Get the tool_call_id of the currently running execute tool
let Some(tool_call_id) = agent
.session
.tracker
.running_execute_tool_call_id()
.map(|s| s.to_string())
else {
return vec![];
};
tracing::info!(tool_call_id = %tool_call_id, "Demoting execute tool to background");
vec![Effect::DemoteToBackground {
session_id,
tool_call_id,
}]
}
// TODO: Add dispatch_cancel_command() once kigi-shell supports proper
// server-side cancellation for /compact. Currently, the compaction handler
// uses spawn_local with no cancellation token, and blindly replaces the
// conversation history when done — so prompts sent after a client-side
// cancel would be lost.
// TaskResult handlers.
pub(super) fn handle_bg_task_killed(
app: &mut AppView,
session_id: String,
task_id: String,
outcome: Option<kigi_tools::types::KillOutcome>,
) -> Vec<Effect> {
use kigi_tools::types::KillOutcome;
if let Some(agent) = find_agent_by_session_id(&mut app.agents, &session_id) {
match outcome {
Some(KillOutcome::Killed) => {
// Stay in pending_kill state — task_completed notification
// will arrive and clear it.
tracing::info!(task_id = %task_id, "Kill signal sent");
}
Some(KillOutcome::AlreadyExited) => {
if let Some(task) = agent.session.bg_tasks.get_mut(&task_id) {
task.pending_kill = false;
task.kill_requested_at = None;
}
}
Some(KillOutcome::NotFound) => {
// Stale row (e.g. restored from a resume replay but the
// process belongs to a previous session lifetime): the
// agent has nothing to kill, so drop the row and finish
// its "Task started" scrollback entry (stops the
// running accent that the replay restore turned on).
tracing::info!(task_id = %task_id, "Task not found, removing");
if let Some(task) = agent.session.bg_tasks.remove(&task_id)
&& let Some(entry_id) = task.scrollback_entry_id
{
agent.scrollback.finish_running(entry_id);
}
}
None => {
// Error envelope or unparseable payload: clear the
// pending state so the user can retry, keep the row.
tracing::warn!(task_id = %task_id, "Kill outcome missing or unparseable");
if let Some(task) = agent.session.bg_tasks.get_mut(&task_id) {
task.pending_kill = false;
task.kill_requested_at = None;
}
}
}
}
vec![]
}