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:
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
|
||||
|
||||
Reference in new issue
Block a user