docs(comments): rewrite comments across all crates to the guidelines

Sweep every first-party crate source (1956 .rs files) to the project comment
guidelines: delete redundant restatements, decorative banners, change
narration, and end-of-line comments; keep and tighten the crucial ones
(invariants, bug rationale, SAFETY blocks, ported-source attribution).

No functional code changed. Every edit is proven comment-only against the
prior tree by a comment-stripping lexer (string/char/raw-string aware) plus a
separate doctest-fence check. Where removing a comment made rustfmt or clippy
want to re-lay-out adjacent code, the minimal triggering comment is restored so
code tokens stay byte-identical.

Gates green: cargo fmt --all --check (0 diffs), cargo check and cargo clippy
--workspace --all-targets (0 warnings).

Adds scripts/check_codegen_comment_guidelines.py — the enforcement gate for
these guidelines (flags banners, end-of-line comments, change narration, and
commented-out code).
This commit is contained in:
ZacharyZhang-NY committed 2026-07-23 16:55:39 -04:00
1 parent ff0fb56c67
commit a02b555e66
1458 files changed
+10729 -21750

No files matched your search

@@ -11,10 +11,6 @@ use crate::app::app_view::{ActiveView, AppView, AuthMode, AuthState, PlatformLog
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]
@@ -45,7 +41,6 @@ pub(super) fn ensure_login_method(app: &mut AppView) {
// No interactive method: leave login_method_id unset (fail-closed).
}
/// Error when no interactive login method is available (empty auth_methods).
fn no_login_method_error(_app: &AppView) -> String {
"No login method available".to_string()
}
@@ -33,7 +33,7 @@ pub(super) fn with_active_agent(app: &mut AppView, f: impl FnOnce(&mut AgentView
}
}
/// Get a shared reference to the active agent view (if any).
/// Resolves through `active_subagent` — see [`with_active_agent`].
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)
@@ -48,7 +48,7 @@ pub(super) fn get_active_agent(app: &AppView) -> Option<&AgentView> {
None
}
/// Get a mutable reference to the active agent view (if any).
/// Resolves through `active_subagent` — see [`with_active_agent`].
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)
@@ -138,11 +138,9 @@ pub(crate) enum SwitchCause {
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.
// No `Dashboard` variant: the dashboard attach path sets
// `DashboardState::attached_agent` directly and never reaches
// `switch_to_agent`, so it would be dead. Any future caller can add it.
}
/// Surface a launch-blocked `--yolo` once on the first agent view (the TUI owns
@@ -18,10 +18,6 @@ use crate::app::agent_view::AgentView;
use crate::app::app_view::{ActiveView, AppView, TrustState};
use agent_client_protocol as acp;
// ---------------------------------------------------------------------------
// Agent Dashboard dispatchers
// ---------------------------------------------------------------------------
/// Build a `DashboardState` from the persisted layout (pins / reorder /
/// grouping), loading + caching `app.dashboard_persisted` on first use. Used
/// both to materialize the real dashboard and to compute a correct cycle order
@@ -120,24 +116,14 @@ pub(super) fn dispatch_open_dashboard(app: &mut AppView) -> Vec<Effect> {
return vec![];
}
// Edge case 24: idempotent toggle — opening from the dashboard view
// itself just closes.
//
// Ctrl+\ is now a single-shot toggle between the
// agent view and the dashboard view. The previous design used
// Ctrl+\ as a 3-state cascade (open dashboard → close popup →
// exit dashboard) which fought the user's mental model of
// "Ctrl+\ flips views". With auto-attach landing a popup on every
// open, the close-popup step would have eaten the user's expected
// exit press. Now Ctrl+\ always exits when already in the
// dashboard; closing the popup-only stays bound to Esc inside the
// popup mouse/key cascade.
// itself just closes. Ctrl+\ is a single-shot flip between the agent
// view and the dashboard; closing a popup-only stays bound to Esc.
if matches!(app.active_view, ActiveView::AgentDashboard) {
return dispatch_exit_dashboard(app);
}
// Preserve in-memory state across reopen.
// `app.dashboard.is_some()` means we've previously initialised
// it; preserve the user's filter / dispatch text / hover /
// selection. Otherwise seed from persisted state.
// Preserve in-memory state across reopen: `app.dashboard.is_some()`
// means it was already initialised, so keep the user's filter /
// dispatch text / hover / selection. Otherwise seed from persisted state.
if app.dashboard.is_none() {
ensure_dashboard_state(app);
} else if let Some(d) = app.dashboard.as_mut() {
@@ -167,30 +153,13 @@ pub(super) fn dispatch_open_dashboard(app: &mut AppView) -> Vec<Effect> {
agent.worktree_label = info.worktree_label;
}
}
// The previous "auto-attach popup overlay" path
// showed BOTH the dashboard (as a top banner) AND the focused
// agent (as a bottom popup) on every `/dashboard` open. The
// stacked layout was confusing — the user couldn't tell which
// view owned the prompt, and the popup's keybindings (Enter
// to send, etc.) had subtle input-routing bugs. Dashboard
// open now shows ONLY the dashboard; pressing Enter on a row
// switches the whole view to the agent's fullscreen view
// (handled in `dispatch_dashboard_attach`).
//
// Always open in NEW-SESSION mode: focus the `[+ New Agent]` button
// (no row selected) so typing a prompt + Enter dispatches a brand
// new agent. Reply mode is opt-in — the user navigates (↑/↓ or j/k)
// or clicks a row to select it, which arms "reply to that agent".
//
// Previously the dashboard pre-seeded `selected` to the agent the
// user came from, which silently armed reply mode: a prompt typed
// right after opening went to the old agent instead of spawning a
// new one, AND the reply path never clears `selected`, so EVERY
// subsequent dispatch kept replying to the same agent — the user
// got "stuck to the same agent" and couldn't quickly dispatch new
// sessions. New-session is the dashboard's primary gesture, so it is
// the default; reply stays one explicit selection away.
//
// (no row selected) so typing a prompt + Enter dispatches a brand new
// agent. Reply mode is opt-in — the user navigates (↑/↓ or j/k) or
// clicks a row to select it, which arms "reply to that agent".
// Pre-seeding `selected` would silently arm reply mode, and because the
// reply path never clears `selected`, trap every subsequent dispatch on
// the same agent.
configure_dashboard_state(app);
app.active_view = ActiveView::AgentDashboard;
// Outside leader mode there is no live leader roster to poll, so the
@@ -204,9 +173,9 @@ pub(super) fn dispatch_open_dashboard(app: &mut AppView) -> Vec<Effect> {
vec![]
}
/// Helper: produce a closure that answers "does this DashboardRowId
/// still exist in `agents`?". Static lifetime not possible (closures
/// borrow), so callers pass `&app.agents`.
/// Produce a closure that answers "does this DashboardRowId still exist
/// in `agents`?". Static lifetime not possible (closures borrow), so
/// callers pass `&app.agents`.
fn dashboard_alive_fn(
agents: &indexmap::IndexMap<AgentId, AgentView>,
) -> impl Fn(&crate::views::dashboard::DashboardRowId) -> bool + '_ {
@@ -233,7 +202,7 @@ pub(super) fn dispatch_exit_dashboard(app: &mut AppView) -> Vec<Effect> {
if let Some(d) = app.dashboard.as_mut() {
d.close_popup();
}
// Return to either Welcome or the most recently active agent.
// Return to an agent view, or Welcome when none remain.
if let Some(id) = app.agents.keys().next().copied() {
app.active_view = ActiveView::Agent(id);
surface_yolo_launch_block_notice(app, id);
@@ -256,9 +225,9 @@ pub(super) fn dispatch_dashboard_attach(
// straight to the agent because `active_view = Agent(id)`, so
// Enter/Shift+Tab/etc. all work as in any regular agent view.
//
// Attaching re-targets the overlay — an overlay stop-confirm armed
// on a previously attached agent (legacy popup row-click path
// reaches here without a key press) must not follow the user in.
// Attaching re-targets the overlay — an overlay stop-confirm armed on
// the agent attached before this (legacy popup row-click path reaches
// here without a key press) must not follow the user in.
clear_pending_overlay_stop(app);
match id {
DashboardRowId::TopLevel(agent_id) => {
@@ -279,9 +248,6 @@ pub(super) fn dispatch_dashboard_attach(
// section cursor / button focus are cleared — exactly one
// cursor target stays active.
d.focus_row(DashboardRowId::TopLevel(agent_id));
// Signal session-overlay mode: render the agent
// wrapped in the bordered frame with cycle/close
// affordances at the top right.
d.attached_agent = Some(agent_id);
}
app.active_view = ActiveView::Agent(agent_id);
@@ -375,10 +341,8 @@ pub(super) fn dispatch_dashboard_attach(
vec![]
}
/// Exit the dashboard's session-overlay: dismiss the bordered
/// chrome and return to the dashboard view. Mirrors the popup
/// `[✗]` close from the older design but applied to the new
/// fullscreen-with-frame layout.
/// Exit the dashboard's session-overlay: dismiss the bordered chrome and
/// return to the dashboard view.
pub(super) fn dispatch_dashboard_overlay_exit(app: &mut AppView) -> Vec<Effect> {
if let Some(d) = app.dashboard.as_mut() {
d.close_popup();
@@ -552,9 +516,6 @@ pub(super) fn dispatch_dashboard_toggle_auto_approve(app: &mut AppView) -> Vec<E
return vec![];
}
// Temporarily borrow active_view so `set_yolo_mode` targets
// the dashboard's selected agent rather than whichever view
// is currently active. Restored before returning.
let saved_view = app.active_view;
app.active_view = ActiveView::Agent(agent_id);
let effects = set_yolo_mode(app, new);
@@ -1078,18 +1039,13 @@ pub(super) fn dispatch_dashboard_dispatch(
}
// The dashboard's dispatch input ALWAYS spawns a new session — it is
// never a reply target. A row being selected is purely the overview
// navigation cursor (Enter on it OPENS the agent); it must not turn
// the input into "reply to that agent". Conflating the two trapped
// the user: navigating to a row (vim j/k) flipped the input to
// "Reply to <agent>" and there was no obvious way back to spawning a
// new session. To converse with an existing agent, open it (navigate
// + Enter, or click) and reply inside its own view.
// never a reply target. A selected row is purely the overview navigation
// cursor (Enter on it OPENS the agent); it must not turn the input into
// "reply to that agent". To converse with an existing agent, open it and
// reply inside its own view.
//
// New-session path.
//
// Return the new AgentId from the inner constructor
// so we don't have to rely on `app.agents.last()`.
// Return the new AgentId from the inner constructor rather than relying
// on `app.agents.last()`.
//
// Carry the dashboard's staged model / plan-mode (set via `/model` and
// `/plan`) onto the new session: the model id seeds `CreateSession`, and
@@ -1238,7 +1194,7 @@ pub(super) fn dispatch_dashboard_dispatch_slash(app: &mut AppView, text: String)
// Registered but not offered on this surface (session-scoped
// hidden from the dropdown, or non-dashboard `dashboard_only`):
// error toast — never spawn a session whose first prompt is the
// slash text (that was worse than the old loud Action toasts).
// slash text.
if !dashboard
.dispatch
.slash_controller
@@ -1496,20 +1452,6 @@ pub(super) fn apply_pending_dispatch_config(
}
}
/// Send or queue a reply typed into the peek panel's `❯ reply` input.
///
/// The reply is enqueued on the row's owning top-level agent and then
/// [`maybe_drain_queue`] decides the rest: an **idle** agent sends it
/// immediately (a turn starts), a **mid-turn** agent keeps it queued so
/// it drains after the current turn finishes. This is the same queue /
/// drain pipeline the agent view's own prompt input uses, so the two
/// surfaces behave identically.
///
/// Subagent rows can't be replied to (they're driven by their parent),
/// so they surface a toast and leave the peek open.
///
/// `attach` (Ctrl+S) additionally walks into the agent's detail
/// view, mirroring the dispatch input's send+open affordance.
/// Cycle the PEEKED agent's live mode (Normal → Plan → Always-Approve →
/// Normal), the peek-panel counterpart to `DashboardCycleMode`. Reuses
/// the shared cycle body `dispatch_cycle_mode_and_sync` by temporarily
@@ -1558,6 +1500,20 @@ pub(super) fn dispatch_dashboard_peek_cycle_mode(app: &mut AppView) -> Vec<Effec
effects
}
/// Send or queue a reply typed into the peek panel's `❯ reply` input.
///
/// The reply is enqueued on the row's owning top-level agent and then
/// [`maybe_drain_queue`] decides the rest: an **idle** agent sends it
/// immediately (a turn starts), a **mid-turn** agent keeps it queued so
/// it drains after the current turn finishes. This is the same queue /
/// drain pipeline the agent view's own prompt input uses, so the two
/// surfaces behave identically.
///
/// Subagent rows can't be replied to (they're driven by their parent),
/// so they surface a toast and leave the peek open.
///
/// `attach` (Ctrl+S) additionally walks into the agent's detail
/// view, mirroring the dispatch input's send+open affordance.
pub(super) fn dispatch_dashboard_peek_reply(
app: &mut AppView,
row: crate::views::dashboard::DashboardRowId,
@@ -1630,8 +1586,6 @@ pub(super) fn dispatch_dashboard_peek_reply(
}
let effects = maybe_drain_queue(agent);
// Clear the reply draft now that it's been accepted, and drop any
// stale error toast.
if let Some(d) = app.dashboard.as_mut() {
d.clear_peek_reply();
d.error_toast = None;
@@ -1794,10 +1748,6 @@ pub(super) fn dispatch_dashboard_stop(app: &mut AppView) -> Vec<Effect> {
return vec![];
};
let now = Instant::now();
// `t.elapsed()` is the idiomatic Instant API
// for "how long since this Instant". Behaviour identical
// to `now.duration_since(*t)` when `t <= now`, which is
// the only case the dispatcher constructs.
let already_confirming = app
.dashboard
.as_ref()
@@ -2015,7 +1965,7 @@ pub(super) fn dispatch_dashboard_reorder(app: &mut AppView, up: bool) -> Vec<Eff
d.reorder.swap(i, i + 1);
}
Some(_) => {
// Already at the bottom — append to end.
// Already at the bottom — nothing to move.
}
None => {
d.reorder.push(sel);
@@ -2055,7 +2005,6 @@ pub(super) fn dispatch_dashboard_permission_select(
request_id: usize,
option_id: acp::PermissionOptionId,
) -> Vec<Effect> {
// Determine the owning AgentId.
let target_id = match &row {
crate::views::dashboard::DashboardRowId::TopLevel(id) => *id,
crate::views::dashboard::DashboardRowId::Subagent { parent, .. } => *parent,
@@ -4,10 +4,6 @@ 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);
@@ -103,8 +99,6 @@ pub(super) fn dispatch_import_claude_cancel(app: &mut AppView) -> Vec<Effect> {
/// 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
@@ -1,16 +1,12 @@
//! Mid-turn interjection dispatch: optimistic local echo, the
//! `kigi/interject` effect, and prompt-history recording. Split out of
//! `dispatch.rs` verbatim (pure code motion).
//! `kigi/interject` effect, and prompt-history recording.
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 `kigi/interject` ext method carrying a client-minted
/// id.
/// Send a mid-turn interjection.
///
/// The shell broadcasts `kigi/session/interjection` to every attached pane so
/// other clients viewing the same session render it too (multi-client /
@@ -42,9 +38,6 @@ pub(super) fn dispatch_interject(
record_interject_prompt_history(agent, &text);
// Push a standard user prompt block locally for instant feedback, and
// record its id so the broadcast echo (`kigi/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
@@ -119,7 +112,6 @@ pub(super) fn dispatch_send_prompt_now(
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 {
@@ -273,7 +265,7 @@ mod tests {
);
}
/// A no-session interject still retires the tip: the clear now runs before
/// A no-session interject still retires the tip: the clear runs before
/// the "No active session" early return, matching the other submit paths.
#[test]
fn interject_without_session_still_clears_ephemeral_tip() {
@@ -120,12 +120,9 @@ pub(super) fn dispatch_enter_plan_mode(
/// 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
/// Optimistic: sets `plan_mode_pending`; the shell confirms via
/// `CurrentModeUpdate`. No explicit rollback — `SetSessionMode` has no failure
/// surface, so on a dropped ACP transport `plan_mode_pending` stays set until
/// the next `CurrentModeUpdate` or session restart.
///
/// Idempotent: same value toasts but skips the ACP round-trip.
@@ -58,16 +58,18 @@ pub(super) fn dispatch_send_remember_note(app: &mut AppView, text: String) -> Ve
let Some(session_id) = agent.session.session_id.clone() else {
// No session — open modal with raw content only (no LLM rewrite).
// no session → no LLM rewrite, Tab disabled; nonce unused since no
// rewrite is in flight
agent.active_modal = Some(ActiveModal::RememberNoteReview {
raw_content: trimmed.clone(),
enhanced_content: None, // no session → no LLM rewrite, Tab disabled
enhanced_content: None,
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
rewrite_nonce: 0,
});
return vec![];
};
@@ -148,7 +150,6 @@ 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 {
@@ -186,7 +187,6 @@ fn extract_session_context(agent: &AgentView) -> String {
},
_ => {}
}
// Stop early once we have enough context.
if user_prompts.len() >= 5 && file_paths.len() >= 20 {
break;
}
@@ -194,15 +194,12 @@ fn extract_session_context(agent: &AgentView) -> String {
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());
@@ -6,10 +6,6 @@ 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
@@ -121,7 +117,6 @@ pub(super) fn dispatch_permission_select(
.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.
@@ -160,7 +155,6 @@ pub(super) fn dispatch_permission_followup(app: &mut AppView, text: String) -> V
return vec![];
};
// Find the RejectOnce option.
let option_id = perm
.options
.iter()
@@ -168,7 +162,6 @@ pub(super) fn dispatch_permission_followup(app: &mut AppView, text: String) -> V
.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(
@@ -179,7 +172,6 @@ pub(super) fn dispatch_permission_followup(app: &mut AppView, text: String) -> V
return vec![];
};
// Include followup message in meta.
let meta = if !text.trim().is_empty() {
serde_json::json!({
"followup_message": text,
@@ -243,7 +235,6 @@ pub(super) fn drain_permission_queue(agent: &mut AgentView) {
)))
.ok();
}
// Queue is now empty — restore stashed prompt.
if let Some(stashed) = agent.permission_stashed_prompt.take() {
agent.prompt.restore(stashed);
}
@@ -258,7 +249,6 @@ pub(super) fn drain_permission_queue(agent: &mut AgentView) {
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);
}
@@ -266,7 +256,6 @@ pub(crate) fn resolve_permission_queue_transition(agent: &mut AgentView) {
// 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;
}
@@ -27,11 +27,6 @@ pub(super) fn consume_chat_kind(app: &mut AppView) -> bool {
app.chat_mode || pending
}
/// Enqueue a prompt and try to drain immediately.
///
/// The prompt is always pushed to the queue first. If the agent is idle
/// (and has a session), `maybe_drain_queue` pops the front prompt and
/// sends it in the same dispatch call — no deferred ticks.
/// Start (if needed) and submit the initial prompt from `kigi "<prompt>"`.
///
/// Shared by the TUI startup path (already authenticated) and the post-login
@@ -104,7 +99,6 @@ pub(super) fn dispatch_show_undo_tip(app: &mut AppView) -> Vec<Effect> {
let Some(agent) = app.agents.get_mut(&id) else {
return vec![];
};
// Shows and increments the per-session count in place (no disk write).
// Emit the impression only when the tip actually took the slot (mirrors
// the `tip.shown` gate), so gated no-ops and TTL refreshes don't count.
agent.show_ephemeral_tip(
@@ -149,7 +143,6 @@ pub(super) fn dispatch_show_plan_nudge(app: &mut AppView) -> Vec<Effect> {
let Some(agent) = app.agents.get_mut(&id) else {
return vec![];
};
// Shows and increments the per-session count in place (no disk write).
// Impression counts only on a real show (see `dispatch_show_undo_tip`).
agent.show_ephemeral_tip(
crate::tips::plan_nudge::plan_nudge_tip(),
@@ -322,8 +315,6 @@ pub(super) fn dispatch_send_prompt_inner(
let mut effects = Vec::new();
// ── Registry-based slash command execution ─────────────────────
// If the text starts with `/`, run it through the slash registry.
// The registry resolves builtins, ACP-advertised commands, and
// unknown commands uniformly. Dispatch is the SOLE execution owner.
// `literal` (chip click) skips this so chip text is never a command.
@@ -348,7 +339,6 @@ pub(super) fn dispatch_send_prompt_inner(
return vec![];
}
// Build execution context.
let exec_result = {
let mut ctx = CommandExecCtx {
models: &agent.session.models,
@@ -416,8 +406,7 @@ pub(super) fn dispatch_send_prompt_inner(
}
};
// Map CommandResult to pager behavior. (MRU persistence is queued
// off-thread inside `record_command_use` above.)
// MRU persistence is queued off-thread inside `record_command_use` above.
match exec_result {
CommandResult::Handled | CommandResult::HandledNoOp => {
if consume_input {
@@ -523,7 +512,7 @@ pub(super) fn dispatch_send_prompt_inner(
}
return dispatch(Action::Quit, app);
} else {
// ── Server-authoritative immediate send (plain prompt only) ──
// Server-authoritative immediate send (plain prompt only).
// A plain prompt typed while a turn is RUNNING is sent to the agent
// immediately instead of being held in the local drip-feed queue. The
// agent appends it to its authoritative `pending_inputs` (no concurrent
@@ -541,7 +530,7 @@ pub(super) fn dispatch_send_prompt_inner(
// suggestions: clear the visible chips here — INSIDE the send/enqueue
// path, after the `reconnect_pending` and active-agent early-return
// guards — so the chips are cleared ONLY when the suggestion actually
// sends/enqueues. Placing it before those guards (the prior fix) cleared
// sends/enqueues. Placing it before those guards cleared
// the chips even when `reconnect_pending` aborted with a toast and no
// send, losing both the chips and the submit. This single clear covers
// BOTH the immediate-send and enqueue subpaths below; `clear_follow_ups`
@@ -763,7 +752,7 @@ pub(super) fn dispatch_send_bash_command(app: &mut AppView, command: String) ->
agent.session.prompt_history.truncate(200);
}
// ── Server-authoritative immediate send for bash while running ──
// Server-authoritative immediate send for bash while running.
// A bash command typed while a turn is RUNNING is sent to the agent
// immediately (it's already a `session/prompt` with bash meta) and echoed
// into the shared queue with `kind="bash"`. On `running_prompt_id`
@@ -1,7 +1,7 @@
//! Prompt-queue dispatch: the server-authoritative immediate-send routing
//! helpers, optimistic queue echoes, the local drip-feed drain
//! ([`maybe_drain_queue`]), the turn-start shim, and the queue-interject
//! action arm. Split out of `dispatch.rs` verbatim (pure code motion).
//! action arm.
use super::ctx::{active_agent_session_id, with_active_agent};
use super::interject::record_interject_prompt_history;
@@ -155,7 +155,6 @@ pub(super) fn drain_prompt_state_to_last_queued(agent: &mut AgentView) {
/// Prepend `<system-reminder>` framing to a cron prompt for the model.
///
/// Delegates to the shared implementation in `kigi_tools::reminders`.
/// The UI shows the raw `prompt` text via `RenderBlock::cron_prompt`; this
/// wrapped version is only sent to the model via `Effect::SendPrompt` so
/// the model knows the message is a scheduled task execution, not a human.
@@ -166,12 +165,6 @@ fn format_cron_prompt(prompt: &str, task_id: &str, human_schedule: &str) -> Stri
/// Try to send the next queued entry (prompt, command, bash, or cron) if the agent is idle.
///
/// Called after enqueue operations and task completions to advance the queue.
///
/// Branches on `QueueEntryKind`:
/// - **Prompt**: pushes user prompt block to scrollback, starts turn, returns `Effect::SendPrompt`
/// - **Command**: starts command, returns the appropriate `Effect` (e.g., `Effect::Compact`)
/// - **BashCommand**: starts turn (no user block), returns `Effect::SendBashCommand`
/// - **Cron**: pushes cron prompt block to scrollback, starts turn, returns `Effect::SendPrompt`
pub(crate) fn maybe_drain_queue(agent: &mut AgentView) -> Vec<Effect> {
use crate::app::agent::QueueEntryKind;
use crate::unified_log as ulog;
@@ -224,7 +217,6 @@ pub(crate) fn maybe_drain_queue(agent: &mut AgentView) -> Vec<Effect> {
return vec![];
};
// Block drain if the user is editing the front prompt.
if let PromptMode::EditingQueued { id, .. } = &agent.prompt_mode
&& agent
.session
@@ -333,14 +325,12 @@ pub(crate) fn maybe_drain_queue(agent: &mut AgentView) -> Vec<Effect> {
}
agent.turn_started_at = Some(Instant::now());
// Scroll to the new prompt and engage follow mode.
let prompt_idx = agent.scrollback.len().saturating_sub(1);
agent.scrollback.set_selected(Some(prompt_idx));
agent.scrollback.scroll_to_entry_top(prompt_idx);
agent.scrollback.enable_follow_with_preserve();
if let Some(mut blocks) = queued.wire_blocks {
// Skill injection: send structured blocks.
// Annotate the first text block's meta with the display text
// so the pager can reconstruct the clean prompt on session
// restore (replay). Without this, replay shows the raw skill
@@ -369,7 +359,6 @@ pub(crate) fn maybe_drain_queue(agent: &mut AgentView) -> Vec<Effect> {
prompt_id,
}]
} else if !queued.images.is_empty() {
// Image-bearing prompt: build text + image content blocks.
// Pass the session cwd so orphan `[Image #N: <path>]`
// placeholders (paste from a previous session, etc.)
// can be recovered from disk via the shared helper.
@@ -387,7 +376,6 @@ pub(crate) fn maybe_drain_queue(agent: &mut AgentView) -> Vec<Effect> {
prompt_id,
}]
} else {
// Normal prompt: send text as-is.
vec![Effect::SendPrompt {
agent_id,
session_id,
@@ -417,7 +405,6 @@ pub(crate) fn maybe_drain_queue(agent: &mut AgentView) -> Vec<Effect> {
agent.session.current_prompt_id = Some(prompt_id.clone());
agent.turn_started_at = Some(Instant::now());
// Engage follow mode so streaming output scrolls into view.
agent.scrollback.enable_follow_with_preserve();
vec![Effect::SendBashCommand {
@@ -600,8 +587,8 @@ pub(crate) fn apply_turn_start_shim(
// turn the leader drained into the running slot: if THIS client originated
// it (its own queued/immediate prompt), it drives it; otherwise it is
// viewing a turn another client drives, so `attached_as_viewer` must flip
// back to true even if this pane has sent prompts before (the flag is no
// longer a one-way latch) — that drives `handle_prompt_complete` + the
// back to true even if this pane has sent prompts before (the flag is
// not a one-way latch) — that drives `handle_prompt_complete` + the
// viewer chrome correctly.
let adopted_from_other_client = !agent.is_self_originated_prompt(&prompt_id);
// Sticky pin + still-armed send-now expect (not cleared on adopt — the
@@ -852,8 +839,6 @@ mod tests {
assert!(out.ends_with("do stuff"));
}
// ── Drain-blocking tests ───────────────────────────────────────────
#[test]
fn drain_blocked_when_editing_front_prompt() {
let mut app = test_app_with_agent();
@@ -1785,7 +1770,7 @@ mod tests {
enqueue_local(&mut app, id, "p2");
enqueue_local(&mut app, id, "p3");
enqueue_local(&mut app, id, "p4");
assert_eq!(app.agents[&id].session.queue_len(), 3); // p2, p3, p4
assert_eq!(app.agents[&id].session.queue_len(), 3);
// End turn for p1 → sets Idle → maybe_drain_queue pops p2 → Running again.
// Queue is now: p3, p4.
@@ -1804,7 +1789,7 @@ mod tests {
// End turn for p2 → should NOT drain p3 (being edited).
let effects = dispatch(end_turn(), &mut app);
assert!(effects.is_empty(), "drain should be blocked: {effects:?}");
assert_eq!(app.agents[&id].session.queue_len(), 2); // p3, p4
assert_eq!(app.agents[&id].session.queue_len(), 2);
// Simulate user saving edited text.
app.agents
@@ -682,8 +682,6 @@ pub(super) fn dispatch_rewind_success(
vec![]
}
// TaskResult handlers.
pub(super) fn handle_rewind_points_loaded(
app: &mut AppView,
agent_id: AgentId,
@@ -15,32 +15,18 @@ 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.
/// Top-level `/fork` dispatcher, resolving the worktree decision (explicit
/// flag, persisted `fork_worktree_mode`, or the [`open_fork_question`] modal).
///
/// 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`.
/// A parent outside a git repository has `current_branch == None` (no
/// `git_head_changed` notification): `--worktree` is rejected and every other
/// path forks with `worktree = false`. That fallback is safe even if the
/// notification simply has not arrived yet — the worktree can be created
/// manually afterwards.
///
/// 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.
/// A missing agent or a parent with no `session_id` are rejected rather than
/// queued: queueing until `SessionLoaded` would mean persisting `ForkArgs`
/// across the `TaskResult`, deferred to v2.
pub(in crate::app::dispatch) fn dispatch_fork(
app: &mut AppView,
args: crate::slash::commands::fork::ForkArgs,
@@ -495,16 +495,6 @@ pub(in crate::app::dispatch) fn reanchor_grouped_selection<T>(
}
state.selected = sel;
}
/// Trigger a deep content search when the session picker query changes.
///
/// Any query of 2+ chars searches content — title matches never suppress
/// it. Forced (Ctrl+/) searches fire immediately; keystrokes otherwise
/// coalesce through [`Effect::DebounceSessionSearch`], whose expiry runs
/// the search only if its seq is still current. Shorter queries clear the
/// content results.
///
/// Checks the active agent's modal first; if no modal session picker
/// exists, falls back to the welcome-screen picker state.
pub(in crate::app::dispatch) fn dispatch_cycle_session_source_filter(
app: &mut AppView,
) -> Vec<Effect> {
@@ -544,6 +534,16 @@ pub(in crate::app::dispatch) fn dispatch_cycle_session_source_filter(
}
vec![]
}
/// Trigger a deep content search when the session picker query changes.
///
/// Any query of 2+ chars searches content — title matches never suppress
/// it. Forced (Ctrl+/) searches fire immediately; keystrokes otherwise
/// coalesce through [`Effect::DebounceSessionSearch`], whose expiry runs
/// the search only if its seq is still current. Shorter queries clear the
/// content results.
///
/// Checks the active agent's modal first; if no modal session picker
/// exists, falls back to the welcome-screen picker state.
pub(in crate::app::dispatch) fn dispatch_trigger_deep_search(
app: &mut AppView,
force: bool,
@@ -1,7 +1,4 @@
//! 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};
@@ -40,7 +40,6 @@ pub(in crate::app::dispatch) fn set_multiline_mode(app: &mut AppView, new: bool)
return vec![];
}
agent.multiline_mode = new;
// Refresh modal snapshot so the indicator reflects the new value.
refresh_open_settings_modals(app);
tracing::info!(
target: "settings",
@@ -72,7 +71,6 @@ pub(in crate::app::dispatch) fn set_render_mermaid(
return vec![];
}
set_render_mermaid_inner(kind);
// Refresh modal snapshot so the picker reflects the new value.
refresh_open_settings_modals(app);
tracing::info!(
target: "settings",
@@ -714,7 +712,6 @@ pub(super) fn set_default_selected_permission_inner(
crate::appearance::permission_cursor::set_default_selected_permission(value);
}
// ---------------------------------------------------------------------------
// Settings setters — unified dispatch for the settings modal and slash
// commands.
//
@@ -725,7 +722,6 @@ pub(super) fn set_default_selected_permission_inner(
//
// PAGER setters (e.g. `set_multiline_mode`) have no persist/rollback,
// so they skip the split.
// ---------------------------------------------------------------------------
/// State-only mutation for `compact_mode`. Updates the in-memory
/// `current_ui` snapshot (read by the modal) and the thread-local cache
@@ -745,7 +741,6 @@ pub(in crate::app::dispatch) fn set_compact_mode(app: &mut AppView, new: bool) -
return vec![];
}
set_compact_mode_inner(app, new);
// Refresh modal snapshot so the indicator reflects the new value.
refresh_open_settings_modals(app);
tracing::info!(target: "settings", key = "compact_mode", value = new, "setting changed");
// Turning the setting off while the short-terminal derivation holds keeps
@@ -778,7 +773,6 @@ pub(super) fn set_timestamps_inner(app: &mut AppView, new: bool) {
pub(in crate::app::dispatch) fn set_timestamps(app: &mut AppView, new: bool) -> Vec<Effect> {
let prev = app.current_ui.show_timestamps.unwrap_or(true);
// Idempotency gate.
if prev == new {
return vec![];
}
@@ -811,7 +805,6 @@ pub(in crate::app::dispatch) fn set_timeline(app: &mut AppView, new: bool) -> Ve
// renders from and what `/timeline` toggles against) — not the separately
// hydrated `current_ui`, which could disagree and make the toggle no-op.
let prev = app.appearance.show_timeline;
// Idempotency gate.
if prev == new {
return vec![];
}
@@ -855,9 +848,9 @@ pub(in crate::app::dispatch) fn set_simple_mode(app: &mut AppView, new: bool) ->
set_simple_mode_inner(app, new);
refresh_open_settings_modals(app);
tracing::info!(target: "settings", key = "simple_mode", value = new, "setting changed");
// Toast label mirrors the renamed registry label
// ("Disable vim input mode") so the user sees the same name in the
// modal and the toast.
// Toast label matches the registry's display label
// ("Disable vim input mode"), not the setting's internal name, so
// the modal and toast agree.
app.show_toast(&save_success_toast("Disable vim input mode", new));
vec![Effect::PersistSetting {
key: "simple_mode",
@@ -866,7 +859,6 @@ pub(in crate::app::dispatch) fn set_simple_mode(app: &mut AppView, new: bool) ->
}]
}
// ---------------------------------------------------------------------------
// Contextual-hint tips: the `contextual_hints.*` per-tip toggles.
//
// SHELL-owned: persisted to `[ui.contextual_hints]`. Each setter writes the
@@ -874,7 +866,6 @@ pub(in crate::app::dispatch) fn set_simple_mode(app: &mut AppView, new: bool) ->
// re-propagates the prompt gates to every agent, so a toggle takes effect at
// runtime (not just on next launch). `write` is a non-capturing closure that
// coerces to `fn` so the tips share one inner.
// ---------------------------------------------------------------------------
/// State-only mutation: write one tip's user-config Option, then re-resolve and
/// fan the resolved gates out to `app` + every agent prompt.
@@ -1009,7 +1000,6 @@ pub(in crate::app::dispatch) fn set_contextual_hint_word_select(
)
}
// ---------------------------------------------------------------------------
// Theme settings: `theme`, `auto_dark_theme`, `auto_light_theme`.
//
// Each has a preview/commit split:
@@ -1021,7 +1011,6 @@ pub(in crate::app::dispatch) fn set_contextual_hint_word_select(
//
// Unknown names: `error!` in outer (registry skew), `warn!` in inner
// (rollback of corrupted config).
// ---------------------------------------------------------------------------
/// Format a "✓ <Label>: <value>" toast for theme-family settings.
/// `value` is the user-friendly display name, not the canonical.
@@ -1069,8 +1058,6 @@ fn auto_theme_setting_is_live(key: &str) -> bool {
crate::theme::cache::is_auto_mode() && system_is_in_matching_mode(key)
}
// ── theme (commit path) ─────────────────────────────────────────────
/// State + cache + visual mutation for `theme`. **Commit path.**
/// Updates `app.current_ui.theme`, toggles `AUTO_MODE` based on
/// whether the value is `"auto"`, and applies the live theme.
@@ -1135,8 +1122,6 @@ pub(in crate::app::dispatch) fn set_theme(app: &mut AppView, new: String) -> Vec
}]
}
// ── theme (preview path) ────────────────────────────────────────────
/// Preview-only mutation for `theme`. Applies the live visual without
/// modifying state, toggling `AUTO_MODE`, persisting, or toasting.
/// For `"auto"`, resolves and applies the theme but does NOT toggle
@@ -1168,8 +1153,6 @@ pub(in crate::app::dispatch) fn preview_theme(_app: &mut AppView, new: String) -
vec![]
}
// ── auto_dark_theme (commit path) ───────────────────────────────────
/// State + cache + visual mutation for `auto_dark_theme`. Commit path.
/// Applies visually only when the setting is live (auto mode + dark).
/// Rejects `"auto"` as an invalid value (log + no-op).
@@ -1239,8 +1222,6 @@ pub(in crate::app::dispatch) fn set_auto_dark_theme(app: &mut AppView, new: Stri
}]
}
// ── auto_dark_theme (preview path) ──────────────────────────────────
/// Preview-only mutation for `auto_dark_theme`. Visual only when live.
fn preview_auto_dark_theme_inner(value: &str) {
let Some(kind) = crate::theme::ThemeKind::from_name(value) else {
@@ -1280,8 +1261,6 @@ pub(in crate::app::dispatch) fn preview_auto_dark_theme(
vec![]
}
// ── auto_light_theme (commit path) ──────────────────────────────────
/// State + cache + visual mutation for `auto_light_theme`. Commit path.
/// Mirror of `set_auto_dark_theme_inner` for the light bucket.
pub(super) fn set_auto_light_theme_inner(app: &mut AppView, value: &str) {
@@ -1352,8 +1331,6 @@ pub(in crate::app::dispatch) fn set_auto_light_theme(
}]
}
// ── auto_light_theme (preview path) ─────────────────────────────────
/// Preview-only mutation for `auto_light_theme`. Mirror of
/// `preview_auto_dark_theme_inner` for the light bucket.
fn preview_auto_light_theme_inner(value: &str) {
@@ -1394,12 +1371,10 @@ pub(in crate::app::dispatch) fn preview_auto_light_theme(
vec![]
}
// ---------------------------------------------------------------------------
// default_model — resolves display name to `ModelId`, then emits both
// `Effect::SwitchModel` (active session) and `Effect::PersistSetting`
// (next-session default). No live preview (model switch has ACP side
// effects).
// ---------------------------------------------------------------------------
/// State-only mutation for `default_model`: set
/// `agent.session.models.current` to the supplied id. Returns `true`
@@ -1490,7 +1465,6 @@ pub(in crate::app::dispatch) fn set_default_model(
return vec![];
}
// Idempotent: same model already active → no-op.
if prev_id.as_ref() == Some(&new_id) {
return vec![];
}
@@ -1531,7 +1505,7 @@ pub(in crate::app::dispatch) fn set_default_model(
// Best-effort session-level switch. The `Effect::SwitchModel`
// pipeline handles its own deferred-switch semantics for the
// no-session-id-yet case (see line 583 of this file).
// no-session-id-yet case (see `deferred_model_switch` below).
if let Some(sid) = session_id {
// We already hold a reference path to the agent above; re-borrow
// mutably here to flip `model_switch_pending`.
@@ -1548,7 +1522,8 @@ pub(in crate::app::dispatch) fn set_default_model(
} else if let Some(agent) = app.agents.get_mut(&aid) {
// No session id yet — stash for
// `EventLoop::on_session_created` to apply once the session
// id materialises. Mirrors `Action::SwitchModel` line 586.
// id materialises. Mirrors the deferred-switch handling in
// `Action::SwitchModel`.
agent.session.deferred_model_switch = Some((new_id, None));
}
effects
@@ -1609,14 +1584,11 @@ pub(in crate::app::dispatch) fn clear_default_model(app: &mut AppView) -> Vec<Ef
}]
}
// ---------------------------------------------------------------------------
// Model-family settings: fork_secondary_model (and formerly
// web_search_model, session_summary_model, default_reasoning_effort).
// Model-family settings: fork_secondary_model.
//
// SHELL-OWNED. Unlike `default_model`, these do NOT mutate live
// runtime state — they update `current_ui` mirrors and persist.
// No live preview. Rollback is purely disk + mirror.
// ---------------------------------------------------------------------------
/// State-only mutation for `fork_secondary_model`. Updates the
/// `app.current_ui.fork_secondary_model` mirror so the modal
@@ -1678,7 +1650,6 @@ pub(in crate::app::dispatch) fn set_fork_secondary_model(
let new_id_str = new_id.0.to_string();
let prev_id_str = app.current_ui.fork_secondary_model.clone();
if prev_id_str == new_id_str {
// Idempotent fast-path: no-op for redundant writes.
return vec![];
}
set_fork_secondary_model_inner(app, new_id_str.clone());
@@ -1706,7 +1677,6 @@ pub(in crate::app::dispatch) fn clear_fork_secondary_model(app: &mut AppView) ->
let baseline = kigi_shell::models::default_model().to_string();
let prev_id_str = app.current_ui.fork_secondary_model.clone();
if prev_id_str == baseline {
// Idempotent: already at baseline.
app.show_toast("\u{2713} Fork secondary model: already at default");
return vec![];
}
@@ -1730,15 +1700,9 @@ pub(in crate::app::dispatch) fn clear_fork_secondary_model(app: &mut AppView) ->
}]
}
// `session_summary_model` and `default_reasoning_effort` setters were
// removed alongside their registry entries. Mirror fields and TOML
// schema stay for compat.
// ---------------------------------------------------------------------------
// max_thoughts_width — Int-valued setting. Registry surface is `i64`;
// clamped to `(min, max)` bounds and cast to `u16`. Live application
// via `app.current_ui.max_thoughts_width`.
// ---------------------------------------------------------------------------
/// Clamp `i64` to the registered `max_thoughts_width` bounds.
/// Bounds imported from `settings::defs` (single source of truth).
@@ -1762,8 +1726,7 @@ pub(in crate::app::dispatch) fn set_max_thoughts_width(app: &mut AppView, new: i
let prev = app.current_ui.max_thoughts_width as i64;
let clamped = clamp_max_thoughts_width(new);
if prev == clamped {
// Idempotent fast-path: no-op for redundant writes. Matches
// the bool setters' idempotency contract.
// Matches the bool setters' idempotency contract.
return vec![];
}
set_max_thoughts_width_inner(app, new);
@@ -1782,17 +1745,12 @@ pub(in crate::app::dispatch) fn set_max_thoughts_width(app: &mut AppView, new: i
}]
}
// `auto_compact_threshold_percent` setter was removed alongside its
// registry entry. Mirror field stays for compat.
// ---------------------------------------------------------------------------
// show_tips, auto_update — SHELL-OWNED `Option<bool>` setters.
// Changes take effect on next session start (restart_required: true).
// Standard inner/outer split. First commit of the default value
// persists (so the resolver sees user intent vs managed default).
// Rollback restores `None` when the target equals the effective
// default (keeps mirror in sync with on-disk state after failure).
// ---------------------------------------------------------------------------
/// Effective-default lookup for the `Option<bool>` AppView mirrors
/// (`show_tips`, `auto_update`, ask_user_question timeout).
@@ -1819,8 +1777,8 @@ pub(in crate::app::dispatch) fn set_show_tips(app: &mut AppView, new: bool) -> V
let prev_state = app.show_tips;
let prev_effective = prev_state.unwrap_or(true);
if prev_effective == new && prev_state.is_some() {
// Idempotent fast-path. `.is_some()` lets the first commit of
// the default value persist (so the resolver sees user intent).
// `.is_some()` lets the first commit of the default value
// persist (so the resolver sees user intent).
return vec![];
}
set_show_tips_inner(app, new);
@@ -1864,10 +1822,8 @@ pub(in crate::app::dispatch) fn set_auto_update(app: &mut AppView, new: bool) ->
}]
}
// ---------------------------------------------------------------------------
// display_refresh_auto_cadence — SHELL-OWNED nested Option on
// `[ui.display_refresh].auto_cadence_enabled`. Restart-required.
// ---------------------------------------------------------------------------
/// State-only mutation for `display_refresh_auto_cadence`.
pub(super) fn set_display_refresh_auto_cadence_inner(app: &mut AppView, value: bool) {
@@ -33,7 +33,6 @@ pub(in crate::app::dispatch) fn save_success_toast(label: &str, on: bool) -> Str
/// snapshots by value; without this, toggles would appear stuck.
pub(crate) fn refresh_open_settings_modals(app: &mut AppView) {
use crate::views::modal::ActiveModal;
// Early exit when no settings modal is open (common case).
if !app.agents.values().any(|a| {
matches!(
a.active_modal,
@@ -146,7 +145,6 @@ pub(in crate::app::dispatch) fn dispatch_open_settings(app: &mut AppView) -> Vec
// mutable borrow on `agent` so the borrow checker is happy.
let registry = app.settings_registry.clone();
let ui_snapshot = app.current_ui.clone();
// Capture app-level fields before the mut-borrow on the agent.
let show_tips_from_app = app.show_tips;
let auto_update_from_app = app.auto_update;
let respect_manual_folds_from_app = app.appearance.scrollback.scroll.respect_manual_folds;
@@ -304,7 +302,6 @@ pub(in crate::app::dispatch) fn dispatch_confirm_reset_setting(
return vec![];
};
// Take the ResetSettingsConfirm modal out and restore Settings.
// Take the ResetSettingsConfirm modal out and capture the
// target key from the variant (single source of truth — the
// Action does NOT carry the key, eliminating the desync risk
@@ -424,11 +421,9 @@ pub(in crate::app::dispatch) fn dispatch_toggle_compact_mode(app: &mut AppView)
/// the `dispatch_toggle_multiline` / `dispatch_toggle_compact_mode` /
/// `dispatch_toggle_timestamps` pattern.
pub(in crate::app::dispatch) fn dispatch_toggle_vim_mode(app: &mut AppView) -> Vec<Effect> {
// Toggle the EFFECTIVE value (the pager cache) so `/vim-mode` works
// from ANY view — including the session-less dashboard. Previously
// this early-returned unless an agent was active, so running
// `/vim-mode` on the dashboard was a silent no-op and the overview's
// j/k navigation (which is gated on vim-mode) never turned on.
// Toggle the EFFECTIVE value (the pager cache), not a per-agent
// field, so `/vim-mode` works from ANY view — including the
// session-less dashboard, whose j/k navigation is gated on vim-mode.
let prev = crate::appearance::cache::load_vim_mode();
let enabled = !prev;
// Propagate to every agent AND every nested subagent view (so
@@ -786,7 +781,6 @@ pub(in crate::app::dispatch) fn action_for_reset(
None
}
}
// max_thoughts_width: direct round-trip.
("max_thoughts_width", SettingValue::Int(i)) => Some(Action::SetMaxThoughtsWidth(*i)),
// plan_mode: "on" / "off" → PlanModeKind.
// "on" arm is a skew guard (default is "off").
@@ -796,7 +790,6 @@ pub(in crate::app::dispatch) fn action_for_reset(
("plan_mode", SettingValue::Enum("on")) => {
Some(Action::SetPlanMode(crate::app::actions::PlanModeKind::On))
}
// show_tips / auto_update / display_refresh_auto_cadence: direct bool.
("show_tips", SettingValue::Bool(b)) => Some(Action::SetShowTips(*b)),
("auto_update", SettingValue::Bool(b)) => Some(Action::SetAutoUpdate(*b)),
("display_refresh_auto_cadence", SettingValue::Bool(b)) => {
@@ -953,7 +946,6 @@ pub(in crate::app::dispatch) fn apply_setting_rollback(
default is unsaved",
);
}
// max_thoughts_width: direct inner call.
("max_thoughts_width", SettingValue::Int(i)) => set_max_thoughts_width_inner(app, *i),
// scroll_speed: direct inner call (clamp handled by inner).
("scroll_speed", SettingValue::Int(i)) => set_scroll_speed_inner(app, *i as u8),
@@ -974,7 +966,6 @@ pub(in crate::app::dispatch) fn apply_setting_rollback(
}
}
("scroll_lines", SettingValue::Int(i)) => set_scroll_lines_inner(app, *i as u8),
// vim_mode: direct inner call.
("vim_mode", SettingValue::Bool(b)) => set_vim_mode_inner(app, *b),
("remember_tool_approvals", SettingValue::Bool(b)) => {
set_remember_tool_approvals_inner(app, *b)
@@ -155,7 +155,6 @@ fn drain_clipboard_target(target: &ClipboardPasteTarget, app: &mut AppView) -> V
}
}
}
/// Handle a completed async task result.
pub(super) fn dispatch_task_result(result: TaskResult, app: &mut AppView) -> Vec<Effect> {
match result {
TaskResult::SessionCreated {
@@ -2,8 +2,6 @@
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).
@@ -23,9 +21,7 @@ fn bash_while_running_is_server_authoritative() {
}
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");
@@ -54,8 +50,8 @@ fn auth_complete_triggers_bundle_status_fetch() {
);
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.
// Pager only refreshes the on-disk catalog snapshot; the bundle
// download runs inside the shell post-auth.
assert!(
effects
.iter()
@@ -13,8 +13,6 @@ fn last_system_text(app: &AppView) -> String {
}
}
// ── /usage dispatch tests ───────────────────────────────────
#[test]
fn show_usage_returns_fetch_usage_effect() {
let mut app = test_app_with_agent();
@@ -1398,9 +1398,9 @@ fn dashboard_plan_description_transforms_snapshot_and_chip_ranges() {
);
}
/// The sessions picker modal was removed; `/sessions` survives as an alias
/// of `/dashboard`. It must resolve to the dashboard command and inherit
/// the dashboard feature-flag gate (hidden by canonical name, fail-closed).
/// `/sessions` is an alias of `/dashboard`; it must resolve to the
/// dashboard command and inherit its feature-flag gate (hidden by
/// canonical name, fail-closed).
#[serial_test::serial(KIGI_AGENT_DASHBOARD)]
#[test]
fn dashboard_slash_sessions_aliases_dashboard() {
@@ -1871,7 +1871,7 @@ fn dashboard_deferred_plan_mode_applied_on_session_created() {
}
/// Any non-empty prompt — even a single character — dispatches a
/// new session (the old 4-char floor was relaxed to 1 char).
/// new session.
#[serial_test::serial(KIGI_AGENT_DASHBOARD)]
#[test]
fn dashboard_dispatch_single_char_creates_session() {
@@ -1971,10 +1971,7 @@ fn dashboard_dispatch_with_top_level_selection_creates_new_session() {
);
}
// -----------------------------------------------------------------
// Enter / Ctrl+S behaviour matrix.
//
// Pins the contract the user spelled out (Ctrl+S is "send + open";
// Enter / Ctrl+S behaviour matrix (Ctrl+S is "send + open";
// Shift/Alt+Enter insert a newline):
//
// button + empty prompt + Enter → Create + open detail
@@ -1987,11 +1984,6 @@ fn dashboard_dispatch_with_top_level_selection_creates_new_session() {
// The dispatch input always spawns a NEW session — a selected row is
// the navigation cursor (Enter on an empty prompt opens it), never a
// reply target.
//
// Tests drive the whole stack — the state handler emits an
// action, the dispatcher runs, and we assert the resulting
// view + selection + attached_agent.
// -----------------------------------------------------------------
/// 2 — Button focused + non-empty + Enter → new session,
/// STAY on the dashboard, no attached_agent.
@@ -2174,10 +2166,8 @@ fn dashboard_attach_top_level_switches_to_agent_view() {
);
}
/// Attach routes through `focus_row`, so a previously selected
/// section header is cleared — the row and section cursors stay
/// mutually exclusive (a bare `selected` assignment used to leave
/// both active).
/// Attach routes through `focus_row`, so any selected section header
/// is cleared — the row and section cursors stay mutually exclusive.
#[serial_test::serial(KIGI_AGENT_DASHBOARD)]
#[test]
fn dashboard_attach_clears_selected_section() {
@@ -2321,11 +2311,10 @@ fn dashboard_attach_subagent_lazily_replays_deferred_transcript() {
/// No auto-attached popup. The user reaches an agent's view by
/// pressing Enter on its row.
///
/// Opening from an agent now lands in NEW-SESSION mode (the
/// Opening from an agent lands in NEW-SESSION mode (the
/// `[+ New Agent]` button focused, no row selected) so typing +
/// Enter dispatches a brand new agent. Previously the dashboard
/// pre-seeded `selected` to the came-from agent, which armed reply
/// mode and trapped the user replying to that one agent.
/// Enter dispatches a brand new agent, rather than pre-seeding
/// `selected` to the came-from agent and arming reply mode.
#[serial_test::serial(KIGI_AGENT_DASHBOARD)]
#[test]
fn dashboard_open_does_not_auto_attach_to_focused_agent() {
@@ -2439,12 +2428,9 @@ fn dashboard_dispatch_after_open_from_agent_spawns_new_sessions() {
);
}
/// Opening from Welcome leaves the `[+ New Agent]`
/// button as the default focus. Previously the dashboard
/// seeded selection to the first agent so Enter would attach
/// without navigating; with the button taking that role,
/// selection stays empty and the button signals what Enter
/// (on an empty prompt) will do.
/// Opening from Welcome leaves the `[+ New Agent]` button as the
/// default focus: selection stays empty, and the button signals
/// what Enter (on an empty prompt) will do.
#[serial_test::serial(KIGI_AGENT_DASHBOARD)]
#[test]
fn dashboard_open_from_welcome_focuses_new_agent_button() {
@@ -2468,9 +2454,6 @@ fn dashboard_open_from_welcome_focuses_new_agent_button() {
);
}
// -----------------------------------------------------------------
// Dashboard mouse-wheel scrolling
//
// Mouse wheel is intentionally decoupled from the selected row:
// scrolling only moves the viewport, leaving `selected` alone.
// `DashboardState::handle_scroll` flags
@@ -2478,7 +2461,6 @@ fn dashboard_open_from_welcome_focuses_new_agent_button() {
// `clamp_viewport` skips its snap-to-selection pull-back so the
// viewport can travel past the cursor. Selection-driven nav
// (arrows, click) clears the flag and re-engages the snap.
// -----------------------------------------------------------------
#[serial_test::serial(KIGI_AGENT_DASHBOARD)]
#[test]
@@ -3364,7 +3346,8 @@ fn dashboard_toggle_auto_approve_flips_yolo_on_selected_agent() {
#[serial_test::serial(KIGI_AGENT_DASHBOARD)]
#[test]
fn dashboard_toggle_auto_approve_with_no_selection_toasts() {
let mut app = test_app(); // no agent
// no agent
let mut app = test_app();
open_dashboard(&mut app);
let effects = dispatch_dashboard_toggle_auto_approve(&mut app);
assert!(effects.is_empty());
@@ -3439,13 +3422,9 @@ fn dashboard_rename_end_to_end_top_level_row() {
);
}
/// `DashboardCancelRename` emits no effects and
/// leaves `display_name` untouched. Previously named
/// `dashboard_rename_cancel_via_esc_does_not_emit_effect`
/// but that name implied Esc keystroke routing — the test
/// actually dispatches `Action::DashboardCancelRename` directly.
/// The Esc-keystroke routing is now pinned by the sibling test
/// `dashboard_rename_esc_keystroke_routes_to_cancel`.
/// `DashboardCancelRename` emits no effects and leaves `display_name`
/// untouched. Esc-keystroke routing to this action is pinned separately
/// by `dashboard_rename_esc_keystroke_routes_to_cancel`.
#[serial_test::serial(KIGI_AGENT_DASHBOARD)]
#[test]
fn dashboard_rename_cancel_action_emits_no_effect() {
@@ -3809,19 +3788,14 @@ fn dashboard_close_shortcuts_help_clears_modal() {
assert!(app.dashboard.as_ref().unwrap().shortcuts_modal.is_none());
}
// -----------------------------------------------------------------
// `[+ New Agent]` button
//
// The header button is the default cursor target when no row
// is selected. Up-arrow from the first row, Esc deselect,
// dashboard-open-from-welcome, and `reanchor_selection`-drops
// all land here. Enter-with-empty-prompt and click both
// dispatch `DashboardCreateNewAgentWithDetail`, which
// creates a session AND switches into detail view. Enter
// with a NON-empty prompt falls through to
// `DashboardDispatch`, which creates the session but stays
// `[+ New Agent]` button: the header button is the default cursor
// target when no row is selected. Up-arrow from the first row, Esc
// deselect, dashboard-open-from-welcome, and `reanchor_selection`-drops
// all land here. Enter-with-empty-prompt and click both dispatch
// `DashboardCreateNewAgentWithDetail`, which creates a session AND
// switches into detail view. Enter with a NON-empty prompt falls
// through to `DashboardDispatch`, which creates the session but stays
// on the dashboard.
// -----------------------------------------------------------------
#[serial_test::serial(KIGI_AGENT_DASHBOARD)]
#[test]
@@ -3986,9 +3960,7 @@ fn dashboard_filter_state_known_token_via_dispatch() {
));
}
// -----------------------------------------------------------------
// Real extract_recent_lines coverage.
// -----------------------------------------------------------------
/// count=0 returns empty even when scrollback has entries.
#[test]
@@ -4136,9 +4108,7 @@ fn extract_recent_lines_strips_ansi() {
assert!(out[0].contains("evil"));
}
// -----------------------------------------------------------------
// Stop-confirm full lifecycle.
// -----------------------------------------------------------------
/// A press > 2s after the first re-arms (does NOT close).
#[serial_test::serial(KIGI_AGENT_DASHBOARD)]
@@ -4254,7 +4224,8 @@ fn dashboard_permission_select_drops_stale_request() {
last_response_truncated: false,
question: Some("q?".into()),
options: vec![("allow".into(), "Allow".into())],
request_id: Some(123), // mismatched id
// mismatched id
request_id: Some(123),
reject_option: None,
},
));
@@ -4687,7 +4658,8 @@ fn dashboard_question_answer_sends_and_clears() {
let effects = dispatch_dashboard_question_answer(
&mut app,
crate::views::dashboard::DashboardRowId::TopLevel(AgentId(0)),
Some(1), // pick "Postgres"
// pick "Postgres"
Some(1),
String::new(),
);
assert!(effects.is_empty());
@@ -4745,7 +4717,8 @@ fn dashboard_question_answer_walks_multiple_questions() {
let fields = compute_peek_fields(&row, &app.agents).expect("ask surfaced");
assert!(fields.question.as_deref().unwrap().starts_with("(1/2)"));
assert!(fields.request_id.is_none());
assert_eq!(fields.reject_option, Some(2)); // 2 options + "Other"
// 2 options + "Other"
assert_eq!(fields.reject_option, Some(2));
// Plant a peek with a stale draft to verify the advance reset.
if let Some(d) = app.dashboard.as_mut() {
let mut p = PeekPanelState::new(row.clone(), fields);
@@ -4787,7 +4760,8 @@ fn dashboard_peek_auto_opens_for_selected_row() {
let mut app = test_app_with_agent();
mark_agent_nonempty(&mut app, AgentId(0));
open_dashboard(&mut app);
let area = Rect::new(0, 0, 80, 24); // tall enough for the peek
// tall enough for the peek
let area = Rect::new(0, 0, 80, 24);
let reg = crate::actions::ActionRegistry::defaults();
// Select a row, then render → the peek opens by default.
@@ -4972,7 +4946,7 @@ fn build_rows_keeps_pinned_empty_local_session() {
assert_eq!(rows.len(), 1, "a pinned empty session is kept");
}
// -- Conversation-origin roster rows (chat-mode dashboard fallback) ----
// Conversation-origin roster rows (chat-mode dashboard fallback).
#[test]
fn dashboard_attach_roster_focuses_existing_local_agent() {
@@ -373,7 +373,6 @@ fn authenticating_seq(app: &AppView) -> u64 {
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)
}
@@ -648,8 +647,6 @@ fn open_session_picker_with(
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
@@ -748,7 +745,6 @@ 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);
}
@@ -792,12 +788,10 @@ fn dashboard_row_order(app: &AppView) -> Vec<crate::views::dashboard::DashboardR
/// 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.
/// Returns the response receiver so tests can verify the response
/// was actually `send`'d through the oneshot — a dropped receiver
/// would let "happy-path" tests assert the queue was popped while
/// masking regressions where the pop happens without a send.
fn push_synthetic_permission(
agent: &mut crate::app::agent_view::AgentView,
id: usize,
@@ -147,8 +147,6 @@ fn accept_word_select_tip_no_op_when_tip_not_showing() {
);
}
// ── /plan slash command tests ─────────────────────────────────────
#[test]
fn slash_plan_no_args_not_in_plan_enters_plan_mode() {
let mut app = test_app_with_agent();
@@ -296,7 +294,6 @@ fn set_plan_mode_mutates_only_active_agent_not_others() {
);
}
// ----------------------------------------------------------------
// `set_yolo_mode` dispatcher unit tests (security-relevant)
//
// SHELL-owned, but with rollback semantics: a disk-write failure
@@ -337,7 +334,6 @@ fn set_plan_mode_mutates_only_active_agent_not_others() {
// - Failure toast: "✗ Could not save permission_mode: {error}"
// — exact format pinned via `assert_eq!` in
// `rollback_permission_mode_reverts_state_no_effect`.
// ----------------------------------------------------------------
/// Slash gate sync: both toggles stay offered while modes change; only the
/// auto feature gate suppresses `/auto`.
@@ -453,10 +449,8 @@ fn set_yolo_mode_off_to_on_emits_persist_with_rollback() {
crate::app::actions::PermissionModePersist::WithRollback("ask"),
"rollback must revert to the prior canonical (was 'ask')"
);
// Explicit session_id assertion
// (previously hidden behind `..` — a regression that
// dropped session_id silently broke the ACP
// notification gate at effects.rs).
// If dropped, session_id silently breaks the ACP
// notification gate at effects.rs.
assert!(
session_id.is_some(),
"session_id must be threaded through for ACP notification gating"
@@ -892,7 +886,7 @@ fn set_yolo_mode_on_drains_multi_item_queue() {
Ok(Ok(acp::RequestPermissionResponse {
outcome: acp::RequestPermissionOutcome::Selected(_),
..
})) => {} // OK
})) => {}
other => panic!(
"item {i} did not receive AllowOnce Selected response: {other:?} — \
drain skipped items beyond the first?",
@@ -970,7 +964,7 @@ fn set_yolo_mode_on_duplicate_dispatch_still_drains_queue() {
Ok(Ok(acp::RequestPermissionResponse {
outcome: acp::RequestPermissionOutcome::Selected(_),
..
})) => {} // OK
})) => {}
other => panic!(
"duplicate dispatch must auto-approve the newly queued permission, got {other:?}",
),
@@ -1377,7 +1371,8 @@ fn cycle_mode_pre_session_normal_to_plan_does_not_persist_permission_mode() {
/// mutation are all gated by the same `app.active_view` guard).
#[test]
fn set_yolo_mode_no_op_when_no_active_agent() {
let mut app = test_app(); // no agent, active_view = Welcome
// No agent, active_view = Welcome.
let mut app = test_app();
let default_yolo_before = app.default_yolo;
let perm_mode_before = app.current_ui.permission_mode.clone();
@@ -1441,7 +1436,6 @@ fn set_yolo_mode_refreshes_open_modal_snapshots() {
);
}
// ----------------------------------------------------------------
// Dispatch-layer integration tests for
// `Action::SetPermissionMode(kind)`.
//
@@ -1472,7 +1466,6 @@ fn set_yolo_mode_refreshes_open_modal_snapshots() {
// - `apply_setting_rollback("permission_mode", Enum("default"))`
// restores `current_ui.permission_mode = Some("default")`
// (preserves canonical; doesn't re-emit any Effect).
// ----------------------------------------------------------------
#[test]
fn set_permission_mode_default_overrides_canonical_to_default() {
@@ -2130,14 +2123,12 @@ fn set_theme_auto_enables_auto_mode_and_persists_auto() {
});
}
// ────────────────────────────────────────────────────────────────────
// set_plan_mode dispatch-level coverage.
//
// Mirrors the `coding_data_sharing` and `yolo` test
// patterns. These exercise the dispatch path directly (not the
// modal Enter path or the slash-command parser path), so they cover
// the same plumbing every entry point ultimately funnels through.
// ────────────────────────────────────────────────────────────────────
/// Idempotent ON: dispatcher sees `prev == new`,
/// toasts but emits NO Effect (saves a wasted ACP round-trip).
@@ -2,19 +2,15 @@
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`.
/// Resetting `permission_mode` must dispatch the typed
/// `Action::SetPermissionMode(PermissionModeKind::Ask)` via recursive
/// dispatch, reaching 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").
// Default is OFF ("ask"); flip yolo on first so the reset is a real transition.
let _ = dispatch(Action::SetYoloMode(true), &mut app);
assert!(app.agents[&AgentId(0)].session.is_yolo());
@@ -36,7 +32,6 @@ fn dispatch_confirm_reset_setting_reset_dispatches_set_permission_mode_for_permi
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)",
@@ -105,7 +100,6 @@ fn set_yolo_mode_on_drains_permission_queue_with_allow_once() {
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",
@@ -231,7 +225,6 @@ fn set_permission_mode_always_approve_blocked_by_policy_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;
@@ -283,27 +276,16 @@ fn set_permission_mode_auto_degrades_to_ask_when_gated_off() {
);
}
/// 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.
/// Rollback with an unknown canonical defensively defaults to "ask" (the
/// safe fallback — fewer prompts on a corrupt rollback value is worse than
/// more prompts). `apply_setting_rollback` also fires `tracing::warn!` for
/// this case, but the test only pins the observable state, not the log.
#[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",
@@ -318,31 +300,23 @@ fn rollback_permission_mode_unknown_canonical_defaults_to_ask() {
"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.
// The toast text does not currently distinguish schema corruption from
// a real disk failure; both produce the standard
// `✗ Could not save permission_mode: …` wording.
}
/// 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".
/// The rollback path (`apply_setting_rollback` into `set_yolo_mode_inner`)
/// must refresh open modal snapshots the same way the success path does.
/// Without this, a modal open when a disk write fails would keep showing a
/// stale "always-approve" indicator after the state rolls 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
// 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();
@@ -354,7 +328,6 @@ fn rollback_permission_mode_refreshes_open_modal_snapshots() {
"pre-rollback snapshot reflects optimistic state (yolo=true)",
);
// Simulate disk-write failure → rollback to "ask".
let _ = dispatch(
Action::TaskComplete(TaskResult::SettingPersistFailed {
key: "permission_mode",
@@ -364,7 +337,6 @@ fn rollback_permission_mode_refreshes_open_modal_snapshots() {
&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");
@@ -395,9 +367,9 @@ fn set_permission_mode_ask_emits_brand_consistent_toast() {
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.
// Toast must brand as "Permission mode", not "Always-approve" — the Ask
// arm must not fall back to `yolo_toast(false)`, which produces the
// wrong brand.
let toast = app.agents[&AgentId(0)]
.toast
.as_ref()
@@ -426,14 +398,12 @@ fn set_permission_mode_ask_emits_brand_consistent_toast() {
}
}
/// 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.
/// A `--yolo` startup sets `agent.session.yolo_mode = true` but leaves
/// `app.current_ui.permission_mode` at `None`. Without 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).
#[test]
fn set_permission_mode_with_live_yolo_and_no_ui_mirror_rolls_back_to_always_approve() {
use crate::app::actions::PermissionModeKind;
@@ -450,8 +420,6 @@ fn set_permission_mode_with_live_yolo_and_no_ui_mirror_rolls_back_to_always_appr
&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"));
@@ -473,17 +441,13 @@ fn set_permission_mode_with_live_yolo_and_no_ui_mirror_rolls_back_to_always_appr
}
}
/// `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.
/// `apply_setting_rollback("permission_mode", Enum("default"))` must
/// preserve the "default" canonical through a failed-persist, 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!(
@@ -491,8 +455,6 @@ fn rollback_permission_mode_default_canonical_preserves_default() {
Some("always-approve"),
);
// Simulate disk-write failure with `rollback_value =
// Enum("default")`.
let effects = dispatch(
Action::TaskComplete(TaskResult::SettingPersistFailed {
key: "permission_mode",
@@ -509,7 +471,6 @@ fn rollback_permission_mode_default_canonical_preserves_default() {
"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",
@@ -526,7 +487,6 @@ fn rollback_permission_mode_default_canonical_preserves_default() {
);
}
/// 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};
@@ -110,8 +110,6 @@ fn show_undo_tip_no_op_when_flag_off() {
assert!(!app.agents[&id].ephemeral_tip.is_active());
}
// ── Small-screen tip (`show_small_screen_tip` + its one-shot trigger) ──
/// `show_small_screen_tip` on a drawable agent shows the tip and increments
/// the per-session seen count in memory (nothing persisted — the fn returns
/// nothing, so it cannot raise effects).
@@ -557,9 +555,9 @@ fn chip_submit_while_enqueued_clears_follow_up_chips() {
// A chip click submitted while a turn is RUNNING *and* the local queue
// is non-empty takes the ENQUEUE path, not immediate-server-send:
// `immediate_server_send_eligible` is false whenever `pending_prompts`
// is non-empty. Before the fix, only the immediate-send branch cleared
// the chips, so this path left them on screen after the user had already
// acted on one. The clear now runs for every `SubmitFollowUp` path.
// is non-empty. The chip clear must run on this path too, not just the
// immediate-send branch, or the chips would linger on screen after the
// user had already acted on one.
let mut app = test_app_with_agent();
let id = AgentId(0);
{
@@ -586,7 +584,7 @@ fn chip_submit_while_enqueued_clears_follow_up_chips() {
.any(|e| matches!(e, Effect::SendPrompt { text, .. } if text == "Summarize")),
"chip must be enqueued, not immediate-sent, got {effects:?}"
);
// The chips are cleared on the enqueue path too (the bug fix).
// The chips are cleared on the enqueue path too.
assert!(
app.agents[&id].follow_ups.is_none(),
"enqueue chip path must clear chips"
@@ -597,7 +595,6 @@ fn chip_submit_while_enqueued_clears_follow_up_chips() {
fn send_prompt_while_running_queues_without_drain() {
let mut app = test_app_with_agent();
let id = AgentId(0);
// Simulate a running turn.
app.agents.get_mut(&id).unwrap().session.state = AgentState::TurnRunning;
let effects = dispatch(Action::SendPrompt("queued".into()), &mut app);
@@ -1689,8 +1686,6 @@ fn bash_while_idle_stays_on_local_path() {
assert!(app.agents[&id].bash_turn);
}
// ── Reconnect-pending dispatch guards ─────────────────────────────
#[test]
fn send_prompt_blocked_during_reconnect() {
let mut app = test_app_with_agent();
@@ -2049,7 +2044,8 @@ fn prompt_history_loaded_refreshes_open_history_search_with_current_query() {
fn agent_send_before_paste_probe_keeps_image() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mut app = test_app_with_agent();
app.project_picker_shown = true; // don't intercept the send with the picker
// don't intercept the send with the picker
app.project_picker_shown = true;
let id = AgentId(0);
{
let agent = app.agents.get_mut(&id).unwrap();
@@ -2131,7 +2127,8 @@ fn interject_before_paste_probe_keeps_image() {
let id = AgentId(0);
{
let agent = app.agents.get_mut(&id).unwrap();
agent.session.state = AgentState::TurnRunning; // interject needs a live turn
// interject needs a live turn
agent.session.state = AgentState::TurnRunning;
agent.set_active_pane(ActivePane::Prompt, true);
agent.prompt.set_text("look at this");
}
@@ -2215,7 +2212,8 @@ fn interject_before_paste_probe_keeps_image() {
#[test]
fn agent_paste_completion_after_switch_does_not_send_to_other_agent() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mut app = test_app_with_agent(); // agent A = AgentId(0), active view
// agent A = AgentId(0), active view
let mut app = test_app_with_agent();
app.project_picker_shown = true;
let a = AgentId(0);
let b = AgentId(1);
@@ -2351,8 +2349,6 @@ fn slash_and_exit_input_does_not_trigger_project_picker() {
assert!(!input_can_trigger_project_picker(" "));
}
// ── Minimal-mode slash gate tests ───────────────────────────────────
/// Returns true if any system block in agent 0's scrollback contains
/// `needle`. Avoids `last_system_text`'s "last block must be System" panic
/// for the allowed-command control (which may leave no system block).
@@ -2409,8 +2405,6 @@ fn minimal_mode_allows_mode_agnostic_slash_command() {
);
}
// ── /queue (ShowQueue) dispatch tests ───────────────────────────────
#[test]
fn show_queue_empty_commits_empty_message() {
let mut app = test_app_with_agent();
@@ -2447,8 +2441,6 @@ fn show_queue_no_active_agent_is_noop() {
assert!(effects.is_empty(), "ShowQueue without an agent is a no-op");
}
// ── Send-now cancel marker suppression (PromptResponse rail) ────────
/// Count of "Turn cancelled by user …" marker blocks in the agent's scrollback.
fn count_cancelled_markers(app: &AppView, id: AgentId) -> usize {
let agent = &app.agents[&id];
@@ -437,7 +437,7 @@ fn slash_model_invalid_arg_produces_scrollback_error() {
/// `/model` + Enter (required args missing) must NOT error into scrollback —
/// it re-opens the prompt in the args phase so the existing dropdown lists
/// the catalog (= the connected providers' models). This is the documented
/// "Blocks" row of `is_command_complete`, which previously had no consumer.
/// "Blocks" row of `is_command_complete`.
#[test]
fn slash_model_no_args_reopens_the_model_picker() {
let mut app = test_app_with_agent();
@@ -1183,9 +1183,7 @@ fn find_agent_by_session_id_finds_inactive_agent() {
/// Cross-setting smoke test.
/// Verifies that the dispatcher routes each Action to the
/// correct setter (catches a copy-paste registration bug
/// where two setters were swapped). The original 5-setting
/// matrix shrank to 2 after the user-feedback drop of
/// `session_picker_grouped` / `load_envrc` / `use_leader`.
/// where two setters were swapped).
#[test]
fn pr13_each_setter_writes_to_its_own_mirror() {
let mut app = test_app_with_agent();
@@ -1201,9 +1199,8 @@ fn pr13_each_setter_writes_to_its_own_mirror() {
/// Three-way alignment pin: the PAGER registry default must agree
/// with `PagerLocalSnapshot::default()` (covered by
/// `defaults_match_pager_state` in `registry::tests`) AND with
/// `AgentView::new`'s runtime initializer. This is the third leg
/// of the triangle that was previously missing — the
/// registry test alone can't see `AgentView::new`'s constant.
/// `AgentView::new`'s runtime initializer — the registry test alone
/// can't see `AgentView::new`'s constant.
#[test]
fn pager_registry_default_matches_agent_view_new_initializer() {
use crate::settings::{SettingKind, SettingOwner, SettingsRegistry};
@@ -1267,9 +1264,9 @@ fn pager_registry_default_matches_agent_view_new_initializer() {
}
}
/// If the user picks the regular "Yes, proceed" option (NOT
/// enable-always-approve), the dispatcher must behave exactly as
/// before — no PersistPermissionMode effect, no YOLO flip. Pins
/// that the new code path is gated strictly on the id check.
/// enable-always-approve), the dispatcher must emit no
/// PersistPermissionMode effect and no YOLO flip: the
/// always-approve path is gated strictly on the id check.
#[test]
fn regular_allow_once_does_not_trigger_always_approve_persist() {
use std::sync::Arc;
@@ -1404,7 +1401,6 @@ fn show_tasks_no_active_agent_is_noop() {
let effects = dispatch(Action::ShowTasks, &mut app);
assert!(effects.is_empty(), "ShowTasks without an agent is a no-op");
}
/// classify_top_level decision matrix.
#[test]
fn classify_top_level_branches() {
use crate::views::dashboard::{RowState, classify_top_level};
@@ -1563,7 +1559,6 @@ fn peek_label_reflects_last_response_type() {
.push_block(RenderBlock::user_prompt("do it"));
assert_eq!(extract_last_response_type(agent), "Idle");
}
/// agent.question_view.is_some() → NeedsInput.
#[test]
fn classify_top_level_question_view_some_is_needs_input() {
use crate::views::dashboard::{RowState, classify_top_level};
@@ -1602,7 +1597,6 @@ fn top_level_label_strips_control_characters() {
);
assert!(top.label.contains("evil"));
}
/// Build a synthetic MouseEvent for tests.
fn mouse_event(
kind: crossterm::event::MouseEventKind,
col: u16,
@@ -1615,11 +1609,6 @@ fn mouse_event(
modifiers: crossterm::event::KeyModifiers::NONE,
}
}
/// Left-click on a row selects it (single click).
/// Single left-click on a row attaches the
/// conversation immediately (was: selects only, required
/// double-click to attach). The user explicitly reported the
/// previous click-to-select behaviour as unresponsive.
#[serial_test::serial(KIGI_AGENT_DASHBOARD)]
#[test]
fn mouse_left_click_attaches_immediately() {
@@ -1645,11 +1634,8 @@ fn mouse_left_click_attaches_immediately() {
}
assert_eq!(d.selected, Some(id));
}
/// Every left-click attaches, including
/// rapid repeated clicks. The previous design used a 500ms window
/// to distinguish single (select) from double (attach) click;
/// the new design makes every click attach so the user's mental
/// model "click = open" always holds.
/// There is no double-click window — every click attaches, so the
/// user's mental model "click = open" always holds.
#[serial_test::serial(KIGI_AGENT_DASHBOARD)]
#[test]
fn mouse_repeated_click_keeps_attaching() {
@@ -1682,9 +1668,8 @@ fn mouse_repeated_click_keeps_attaching() {
other => panic!("expected DashboardAttach on second click, got {other:?}"),
}
}
/// Clicks after the previous 500ms-double-click
/// window also attach (the previous test asserted single-click
/// behaviour for >500ms-apart clicks; now every click attaches).
/// Clicks more than 500ms apart also attach — there is no
/// double-click window.
#[serial_test::serial(KIGI_AGENT_DASHBOARD)]
#[test]
fn mouse_click_after_long_pause_still_attaches() {
@@ -1715,7 +1700,6 @@ fn mouse_click_after_long_pause_still_attaches() {
other => panic!("expected DashboardAttach, got {other:?}"),
}
}
/// Click on the peek close-button rect closes the peek.
#[serial_test::serial(KIGI_AGENT_DASHBOARD)]
#[test]
fn mouse_click_on_peek_close_rect_clears_peek() {
@@ -2,8 +2,6 @@
use super::*;
// ── Worktree session tests ───────────────────────────────────────
#[test]
fn open_new_worktree_dialog_sets_dialog_state() {
let mut app = test_app();
@@ -26,7 +24,6 @@ fn worktree_forked_sets_session_id_eagerly_and_emits_load() {
);
let id = AgentId(0);
// Before WorktreeForked: session_id is None, loading_replay is false.
assert!(app.agents[&id].session.session_id.is_none());
assert!(!app.agents[&id].session.loading_replay);
@@ -45,21 +42,17 @@ fn worktree_forked_sets_session_id_eagerly_and_emits_load() {
&mut app,
);
// session_id set eagerly.
assert_eq!(
app.agents[&id].session.session_id,
Some(acp::SessionId::new("forked-sess-1"))
);
// loading_replay enabled so UI suppresses redraws during replay.
assert!(app.agents[&id].session.loading_replay);
// CWD updated to worktree.
assert_eq!(app.agents[&id].session.cwd, session_cwd);
assert!(app.agents[&id].session.is_worktree);
// Emits LoadSession effect.
assert_eq!(effects.len(), 1);
assert!(matches!(&effects[0], Effect::LoadSession { session_id, .. }
if session_id == "forked-sess-1"));
// Scrollback has the "Worktree ready" message.
assert!(!app.agents[&id].scrollback.is_empty());
}
@@ -93,13 +86,11 @@ fn worktree_forked_with_restore_shows_summary_in_scrollback() {
&mut app,
);
// Should emit LoadSession.
assert_eq!(effects.len(), 1);
assert!(matches!(
&effects[0],
Effect::LoadSession { session_id, .. } if session_id == "forked-sess-2"
));
// Scrollback should contain the restore summary.
let has_restore_msg = app.agents[&id]
.scrollback
.entries_in_range(0..app.agents[&id].scrollback.len())
@@ -161,7 +152,6 @@ fn worktree_forked_with_restore_failure_shows_warning_banner() {
);
assert!(text.contains("restore aborted"));
assert!(text.contains("MERGE_HEAD present"));
// The success banner must NOT also appear.
let success_present = entries
.iter()
.any(|e| matches!(&e.block, RenderBlock::System(s) if s.text.contains("Code restored")));
@@ -412,7 +402,6 @@ fn dispatch_fork_no_flag_non_git_skips_modal_and_forks_without_worktree() {
Action::Fork(fork_args(None, Some("explore offline"))),
&mut app,
);
// Must skip the modal and emit ForkSession immediately.
assert!(
matches!(effects.as_slice(), [Effect::ForkSession { .. }]),
"non-git cwd must skip modal and emit ForkSession, got {effects:?}"
@@ -450,7 +439,6 @@ fn dispatch_fork_no_flag_always_opens_question_modal() {
}
other => panic!("expected Fork, got {other:?}"),
}
// The modal must offer four options: Yes / No / Always / Never.
assert_eq!(
qv.questions[0].options.len(),
4,
@@ -1846,7 +1846,6 @@ fn dashboard_stop_double_press_via_handle_key_closes_top_level() {
"second Ctrl+X via handle_input must close the target agent (Issue 300 regression)",
);
}
/// Top-level resolver round-trip via real AgentView.
#[test]
fn session_id_resolver_round_trip_top_level() {
use crate::views::dashboard::{DashboardRowId, PersistedRowId, SessionIdResolver};
@@ -1864,7 +1863,6 @@ fn session_id_resolver_round_trip_top_level() {
};
assert!(resolver.resolve(&absent).is_none());
}
/// Subagent resolver round-trip.
#[test]
fn session_id_resolver_round_trip_subagent() {
use crate::views::dashboard::{DashboardRowId, PersistedRowId, SessionIdResolver};
@@ -1606,7 +1606,7 @@ fn build_mode_query_arms_debounce_despite_title_hits_and_force_skips_it() {
);
}
/// Build mode: a sub-2-char query clears the content results AND
/// invalidates the previously armed debounce, so its late expiry can't
/// invalidates the debounce armed earlier, so its late expiry can't
/// resurrect the search.
#[test]
fn build_mode_short_query_clears_results_and_invalidates_armed_debounce() {
@@ -73,8 +73,6 @@ fn session_created_with_flag_but_modal_closed_clears_flag_no_fetches() {
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();
@@ -125,8 +123,6 @@ fn dispatch_new_session_skips_modal_in_non_git_repo() {
);
}
// ── Session close (shared with dashboard) ─────────────────────────────
#[test]
fn close_inactive_agent_drops_it() {
let mut app = three_agent_app();
@@ -184,9 +184,9 @@ fn stashed_model_keeps_model_when_unsupported() {
#[test]
fn effort_max_rejected_when_model_offers_no_max() {
// Since the Xhigh/Max split, "max" is its own canonical level — a model
// whose menu has no max-valued option rejects it with the offered list
// (previously the parse alias silently rode it onto the xhigh option).
// "max" is its own canonical level, distinct from "xhigh": a model
// whose menu has no max-valued option rejects it with the offered
// list rather than falling back to the xhigh option.
let models = models_with_current(true);
let out = take_deferred_model_switch(None, &models, Some("max"));
assert_eq!(
@@ -126,10 +126,10 @@ fn toggle_vim_mode_propagates_to_open_subagent_views() {
);
}
/// `/vim-mode` must toggle vim from the DASHBOARD too (not just an
/// agent view) — previously it early-returned unless an agent was
/// active, so it was a silent no-op and the overview's j/k never
/// turned on. Turning vim ON also focuses the overview so j/k
/// navigate immediately; turning it OFF returns focus to the input.
/// agent view) — an early return without an active agent would make
/// this a silent no-op and the overview's j/k would never turn on.
/// Turning vim ON also focuses the overview so j/k navigate
/// immediately; turning it OFF returns focus to the input.
#[serial_test::serial(KIGI_AGENT_DASHBOARD)]
#[test]
fn toggle_vim_mode_works_on_dashboard_and_focuses_overview() {
@@ -776,11 +776,9 @@ fn refresh_open_settings_modals_updates_reset_confirm_settings_state() {
_ => panic!("expected ResetSettingsConfirm still active"),
}
}
/// The
/// dispatch-arm bail-out path is verified directly via
/// `#[should_panic]` in debug mode rather than the previous
/// `if cfg!(debug_assertions) { return; }` placebo which gave
/// debug-mode CI no coverage of the routing-bug bail-out.
/// The dispatch-arm bail-out path is verified directly via
/// `#[should_panic]` in debug mode, giving debug-mode CI real
/// coverage of the routing-bug bail-out.
#[test]
#[cfg(debug_assertions)]
#[should_panic(expected = "OpenResetConfirm dispatched without an open Settings modal")]
@@ -42,7 +42,6 @@ fn send_while_idle_with_nonempty_shared_queue_routes_to_server() {
_ => 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"
@@ -220,8 +219,6 @@ fn show_usage_on_welcome_screen_is_noop() {
);
}
// ── Minimal update-notice tests ──────────────────────────────────────
#[test]
fn minimal_update_notice_commits_a_system_block() {
let mut app = test_app_with_agent();
@@ -541,16 +541,12 @@ fn switch_model_complete_success_updates_model_and_pushes_message() {
&mut app,
);
// Pending flag cleared.
assert!(!app.agents[&id].session.model_switch_pending);
// Current model updated.
assert_eq!(
app.agents[&id].session.models.current,
Some(model_id.clone())
);
// Success message pushed to scrollback.
assert_eq!(app.agents[&id].scrollback.len(), initial_scrollback + 1);
// PersistPreferredModel effect emitted.
assert_eq!(effects.len(), 1);
assert!(matches!(
&effects[0],
@@ -632,7 +628,8 @@ fn switch_model_complete_persists_resolved_effort_from_catalog_meta() {
Action::TaskComplete(TaskResult::SwitchModelComplete {
agent_id: id,
model_id: model_id.clone(),
effort: None, // user typed `/model Blackbox 4.7` with no effort
// user typed `/model Blackbox 4.7` with no effort
effort: None,
result: Ok(()),
prev_model_id: None,
}),
@@ -752,9 +749,7 @@ fn switch_model_complete_failure_pushes_error_and_clears_pending() {
assert!(effects.is_empty());
// Pending flag cleared.
assert!(!app.agents[&id].session.model_switch_pending);
// Current model unchanged.
assert_eq!(app.agents[&id].session.models.current, old_current);
// Error message pushed to scrollback.
assert_eq!(app.agents[&id].scrollback.len(), initial_scrollback + 1);
}
@@ -794,16 +789,13 @@ fn switch_model_incompatible_agent_shows_question_modal() {
// No effects emitted (modal is synchronous state).
assert!(effects.is_empty());
// Pending flag cleared.
assert!(!app.agents[&id].session.model_switch_pending);
// Question modal is open.
assert!(app.agents[&id].question_view.is_some());
let qv = app.agents[&id].question_view.as_ref().unwrap();
assert!(matches!(
qv.local_kind,
Some(crate::views::question_view::LocalQuestionKind::AgentTypeMismatch { .. })
));
// No error message pushed to scrollback.
assert_eq!(app.agents[&id].scrollback.len(), initial_scrollback);
}
@@ -855,7 +847,6 @@ fn incompatible_agent_rollback_restores_previous_model() {
&mut app,
);
// models.current must be rolled back to the previous model.
assert_eq!(
app.agents[&id].session.models.current,
Some(prev_model),
@@ -901,12 +892,10 @@ fn incompatible_agent_closes_active_modal() {
&mut app,
);
// Active modal must be closed.
assert!(
app.agents[&id].active_modal.is_none(),
"active modal must be closed when IncompatibleAgent fires",
);
// Question modal must be open.
assert!(
app.agents[&id].question_view.is_some(),
"question modal must be open",
@@ -950,7 +939,6 @@ fn same_agent_type_switch_no_modal() {
// Model should be switched, no modal.
assert_eq!(app.agents[&id].session.models.current, Some(model_b));
assert!(app.agents[&id].question_view.is_none());
// Should emit PersistPreferredModel.
assert!(
effects
.iter()
@@ -965,10 +953,8 @@ fn switch_model_pending_lifecycle() {
let id = AgentId(0);
let model_id = acp::ModelId::new(std::sync::Arc::from("kigi-4.5"));
// Initially false.
assert!(!app.agents[&id].session.model_switch_pending);
// Action sets pending.
dispatch(
Action::SwitchModel {
model_id: model_id.clone(),
@@ -978,7 +964,6 @@ fn switch_model_pending_lifecycle() {
);
assert!(app.agents[&id].session.model_switch_pending);
// TaskResult clears pending.
dispatch(
Action::TaskComplete(TaskResult::SwitchModelComplete {
agent_id: id,
@@ -1012,7 +997,6 @@ fn no_deferred_switch_means_no_extra_effect() {
&mut app,
);
// No SwitchModel effect.
assert!(
!effects
.iter()
@@ -1178,8 +1162,6 @@ fn available_commands_refreshed_empty_is_noop() {
);
}
// -- Session deletion from the /resume picker -----------------------
#[test]
fn delete_session_complete_removes_only_matching_source_and_id() {
use crate::views::modal::ActiveModal;
@@ -1470,13 +1452,11 @@ fn rename_session_failed_keeps_local_display_name_and_pushes_system_block() {
&mut app,
);
// No rollback.
assert_eq!(
app.agents[&AgentId(0)].display_name.as_deref(),
Some("optimistic title"),
"display_name must NOT roll back on RenameSessionFailed"
);
// System block appended with the error.
let scrollback = &app.agents[&AgentId(0)].scrollback;
assert_eq!(
scrollback.len(),
@@ -1500,10 +1480,9 @@ fn rename_session_failed_keeps_local_display_name_and_pushes_system_block() {
fn rollback_known_key_reverts_cache_and_no_effect() {
use crate::settings::SettingValue;
let mut app = test_app_with_agent();
// Toggle to true first.
let _ = dispatch(Action::SetCompactMode(true), &mut app);
assert!(app.current_ui.compact_mode);
// Now simulate persist failure that rolls back to false.
// Simulate persist failure that rolls back to false.
let effects = dispatch(
Action::TaskComplete(TaskResult::SettingPersistFailed {
key: "compact_mode",
@@ -1516,7 +1495,6 @@ fn rollback_known_key_reverts_cache_and_no_effect() {
effects.is_empty(),
"rollback path must NOT emit any new Effects (would loop)",
);
// Cache is reverted.
assert!(!app.current_ui.compact_mode);
}
@@ -1689,8 +1667,6 @@ fn rollback_to_always_approve_blocked_by_policy_pin() {
assert!(!app.default_yolo);
}
// -- SessionListLoaded ------------------------------------------------
/// Canary: an empty list surfaces the generic "no sessions" toast.
#[test]
fn session_list_empty_shows_generic_toast() {
@@ -195,8 +195,8 @@ fn open_block_viewer_uses_markdown_viewer_for_agent_message_with_image_ref() {
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).
// Agent messages with image refs open the normal markdown viewer;
// inline media rendering happens in the tool call block.
assert!(agent.block_viewer.is_some());
}
@@ -230,15 +230,15 @@ fn open_block_viewer_opens_image_only_blocks_natively() {
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.
// Generated media opens in the OS-native viewer (fire-and-forget);
// 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 --------------
// Plugins tab: group-collapse seeding on PluginsListLoaded
fn plugins_list_response() -> kigi_hooks_plugins_types::PluginsListResponse {
use crate::views::extensions_modal::test_plugin_info;
@@ -8,7 +8,7 @@ use super::*;
/// `Internal error: "session failed to respond"`. An `acp::Error` carries
/// no `promptId`, so before the Err-arm gate this error was misattributed
/// to the running turn and rendered as a spurious "Turn failed", detonating
/// an unrelated in-flight turn. The handler now gates the Err arm on the
/// an unrelated in-flight turn. The handler gates the Err arm on the
/// `prompt_id` the pager minted for that RPC: an error whose id is NOT the
/// running turn is discarded; the running turn is left untouched.
#[test]
@@ -1022,13 +1022,11 @@ fn bg_task_kill_failed_clears_pending_kill_on_inactive_agent() {
assert!(task.kill_requested_at.is_none());
}
/// build_rows handles many subagents (placeholder).
#[test]
fn build_rows_collapses_many_subagents() {
use crate::views::dashboard::build_rows;
let mut app = test_app_with_agent();
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
// Insert 9 subagents.
for i in 0..9 {
let info = make_test_subagent(&format!("c{i}"), &format!("sa{i}"));
agent
@@ -1078,7 +1076,6 @@ fn build_rows_seven_subagents_no_placeholder() {
assert!(!rows.last().unwrap().is_more_placeholder);
}
/// At the threshold (exactly 8), no placeholder.
#[test]
fn build_rows_eight_subagents_no_placeholder() {
use crate::views::dashboard::build_rows;
@@ -1208,7 +1205,6 @@ fn subagent_label_strips_control_characters() {
"subagent label must not retain \\x1b: {:?}",
sub.label
);
// Visible characters survive.
assert!(
sub.label.contains("evil"),
"sanitised label should preserve printable characters, got {:?}",
@@ -12,7 +12,6 @@ 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 {
@@ -254,7 +253,6 @@ pub(super) fn dispatch_open_block_viewer(app: &mut AppView) {
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)
@@ -68,8 +68,8 @@ pub(super) fn dispatch_cancel_turn(app: &mut AppView) -> Vec<Effect> {
// 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.
// 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![];
@@ -502,7 +502,6 @@ pub(super) fn dispatch_demote_to_background(app: &mut AppView) -> Vec<Effect> {
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