Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.
Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
ptyctl, ptyctl-cli, third_party/ unchanged; proto package
xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
(templates re-encrypted)
Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
module & dc_log, heap-profile uploader, auth-diagnostics uploader,
session-analytics halves of feedback; local zero-egress observability
preserved in new kigi-log crate (unified log, --debug firehose,
subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
shell util
Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted
Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean
Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
(new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
fast-worktree); RSS measurement tests serialized via serial_test
Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
notices sustained; kigi-tools ported-code notices extended; README,
CONTRIBUTING, SECURITY, AGENTS.md rewritten
Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
10218 lines
434 KiB
Rust
10218 lines
434 KiB
Rust
//! Dashboard state types — `DashboardState`, `DashboardRowId`, filters,
|
||
//! grouping, persistence.
|
||
|
||
use std::collections::BTreeSet;
|
||
use std::path::{Path, PathBuf};
|
||
use std::time::Instant;
|
||
|
||
use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
|
||
use ratatui::layout::Rect;
|
||
|
||
use super::peek::PeekPanelState;
|
||
use super::row::DashboardRow;
|
||
use crate::actions::ActionRegistry;
|
||
use crate::app::actions::Action;
|
||
use crate::app::agent::AgentId;
|
||
use crate::app::app_view::InputOutcome;
|
||
use crate::key;
|
||
use crate::views::prompt_widget::PromptWidget;
|
||
|
||
const PROMPT_MULTI_CLICK_MS: u128 = 300;
|
||
|
||
/// Stable identity for a dashboard row.
|
||
///
|
||
/// Top-level rows key by `AgentId`. Subagent rows key by their parent
|
||
/// agent + `child_session_id` (the parent's hash map key in
|
||
/// `AgentView::subagent_sessions`). When the parent closes, the subagent
|
||
/// rows naturally disappear because the row builder iterates
|
||
/// `app.agents.values()` — no separate cleanup needed.
|
||
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||
pub enum DashboardRowId {
|
||
TopLevel(AgentId),
|
||
Subagent {
|
||
parent: AgentId,
|
||
child_session_id: String,
|
||
},
|
||
/// A leader-roster-only row: a session hosted by the leader (or a
|
||
/// remote host) that this client is NOT locally attached to. Keyed
|
||
/// by the roster `session_id`. Not locally controllable.
|
||
Roster {
|
||
session_id: String,
|
||
},
|
||
}
|
||
|
||
impl DashboardRowId {
|
||
/// True for a subagent row. Used to gate the "rename" affordance —
|
||
/// subagents are read-only in this version.
|
||
pub fn is_subagent(&self) -> bool {
|
||
matches!(self, Self::Subagent { .. })
|
||
}
|
||
}
|
||
|
||
/// A dispatch-input send (spawn a new session) stashed while a clipboard
|
||
/// attachment probe is off-thread.
|
||
///
|
||
/// Only the `attach` flag is captured; the text is re-read from the live
|
||
/// widget when the send is re-issued, so a freshly attached image chip (and
|
||
/// its aligned chip range) travels with it. Stashed per surface — see
|
||
/// [`DashboardState::take_deferred_sends_after_paste`].
|
||
#[derive(Debug)]
|
||
pub(crate) struct DeferredDispatchSend {
|
||
pub(crate) attach: bool,
|
||
}
|
||
|
||
/// A peek-reply send (reply to an existing agent) stashed while a clipboard
|
||
/// attachment probe is off-thread. `row` pins the reply's target so a peek
|
||
/// that closed or moved to another row drops the stash instead of replying to
|
||
/// the wrong agent.
|
||
#[derive(Debug)]
|
||
pub(crate) struct DeferredPeekSend {
|
||
pub(crate) row: DashboardRowId,
|
||
pub(crate) attach: bool,
|
||
}
|
||
|
||
/// Persistent identity for a pinned/reordered row.
|
||
///
|
||
/// Replaces the previous `AgentId(usize)`-keyed
|
||
/// persistence which was meaningless across restarts (and worse,
|
||
/// could attach to the *wrong* agent if `next_agent_id` happened to
|
||
/// reissue the same `usize`).
|
||
///
|
||
/// Top-level rows persist by ACP `session_id`. Subagent rows persist
|
||
/// by (parent's `session_id`, `child_session_id`). When the dashboard
|
||
/// opens, we walk `app.agents` and resolve each `PersistedRowId` to
|
||
/// the current process's `DashboardRowId`; unresolved ids are dropped
|
||
/// (handled by [`DashboardState::gc_stale_refs`]).
|
||
///
|
||
/// Rows whose underlying session has not yet been created (and thus
|
||
/// have no `session_id`) cannot be persisted — they survive only
|
||
/// in-memory across the current process lifetime.
|
||
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||
pub enum PersistedRowId {
|
||
TopLevel {
|
||
session_id: String,
|
||
},
|
||
Subagent {
|
||
parent_session_id: String,
|
||
child_session_id: String,
|
||
},
|
||
}
|
||
|
||
impl PersistedRowId {
|
||
/// On-disk serialisation. `top:<session_id>` or
|
||
/// `sub:<parent_session_id>:<child_session_id>`. The session ids
|
||
/// themselves are opaque to the dashboard — we never split them.
|
||
/// The first colon after `sub:` is the only one we split on.
|
||
pub fn to_key(&self) -> String {
|
||
match self {
|
||
Self::TopLevel { session_id } => format!("top:{session_id}"),
|
||
Self::Subagent {
|
||
parent_session_id,
|
||
child_session_id,
|
||
} => format!("sub:{parent_session_id}:{child_session_id}"),
|
||
}
|
||
}
|
||
|
||
/// Parse a persisted key back. Returns `None` for malformed input.
|
||
pub fn from_key(s: &str) -> Option<Self> {
|
||
if let Some(sid) = s.strip_prefix("top:") {
|
||
if sid.is_empty() {
|
||
return None;
|
||
}
|
||
return Some(Self::TopLevel {
|
||
session_id: sid.to_string(),
|
||
});
|
||
}
|
||
if let Some(rest) = s.strip_prefix("sub:")
|
||
&& let Some((parent, child)) = rest.split_once(':')
|
||
&& !parent.is_empty()
|
||
&& !child.is_empty()
|
||
{
|
||
return Some(Self::Subagent {
|
||
parent_session_id: parent.to_string(),
|
||
child_session_id: child.to_string(),
|
||
});
|
||
}
|
||
None
|
||
}
|
||
}
|
||
|
||
/// Window within which a second `Ctrl+X` press confirms closing the
|
||
/// selected agent. Shared by the dispatcher (which gates the actual
|
||
/// close) and the footer (which only paints the "press again" hint
|
||
/// while the window is live).
|
||
pub const STOP_CONFIRM_WINDOW: std::time::Duration = std::time::Duration::from_secs(2);
|
||
|
||
/// Coarse state used for the dashboard grouping.
|
||
///
|
||
/// See [`super::row::classify_top_level`] / [`super::row::classify_subagent`]
|
||
/// for the mapping rules.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||
pub enum RowState {
|
||
/// Pending permission OR pending ask_user_question (top-level only;
|
||
/// subagents never enter this state in this version).
|
||
NeedsInput,
|
||
/// Live turn or command running.
|
||
Working,
|
||
/// Alive, idle.
|
||
Idle,
|
||
/// A roster-only session: idle / dormant in another pager process
|
||
/// and never loaded in this one. Local agents never classify as
|
||
/// `Inactive` (see `classify_top_level`) — only
|
||
/// `roster_activity_to_state` produces it, so the "Idle" section
|
||
/// stays focused on sessions you're actively cycling between.
|
||
Inactive,
|
||
/// Finished + status == "completed".
|
||
Completed,
|
||
/// Finished + status in ("failed", "cancelled").
|
||
Failed,
|
||
/// Goal blocked / paused for human reasons. Reserved — unused in this version.
|
||
Blocked,
|
||
}
|
||
|
||
impl RowState {
|
||
/// Sort priority used inside a state group: higher = floats up.
|
||
/// Pinned rows always float to the absolute top regardless of state.
|
||
pub fn group_priority(self) -> u8 {
|
||
match self {
|
||
Self::NeedsInput => 6,
|
||
Self::Working => 5,
|
||
Self::Blocked => 4,
|
||
Self::Idle => 3,
|
||
// Below Idle (these aren't loaded here, so they're less
|
||
// immediately actionable) but above Done/Failed (they're
|
||
// still live, resumable sessions).
|
||
Self::Inactive => 2,
|
||
Self::Completed => 1,
|
||
Self::Failed => 0,
|
||
}
|
||
}
|
||
|
||
/// Human-readable group header.
|
||
pub fn group_label(self) -> &'static str {
|
||
match self {
|
||
// Shorter, punchier labels. "Done" reads cleaner as a group
|
||
// header than the past-tense "Completed" did.
|
||
Self::NeedsInput => "Awaiting",
|
||
Self::Working => "Working",
|
||
Self::Idle => "Idle",
|
||
Self::Inactive => "Inactive",
|
||
Self::Completed => "Done",
|
||
Self::Failed => "Failed",
|
||
Self::Blocked => "Blocked",
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Stable identity for a collapsible/selectable dashboard section
|
||
/// header. Sections only exist in [`Grouping::State`] mode: the
|
||
/// cross-cutting "Pinned" block plus one section per [`RowState`]
|
||
/// group. Keyed by the stable group identity (not a positional index)
|
||
/// so collapse / selection survive re-sorting and row churn.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||
pub enum SectionKey {
|
||
/// The "Pinned" block header.
|
||
Pinned,
|
||
/// A per-state group header (Working / Awaiting / Idle / …).
|
||
State(RowState),
|
||
}
|
||
|
||
/// A keyboard-navigable cursor target in the dashboard list: a
|
||
/// collapsible section header or a row. Built in display order (see
|
||
/// `render::focusables`) so Up/Down navigation and the section/row
|
||
/// cursor stay in lockstep with what the renderer paints. A collapsed
|
||
/// section contributes only its header (its rows are absent), so nav
|
||
/// skips hidden rows.
|
||
#[derive(Debug, Clone, PartialEq)]
|
||
pub enum Focusable {
|
||
Section(SectionKey),
|
||
Row(DashboardRowId),
|
||
/// The Idle group's "N more" toggle row. Present only
|
||
/// when the Idle group is capped (more than `MAX_VISIBLE_IDLE`
|
||
/// agents, the overflow older than the freshness window). Activating
|
||
/// it (Enter / click) flips [`DashboardState::idle_show_all`].
|
||
IdleOverflow,
|
||
}
|
||
|
||
/// Grouping mode for the dashboard.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||
pub enum Grouping {
|
||
/// Group by `RowState` (the default).
|
||
#[default]
|
||
State,
|
||
/// Group by working directory (one section per cwd).
|
||
Directory,
|
||
}
|
||
|
||
impl Grouping {
|
||
pub fn toggled(self) -> Self {
|
||
match self {
|
||
Self::State => Self::Directory,
|
||
Self::Directory => Self::State,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A filter for the visible rows.
|
||
///
|
||
/// Parsed from the dispatch input — see [`parse_filter`]. The dispatcher
|
||
/// applies it in [`super::row::apply_filter`].
|
||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||
pub enum Filter {
|
||
/// Show everything.
|
||
#[default]
|
||
None,
|
||
/// Match by agent label (`a:<name>`). Case-insensitive substring.
|
||
Agent(String),
|
||
/// Match by row state (`s:<state>`).
|
||
State(RowState),
|
||
/// Free-text substring match against label + cwd display.
|
||
Substring(String),
|
||
}
|
||
|
||
impl Filter {
|
||
/// Convenient sum constructor.
|
||
pub fn from_value(v: FilterValue) -> Self {
|
||
match v {
|
||
FilterValue::None => Self::None,
|
||
FilterValue::Agent(s) => {
|
||
if s.trim().is_empty() {
|
||
Self::None
|
||
} else {
|
||
Self::Agent(s)
|
||
}
|
||
}
|
||
FilterValue::State(rs) => Self::State(rs),
|
||
FilterValue::Substring(s) => {
|
||
if s.trim().is_empty() {
|
||
Self::None
|
||
} else {
|
||
Self::Substring(s)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Whether this filter would hide any rows. Used to decide whether
|
||
/// `Esc` should clear the filter before exiting.
|
||
pub fn is_active(&self) -> bool {
|
||
!matches!(self, Self::None)
|
||
}
|
||
}
|
||
|
||
/// Transport type for the [`Action::DashboardSetFilter`] action. Kept
|
||
/// separate from [`Filter`] so the action stays `Debug + Clone` without
|
||
/// requiring `Filter` to be `Hash`.
|
||
#[derive(Debug, Clone)]
|
||
pub enum FilterValue {
|
||
None,
|
||
Agent(String),
|
||
State(RowState),
|
||
Substring(String),
|
||
}
|
||
|
||
/// Persisted dashboard configuration stored under `[dashboard]` in
|
||
/// `~/.kigi/config.toml`. Lenient — corrupted fields fall back to
|
||
/// defaults (edge case 12).
|
||
///
|
||
/// Pinned + reorder are keyed by stable session ids (see
|
||
/// [`PersistedRowId`]), not by per-process `AgentId`. The dashboard
|
||
/// resolves them to live `DashboardRowId` at open time via
|
||
/// [`PersistedDashboard::resolve`].
|
||
#[derive(Debug, Clone, Default)]
|
||
pub struct PersistedDashboard {
|
||
pub enabled: bool,
|
||
pub grouping: Grouping,
|
||
pub pinned: BTreeSet<PersistedRowId>,
|
||
pub reorder: Vec<PersistedRowId>,
|
||
}
|
||
|
||
impl PersistedDashboard {
|
||
/// Construct a `PersistedDashboard` with feature-flag defaults.
|
||
pub fn defaults() -> Self {
|
||
Self {
|
||
enabled: true,
|
||
grouping: Grouping::default(),
|
||
pinned: BTreeSet::new(),
|
||
reorder: Vec::new(),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// In-memory dashboard state.
|
||
///
|
||
/// Refreshed every render frame off `app.agents`. Selection is keyed by
|
||
/// `DashboardRowId` so a rename / reorder / completion does not invalidate
|
||
/// the cursor as long as the row's id is stable.
|
||
pub struct DashboardState {
|
||
/// Currently selected row id. May be `None` when no rows are visible.
|
||
pub selected: Option<DashboardRowId>,
|
||
/// Hover target for visual feedback.
|
||
pub hovered_row: Option<DashboardRowId>,
|
||
/// Section-header cursor target. When `Some`, a collapsible section
|
||
/// title (e.g. "Working") holds the cursor instead of a row or the
|
||
/// `[+ New Agent]` button. Mutually exclusive with [`Self::selected`]
|
||
/// and [`Self::new_agent_button_focused`].
|
||
pub selected_section: Option<SectionKey>,
|
||
/// Hovered section header (mouse-move driven); the renderer brightens
|
||
/// its text. Independent of [`Self::hovered_row`].
|
||
pub hovered_section: Option<SectionKey>,
|
||
/// Sections the user has collapsed (their rows are hidden). In-memory
|
||
/// for the dashboard's lifetime; keyed by stable [`SectionKey`].
|
||
pub collapsed_sections: std::collections::HashSet<SectionKey>,
|
||
/// Cursor sits on the Idle group's "N more" overflow
|
||
/// toggle row. The fourth cursor target — mutually exclusive with
|
||
/// [`Self::selected`], [`Self::selected_section`], and
|
||
/// [`Self::new_agent_button_focused`] (enforced via the `focus_*`
|
||
/// helpers).
|
||
pub selected_idle_overflow: bool,
|
||
/// Mouse is hovering the Idle overflow toggle row; the renderer
|
||
/// brightens its text. Independent of [`Self::hovered_row`].
|
||
pub hovered_idle_overflow: bool,
|
||
/// When `true`, the Idle group shows every agent; when `false`
|
||
/// (default) it caps to the `MAX_VISIBLE_IDLE` most-recent agents
|
||
/// plus anything still inside the freshness window, folding the rest
|
||
/// behind the overflow row. In-memory only (resets on pager restart),
|
||
/// like section collapse.
|
||
pub idle_show_all: bool,
|
||
/// Pinned rows (persisted across pager restarts when their underlying
|
||
/// agent still exists; stale ids are garbage-collected at open time).
|
||
pub pinned: BTreeSet<DashboardRowId>,
|
||
/// User reorderings — explicit position overrides applied AFTER the
|
||
/// group + last_change sort.
|
||
pub reorder: Vec<DashboardRowId>,
|
||
/// Current grouping mode.
|
||
pub grouping: Grouping,
|
||
/// Current filter.
|
||
pub filter: Filter,
|
||
/// Search mode (toggled by `Ctrl+/`). While active the
|
||
/// [`Self::dispatch`] buffer is interpreted as a live filter
|
||
/// query rather than a prompt: every keystroke reparses into
|
||
/// [`Self::filter`], the prompt prefix flips from `❯` to a
|
||
/// yellow `Search:`, and Enter confirms (keeps the filter,
|
||
/// leaves search) while Esc / `Ctrl+/` cancel (clear it).
|
||
pub search_mode: bool,
|
||
/// Dispatch / filter input widget.
|
||
pub dispatch: PromptWidget,
|
||
/// Effects queued by dashboard input (e.g. slash MRU persist after Tab accept).
|
||
pub(crate) pending_effects: Vec<crate::app::actions::Effect>,
|
||
/// In-flight deferred clipboard attachment probes for this dashboard. A send
|
||
/// while `> 0` is stashed per surface so a paste-then-immediate-send never
|
||
/// builds content blocks before the image attaches.
|
||
pub(crate) paste_probe_in_flight: usize,
|
||
/// A dispatch-input send deferred until the in-flight paste probe(s)
|
||
/// complete; re-issued (with the now-updated widget text) on completion.
|
||
pub(crate) deferred_dispatch_send: Option<DeferredDispatchSend>,
|
||
/// A peek-reply send deferred the same way; per-surface slots so stashing
|
||
/// one surface can never overwrite the other's pending send.
|
||
pub(crate) deferred_peek_send: Option<DeferredPeekSend>,
|
||
/// Peek panel state (Space toggles).
|
||
pub peek: Option<PeekPanelState>,
|
||
/// The peek panel's `❯ reply` input — a full [`PromptWidget`] so
|
||
/// the reply gets paste chips (`[Pasted: N lines]`), word
|
||
/// navigation, undo, and text selection exactly like [`Self::dispatch`].
|
||
/// Owned here (NOT inside [`PeekPanelState`]) because a widget
|
||
/// carries a fuzzy-file-matcher daemon thread and the panel struct
|
||
/// is rebuilt whenever the selection cursor lands on a row. The
|
||
/// draft is cleared when the peeked row changes or the panel
|
||
/// closes (see [`Self::set_peek`]). `@` file completion is live
|
||
/// (polled each tick, rooted at the peeked agent's cwd — see
|
||
/// [`Self::peek_reply_cwd`]); slash completion stays inert because
|
||
/// the dashboard never refreshes its snapshot for this widget.
|
||
pub peek_reply: PromptWidget,
|
||
/// Last frame's screen rect of the peek reply input (the `❯ reply`
|
||
/// row, or the reject-feedback slot in question mode). Mirrors
|
||
/// [`Self::dispatch_rect`]: consumed by mouse handling for
|
||
/// click-to-focus and drag text selection. `None` while the peek
|
||
/// is closed.
|
||
pub peek_reply_rect: Option<Rect>,
|
||
/// Directory the reply's `@` file-search daemon is currently rooted
|
||
/// at. Tracked so [`Self::ensure_peek_reply_cwd`] can skip a
|
||
/// `retarget` (which rebuilds the daemon thread) when the peeked
|
||
/// agent's cwd hasn't actually changed. `None` = the construction
|
||
/// default (`.`); set to the launch cwd at dashboard open.
|
||
peek_reply_cwd: Option<PathBuf>,
|
||
/// Cwd of the currently-peeked agent, recorded by the render pass
|
||
/// (which has the agents map). Applied lazily to the reply's `@`
|
||
/// picker on first compose — NOT on every cursor move — so merely
|
||
/// navigating past agents in other directories spawns no matcher
|
||
/// threads. `None` when no row is peeked or the row has no local
|
||
/// agent (roster).
|
||
peek_reply_target_cwd: Option<PathBuf>,
|
||
/// Inline-rename state for `Ctrl+R` — `Some` when the cursor is on a
|
||
/// row and the user is mid-edit.
|
||
pub rename: Option<RenameDraft>,
|
||
/// Pending dispatch feedback toast (e.g. "✗ Session no longer
|
||
/// exists"). Rendered verbatim by `paint_dispatch_feedback_badge`;
|
||
/// error messages are built via [`Self::set_error_toast`].
|
||
pub error_toast: Option<String>,
|
||
/// Pending stop confirmation. `Some((row, set_at))` after the first
|
||
/// `Ctrl+X` press on a top-level row. The second press within
|
||
/// [`STOP_CONFIRM_WINDOW`] closes the agent. Mirrors the session-close
|
||
/// close-confirm pattern.
|
||
pub stop_confirm: Option<(DashboardRowId, Instant)>,
|
||
/// Tick counter for spinner animation. The
|
||
/// counter is bumped by [`crate::app::app_view::AppView::tick`]
|
||
/// (NOT the renderer, which is read-only).
|
||
/// `SPINNER_DIVISOR` divides the index so the on-screen
|
||
/// animation stays under 10 Hz at the ~30 Hz tick rate.
|
||
pub spinner_tick: u64,
|
||
/// Last frame's row layout — hit areas keyed by row id. Used by
|
||
/// mouse handling to map (col, row) → row id without scanning the
|
||
/// row list a second time.
|
||
pub row_rects: Vec<(DashboardRowId, Rect)>,
|
||
/// Last frame's section-header hit areas keyed by [`SectionKey`].
|
||
/// Used by mouse handling to map (col, row) → section for
|
||
/// click-to-toggle and hover. Rebuilt every render.
|
||
pub section_rects: Vec<(SectionKey, Rect)>,
|
||
/// Last frame's hit area for the Idle "N more" overflow
|
||
/// toggle row, when painted. Consumed by mouse handling for
|
||
/// click-to-toggle and hover. `None` when the Idle group isn't capped.
|
||
pub idle_overflow_rect: Option<Rect>,
|
||
/// Last frame's content-area rect for resize-aware peek toggling.
|
||
pub last_area: Rect,
|
||
/// When `Some(id)`, an agent conversation is
|
||
/// attached as a popup overlay on top of the dashboard. The
|
||
/// agent's full view (scrollback + prompt) renders inside the
|
||
/// popup rect; input is routed to that agent (Esc closes the
|
||
/// popup, returning to the dashboard rows). For subagent
|
||
/// attaches, the parent agent's `active_subagent` field is also
|
||
/// set so the subagent's child view renders within the parent's
|
||
/// own subagent takeover.
|
||
pub attached_agent: Option<AgentId>,
|
||
/// Hit rect for the popup's `[✗]` close
|
||
/// affordance. Populated by the renderer when the popup chrome
|
||
/// is painted; consumed by `handle_mouse` to dispatch a popup
|
||
/// close on click. `None` when no popup is open or the popup
|
||
/// is too small for the affordance.
|
||
pub popup_close_rect: Option<Rect>,
|
||
/// Outer rect of the popup overlay (border
|
||
/// included). Populated by the renderer; consumed by
|
||
/// `handle_mouse` to:
|
||
/// - swallow clicks that land on the popup chrome (border /
|
||
/// title row) instead of dispatching dashboard actions, and
|
||
/// - dispatch `DashboardAttach(clicked_row)` when the user
|
||
/// clicks a dashboard row OUTSIDE this rect (switches the
|
||
/// popup target to the clicked row).
|
||
pub popup_outer_rect: Option<Rect>,
|
||
/// Hit area for the header's `[+ New Agent]`
|
||
/// button. Painted by `render_header` and consumed by the mouse
|
||
/// handler to create a new session. Carries both the rect (for
|
||
/// click hit-testing) and a `hovered` flag (driven by mouse-move
|
||
/// events; the renderer reads it to paint a hover highlight). The
|
||
/// rect is `None` when the header is too narrow to fit the button
|
||
/// or has zero area.
|
||
pub new_agent_button_hit: crate::app::agent_view::HitArea,
|
||
/// Items-area rect of the open slash-completion dropdown (set by
|
||
/// `render_slash_dropdown`). Lets the mouse handler route a wheel /
|
||
/// click inside the dropdown to the completion list rather than the
|
||
/// row list. `None` when the dropdown is closed.
|
||
pub slash_dropdown_items_area: Option<Rect>,
|
||
/// Slash dropdown hit-test state from the renderer (see
|
||
/// [`crate::views::slash_dropdown::RenderedDropdown`]).
|
||
pub slash_dropdown_hit: crate::views::slash_dropdown::RenderedDropdown,
|
||
/// Mirror of [`Self::slash_dropdown_items_area`] for the session-less
|
||
/// `@` file-context picker dropdown (set by
|
||
/// `render_file_search_dropdown`).
|
||
pub file_search_dropdown_items_area: Option<Rect>,
|
||
/// Two-focus model: `false` = the dispatch **input bar** is focused
|
||
/// (typing); `true` = the **overview list** is focused (navigating).
|
||
/// Tab toggles the two. When the list is focused, `j`/`k` (vim) and
|
||
/// the arrows move between agent rows, the input dims + hides its
|
||
/// caret, and printable keys hand focus back to the input. This is
|
||
/// the explicit vim-style focus separation; the cursor itself
|
||
/// (`selected` row / `new_agent_button_focused`) is shared.
|
||
pub list_focused: bool,
|
||
/// When an agent is attached from the dashboard
|
||
/// (Enter on a row), the agent's view paints inside a bordered
|
||
/// "session overlay" frame, similar to the subagent fullscreen
|
||
/// takeover. These hit areas are populated by the overlay's
|
||
/// renderer and consumed by the mouse handler — each carries
|
||
/// both the rect (for click hit-testing) and a `hovered` flag
|
||
/// (driven by mouse-move events; the renderer reads it to
|
||
/// paint a hover highlight).
|
||
///
|
||
/// - `overlay_close_hit` → `[✗]` close affordance
|
||
/// - `overlay_prev_hit` / `overlay_next_hit` → `[Prev]` /
|
||
/// `[Next]` affordances that cycle through the dashboard's
|
||
/// visible row list
|
||
///
|
||
/// All three are cleared by `close_popup` / `exit_overlay` so
|
||
/// they can't outlive the overlay state.
|
||
pub overlay_close_hit: crate::app::agent_view::HitArea,
|
||
pub overlay_prev_hit: crate::app::agent_view::HitArea,
|
||
pub overlay_next_hit: crate::app::agent_view::HitArea,
|
||
/// Last mouse position (col, row). Used by hover and double-click
|
||
/// detection.
|
||
pub last_mouse_pos: Option<(u16, u16)>,
|
||
/// Last mouse-down on a row + timestamp, used to detect a
|
||
/// double-click as the "attach" gesture.
|
||
pub last_click: Option<(DashboardRowId, Instant)>,
|
||
/// Last mouse-down on dispatch / peek-reply (paste-chip double-click).
|
||
pub last_prompt_click: Option<Instant>,
|
||
/// Rect that closes the peek when clicked (`[×]`).
|
||
pub peek_close_rect: Option<Rect>,
|
||
/// Outer rect of the dispatch input box (set by `render_dashboard`
|
||
/// when the box is painted). Consumed by the mouse handler so a
|
||
/// click anywhere on the box focuses the input — i.e. clears
|
||
/// `list_focused` — regardless of vim mode. `None` while peek mode
|
||
/// or an attached-agent overlay replaces the input.
|
||
pub dispatch_rect: Option<Rect>,
|
||
/// Top of the visible row window. Clamped so the
|
||
/// selected row stays visible. Wheel + PgUp/PgDn adjust this.
|
||
pub viewport_offset: usize,
|
||
/// True while the user is browsing the row list via the mouse
|
||
/// wheel (or any pure-viewport scroll affordance). When set,
|
||
/// [`Self::clamp_viewport`] skips its snap-to-selection
|
||
/// pull-back so the viewport can travel past the selected row.
|
||
/// Cleared on any selection-driven nav (arrow keys, click,
|
||
/// filter rebuild, attach) so the viewport re-engages the
|
||
/// selection follow.
|
||
pub manual_scroll_active: bool,
|
||
/// `Ctrl+.` opens a searchable cheatsheet of every action
|
||
/// bound for the dashboard, mirroring the agent view's
|
||
/// `ShortcutsHelp` modal. `Some` while the modal is open;
|
||
/// input is routed to it before the dashboard's own
|
||
/// handlers, and the renderer paints it on top of the row
|
||
/// list. Cleared on close (Esc, `[✗]`, or the chrome's
|
||
/// CloseRequested).
|
||
pub shortcuts_modal: Option<Box<ShortcutsModalState>>,
|
||
/// True when the header's `[+ New Agent]` button has focus.
|
||
///
|
||
/// The button is the default selection target when no row is
|
||
/// selected — Up-arrow from the first row, Esc deselect, and
|
||
/// dashboard-open-without-prior-agent all land here. While
|
||
/// focused, Enter with an empty prompt creates a session and
|
||
/// opens detail view; click on the button does the same. The
|
||
/// renderer paints the button with a brighter foreground so
|
||
/// the cursor's location is obvious.
|
||
///
|
||
/// Invariant: when `new_agent_button_focused == true`,
|
||
/// `selected == None`. Any path that sets `selected` to
|
||
/// `Some(_)` must also clear this flag — see
|
||
/// [`Self::focus_row`] / [`Self::focus_new_agent_button`].
|
||
pub new_agent_button_focused: bool,
|
||
/// Model chosen for the *next* agent spawned from the dispatch input,
|
||
/// set by `/model <name> [effort]` (intercepted in
|
||
/// `dispatch_dashboard_dispatch_slash`). `None` → spawn on the default
|
||
/// model. Sticky across dispatches; reset to `None` on every
|
||
/// dashboard-open (alongside `pending_mode`).
|
||
pub pending_model: Option<PendingDispatchModel>,
|
||
/// Mode the next spawned agent starts in. Cycled with Shift+Tab and set
|
||
/// by `/plan`. Sticky across dispatches; re-seeded from
|
||
/// `app.default_yolo` on every dashboard-open (alongside `pending_model`).
|
||
pub pending_mode: DashboardDispatchMode,
|
||
/// Snapshot of the app-wide model catalog, seeded at dashboard-open so
|
||
/// the session-less slash dropdown can suggest real model names for
|
||
/// `/model`. Without this the dropdown would fall back to an empty
|
||
/// `ModelState` and offer no completions.
|
||
pub models: crate::acp::model_state::ModelState,
|
||
/// Location picker modal — `Some` while open. Lets the user change the
|
||
/// working directory new dashboard sessions spawn in. Opened via the
|
||
/// header location label (click), `Ctrl+L`, or `/cd`; input is routed
|
||
/// here before the dashboard's own handlers, and the renderer paints
|
||
/// it on top of the row list. Reset on dashboard-open.
|
||
pub location_picker: Option<LocationPickerState>,
|
||
/// Hit area for the header's clickable location label. Painted by
|
||
/// `render_header` and consumed by the mouse handler to open the
|
||
/// location picker. `None` when the header is too narrow to paint it.
|
||
pub location_hit: crate::app::agent_view::HitArea,
|
||
/// When `true`, agents dispatched from the dashboard are created in a
|
||
/// fresh git worktree (rooted at the current cwd) instead of in the cwd
|
||
/// directly: creating an agent first opens [`Self::worktree_dialog`] to
|
||
/// collect a label. Toggled by the "worktree" button on the location
|
||
/// picker's path row and persisted here so it survives the modal
|
||
/// closing. Reset to `false` on every dashboard-open.
|
||
pub dispatch_worktree: bool,
|
||
/// Whether the current working directory is inside a git repo. Synced
|
||
/// from `AppView::cwd_has_git_ancestor` on dashboard-open and on every
|
||
/// location change. Worktrees require a git repo, so this gates the
|
||
/// `Ctrl+W` worktree toggle and forces [`Self::dispatch_worktree`] off
|
||
/// outside a repo (the dashboard is never in worktree mode in a non-git
|
||
/// directory).
|
||
pub cwd_has_git_ancestor: bool,
|
||
/// Working directory new sessions dispatch from — a snapshot of
|
||
/// `AppView::cwd`, synced on dashboard-open and on every location change.
|
||
/// The header renders from this (not the process cwd) so it tracks a
|
||
/// `/cd` even before `Effect::SetWorkingDir` lands, or if it fails.
|
||
pub cwd: PathBuf,
|
||
/// Worktree-label dialog — `Some` while the user is naming a worktree
|
||
/// for a dashboard-dispatched agent. Reuses the welcome screen's
|
||
/// [`NewWorktreeDialogState`](crate::app::app_view::NewWorktreeDialogState)
|
||
/// widget; input is routed here and the renderer overlays it.
|
||
pub worktree_dialog: Option<crate::app::app_view::NewWorktreeDialogState>,
|
||
/// Prompt state stashed while [`Self::worktree_dialog`] is open.
|
||
pub pending_worktree_prompt: Option<crate::views::prompt_widget::StashedPrompt>,
|
||
/// Whether confirming the in-flight [`Self::worktree_dialog`] should open
|
||
/// the new agent's detail view (`true` — the `[+ New Agent]` button and
|
||
/// `Ctrl+S` "send + open") or stay on the dashboard (`false` — a plain
|
||
/// `Enter` prompt-send). Stashed alongside [`Self::pending_worktree_prompt`]
|
||
/// when the dialog opens; consumed on confirm. Mirrors the `attach` flag of
|
||
/// the non-worktree dispatch path.
|
||
pub pending_worktree_attach: bool,
|
||
/// Surface-local compose mode for dispatch + peek (not persisted; not
|
||
/// shared with agent sessions). `/multiline` or Ctrl+M.
|
||
pub multiline_mode: bool,
|
||
}
|
||
|
||
/// Mode staged for the next agent the dashboard spawns. Mirrors the agent
|
||
/// view's Shift+Tab cycle (Normal → Plan → Always-Approve → Normal).
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||
pub enum DashboardDispatchMode {
|
||
#[default]
|
||
Normal,
|
||
Plan,
|
||
AlwaysApprove,
|
||
}
|
||
|
||
impl DashboardDispatchMode {
|
||
/// Advance to the next mode in the Shift+Tab rotation.
|
||
pub fn cycle(self) -> Self {
|
||
match self {
|
||
Self::Normal => Self::Plan,
|
||
Self::Plan => Self::AlwaysApprove,
|
||
Self::AlwaysApprove => Self::Normal,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A model (and optional reasoning effort) staged for the next agent the
|
||
/// dashboard spawns. `display` is the human-readable model name, stored so
|
||
/// the renderer can show the indicator without a live `ModelState`.
|
||
#[derive(Debug, Clone)]
|
||
pub struct PendingDispatchModel {
|
||
pub id: agent_client_protocol::ModelId,
|
||
pub effort: Option<kigi_shell::sampling::types::ReasoningEffort>,
|
||
pub display: String,
|
||
}
|
||
|
||
/// Snapshot of the shortcuts cheatsheet modal — entries, picker /
|
||
/// chrome state, and the user's filter / collapse preferences. The
|
||
/// fields mirror `crate::views::modal::ActiveModal::ShortcutsHelp`
|
||
/// so the existing `views::shortcuts_help::*` helpers (build,
|
||
/// filter, render-input) work without re-plumbing. No `Debug` /
|
||
/// `Clone` derive — `ShortcutsHelpEntry` lacks both, and this
|
||
/// state is owned in-place by `DashboardState` without cloning
|
||
/// anywhere.
|
||
pub struct ShortcutsModalState {
|
||
pub entries: Vec<crate::views::shortcuts_help::ShortcutsHelpEntry>,
|
||
pub state: crate::views::picker::PickerState,
|
||
pub window: crate::views::modal_window::ModalWindowState,
|
||
pub filter_active: bool,
|
||
pub collapsed_sections: std::collections::HashSet<usize>,
|
||
pub expanded_ids: std::collections::HashSet<crate::views::shortcuts_help::ExpandKey>,
|
||
pub mode: crate::views::shortcuts_help::ShortcutsHelpMode,
|
||
}
|
||
|
||
/// In-progress rename for `Ctrl+R`.
|
||
#[derive(Debug, Clone)]
|
||
pub struct RenameDraft {
|
||
pub row: DashboardRowId,
|
||
pub draft: String,
|
||
}
|
||
|
||
/// One selectable directory in the location picker (see
|
||
/// [`LocationPickerState`]). `path` is the absolute directory; `label` /
|
||
/// `detail` are the pre-formatted display strings (basename, `~/display`
|
||
/// path, relative time, `(current)` marker). `worktree` is the managed
|
||
/// worktree's human label when this directory is a worktree root, used to
|
||
/// render a styled badge.
|
||
#[derive(Debug, Clone)]
|
||
pub struct LocationCandidate {
|
||
pub path: PathBuf,
|
||
pub label: String,
|
||
pub detail: String,
|
||
pub worktree: Option<String>,
|
||
}
|
||
|
||
/// Max directory entries listed per parent (bounds the cost of a
|
||
/// `readdir` on a huge directory) and max rows shown to the picker.
|
||
const LOCATION_DIR_LISTING_CAP: usize = 1000;
|
||
const LOCATION_VISIBLE_CAP: usize = 200;
|
||
|
||
/// State for the dashboard's location picker modal (change the directory new
|
||
/// sessions spawn in).
|
||
///
|
||
/// Hybrid autocomplete: a non-path query fuzzy-filters [`Self::recents`];
|
||
/// once the query looks like a path (`/`, `~`, or contains `/`) it switches
|
||
/// to shell-style directory completion over the `readdir` of its parent.
|
||
pub struct LocationPickerState {
|
||
pub picker: crate::views::picker::PickerState,
|
||
pub window: crate::views::modal_window::ModalWindowState,
|
||
/// Static suggestions shown when the query isn't a path: current cwd
|
||
/// first, then recent project dirs.
|
||
pub recents: Vec<LocationCandidate>,
|
||
/// Base directory for resolving relative typed paths (the pager cwd
|
||
/// at open time).
|
||
pub base_cwd: PathBuf,
|
||
/// Worktree root path → label index, built once at open. Used to tag
|
||
/// recents and directory suggestions that are managed worktrees.
|
||
worktrees: std::collections::HashMap<PathBuf, String>,
|
||
/// Cached subdirectories of the current path-mode parent.
|
||
dir_listing: Vec<LocationCandidate>,
|
||
/// The parent directory [`Self::dir_listing`] was read from. Used to
|
||
/// skip the `readdir` when only the final (partial) segment changes.
|
||
dir_listing_parent: Option<PathBuf>,
|
||
/// Content-row hit areas from the last render, for mouse click /
|
||
/// hover. `None` until the modal renders once.
|
||
pub content_hits: Option<crate::views::picker::PickerContentHitAreas>,
|
||
/// Inline error (e.g. "Not a directory") shown under the title when a
|
||
/// chosen path fails to resolve. Cleared when the modal re-opens.
|
||
pub error: Option<String>,
|
||
/// When `true`, applying the chosen location also arms worktree mode on
|
||
/// the dashboard (`DashboardState::dispatch_worktree`), so the next
|
||
/// dispatched agent spawns in a fresh worktree. Toggled by the path-row
|
||
/// "worktree" button; seeded from the dashboard flag on open.
|
||
pub worktree_mode: bool,
|
||
/// Hit area (rect + hover) of the path-row "worktree" toggle button.
|
||
/// The rect is set during render (cleared when the button is hidden —
|
||
/// modal too narrow, or the target isn't a repo); `hovered` is driven by
|
||
/// the modal mouse handler so the button can brighten on hover.
|
||
pub worktree_hit: crate::app::agent_view::HitArea,
|
||
/// Memoized git-repo check for the worktree toggle: `(dir, is_repo)`.
|
||
/// Avoids re-`stat`ing on every render frame while the selection /
|
||
/// query (and thus the target directory) is unchanged.
|
||
worktree_repo_cache: Option<(PathBuf, bool)>,
|
||
}
|
||
|
||
impl LocationPickerState {
|
||
/// Build a fresh picker over the static `recents` list, resolving
|
||
/// relative typed paths against `base_cwd`. `worktrees` is the
|
||
/// root-path → label index used to tag worktree directories. The
|
||
/// search field is active so the cursor shows immediately and a path
|
||
/// can be typed.
|
||
pub fn new(
|
||
recents: Vec<LocationCandidate>,
|
||
base_cwd: PathBuf,
|
||
worktrees: std::collections::HashMap<PathBuf, String>,
|
||
) -> Self {
|
||
let picker = crate::views::picker::PickerState {
|
||
search_active: true,
|
||
..crate::views::picker::PickerState::default()
|
||
};
|
||
Self {
|
||
picker,
|
||
window: crate::views::modal_window::ModalWindowState::new(),
|
||
recents,
|
||
base_cwd,
|
||
worktrees,
|
||
dir_listing: Vec::new(),
|
||
dir_listing_parent: None,
|
||
content_hits: None,
|
||
error: None,
|
||
worktree_mode: false,
|
||
worktree_hit: crate::app::agent_view::HitArea::default(),
|
||
worktree_repo_cache: None,
|
||
}
|
||
}
|
||
|
||
/// Whether `dir` is inside a git repo (worktrees require one), memoized
|
||
/// so navigating the list doesn't re-`stat` on every render frame.
|
||
pub fn target_is_repo(&mut self, dir: &Path) -> bool {
|
||
if let Some((cached, is_repo)) = &self.worktree_repo_cache
|
||
&& cached == dir
|
||
{
|
||
return *is_repo;
|
||
}
|
||
let is_repo = dir.ancestors().any(|p| p.join(".git").exists());
|
||
self.worktree_repo_cache = Some((dir.to_path_buf(), is_repo));
|
||
is_repo
|
||
}
|
||
|
||
/// Whether the current query should be treated as a filesystem path
|
||
/// (directory completion) rather than a fuzzy filter over recents.
|
||
pub fn query_is_path(&self) -> bool {
|
||
let q = &self.picker.query;
|
||
q.starts_with('/')
|
||
|| q.starts_with('~')
|
||
|| q.contains('/')
|
||
// Windows: native absolute paths (`C:\…`) and backslash
|
||
// separators. Gated so a literal backslash in a Unix filename
|
||
// never forces path mode on non-Windows hosts.
|
||
|| (cfg!(windows) && (q.contains('\\') || has_windows_drive_prefix(q)))
|
||
}
|
||
|
||
/// Split a path-mode query into the `(parent_dir, partial_name)` to
|
||
/// complete: everything up to the last separator resolves to a directory,
|
||
/// and the trailing segment is the prefix to match. `~` expands to
|
||
/// home; relative parents join [`Self::base_cwd`]. The separator is `/`
|
||
/// on all hosts and additionally `\` on Windows.
|
||
fn path_query_parts(&self) -> (PathBuf, String) {
|
||
let q = self.picker.query.as_str();
|
||
// Last path separator: `/` always; `\` additionally on Windows.
|
||
let sep = match (q.rfind('/'), cfg!(windows).then(|| q.rfind('\\')).flatten()) {
|
||
(Some(a), Some(b)) => Some(a.max(b)),
|
||
(a, b) => a.or(b),
|
||
};
|
||
match sep {
|
||
Some(i) => {
|
||
let parent = resolve_dir_prefix(&q[..=i], &self.base_cwd);
|
||
(parent, q[i + 1..].to_string())
|
||
}
|
||
// No separator: a bare `~` (or `~name`) lists home; on Windows a
|
||
// bare drive (`C:`) lists that drive's root.
|
||
None => {
|
||
if cfg!(windows) && has_windows_drive_prefix(q) && q.len() == 2 {
|
||
(PathBuf::from(format!("{q}\\")), String::new())
|
||
} else {
|
||
let home = dirs::home_dir().unwrap_or_else(|| self.base_cwd.clone());
|
||
(home, q.trim_start_matches('~').to_string())
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Re-`readdir` the path-mode parent when it changes. Cheap no-op when
|
||
/// the query isn't a path or the parent is unchanged (typing within a
|
||
/// directory only re-filters the cached listing).
|
||
pub fn refresh_suggestions(&mut self) {
|
||
if !self.query_is_path() {
|
||
if self.dir_listing_parent.is_some() {
|
||
self.dir_listing.clear();
|
||
self.dir_listing_parent = None;
|
||
}
|
||
return;
|
||
}
|
||
let (parent, _partial) = self.path_query_parts();
|
||
if self.dir_listing_parent.as_deref() == Some(parent.as_path()) {
|
||
return;
|
||
}
|
||
self.dir_listing = read_subdirs(&parent, &self.worktrees);
|
||
self.dir_listing_parent = Some(parent);
|
||
self.picker.selected = 0;
|
||
self.picker.scroll_offset = None;
|
||
}
|
||
|
||
/// The effective list shown + selected from, given the current query.
|
||
/// Path mode → cached subdirs prefix-matched against the partial
|
||
/// (dot-directories hidden unless the partial starts with `.`); recents
|
||
/// mode → the static list fuzzy-filtered by substring. Capped at
|
||
/// [`LOCATION_VISIBLE_CAP`] rows.
|
||
pub fn visible_candidates(&self) -> Vec<LocationCandidate> {
|
||
if self.query_is_path() {
|
||
let (_parent, partial) = self.path_query_parts();
|
||
let pl = partial.to_lowercase();
|
||
let want_hidden = partial.starts_with('.');
|
||
self.dir_listing
|
||
.iter()
|
||
.filter(|c| {
|
||
if !want_hidden && c.label.starts_with('.') {
|
||
return false;
|
||
}
|
||
pl.is_empty() || c.label.to_lowercase().starts_with(&pl)
|
||
})
|
||
.take(LOCATION_VISIBLE_CAP)
|
||
.cloned()
|
||
.collect()
|
||
} else {
|
||
let q = self.picker.query.trim().to_lowercase();
|
||
self.recents
|
||
.iter()
|
||
.filter(|c| {
|
||
q.is_empty()
|
||
|| c.label.to_lowercase().contains(&q)
|
||
|| c.detail.to_lowercase().contains(&q)
|
||
|| c.path.to_string_lossy().to_lowercase().contains(&q)
|
||
})
|
||
.take(LOCATION_VISIBLE_CAP)
|
||
.cloned()
|
||
.collect()
|
||
}
|
||
}
|
||
|
||
/// Resolve the path to apply on Enter: the selected visible row, else
|
||
/// the raw typed query (so a path with no matching suggestion still
|
||
/// navigates and validates downstream). `None` when nothing applies.
|
||
pub fn chosen_input(&self) -> Option<String> {
|
||
let visible = self.visible_candidates();
|
||
if let Some(c) = visible.get(self.picker.selected) {
|
||
return Some(c.path.to_string_lossy().into_owned());
|
||
}
|
||
let q = self.picker.query.trim();
|
||
if !q.is_empty() {
|
||
return Some(q.to_string());
|
||
}
|
||
None
|
||
}
|
||
}
|
||
|
||
/// `true` when `s` starts with a Windows drive prefix like `C:`. Used only
|
||
/// under `cfg!(windows)` to route native absolute paths into path mode.
|
||
fn has_windows_drive_prefix(s: &str) -> bool {
|
||
let b = s.as_bytes();
|
||
b.len() >= 2 && b[0].is_ascii_alphabetic() && b[1] == b':'
|
||
}
|
||
|
||
/// Resolve a path-prefix ending in a separator (e.g. `~/src/`, `/etc/`,
|
||
/// `../`, or on Windows `C:\Users\`) to an absolute directory. `~` expands
|
||
/// to home; relative prefixes join `base`.
|
||
fn resolve_dir_prefix(prefix: &str, base: &Path) -> PathBuf {
|
||
// Strip trailing separators — `/` always, `\` additionally on Windows.
|
||
let trimmed = if cfg!(windows) {
|
||
prefix.trim_end_matches(['/', '\\'])
|
||
} else {
|
||
prefix.trim_end_matches('/')
|
||
};
|
||
if prefix.starts_with('/') {
|
||
if trimmed.is_empty() {
|
||
PathBuf::from("/")
|
||
} else {
|
||
PathBuf::from(trimmed)
|
||
}
|
||
} else if cfg!(windows) && Path::new(prefix).is_absolute() {
|
||
// Windows drive-absolute (`C:\…`) or UNC. Keep the root separator for
|
||
// a bare drive (`C:\` trims to `C:`, which is drive-RELATIVE without it).
|
||
if has_windows_drive_prefix(trimmed) && trimmed.len() == 2 {
|
||
PathBuf::from(format!("{trimmed}\\"))
|
||
} else {
|
||
PathBuf::from(trimmed)
|
||
}
|
||
} else if trimmed == "~" || prefix.starts_with("~/") {
|
||
let rest = trimmed.trim_start_matches('~').trim_start_matches('/');
|
||
let mut home = dirs::home_dir().unwrap_or_else(|| base.to_path_buf());
|
||
if !rest.is_empty() {
|
||
home.push(rest);
|
||
}
|
||
home
|
||
} else if trimmed.is_empty() {
|
||
base.to_path_buf()
|
||
} else {
|
||
base.join(trimmed)
|
||
}
|
||
}
|
||
|
||
/// List the immediate subdirectories of `parent` as location candidates,
|
||
/// sorted case-insensitively by name and capped at
|
||
/// [`LOCATION_DIR_LISTING_CAP`]. Returns empty for an unreadable parent.
|
||
/// Subdirectories that are managed worktree roots are tagged from the
|
||
/// `worktrees` index. The lookup key is the entry's fully canonicalized
|
||
/// path (matching how the index keys + recent rows are built), so a
|
||
/// symlinked worktree directory still matches. The canonicalization is
|
||
/// skipped entirely when there are no worktrees to match against.
|
||
fn read_subdirs(
|
||
parent: &Path,
|
||
worktrees: &std::collections::HashMap<PathBuf, String>,
|
||
) -> Vec<LocationCandidate> {
|
||
let Ok(entries) = std::fs::read_dir(parent) else {
|
||
return Vec::new();
|
||
};
|
||
let mut out: Vec<LocationCandidate> = Vec::new();
|
||
for entry in entries.flatten() {
|
||
let path = entry.path();
|
||
// `path.is_dir()` follows symlinks so symlinked dirs are offered.
|
||
if path.is_dir() {
|
||
let name = entry.file_name().to_string_lossy().into_owned();
|
||
// Match the index's canonical keys; resolve symlinks on the
|
||
// entry itself (a lexical `parent_canon.join(name)` would not).
|
||
let worktree = if worktrees.is_empty() {
|
||
None
|
||
} else {
|
||
let canon = dunce::canonicalize(&path).unwrap_or_else(|_| path.clone());
|
||
worktrees.get(&canon).cloned()
|
||
};
|
||
out.push(LocationCandidate {
|
||
label: name,
|
||
detail: crate::project_picker::sources::display_path(&path),
|
||
path,
|
||
worktree,
|
||
});
|
||
if out.len() >= LOCATION_DIR_LISTING_CAP {
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
out.sort_by_key(|a| a.label.to_lowercase());
|
||
out
|
||
}
|
||
|
||
/// Per-call config for the location picker's shared-picker input handler.
|
||
/// Search is always active (no `/` to start), search is not disabled, and
|
||
/// rows are flat (no expand / tabs / filter / action keys).
|
||
fn location_picker_config<'a>() -> crate::views::picker::PickerConfig<'a> {
|
||
crate::views::picker::PickerConfig {
|
||
title: None,
|
||
show_search_hint: false,
|
||
expandable: false,
|
||
esc_clears_query: false,
|
||
shortcuts: None,
|
||
pending_hint: None,
|
||
shortcuts_area: None,
|
||
non_selectable: &[],
|
||
non_selectable_clickable: &[],
|
||
tabs: None,
|
||
active_tab: 0,
|
||
filter_label: None,
|
||
filter_key_hint: None,
|
||
filter_active: false,
|
||
action_keys: &[],
|
||
disable_search: false,
|
||
compact_bottom_bar: false,
|
||
search_only_on_slash: false,
|
||
vim_normal_first: crate::appearance::cache::load_vim_mode(),
|
||
}
|
||
}
|
||
|
||
/// Resolve session ids → live `DashboardRowId`s and back.
|
||
///
|
||
/// Built once at dashboard-open time from `app.agents`. Embodies
|
||
/// the fix: pin/reorder persistence keys are stable session
|
||
/// ids (not per-process `AgentId`), so a restart resolves them
|
||
/// against the new process's agents instead of attaching to whatever
|
||
/// happens to have the same `AgentId(usize)`.
|
||
pub struct SessionIdResolver {
|
||
/// session_id → AgentId (top-level).
|
||
top: std::collections::HashMap<String, crate::app::agent::AgentId>,
|
||
/// agent_session_id → set of (child_session_id, AgentId-of-parent).
|
||
subs: std::collections::HashMap<
|
||
String,
|
||
std::collections::HashMap<String, crate::app::agent::AgentId>,
|
||
>,
|
||
/// Reverse: AgentId → session_id (for `to_persisted`).
|
||
top_rev: std::collections::HashMap<crate::app::agent::AgentId, String>,
|
||
}
|
||
|
||
impl SessionIdResolver {
|
||
/// Build from the live agents map.
|
||
///
|
||
/// Collisions on `session_id` (two agents sharing
|
||
/// the same ACP session id; rare but possible via resume / fork
|
||
/// re-use) are detected at table-build time. The first mapping
|
||
/// wins; the colliding entry is logged via `tracing::warn!` and
|
||
/// dropped. Resolve queries for a collided id therefore return
|
||
/// the first-seen `AgentId`, which is deterministic given the
|
||
/// `IndexMap` iteration order.
|
||
pub fn from_agents(
|
||
agents: &indexmap::IndexMap<crate::app::agent::AgentId, crate::app::agent_view::AgentView>,
|
||
) -> Self {
|
||
let mut top = std::collections::HashMap::new();
|
||
let mut top_rev = std::collections::HashMap::new();
|
||
let mut subs: std::collections::HashMap<
|
||
String,
|
||
std::collections::HashMap<String, crate::app::agent::AgentId>,
|
||
> = std::collections::HashMap::new();
|
||
for (id, agent) in agents {
|
||
if let Some(sid) = agent.session.session_id.as_ref() {
|
||
let sid_str = sid.0.to_string();
|
||
if let Some(prev) = top.get(&sid_str) {
|
||
// Top-level collision: keep first mapping.
|
||
// And call out the subagents we're
|
||
// dropping by name so a debugger can correlate the
|
||
// missing children back to the losing parent.
|
||
let dropped: Vec<&str> =
|
||
agent.subagent_sessions.keys().map(String::as_str).collect();
|
||
tracing::warn!(
|
||
session_id = %sid_str,
|
||
first = ?prev,
|
||
duplicate = ?id,
|
||
dropped_subagents = ?dropped,
|
||
"SessionIdResolver: duplicate session_id; keeping first mapping (subagents of losing parent are dropped)"
|
||
);
|
||
} else {
|
||
top.insert(sid_str.clone(), *id);
|
||
top_rev.insert(*id, sid_str.clone());
|
||
// Populate the subs map via the
|
||
// entry()/or_insert_with() API so the "first wins"
|
||
// semantics are explicit. Defensive: a colliding
|
||
// child_session_id within a single parent's map
|
||
// would also warn rather than silently overwrite.
|
||
let children = subs.entry(sid_str.clone()).or_default();
|
||
for child_sid in agent.subagent_sessions.keys() {
|
||
if let Some(prev_child) = children.get(child_sid) {
|
||
tracing::warn!(
|
||
parent_session_id = %sid_str,
|
||
child_session_id = %child_sid,
|
||
first = ?prev_child,
|
||
duplicate = ?id,
|
||
"SessionIdResolver: duplicate child session_id under same parent; keeping first mapping"
|
||
);
|
||
} else {
|
||
children.insert(child_sid.clone(), *id);
|
||
}
|
||
}
|
||
if children.is_empty() {
|
||
subs.remove(&sid_str);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
Self { top, subs, top_rev }
|
||
}
|
||
|
||
/// Empty resolver — used when no agents have been loaded yet.
|
||
/// Resolves nothing, persists nothing.
|
||
pub fn empty() -> Self {
|
||
Self {
|
||
top: std::collections::HashMap::new(),
|
||
subs: std::collections::HashMap::new(),
|
||
top_rev: std::collections::HashMap::new(),
|
||
}
|
||
}
|
||
|
||
pub fn resolve(&self, pid: &PersistedRowId) -> Option<DashboardRowId> {
|
||
match pid {
|
||
PersistedRowId::TopLevel { session_id } => self
|
||
.top
|
||
.get(session_id)
|
||
.copied()
|
||
.map(DashboardRowId::TopLevel),
|
||
PersistedRowId::Subagent {
|
||
parent_session_id,
|
||
child_session_id,
|
||
} => self
|
||
.subs
|
||
.get(parent_session_id)
|
||
.and_then(|m| m.get(child_session_id).copied())
|
||
.map(|parent| DashboardRowId::Subagent {
|
||
parent,
|
||
child_session_id: child_session_id.clone(),
|
||
}),
|
||
}
|
||
}
|
||
|
||
pub fn to_persisted(&self, id: &DashboardRowId) -> Option<PersistedRowId> {
|
||
match id {
|
||
DashboardRowId::TopLevel(agent_id) => {
|
||
self.top_rev
|
||
.get(agent_id)
|
||
.map(|sid| PersistedRowId::TopLevel {
|
||
session_id: sid.clone(),
|
||
})
|
||
}
|
||
DashboardRowId::Subagent {
|
||
parent,
|
||
child_session_id,
|
||
} => {
|
||
let parent_sid = self.top_rev.get(parent)?.clone();
|
||
Some(PersistedRowId::Subagent {
|
||
parent_session_id: parent_sid,
|
||
child_session_id: child_session_id.clone(),
|
||
})
|
||
}
|
||
// Roster-only rows are ephemeral (not locally hosted) and are
|
||
// never persisted across restarts.
|
||
DashboardRowId::Roster { .. } => None,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Default for DashboardState {
|
||
fn default() -> Self {
|
||
Self::new()
|
||
}
|
||
}
|
||
|
||
impl DashboardState {
|
||
/// Construct an empty dashboard state. Persistence is applied via
|
||
/// [`Self::apply_persisted`] right after `new()` at the open site.
|
||
pub fn new() -> Self {
|
||
// The dashboard is session-less: its dispatch input offers only
|
||
// pager-global slash commands (hide session-scoped ones such as
|
||
// /compact, /fork, /rewind — they make no sense with no session).
|
||
let mut dispatch = PromptWidget::new();
|
||
dispatch.hide_session_scoped_commands();
|
||
// The peek reply is a quick single-row input — compact mode
|
||
// lowers the `[Pasted: N lines]` chip threshold to 2 lines so
|
||
// multi-line pastes fold instead of overflowing the row.
|
||
let mut peek_reply = PromptWidget::new();
|
||
peek_reply.set_compact(true);
|
||
Self {
|
||
selected: None,
|
||
hovered_row: None,
|
||
selected_section: None,
|
||
hovered_section: None,
|
||
// The "Inactive" section (roster-only idle/dormant sessions
|
||
// owned by OTHER pager processes — see `RowState::Inactive`)
|
||
// is background noise relative to the sessions you're actively
|
||
// cycling between, so it starts collapsed: the actionable
|
||
// groups (Awaiting / Working / Idle) own the viewport on open.
|
||
// Collapse isn't persisted to disk, so expanding it survives
|
||
// reopen for this process lifetime but resets to collapsed on
|
||
// the next pager start — i.e. collapsed "by default".
|
||
collapsed_sections: std::iter::once(SectionKey::State(RowState::Inactive)).collect(),
|
||
selected_idle_overflow: false,
|
||
hovered_idle_overflow: false,
|
||
idle_show_all: false,
|
||
pinned: BTreeSet::new(),
|
||
reorder: Vec::new(),
|
||
grouping: Grouping::default(),
|
||
filter: Filter::None,
|
||
search_mode: false,
|
||
dispatch,
|
||
pending_effects: Vec::new(),
|
||
paste_probe_in_flight: 0,
|
||
deferred_dispatch_send: None,
|
||
deferred_peek_send: None,
|
||
peek: None,
|
||
peek_reply,
|
||
peek_reply_rect: None,
|
||
peek_reply_cwd: None,
|
||
peek_reply_target_cwd: None,
|
||
rename: None,
|
||
error_toast: None,
|
||
stop_confirm: None,
|
||
spinner_tick: 0,
|
||
row_rects: Vec::new(),
|
||
section_rects: Vec::new(),
|
||
idle_overflow_rect: None,
|
||
last_area: Rect::default(),
|
||
attached_agent: None,
|
||
popup_close_rect: None,
|
||
popup_outer_rect: None,
|
||
new_agent_button_hit: crate::app::agent_view::HitArea::default(),
|
||
slash_dropdown_items_area: None,
|
||
slash_dropdown_hit: Default::default(),
|
||
file_search_dropdown_items_area: None,
|
||
list_focused: false,
|
||
overlay_close_hit: crate::app::agent_view::HitArea::default(),
|
||
overlay_prev_hit: crate::app::agent_view::HitArea::default(),
|
||
overlay_next_hit: crate::app::agent_view::HitArea::default(),
|
||
last_mouse_pos: None,
|
||
last_click: None,
|
||
last_prompt_click: None,
|
||
peek_close_rect: None,
|
||
dispatch_rect: None,
|
||
viewport_offset: 0,
|
||
manual_scroll_active: false,
|
||
shortcuts_modal: None,
|
||
pending_model: None,
|
||
pending_mode: DashboardDispatchMode::Normal,
|
||
models: crate::acp::model_state::ModelState::default(),
|
||
location_picker: None,
|
||
location_hit: crate::app::agent_view::HitArea::default(),
|
||
dispatch_worktree: false,
|
||
cwd_has_git_ancestor: false,
|
||
cwd: std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")),
|
||
worktree_dialog: None,
|
||
pending_worktree_prompt: None,
|
||
pending_worktree_attach: false,
|
||
multiline_mode: false,
|
||
// Fresh dashboard with no rows seeded → the `[+ New
|
||
// Agent]` button is the default cursor target. Open
|
||
// sites that want a specific row seeded call
|
||
// `focus_row` after construction, which clears this
|
||
// flag atomically.
|
||
new_agent_button_focused: true,
|
||
}
|
||
}
|
||
|
||
/// Adopt the shared slash MRU store (owned by `AppView`) into both the
|
||
/// dispatch input and the peek-reply input so dashboard slash completion
|
||
/// shares command recency with agent prompts.
|
||
pub(crate) fn adopt_slash_mru(
|
||
&mut self,
|
||
mru: std::rc::Rc<std::cell::RefCell<crate::slash::mru::SlashMru>>,
|
||
) {
|
||
self.dispatch.adopt_slash_mru(mru.clone());
|
||
self.peek_reply.adopt_slash_mru(mru);
|
||
}
|
||
|
||
pub(crate) fn set_screen_mode(&mut self, mode: crate::app::ScreenMode) {
|
||
self.dispatch.set_screen_mode(mode);
|
||
self.peek_reply.set_screen_mode(mode);
|
||
}
|
||
|
||
pub(crate) fn set_recap_visible(&mut self, visible: bool) {
|
||
self.dispatch.set_recap_visible(visible);
|
||
self.peek_reply.set_recap_visible(visible);
|
||
}
|
||
|
||
/// Gate `/auto` on both dashboard prompt registries (dispatch + peek
|
||
/// reply). See [`crate::slash::SlashController::set_auto_mode_available`].
|
||
pub(crate) fn set_auto_mode_available(&mut self, available: bool) {
|
||
self.dispatch.set_auto_mode_available(available);
|
||
self.peek_reply.set_auto_mode_available(available);
|
||
}
|
||
|
||
/// Replace the restricted slash-command deny list on both dashboard
|
||
/// prompt registries (dispatch + peek reply).
|
||
pub(crate) fn set_restricted_commands(&mut self, names: &[String]) {
|
||
self.dispatch.set_restricted_commands(names);
|
||
self.peek_reply.set_restricted_commands(names);
|
||
}
|
||
|
||
/// Set the dispatch feedback slot to an error `msg`, prefixed with
|
||
/// the error glyph (`✗`, or `x` on legacy consoles). The badge
|
||
/// (`paint_dispatch_feedback_badge`) paints the slot VERBATIM in a
|
||
/// neutral colour, so this leading glyph is what marks the message
|
||
/// as an error. Use for error messages without a glyph of their own
|
||
/// (fixed literals, slash-command `CommandResult::Error` strings);
|
||
/// pass-through messages that already carry their own glyph (e.g.
|
||
/// `show_toast` builders, slash-command `CommandResult::Message`
|
||
/// results) must be assigned to `error_toast` directly.
|
||
pub(crate) fn set_error_toast(&mut self, msg: &str) {
|
||
self.error_toast = Some(format!("{} {msg}", crate::glyphs::ballot_x()));
|
||
}
|
||
|
||
/// Focus the header's `[+ New Agent]` button. Clears any
|
||
/// row selection so the "button focused → no row selected"
|
||
/// invariant stays honoured. Idempotent — safe to call when
|
||
/// the button is already focused.
|
||
pub fn focus_new_agent_button(&mut self) {
|
||
self.new_agent_button_focused = true;
|
||
self.selected = None;
|
||
self.selected_section = None;
|
||
self.selected_idle_overflow = false;
|
||
}
|
||
|
||
/// Focus the row identified by `id`. Clears the
|
||
/// `new_agent_button_focused` flag so the two cursor states
|
||
/// stay mutually exclusive. Any caller that mutates
|
||
/// `selected` directly bypasses this helper at its own
|
||
/// risk — the invariant only holds when both fields are
|
||
/// written through here.
|
||
pub fn focus_row(&mut self, id: DashboardRowId) {
|
||
self.selected = Some(id);
|
||
self.new_agent_button_focused = false;
|
||
self.selected_section = None;
|
||
self.selected_idle_overflow = false;
|
||
}
|
||
|
||
/// Focus the section header identified by `key` — the third cursor
|
||
/// target alongside rows and the `[+ New Agent]` button. Clears the
|
||
/// row selection and button focus so exactly one cursor is active.
|
||
pub fn focus_section(&mut self, key: SectionKey) {
|
||
self.selected_section = Some(key);
|
||
self.selected = None;
|
||
self.new_agent_button_focused = false;
|
||
self.selected_idle_overflow = false;
|
||
}
|
||
|
||
/// Focus the Idle group's "N more" overflow toggle —
|
||
/// the fourth cursor target. Clears the other three so exactly one
|
||
/// cursor is active.
|
||
pub fn focus_idle_overflow(&mut self) {
|
||
self.selected_idle_overflow = true;
|
||
self.selected = None;
|
||
self.selected_section = None;
|
||
self.new_agent_button_focused = false;
|
||
}
|
||
|
||
/// Toggle whether the Idle group shows every agent (`true`) or caps
|
||
/// to the recent ones with the rest folded behind the overflow row
|
||
/// (`false`). In-memory only — resets to capped on the next pager
|
||
/// start. Mirrors the in-memory lifetime of section collapse.
|
||
pub fn toggle_idle_show_all(&mut self) {
|
||
self.idle_show_all = !self.idle_show_all;
|
||
}
|
||
|
||
/// Whether `key`'s section is currently collapsed (its rows hidden).
|
||
pub fn is_section_collapsed(&self, key: SectionKey) -> bool {
|
||
self.collapsed_sections.contains(&key)
|
||
}
|
||
|
||
/// Collapse (`true`) or expand (`false`) the section identified by
|
||
/// `key`. Idempotent. Shared by the keyboard (Left/Right on a
|
||
/// selected header) and the mouse (click on a header).
|
||
pub fn set_section_collapsed(&mut self, key: SectionKey, collapsed: bool) {
|
||
if collapsed {
|
||
self.collapsed_sections.insert(key);
|
||
} else {
|
||
self.collapsed_sections.remove(&key);
|
||
}
|
||
}
|
||
|
||
/// Toggle the collapsed state of the section identified by `key`.
|
||
pub fn toggle_section(&mut self, key: SectionKey) {
|
||
if !self.collapsed_sections.remove(&key) {
|
||
self.collapsed_sections.insert(key);
|
||
}
|
||
}
|
||
|
||
/// Enter search mode (`Ctrl+/`). The dispatch buffer becomes a
|
||
/// live filter query and the prompt prefix flips to a yellow
|
||
/// `Search:`. Starts fresh — clears any half-typed dispatch text
|
||
/// and the prior filter so the query builds from empty.
|
||
pub fn enter_search_mode(&mut self) {
|
||
self.search_mode = true;
|
||
self.dispatch.set_text("");
|
||
self.filter = Filter::None;
|
||
self.error_toast = None;
|
||
self.manual_scroll_active = false;
|
||
}
|
||
|
||
/// Leave search mode and CANCEL: clears the filter and the query
|
||
/// buffer, restoring the normal dispatch prompt. (Enter instead
|
||
/// CONFIRMS — it keeps the filter applied and only flips
|
||
/// `search_mode` off; see [`Self::handle_key`].)
|
||
pub fn exit_search_mode(&mut self) {
|
||
self.search_mode = false;
|
||
self.dispatch.set_text("");
|
||
self.filter = Filter::None;
|
||
self.manual_scroll_active = false;
|
||
}
|
||
|
||
/// Construct from persisted state, resolving session-id keys to
|
||
/// live `DashboardRowId`s via the given resolver. Unresolvable ids
|
||
/// (sessions that have closed, or never existed) are dropped — see
|
||
/// edge case 20.
|
||
pub fn from_persisted(p: &PersistedDashboard, resolver: &SessionIdResolver) -> Self {
|
||
let mut s = Self::new();
|
||
s.apply_persisted(p, resolver);
|
||
s
|
||
}
|
||
|
||
/// Apply persisted fields, resolving via the given session-id
|
||
/// resolver. Idempotent — safe to call twice. Kept
|
||
/// `pub(super)` because the only caller is `from_persisted`; the
|
||
/// previous `pub` advertised a public contract the function
|
||
/// doesn't quite satisfy (it clobbers all persistence-backed
|
||
/// fields rather than merging).
|
||
pub(super) fn apply_persisted(&mut self, p: &PersistedDashboard, resolver: &SessionIdResolver) {
|
||
self.grouping = p.grouping;
|
||
self.pinned = p
|
||
.pinned
|
||
.iter()
|
||
.filter_map(|pid| resolver.resolve(pid))
|
||
.collect();
|
||
self.reorder = p
|
||
.reorder
|
||
.iter()
|
||
.filter_map(|pid| resolver.resolve(pid))
|
||
.collect();
|
||
}
|
||
|
||
/// Snapshot back to a persistable form. Top-level rows whose
|
||
/// underlying agent does not yet have a `session_id` are dropped
|
||
/// (they can't be persisted yet — but they'll remain in the
|
||
/// in-memory state for the rest of this process lifetime).
|
||
pub fn to_persisted(&self, enabled: bool, resolver: &SessionIdResolver) -> PersistedDashboard {
|
||
PersistedDashboard {
|
||
enabled,
|
||
grouping: self.grouping,
|
||
pinned: self
|
||
.pinned
|
||
.iter()
|
||
.filter_map(|id| resolver.to_persisted(id))
|
||
.collect(),
|
||
reorder: self
|
||
.reorder
|
||
.iter()
|
||
.filter_map(|id| resolver.to_persisted(id))
|
||
.collect(),
|
||
}
|
||
}
|
||
|
||
/// Garbage-collect stale row ids from `pinned` / `reorder`.
|
||
///
|
||
/// Called on dashboard open: any persisted id whose underlying
|
||
/// agent/subagent no longer exists is silently dropped. Avoids edge
|
||
/// case 20 (a pinned row whose agent was deleted).
|
||
///
|
||
/// Also clears an in-flight `rename` whose row
|
||
/// disappeared (parent closed, subagent finished, etc.), so a
|
||
/// Commit-Enter doesn't silently drop the draft on a phantom row.
|
||
///
|
||
/// Extends the gc to `peek`, `hovered_row`, and
|
||
/// `last_click`. The previous version only cleared `pinned`,
|
||
/// `reorder`, `rename`, and `selected`. A stale `peek` would
|
||
/// render cached content for a dead row; a stale `last_click`
|
||
/// could trigger a double-click "attach" against whatever new
|
||
/// row took the cell.
|
||
///
|
||
/// Drop the "Row no longer exists; rename
|
||
/// cancelled" toast on stale rename. The toast fired on every
|
||
/// dashboard open if a row was closed externally, surprising the
|
||
/// user (they hadn't done anything since). The clear itself is
|
||
/// preserved — silently clearing the rename matches the silent
|
||
/// gc on `pinned` / `reorder` / `selected` / `peek` /
|
||
/// `hovered_row` / `last_click`. the earlier invariant ("Commit
|
||
/// Enter can't dispatch against a phantom row") is preserved
|
||
/// because the renamed row can't render the overlay if it's
|
||
/// gone, so Enter can't reach the commit path.
|
||
pub fn gc_stale_refs(&mut self, alive: &dyn Fn(&DashboardRowId) -> bool) {
|
||
self.pinned.retain(|id| alive(id));
|
||
self.reorder.retain(|id| alive(id));
|
||
if let Some(rn) = self.rename.as_ref()
|
||
&& !alive(&rn.row)
|
||
{
|
||
self.rename = None;
|
||
}
|
||
if let Some(sel) = self.selected.as_ref()
|
||
&& !alive(sel)
|
||
{
|
||
self.selected = None;
|
||
}
|
||
if let Some(p) = self.peek.as_ref()
|
||
&& !alive(&p.row)
|
||
{
|
||
self.set_peek(None);
|
||
}
|
||
if let Some(h) = self.hovered_row.as_ref()
|
||
&& !alive(h)
|
||
{
|
||
self.hovered_row = None;
|
||
}
|
||
if let Some((id, _)) = self.last_click.as_ref()
|
||
&& !alive(id)
|
||
{
|
||
self.last_click = None;
|
||
}
|
||
// `attached_agent` is keyed by `AgentId`, not
|
||
// `DashboardRowId`, so we materialise the equivalent
|
||
// `DashboardRowId::TopLevel` and probe `alive`. Clears the
|
||
// popup gracefully when the underlying session was closed
|
||
// outside the dashboard (e.g. via another surface).
|
||
if let Some(agent_id) = self.attached_agent
|
||
&& !alive(&DashboardRowId::TopLevel(agent_id))
|
||
{
|
||
// close_popup() also clears
|
||
// `popup_close_rect` / `popup_outer_rect` so the
|
||
// invariant ("None attached_agent → None hit rects")
|
||
// holds at every close site, not just here.
|
||
self.close_popup();
|
||
}
|
||
}
|
||
|
||
/// Switch grouping (`Ctrl+G`).
|
||
pub fn toggle_grouping(&mut self) {
|
||
self.grouping = self.grouping.toggled();
|
||
// Section headers only exist in State grouping. If the cursor was
|
||
// on a section header and grouping just left State mode, move it
|
||
// somewhere valid so the cursor doesn't vanish.
|
||
if self.selected_section.is_some() && !matches!(self.grouping, Grouping::State) {
|
||
self.focus_new_agent_button();
|
||
}
|
||
// Re-engage selection follow — a grouping switch reshuffles
|
||
// the visible row order, so the user's prior manual-scroll
|
||
// position no longer points at the same content.
|
||
self.clear_manual_scroll();
|
||
}
|
||
|
||
/// Toggle pin on the selected row. Returns the toggled id, or
|
||
/// `None` if no row is selected.
|
||
pub fn toggle_pin_selected(&mut self) -> Option<DashboardRowId> {
|
||
let id = self.selected.clone()?;
|
||
if !self.pinned.remove(&id) {
|
||
self.pinned.insert(id.clone());
|
||
}
|
||
Some(id)
|
||
}
|
||
|
||
/// Forward a scroll event from the app-level mouse pipeline.
|
||
/// Actually adjust `viewport_offset`. The
|
||
/// renderer clamps against the visible row count.
|
||
///
|
||
/// Mouse wheel is decoupled from the selected row: scrolling
|
||
/// only moves the viewport, leaving `selected` alone. Setting
|
||
/// `manual_scroll_active` tells [`Self::clamp_viewport`] to skip
|
||
/// the snap-to-selection pull-back that the keyboard nav path
|
||
/// relies on, so the viewport can travel past the selected row
|
||
/// (e.g. a 50-row list with selection on row 2 and the user
|
||
/// wheeling down to row 40). The flag is cleared by any
|
||
/// selection-driven update — arrow keys, hover-click, filter
|
||
/// rebuild — so the snap re-engages the moment the cursor
|
||
/// becomes the source of truth again.
|
||
pub fn handle_scroll(&mut self, lines: i32) {
|
||
if lines == 0 {
|
||
return;
|
||
}
|
||
self.manual_scroll_active = true;
|
||
if lines > 0 {
|
||
self.viewport_offset = self.viewport_offset.saturating_add(lines as usize);
|
||
} else {
|
||
self.viewport_offset = self.viewport_offset.saturating_sub((-lines) as usize);
|
||
}
|
||
}
|
||
|
||
/// Re-engage selection-driven viewport tracking. Called by any
|
||
/// path that owns selection (arrow keys, dispatcher-side
|
||
/// `DashboardSelect*`, row click, filter / grouping change) so
|
||
/// the next render snaps the viewport back to the selected row.
|
||
/// No-op when the flag is already clear.
|
||
pub fn clear_manual_scroll(&mut self) {
|
||
self.manual_scroll_active = false;
|
||
}
|
||
|
||
/// Clamp `viewport_offset` so that `selected_line_idx` (if any)
|
||
/// stays within the visible `viewport_h` window. Returns the
|
||
/// clamped offset (same as `self.viewport_offset` after the call).
|
||
///
|
||
/// Extracted from a private body inside `render_rows`
|
||
/// so the clamp logic can be exercised in isolation. Both
|
||
/// `render_rows` and `render_narrow_rows` still call this helper
|
||
/// mid-render: viewport clamping needs the live row-count, which
|
||
/// is itself computed at render time from the grouped row list.
|
||
/// Moving the call out of the renderers would require re-deriving
|
||
/// the same grouping in the dispatcher, so we keep the
|
||
/// snap-to-selection side-effect intentional. The
|
||
/// renderer is *factored for testability*; it is not strictly
|
||
/// read-only.
|
||
///
|
||
/// The snap-to-selection step is SKIPPED while
|
||
/// `manual_scroll_active` is true — that flag means the user is
|
||
/// driving the viewport directly via the mouse wheel and we
|
||
/// must not pull them back to wherever the cursor happens to
|
||
/// sit. The bounds clamp (`offset <= max_offset`) still runs so
|
||
/// scrolling past the bottom edge is a soft stop, not a runaway.
|
||
pub fn clamp_viewport(
|
||
&mut self,
|
||
selected_line: Option<usize>,
|
||
viewport_h: usize,
|
||
total_lines: usize,
|
||
) -> usize {
|
||
let mut offset = self.viewport_offset;
|
||
if !self.manual_scroll_active
|
||
&& let Some(sel_idx) = selected_line
|
||
&& viewport_h > 0
|
||
{
|
||
if sel_idx < offset {
|
||
offset = sel_idx;
|
||
} else if sel_idx >= offset + viewport_h {
|
||
offset = sel_idx + 1 - viewport_h;
|
||
}
|
||
}
|
||
let max_offset = total_lines.saturating_sub(viewport_h);
|
||
if offset > max_offset {
|
||
offset = max_offset;
|
||
}
|
||
self.viewport_offset = offset;
|
||
offset
|
||
}
|
||
|
||
/// Set the peek panel, enforcing the
|
||
/// "`peek_close_rect` is None whenever `peek` is None" invariant.
|
||
/// Every site that toggles `peek` now goes
|
||
/// through this helper so the close-rect can't be left stale.
|
||
///
|
||
/// Reply-draft lifecycle: the dashboard-owned [`Self::peek_reply`]
|
||
/// draft dies with the panel it was typed for — it is cleared
|
||
/// whenever the peeked ROW changes through this helper (close,
|
||
/// open, or retarget). Per-frame refreshes of an open panel go
|
||
/// through `PeekPanelState::apply_fields` (not here), so an
|
||
/// in-progress draft survives live updates.
|
||
pub fn set_peek(&mut self, peek: Option<PeekPanelState>) {
|
||
if peek.is_none() {
|
||
self.peek_close_rect = None;
|
||
self.peek_reply_rect = None;
|
||
}
|
||
let row_changed = peek.as_ref().map(|p| &p.row) != self.peek.as_ref().map(|p| &p.row);
|
||
if row_changed {
|
||
self.clear_peek_reply();
|
||
}
|
||
self.peek = peek;
|
||
}
|
||
|
||
/// Clear the peek reply draft AND its undo history.
|
||
///
|
||
/// The history wipe is the load-bearing part: `set_text("")` alone
|
||
/// records a `Replace` checkpoint, leaving the prior draft one
|
||
/// `Ctrl+Z` away. On a row change that resurrected reply would
|
||
/// target a DIFFERENT agent — exactly the mis-send the draft clear
|
||
/// exists to prevent — so every lifecycle clear (row change, panel
|
||
/// open/close, post-send, per-question reset) routes through here.
|
||
pub(crate) fn clear_peek_reply(&mut self) {
|
||
self.peek_reply.set_text("");
|
||
self.peek_reply.clear_history();
|
||
self.last_prompt_click = None;
|
||
}
|
||
|
||
/// Record the peeked agent's working directory so the reply's `@`
|
||
/// picker can later resolve `@paths` against it. Called by the
|
||
/// render pass (the only place with the agents map); the actual
|
||
/// `retarget` is deferred to [`Self::ensure_peek_reply_cwd`].
|
||
pub(crate) fn set_peek_reply_target_cwd(&mut self, cwd: Option<PathBuf>) {
|
||
self.peek_reply_target_cwd = cwd;
|
||
}
|
||
|
||
/// Lazily root the reply's `@` file-search daemon at the peeked
|
||
/// agent's cwd (recorded in [`Self::peek_reply_target_cwd`]).
|
||
///
|
||
/// Applied only when it differs from the daemon's current root and
|
||
/// only at the moment the user composes into the reply — never on a
|
||
/// bare cursor move — because `retarget` rebuilds the matcher daemon
|
||
/// thread. So navigating past a dozen agents in other directories
|
||
/// costs nothing; the (single) retarget happens on the first
|
||
/// keystroke/paste into the reply, deduped by cwd.
|
||
fn ensure_peek_reply_cwd(&mut self) {
|
||
if let Some(target) = self.peek_reply_target_cwd.clone()
|
||
&& self.peek_reply_cwd.as_deref() != Some(target.as_path())
|
||
{
|
||
self.peek_reply.file_search.retarget(&target);
|
||
self.peek_reply_cwd = Some(target);
|
||
}
|
||
}
|
||
|
||
/// The file-search state backing the `@` dropdown that is actually
|
||
/// on screen: the peek reply's while the panel is open (the dropdown
|
||
/// is drawn from `peek_reply` then — see `render_dashboard`),
|
||
/// otherwise the dispatch box's. Used to route mouse-wheel scrolling
|
||
/// to the SAME picker the user is looking at, so wheel navigation
|
||
/// matches the rendered list.
|
||
pub(crate) fn dropdown_file_search_mut(
|
||
&mut self,
|
||
) -> &mut crate::views::file_search::FileSearchState {
|
||
if self.peek.is_some() {
|
||
&mut self.peek_reply.file_search
|
||
} else {
|
||
&mut self.dispatch.file_search
|
||
}
|
||
}
|
||
|
||
/// Feed a key to the reply widget after ensuring its `@` picker is
|
||
/// rooted at the peeked agent's cwd. All reply-composing paths route
|
||
/// through here so the lazy retarget lands before a freshly typed
|
||
/// `@` kicks off the directory walk on a stale root.
|
||
fn peek_reply_handle_key(
|
||
&mut self,
|
||
key: &KeyEvent,
|
||
) -> crate::views::prompt_widget::PromptEvent {
|
||
self.ensure_peek_reply_cwd();
|
||
self.peek_reply.handle_key(key)
|
||
}
|
||
|
||
/// Convenience for closing the peek panel.
|
||
pub fn close_peek(&mut self) {
|
||
self.set_peek(None);
|
||
}
|
||
|
||
/// Atomically clear all popup state
|
||
/// when the overlay closes. `attached_agent`, `popup_close_rect`,
|
||
/// and `popup_outer_rect` are semantically linked: a `None`
|
||
/// `attached_agent` should never leave behind "stale Some" hit
|
||
/// rects, because a future contributor reading
|
||
/// `popup_outer_rect` without the `attached_agent` guard would
|
||
/// get a phantom hit area. Centralising the clear here keeps the
|
||
/// invariant honest at every close site (Esc / Ctrl+\\ key
|
||
/// handlers, `[✗]` mouse click, `dispatch_exit_dashboard`,
|
||
/// `dispatch_open_dashboard`'s toggle branch).
|
||
pub fn close_popup(&mut self) {
|
||
self.attached_agent = None;
|
||
self.popup_close_rect = None;
|
||
self.popup_outer_rect = None;
|
||
self.overlay_close_hit.clear();
|
||
self.overlay_prev_hit.clear();
|
||
self.overlay_next_hit.clear();
|
||
}
|
||
|
||
/// Top-level input handler. Mirrors `AgentView::handle_input` —
|
||
/// returns an [`InputOutcome`] for the app to dispatch.
|
||
pub fn handle_input(&mut self, ev: &Event, registry: &ActionRegistry) -> InputOutcome {
|
||
self.handle_input_with_paste_provenance(
|
||
ev,
|
||
registry,
|
||
crate::app::app_view::PasteProvenance::Terminal,
|
||
)
|
||
}
|
||
|
||
pub(crate) fn handle_input_with_paste_provenance(
|
||
&mut self,
|
||
ev: &Event,
|
||
registry: &ActionRegistry,
|
||
paste_provenance: crate::app::app_view::PasteProvenance,
|
||
) -> InputOutcome {
|
||
debug_assert!(
|
||
matches!(ev, Event::Paste(_))
|
||
|| paste_provenance == crate::app::app_view::PasteProvenance::Terminal,
|
||
"non-paste dashboard events cannot carry paste provenance"
|
||
);
|
||
// Cheatsheet modal owns the keyboard / mouse when open —
|
||
// its own search input, picker scroll, and chrome buttons
|
||
// would all be inconsistent if the dashboard's row nav got
|
||
// a parallel say. Mirrors how `agent_view` short-circuits
|
||
// any `active_modal` before the per-pane handlers run.
|
||
if self.shortcuts_modal.is_some() {
|
||
return self.handle_shortcuts_modal_input(ev);
|
||
}
|
||
|
||
// The location picker owns input while open — its query field,
|
||
// row nav, and chrome buttons would all be inconsistent if the
|
||
// dashboard's own handlers got a parallel say.
|
||
if self.location_picker.is_some() {
|
||
return self.handle_location_picker_input(ev);
|
||
}
|
||
|
||
// The worktree-label dialog (shown when a dashboard agent is
|
||
// dispatched with worktree mode armed) owns input while open.
|
||
if self.worktree_dialog.is_some() {
|
||
return self.handle_worktree_dialog_input(ev);
|
||
}
|
||
|
||
// Rename mode owns the keyboard until Enter / Esc.
|
||
if let Some(ref mut rn) = self.rename {
|
||
if let Event::Key(key) = ev
|
||
&& key.kind != KeyEventKind::Release
|
||
{
|
||
return handle_rename_key(rn, key);
|
||
}
|
||
return InputOutcome::Unchanged;
|
||
}
|
||
|
||
match ev {
|
||
Event::Key(key) if key.kind != KeyEventKind::Release => self.handle_key(key, registry),
|
||
Event::Mouse(mouse) => self.handle_mouse(mouse),
|
||
// Bracketed paste — wrap magic first (never as text); when the
|
||
// peek panel is open it owns the paste (text + images into
|
||
// `peek_reply`), mirroring the Ctrl/Cmd+V chord in
|
||
// `handle_peek_key` (without this, terminals that deliver paste
|
||
// as `Event::Paste` would leak into the HIDDEN new-session
|
||
// dispatch input). Otherwise route through the dispatch paste
|
||
// pipeline.
|
||
Event::Paste(text) => {
|
||
if let Some(wrap) =
|
||
crate::wrap_clipboard_image::try_decode_wrap_host_image_paste(text)
|
||
{
|
||
return match wrap {
|
||
crate::wrap_clipboard_image::WrapImagePaste::Image(data) => {
|
||
let pasted = crate::prompt_images::from_clipboard_data(&data);
|
||
if self.peek.is_some() {
|
||
if let Some(p) = self.peek.as_mut() {
|
||
p.focused = true;
|
||
}
|
||
self.ensure_peek_reply_cwd();
|
||
self.attach_peek_pasted_image(pasted).0
|
||
} else {
|
||
self.attach_pasted_image(pasted).0
|
||
}
|
||
}
|
||
crate::wrap_clipboard_image::WrapImagePaste::NoImage => {
|
||
InputOutcome::Unchanged
|
||
}
|
||
};
|
||
}
|
||
self.handle_bracketed_paste(
|
||
text,
|
||
self.peek.is_some(),
|
||
paste_provenance.may_probe_clipboard_attachments(),
|
||
)
|
||
}
|
||
_ => InputOutcome::Unchanged,
|
||
}
|
||
}
|
||
|
||
/// Bracketed-paste payload into the dispatch input (`peek = false`) or the
|
||
/// peek reply (`peek = true`).
|
||
///
|
||
/// A file-path / URL paste attaches as `[Image #N]` / path chips
|
||
/// synchronously and wins; else the clipboard image / file-url probe defers
|
||
/// off the event loop (the image wins over the caption, which is inserted on
|
||
/// completion only if no image is found); else plain text inserts
|
||
/// synchronously. Mirrors [`Self::handle_paste_key_deferred`] for the
|
||
/// Ctrl/Cmd+V chord and the agent prompt's paste handling.
|
||
fn handle_bracketed_paste(
|
||
&mut self,
|
||
text: &str,
|
||
peek: bool,
|
||
probe_clipboard_attachments: bool,
|
||
) -> InputOutcome {
|
||
// The peek reply has extra preconditions the dispatch input does not.
|
||
let in_question = if peek {
|
||
let Some(p) = self.peek.as_ref() else {
|
||
return InputOutcome::Unchanged;
|
||
};
|
||
// In question mode the `❯ reply` line is hidden — the reply widget
|
||
// only backs the `RejectOnce` / "Other" free-text option. Accepting
|
||
// a paste when that option isn't selected would silently fill an
|
||
// invisible buffer that resurfaces if the user later highlights it.
|
||
let in_question = p.question.is_some();
|
||
if in_question {
|
||
let on_reject = p.selected_option.is_some() && p.reject_option == p.selected_option;
|
||
if !on_reject {
|
||
return InputOutcome::Unchanged;
|
||
}
|
||
}
|
||
// Pasting implies an intent to reply — focus the input and root the
|
||
// `@` picker at the peeked agent's cwd (a pasted `@path` walks it).
|
||
if let Some(p) = self.peek.as_mut() {
|
||
p.focused = true;
|
||
}
|
||
self.ensure_peek_reply_cwd();
|
||
in_question
|
||
} else {
|
||
false
|
||
};
|
||
|
||
// Question mode is text-only on the wire — never attach/defer an image.
|
||
if in_question {
|
||
return self.insert_pasted_caption(Some(text), true).0;
|
||
}
|
||
|
||
// Pasted text may be image file path(s) / `file://` URL(s) (drag-drop
|
||
// from Finder, or Copy on a file). Resolve them synchronously and win —
|
||
// before deferring the clipboard probe, so a path paste is not ALSO
|
||
// attached as the clipboard raster (no double insert).
|
||
if !text.trim().is_empty() {
|
||
let images = crate::prompt_images::try_read_images_from_paste(text);
|
||
if !images.is_empty() {
|
||
for img in images {
|
||
if peek {
|
||
self.attach_peek_pasted_image(img);
|
||
} else {
|
||
self.attach_pasted_image(img);
|
||
}
|
||
}
|
||
return InputOutcome::Changed;
|
||
}
|
||
}
|
||
|
||
// Defer the clipboard image / file-url probe (osascript) off the event
|
||
// loop; the image wins over the caption, so the caption is NOT inserted
|
||
// now — it lands on completion only if the probe finds no image. The
|
||
// native snapshot gate skips plain text with no raster so common text
|
||
// pastes never enqueue. Skip large / multi-line pastes that are
|
||
// obviously text; no empty-clipboard telemetry on the bracketed path.
|
||
let should_probe = text.len() < 4096 && !text.contains('\n');
|
||
if probe_clipboard_attachments
|
||
&& should_probe
|
||
&& let Some(change_count) = crate::clipboard::attachment_probe_gate(Some(text))
|
||
{
|
||
self.enqueue_clipboard_attachment_probe(
|
||
crate::app::actions::ClipboardPasteSource::BracketedDeferred {
|
||
text: text.to_owned(),
|
||
},
|
||
peek,
|
||
change_count,
|
||
);
|
||
return InputOutcome::Changed;
|
||
}
|
||
|
||
if text.trim().is_empty() {
|
||
return InputOutcome::Unchanged;
|
||
}
|
||
|
||
// Plain text paste (no raster).
|
||
self.insert_pasted_caption(Some(text), peek).0
|
||
}
|
||
|
||
/// Attach a pasted image to the dispatch input as an `[Image #N]` chip.
|
||
fn attach_pasted_image(
|
||
&mut self,
|
||
mut pasted: crate::prompt_images::PastedImage,
|
||
) -> (InputOutcome, crate::app::actions::ClipboardPasteCompletion) {
|
||
let preparation = pasted.preview_preparation();
|
||
// Dashboard prompts keep display metadata while durable paths remain session-owned.
|
||
pasted.session_image_path = None;
|
||
pasted.staged_temp_path = None;
|
||
let completion = match self.dispatch.insert_image(pasted) {
|
||
Ok(()) => {
|
||
if let Some(preparation) = preparation {
|
||
self.pending_effects.push(
|
||
crate::app::actions::Effect::PreparePromptImagePreview { preparation },
|
||
);
|
||
}
|
||
crate::app::actions::ClipboardPasteCompletion::Handled
|
||
}
|
||
Err(msg) => {
|
||
self.set_error_toast(&msg);
|
||
crate::app::actions::ClipboardPasteCompletion::Failed(
|
||
crate::app::actions::ClipboardPasteFailure::AlreadyReported,
|
||
)
|
||
}
|
||
};
|
||
(InputOutcome::Changed, completion)
|
||
}
|
||
|
||
/// Insert a plain-text (caption) paste into the dispatch input (`peek =
|
||
/// false`) or the peek reply (`peek = true`); the image / file-url portion of
|
||
/// a paste is handled by the deferred probe. Whitespace-only text is a no-op
|
||
/// (matches the bracketed arm — no stray spaces inserted).
|
||
fn insert_pasted_caption(
|
||
&mut self,
|
||
text: Option<&str>,
|
||
peek: bool,
|
||
) -> (InputOutcome, crate::app::actions::ClipboardTextInsertion) {
|
||
use crate::app::actions::ClipboardTextInsertion;
|
||
let Some(text) = text else {
|
||
// Clipboard entirely empty — consume the key.
|
||
return (InputOutcome::Changed, ClipboardTextInsertion::Empty);
|
||
};
|
||
if text.trim().is_empty() {
|
||
return (InputOutcome::Unchanged, ClipboardTextInsertion::Empty);
|
||
}
|
||
let event = if peek {
|
||
self.peek_reply.handle_paste(text)
|
||
} else {
|
||
self.dispatch.handle_paste(text)
|
||
};
|
||
if !peek && matches!(event, crate::views::prompt_widget::PromptEvent::Edited) {
|
||
self.dispatch.refresh_slash(&self.models);
|
||
}
|
||
let completion = match event {
|
||
crate::views::prompt_widget::PromptEvent::Edited => ClipboardTextInsertion::Inserted,
|
||
crate::views::prompt_widget::PromptEvent::Ignored => ClipboardTextInsertion::Failed,
|
||
};
|
||
(InputOutcome::Changed, completion)
|
||
}
|
||
|
||
/// Enqueue attachment probing off-thread so paste-then-send remains ordered.
|
||
fn enqueue_clipboard_attachment_probe(
|
||
&mut self,
|
||
source: crate::app::actions::ClipboardPasteSource,
|
||
peek: bool,
|
||
change_count: Option<u64>,
|
||
) {
|
||
let target = if peek {
|
||
// Callers only pass `peek = true` with the panel open; stamping the
|
||
// row lets the completion drop a paste whose panel closed/moved.
|
||
let Some(p) = self.peek.as_ref() else {
|
||
return;
|
||
};
|
||
crate::app::actions::ClipboardPasteTarget::DashboardPeek { row: p.row.clone() }
|
||
} else {
|
||
crate::app::actions::ClipboardPasteTarget::DashboardDispatch
|
||
};
|
||
self.paste_probe_in_flight += 1;
|
||
self.pending_effects
|
||
.push(crate::app::actions::Effect::ProbeClipboardAttachment {
|
||
ctx: crate::app::actions::ClipboardPasteContext { target, source },
|
||
change_count,
|
||
});
|
||
}
|
||
|
||
/// Attach a pasted image to the peek reply as an `[Image #N]` chip.
|
||
fn attach_peek_pasted_image(
|
||
&mut self,
|
||
mut pasted: crate::prompt_images::PastedImage,
|
||
) -> (InputOutcome, crate::app::actions::ClipboardPasteCompletion) {
|
||
if self.peek.as_ref().is_some_and(|p| p.question.is_some()) {
|
||
return (
|
||
InputOutcome::Unchanged,
|
||
crate::app::actions::ClipboardPasteCompletion::Dropped,
|
||
);
|
||
}
|
||
let preparation = pasted.preview_preparation();
|
||
pasted.session_image_path = None;
|
||
pasted.staged_temp_path = None;
|
||
let completion = match self.peek_reply.insert_image(pasted) {
|
||
Ok(()) => {
|
||
if let Some(preparation) = preparation {
|
||
self.pending_effects.push(
|
||
crate::app::actions::Effect::PreparePromptImagePreview { preparation },
|
||
);
|
||
}
|
||
crate::app::actions::ClipboardPasteCompletion::Handled
|
||
}
|
||
Err(msg) => {
|
||
self.set_error_toast(&msg);
|
||
crate::app::actions::ClipboardPasteCompletion::Failed(
|
||
crate::app::actions::ClipboardPasteFailure::AlreadyReported,
|
||
)
|
||
}
|
||
};
|
||
(InputOutcome::Changed, completion)
|
||
}
|
||
|
||
/// Ctrl/Cmd+V into the dispatch input (`peek = false`) or the peek reply
|
||
/// (`peek = true`): a pasted file path resolves synchronously and wins; else
|
||
/// the clipboard raster/file-url probe defers off the event loop (the image
|
||
/// wins over the caption, inserted on completion only if no image); else
|
||
/// plain text with no raster inserts synchronously.
|
||
fn handle_paste_key_deferred(
|
||
&mut self,
|
||
clipboard_text: crate::app::actions::ClipboardTextRead,
|
||
peek: bool,
|
||
) -> InputOutcome {
|
||
// The peek reply has extra preconditions the dispatch input does not.
|
||
let in_question = if peek {
|
||
let Some(p) = self.peek.as_ref() else {
|
||
return InputOutcome::Unchanged;
|
||
};
|
||
let in_question = p.question.is_some();
|
||
if in_question {
|
||
let on_reject = p.selected_option.is_some() && p.reject_option == p.selected_option;
|
||
if !on_reject {
|
||
return InputOutcome::Unchanged;
|
||
}
|
||
}
|
||
// Pasting implies an intent to reply — focus + root the `@` picker.
|
||
if let Some(p) = self.peek.as_mut() {
|
||
p.focused = true;
|
||
}
|
||
self.ensure_peek_reply_cwd();
|
||
in_question
|
||
} else {
|
||
false
|
||
};
|
||
|
||
// Question mode is text-only on the wire — never attach/defer an image.
|
||
if in_question {
|
||
return self
|
||
.insert_pasted_caption(clipboard_text.as_deref(), true)
|
||
.0;
|
||
}
|
||
|
||
// A pasted file path resolves synchronously and wins (drag-drop / Finder
|
||
// Cmd+C) — before deferring, so it is not double-attached.
|
||
if let Some(text) = clipboard_text.as_deref()
|
||
&& !text.trim().is_empty()
|
||
{
|
||
let images = crate::prompt_images::try_read_images_from_paste(text);
|
||
if !images.is_empty() {
|
||
for img in images {
|
||
if peek {
|
||
self.attach_peek_pasted_image(img);
|
||
} else {
|
||
self.attach_pasted_image(img);
|
||
}
|
||
}
|
||
return InputOutcome::Changed;
|
||
}
|
||
}
|
||
|
||
// Image likely on the pasteboard → defer; the image wins over the
|
||
// caption (caption inserted on completion only if the probe finds none).
|
||
if let Some(change_count) =
|
||
crate::clipboard::attachment_probe_gate(clipboard_text.as_deref())
|
||
{
|
||
self.enqueue_clipboard_attachment_probe(
|
||
crate::app::actions::ClipboardPasteSource::ClipboardKey {
|
||
text: clipboard_text,
|
||
tip_showing: false,
|
||
},
|
||
peek,
|
||
change_count,
|
||
);
|
||
return InputOutcome::Changed;
|
||
}
|
||
|
||
// No raster / lone URL: insert the plain text synchronously.
|
||
self.insert_pasted_caption(clipboard_text.as_deref(), peek)
|
||
.0
|
||
}
|
||
|
||
/// Attach the result of a deferred clipboard attachment probe
|
||
/// ([`Effect::ProbeClipboardAttachment`]) to the surface named by
|
||
/// `ctx.target` (dispatch input or peek reply). The heavy read/decode
|
||
/// already ran off-thread; this only mutates input state on the event loop.
|
||
pub(crate) fn complete_clipboard_attachment_paste(
|
||
&mut self,
|
||
ctx: crate::app::actions::ClipboardPasteContext,
|
||
image: crate::app::actions::ProbedAttachment,
|
||
file_urls: Option<String>,
|
||
) -> crate::app::actions::ClipboardPasteCompletion {
|
||
use crate::app::actions::{
|
||
ClipboardPasteCompletion, ClipboardPasteFailure, ClipboardPasteTarget, ProbedAttachment,
|
||
};
|
||
let peek_row = match &ctx.target {
|
||
ClipboardPasteTarget::DashboardPeek { row } => Some(row.clone()),
|
||
_ => None,
|
||
};
|
||
let peek = peek_row.is_some();
|
||
self.paste_probe_in_flight = self.paste_probe_in_flight.saturating_sub(1);
|
||
// Drop a peek completion if the panel closed OR moved to another row
|
||
// between enqueue and now, so the attachment can't land in the hidden
|
||
// reply buffer or a different agent's reply (mirrors the agent's
|
||
// agent-id-gone guard in dispatch).
|
||
if let Some(row) = &peek_row
|
||
&& self.peek.as_ref().is_none_or(|p| p.row != *row)
|
||
{
|
||
return ClipboardPasteCompletion::Dropped;
|
||
}
|
||
// A question that arrived on the peeked row mid-probe makes the reply
|
||
// text-only on the wire: attachments are discarded LOUDLY below (the
|
||
// attach helper's silent question no-op would drop them with zero
|
||
// feedback), and the caption/wrap paths stay suppressed.
|
||
let peek_in_question = peek && self.peek.as_ref().is_some_and(|p| p.question.is_some());
|
||
let insert_deferred_text = !peek_in_question
|
||
&& matches!(
|
||
&image,
|
||
ProbedAttachment::NoRaster
|
||
| ProbedAttachment::ProbeDropped
|
||
| ProbedAttachment::ProbeFailed
|
||
);
|
||
let mut attachment = match image {
|
||
ProbedAttachment::Image(pasted) => {
|
||
if peek_in_question {
|
||
self.set_error_toast("Pasted image discarded — reply switched to a question");
|
||
ClipboardPasteCompletion::Dropped
|
||
} else {
|
||
let (_, completion) = if peek {
|
||
self.attach_peek_pasted_image(pasted)
|
||
} else {
|
||
self.attach_pasted_image(pasted)
|
||
};
|
||
completion
|
||
}
|
||
}
|
||
ProbedAttachment::PersistFailed(msg) => {
|
||
self.set_error_toast(&msg);
|
||
ClipboardPasteCompletion::Failed(ClipboardPasteFailure::AlreadyReported)
|
||
}
|
||
ProbedAttachment::NoRaster => ClipboardPasteCompletion::FullMiss,
|
||
ProbedAttachment::ProbeDropped => ClipboardPasteCompletion::Dropped,
|
||
ProbedAttachment::ProbeFailed => {
|
||
ClipboardPasteCompletion::Failed(ClipboardPasteFailure::AttachmentRead)
|
||
}
|
||
};
|
||
if peek_in_question && attachment == ClipboardPasteCompletion::FullMiss {
|
||
if file_urls.as_deref().is_some_and(|urls| {
|
||
!crate::prompt_images::try_read_images_from_paste(urls).is_empty()
|
||
}) {
|
||
self.set_error_toast("Pasted image discarded — reply switched to a question");
|
||
}
|
||
attachment = ClipboardPasteCompletion::Dropped;
|
||
}
|
||
let file = if attachment == ClipboardPasteCompletion::FullMiss {
|
||
file_urls.and_then(|urls| {
|
||
let images = crate::prompt_images::try_read_images_from_paste(&urls);
|
||
if images.is_empty() {
|
||
return None;
|
||
}
|
||
let mut completion =
|
||
ClipboardPasteCompletion::Failed(ClipboardPasteFailure::AlreadyReported);
|
||
for img in images {
|
||
let (_, inserted) = if peek {
|
||
self.attach_peek_pasted_image(img)
|
||
} else {
|
||
self.attach_pasted_image(img)
|
||
};
|
||
if inserted == ClipboardPasteCompletion::Handled {
|
||
completion = ClipboardPasteCompletion::Handled;
|
||
}
|
||
}
|
||
Some(completion)
|
||
})
|
||
} else {
|
||
None
|
||
};
|
||
let text = if insert_deferred_text {
|
||
ctx.source
|
||
.text_to_insert_on_miss()
|
||
.filter(|text| !text.trim().is_empty())
|
||
.map(|text| self.insert_pasted_caption(Some(text), peek).1)
|
||
} else {
|
||
None
|
||
};
|
||
let completion = crate::app::actions::reduce_clipboard_paste_completion(
|
||
&ctx.source,
|
||
attachment,
|
||
file,
|
||
text,
|
||
);
|
||
if completion == ClipboardPasteCompletion::FullMiss && ctx.source.is_clipboard_key() {
|
||
crate::clipboard::log_paste_key_empty_host_clipboard(ctx.target.surface_str());
|
||
}
|
||
completion
|
||
}
|
||
|
||
/// After a deferred paste probe completes, take the sends stashed while the
|
||
/// probe(s) were in flight — dispatch first, then peek — rebuilding each
|
||
/// from its now-updated widget text so a freshly attached image chip (and
|
||
/// its aligned range) travels with it. Returns empty while probes remain in
|
||
/// flight. A peek stash whose panel closed, moved to another row, or now
|
||
/// shows a question is dropped with a toast (the reply draft stays in the
|
||
/// widget).
|
||
pub(crate) fn take_deferred_sends_after_paste(&mut self) -> Vec<crate::app::actions::Action> {
|
||
if self.paste_probe_in_flight != 0 {
|
||
return Vec::new();
|
||
}
|
||
let mut actions = Vec::new();
|
||
if let Some(DeferredDispatchSend { attach }) = self.deferred_dispatch_send.take() {
|
||
actions.push(crate::app::actions::Action::DashboardDispatch {
|
||
text: self.dispatch.text().to_string(),
|
||
attach,
|
||
});
|
||
}
|
||
if let Some(DeferredPeekSend { row, attach }) = self.deferred_peek_send.take() {
|
||
let same_row = self.peek.as_ref().is_some_and(|p| p.row == row);
|
||
let in_question = self.peek.as_ref().is_some_and(|p| p.question.is_some());
|
||
if same_row && !in_question {
|
||
actions.push(crate::app::actions::Action::DashboardPeekReply {
|
||
row,
|
||
text: self.peek_reply.text().to_string(),
|
||
attach,
|
||
});
|
||
} else if !same_row {
|
||
// Never reply to a row the user is no longer peeking.
|
||
self.set_error_toast("Reply canceled — peek panel changed");
|
||
} else {
|
||
// A question now owns the panel (Enter answers it there, and the
|
||
// reply dispatch would silently queue a prompt + wipe the draft
|
||
// behind the dialog) — drop the stash; the draft stays put.
|
||
self.set_error_toast("Reply canceled — answer the question first");
|
||
}
|
||
}
|
||
actions
|
||
}
|
||
|
||
/// Handle a key while the peek panel is open.
|
||
///
|
||
/// The peek panel's `❯ reply` line is a live [`PromptWidget`]
|
||
/// editor ([`Self::peek_reply`]), so when peek is open it owns the
|
||
/// bare keys: printable characters, Backspace/Delete/arrows, and
|
||
/// the widget's editing chords (word nav, `Ctrl+A`/`Ctrl+E`,
|
||
/// `Alt+Backspace`, undo, Shift+arrow selection, inline paste)
|
||
/// all edit the reply. Up/Down move the caret WITHIN the reply when
|
||
/// it has content (multi-line drafts); when the reply is empty (or
|
||
/// unfocused via Tab) they switch the peeked agent instead (the
|
||
/// panel follows the selection cursor). Enter sends
|
||
/// / queues the reply (Ctrl+S sends AND opens the agent; Shift+Enter
|
||
/// / Alt+Enter insert a newline); Esc closes the panel; and 1–9
|
||
/// select (toggle) a pending question's option, after which Enter
|
||
/// answers the selection.
|
||
///
|
||
/// `dashboard_owned` is whether the key resolved to a
|
||
/// `When::DashboardFocused` registry binding (`Ctrl+X` stop,
|
||
/// `Shift+↑/↓` reorder, `Shift+Tab` mode, …) — those return `None`
|
||
/// so the dashboard handler (and the remaining app-global
|
||
/// shortcuts) still fire with the panel open.
|
||
///
|
||
/// Returns `Some` for keys the panel consumes and `None` for keys
|
||
/// it leaves to the normal dashboard / global handling.
|
||
fn handle_peek_key(
|
||
&mut self,
|
||
key: &KeyEvent,
|
||
from_registry: Option<crate::actions::ActionId>,
|
||
) -> Option<InputOutcome> {
|
||
let dashboard_owned = from_registry.is_some();
|
||
// Paste → reply widget (text + images). Handled up-front because
|
||
// the paste chord carries CONTROL / SUPER and must not be
|
||
// mistaken for a dashboard chord.
|
||
if crate::input::key::is_paste_key(key) {
|
||
let clipboard_text = crate::app::actions::ClipboardTextRead::from_result(
|
||
crate::clipboard::system_clipboard_read_text(),
|
||
);
|
||
return Some(self.handle_paste_key_deferred(clipboard_text, /* peek */ true));
|
||
}
|
||
|
||
// Ctrl+C / Ctrl+D must reach the app-global quit handler (the
|
||
// double-press-to-quit fallback fires only when the view returns
|
||
// `Unchanged`). Returning `Unchanged` here bubbles them up cleanly
|
||
// instead of letting them leak into — and be swallowed by — the
|
||
// reply widget (whose Ctrl+C would clear the draft instead).
|
||
if key!('c', CONTROL).matches(key) || key!('d', CONTROL).matches(key) {
|
||
return Some(InputOutcome::Unchanged);
|
||
}
|
||
|
||
// `@`-file-search intercept — while the reply's context picker
|
||
// dropdown is open it owns Up/Down/PageUp-Down/Tab/Enter/Esc/
|
||
// Ctrl+P-N (navigate / accept / dismiss), mirroring the dispatch
|
||
// box's intercept. Routed BEFORE the peek's own nav / send /
|
||
// close handlers so those keys steer the dropdown instead. Keys
|
||
// the picker ignores fall through to the normal peek handling
|
||
// below (so e.g. Ctrl+X stop still fires with the dropdown up).
|
||
if self.peek_reply.file_search_visible() {
|
||
match self.peek_reply_handle_key(key) {
|
||
crate::views::prompt_widget::PromptEvent::Edited => {
|
||
return Some(InputOutcome::Changed);
|
||
}
|
||
crate::views::prompt_widget::PromptEvent::Ignored => {}
|
||
}
|
||
}
|
||
|
||
// Empty peek reply: shortcuts help opens instead of typing.
|
||
if matches!(
|
||
from_registry,
|
||
Some(crate::actions::ActionId::DashboardShortcutsHelp)
|
||
) && self.peek_reply.text().is_empty()
|
||
{
|
||
return Some(InputOutcome::Action(Action::DashboardOpenShortcutsHelp));
|
||
}
|
||
|
||
// Dashboard-owned chords fall through so the registry actions
|
||
// (Ctrl+X stop, Ctrl+T pin, Shift+↑/↓ reorder, Shift+Tab mode,
|
||
// …) and the hardcoded Ctrl+/ search toggle keep working with
|
||
// the panel open. Keys the peek itself owns are exempt:
|
||
// - bare / Shift printable chars TYPE into the reply (vim's
|
||
// bare `j`/`k` nav lose to typing; `?` help only when the
|
||
// reply is non-empty — empty handled above),
|
||
// - bare Up/Down are the peek's agent switcher (handled
|
||
// below, independent of the dispatch box's focus gating),
|
||
// - Esc / Enter / Tab drive the peek's own affordances.
|
||
// Everything else — non-owned Ctrl/Alt/Super editing chords
|
||
// (word nav, kill-line, undo, …) — falls through to the reply
|
||
// widget delegation at the bottom.
|
||
let is_typing_char = matches!(key.code, KeyCode::Char(_))
|
||
&& (key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT);
|
||
let peek_owned = is_typing_char
|
||
|| matches!(key.code, KeyCode::Esc | KeyCode::Enter)
|
||
|| (matches!(key.code, KeyCode::Up | KeyCode::Down | KeyCode::Tab)
|
||
&& key.modifiers.is_empty());
|
||
if (dashboard_owned && !peek_owned) || key!('/', CONTROL).matches(key) {
|
||
return None;
|
||
}
|
||
|
||
// Ctrl+S = "send + open": send / queue the reply AND walk into
|
||
// the agent's detail view. This is the chord that replaced
|
||
// Shift+Enter (now freed, with Alt+Enter, for newline). An empty
|
||
// reply — or any pending question, which has no reply line —
|
||
// simply opens the agent. Handled before the question / Esc / Tab
|
||
// blocks so it works in every peek state.
|
||
if key!('s', CONTROL).matches(key) {
|
||
let Some(row) = self.peek.as_ref().map(|p| p.row.clone()) else {
|
||
return Some(InputOutcome::Unchanged);
|
||
};
|
||
let reply_text = self.peek_reply.text().to_string();
|
||
let has_question = self.peek.as_ref().is_some_and(|p| p.question.is_some());
|
||
if has_question || reply_text.trim().is_empty() {
|
||
return Some(InputOutcome::Action(Action::DashboardAttach(row)));
|
||
}
|
||
return Some(InputOutcome::Action(Action::DashboardPeekReply {
|
||
row,
|
||
text: reply_text,
|
||
attach: true,
|
||
}));
|
||
}
|
||
|
||
// Number keys 1–9 SELECT (toggle) the matching option when a question
|
||
// is showing — they no longer answer directly. Selecting focuses the
|
||
// panel so it becomes an answer surface (`Enter` then answers);
|
||
// pressing the selected option's key again deselects it (→ navigation).
|
||
if let KeyCode::Char(c) = key.code
|
||
&& let Some(d) = c.to_digit(10)
|
||
&& (1..=9).contains(&d)
|
||
&& self.peek.as_ref().is_some_and(|p| p.question.is_some())
|
||
{
|
||
let idx = (d - 1) as usize;
|
||
let valid = self.peek.as_ref().is_some_and(|p| idx < p.options.len());
|
||
if !valid {
|
||
let n_opts = self.peek.as_ref().map(|p| p.options.len()).unwrap_or(0);
|
||
self.set_error_toast(&format!("No such option (only {n_opts} available)"));
|
||
return Some(InputOutcome::Changed);
|
||
}
|
||
if let Some(p) = self.peek.as_mut() {
|
||
p.focused = true;
|
||
p.selected_option = if p.selected_option == Some(idx) {
|
||
None
|
||
} else {
|
||
Some(idx)
|
||
};
|
||
}
|
||
return Some(InputOutcome::Changed);
|
||
}
|
||
|
||
// Esc: the peek is shown by default while a row is selected, so
|
||
// Esc UNSELECTS — first clearing a typed reply, then deselecting
|
||
// the row and focusing the `[+ New Agent]` button (which closes
|
||
// the peek and brings back the new-session input).
|
||
if matches!(key.code, KeyCode::Esc) {
|
||
if !self.peek_reply.text().is_empty() {
|
||
self.clear_peek_reply();
|
||
return Some(InputOutcome::Changed);
|
||
}
|
||
self.set_peek(None);
|
||
self.focus_new_agent_button();
|
||
return Some(InputOutcome::Changed);
|
||
}
|
||
|
||
// Tab toggles focus between the reply input and the row list,
|
||
// mirroring the dispatch box's two-focus model. Unfocusing dims
|
||
// the panel border and hides the caret (see `render_peek_panel`).
|
||
if matches!(key.code, KeyCode::Tab) && key.modifiers.is_empty() {
|
||
if let Some(p) = self.peek.as_mut() {
|
||
p.focused = !p.focused;
|
||
}
|
||
return Some(InputOutcome::Changed);
|
||
}
|
||
|
||
// When a permission / ask-tool question is showing and the panel is
|
||
// focused it's an option picker. With NO option selected (the
|
||
// default) it stays a navigation surface: `↑`/`↓` switch agents and
|
||
// `Enter` opens the row in detail. Once an option is selected (number
|
||
// key) it becomes a modal answer surface: `↑`/`↓` move within the
|
||
// options (spilling to the prev/next agent at the first/last option),
|
||
// `Enter` answers, and the `RejectOnce` ("No") option accepts inline
|
||
// free-text feedback.
|
||
let vim_mode = crate::appearance::cache::load_vim_mode();
|
||
let question_mode = self
|
||
.peek
|
||
.as_ref()
|
||
.is_some_and(|p| p.focused && p.question.is_some() && !p.options.is_empty());
|
||
if question_mode {
|
||
let selected = self.peek.as_ref().and_then(|p| p.selected_option);
|
||
let opt_count = self.peek.as_ref().map(|p| p.options.len()).unwrap_or(0);
|
||
let on_reject = selected.is_some()
|
||
&& self
|
||
.peek
|
||
.as_ref()
|
||
.is_some_and(|p| p.reject_option == selected);
|
||
match (key.code, selected) {
|
||
// ── No option selected → navigate agents / open ──
|
||
(KeyCode::Up, None) => {
|
||
return Some(InputOutcome::Action(Action::DashboardSelectPrev));
|
||
}
|
||
(KeyCode::Down, None) => {
|
||
return Some(InputOutcome::Action(Action::DashboardSelectNext));
|
||
}
|
||
(KeyCode::Enter, None) => {
|
||
let row = self.peek.as_ref().map(|p| p.row.clone());
|
||
return Some(
|
||
row.map(|r| InputOutcome::Action(Action::DashboardAttach(r)))
|
||
.unwrap_or(InputOutcome::Unchanged),
|
||
);
|
||
}
|
||
// Right (and vim `l`) open detail on the nav surface;
|
||
// without this the modal catch-all below swallows them.
|
||
(KeyCode::Right | KeyCode::Char('l'), None)
|
||
if key.modifiers.is_empty()
|
||
&& (matches!(key.code, KeyCode::Right) || vim_mode) =>
|
||
{
|
||
let row = self.peek.as_ref().map(|p| p.row.clone());
|
||
return Some(
|
||
row.map(|r| InputOutcome::Action(Action::DashboardAttach(r)))
|
||
.unwrap_or(InputOutcome::Unchanged),
|
||
);
|
||
}
|
||
// ── Option selected → move within options (spill at edges) ──
|
||
(KeyCode::Up, Some(0)) => {
|
||
return Some(InputOutcome::Action(Action::DashboardSelectPrev));
|
||
}
|
||
(KeyCode::Up, Some(i)) => {
|
||
if let Some(p) = self.peek.as_mut() {
|
||
p.selected_option = Some(i - 1);
|
||
}
|
||
return Some(InputOutcome::Changed);
|
||
}
|
||
(KeyCode::Down, Some(i)) if i + 1 >= opt_count => {
|
||
return Some(InputOutcome::Action(Action::DashboardSelectNext));
|
||
}
|
||
(KeyCode::Down, Some(i)) => {
|
||
if let Some(p) = self.peek.as_mut() {
|
||
p.selected_option = Some(i + 1);
|
||
}
|
||
return Some(InputOutcome::Changed);
|
||
}
|
||
(KeyCode::Enter, Some(i)) => {
|
||
let is_ask = self.peek.as_ref().is_some_and(|p| p.is_ask_question());
|
||
if on_reject
|
||
&& matches!(
|
||
self.peek_reply.try_element_interaction(key),
|
||
Some(crate::views::prompt_widget::ElementInteraction::Inlined)
|
||
)
|
||
{
|
||
return Some(InputOutcome::Changed);
|
||
}
|
||
// On the reject/free-text option with typed text: submit
|
||
// the ask "Other" answer, or the permission rejection +
|
||
// feedback message.
|
||
let feedback = on_reject
|
||
.then(|| self.peek_reply.text_without_image_chips())
|
||
.filter(|t| !t.trim().is_empty());
|
||
if let Some(text) = feedback {
|
||
let row = self.peek.as_ref().map(|p| p.row.clone());
|
||
let request_id = self.peek.as_ref().and_then(|p| p.request_id);
|
||
if let Some(row) = row {
|
||
if is_ask {
|
||
return Some(InputOutcome::Action(
|
||
Action::DashboardQuestionAnswer {
|
||
row,
|
||
option_idx: None,
|
||
freeform: text,
|
||
},
|
||
));
|
||
} else if let Some(request_id) = request_id {
|
||
return Some(InputOutcome::Action(
|
||
Action::DashboardPermissionFollowup {
|
||
row,
|
||
request_id,
|
||
text,
|
||
},
|
||
));
|
||
}
|
||
}
|
||
return Some(InputOutcome::Unchanged);
|
||
}
|
||
// Otherwise answer the selected option — `peek_number_key`
|
||
// routes to the permission / ask answer action by source.
|
||
if let Some(action) = super::peek::peek_number_key(self, i + 1) {
|
||
return Some(InputOutcome::Action(action));
|
||
}
|
||
return Some(InputOutcome::Unchanged);
|
||
}
|
||
_ => {}
|
||
}
|
||
// Free-text feedback editing — only when the reject option is
|
||
// the selected one. Delegated to the reply widget so the
|
||
// feedback field gets the same editing surface (selection,
|
||
// word ops, chips) as the main reply line.
|
||
if on_reject
|
||
&& matches!(
|
||
self.peek_reply_handle_key(key),
|
||
crate::views::prompt_widget::PromptEvent::Edited
|
||
)
|
||
{
|
||
return Some(InputOutcome::Changed);
|
||
}
|
||
// Modal while the question picker is up: consume any other key so
|
||
// it can't leak into the (hidden) reply row or row navigation.
|
||
return Some(InputOutcome::Unchanged);
|
||
}
|
||
|
||
let focused = self.peek.as_ref().map(|p| p.focused).unwrap_or(false);
|
||
|
||
// BARE Up/Down switch the peeked agent ONLY while the reply is a
|
||
// navigation surface — either unfocused (Tab → row nav) or empty
|
||
// (a browse convenience, mirroring the dispatch input's
|
||
// empty-prompt gate). With a non-empty FOCUSED reply the arrows
|
||
// move the caret WITHIN the text instead (multi-line drafts), so
|
||
// typing then arrowing edits the reply rather than jumping to
|
||
// another agent — they fall through to the widget delegation
|
||
// below.
|
||
//
|
||
// MODIFIED arrows (e.g. `Shift+↑/↓` row reorder) must fall through
|
||
// to the registry actions regardless — matching on `key.code`
|
||
// alone would otherwise swallow them as agent-switch and the peek
|
||
// (shown by default on selection) would make reorder appear broken.
|
||
if key.modifiers.is_empty()
|
||
&& matches!(key.code, KeyCode::Up | KeyCode::Down)
|
||
&& (!focused || self.peek_reply.text().is_empty())
|
||
{
|
||
return Some(InputOutcome::Action(match key.code {
|
||
KeyCode::Up => Action::DashboardSelectPrev,
|
||
_ => Action::DashboardSelectNext,
|
||
}));
|
||
}
|
||
|
||
// Open detail (mirror of overlay Left-to-back). Right: nav surface
|
||
// (unfocused or empty). Vim `l`: unfocused only — focused reply
|
||
// must type literal `l`, same as focused `j`/`k`.
|
||
let open_detail = key.modifiers.is_empty()
|
||
&& match key.code {
|
||
KeyCode::Right => !focused || self.peek_reply.text().is_empty(),
|
||
KeyCode::Char('l') if vim_mode => !focused,
|
||
_ => false,
|
||
};
|
||
if open_detail {
|
||
let Some(row) = self.peek.as_ref().map(|p| p.row.clone()) else {
|
||
return Some(InputOutcome::Unchanged);
|
||
};
|
||
return Some(InputOutcome::Action(Action::DashboardAttach(row)));
|
||
}
|
||
|
||
if matches!(key.code, KeyCode::Enter) {
|
||
let mod_enter = crate::input::is_mod_enter(key);
|
||
if focused
|
||
&& !mod_enter
|
||
&& matches!(
|
||
self.peek_reply.try_element_interaction(key),
|
||
Some(crate::views::prompt_widget::ElementInteraction::Inlined)
|
||
)
|
||
{
|
||
return Some(InputOutcome::Changed);
|
||
}
|
||
let enter_is_newline =
|
||
focused && compose_enter_is_newline(self.multiline_mode, mod_enter);
|
||
if !enter_is_newline {
|
||
let Some(row) = self.peek.as_ref().map(|p| p.row.clone()) else {
|
||
return Some(InputOutcome::Unchanged);
|
||
};
|
||
if !focused && vim_mode {
|
||
if let Some(p) = self.peek.as_mut() {
|
||
p.focused = true;
|
||
}
|
||
return Some(InputOutcome::Changed);
|
||
}
|
||
let reply_text = self.peek_reply.text().to_string();
|
||
if !focused || reply_text.trim().is_empty() {
|
||
return Some(InputOutcome::Action(Action::DashboardAttach(row)));
|
||
}
|
||
return Some(InputOutcome::Action(Action::DashboardPeekReply {
|
||
row,
|
||
text: reply_text,
|
||
attach: false,
|
||
}));
|
||
}
|
||
}
|
||
|
||
// Ctrl+M: ToggleMultiline is PromptFocused only, so hardcode here.
|
||
if key!('m', CONTROL).matches(key) {
|
||
return Some(InputOutcome::Action(Action::SetMultilineMode(
|
||
!self.multiline_mode,
|
||
)));
|
||
}
|
||
|
||
// Space is just text now (the peek is tied to selection, so there
|
||
// is no Space-to-close — use Esc to unselect). It falls through to
|
||
// the reply editor below.
|
||
if focused {
|
||
// Focused reply input — everything else is an edit attempt,
|
||
// delegated to the reply widget (chars, Backspace/Delete,
|
||
// arrows, word nav, kill-line, undo, Shift+arrow selection,
|
||
// inline paste, …). Keys the widget ignores are still
|
||
// CONSUMED (`Unchanged`) so they can't leak into the hidden
|
||
// dispatch input below; app-global shortcuts still fire off
|
||
// the `Unchanged` bubble-up.
|
||
return Some(match self.peek_reply_handle_key(key) {
|
||
crate::views::prompt_widget::PromptEvent::Edited => InputOutcome::Changed,
|
||
crate::views::prompt_widget::PromptEvent::Ignored => InputOutcome::Unchanged,
|
||
});
|
||
}
|
||
|
||
// Vim unfocused: j/k select rows; i focuses reply without typing `i`.
|
||
if vim_mode && key.modifiers.is_empty() {
|
||
match key.code {
|
||
KeyCode::Char('j') => {
|
||
return Some(InputOutcome::Action(Action::DashboardSelectNext));
|
||
}
|
||
KeyCode::Char('k') => {
|
||
return Some(InputOutcome::Action(Action::DashboardSelectPrev));
|
||
}
|
||
KeyCode::Char('i') => {
|
||
if let Some(p) = self.peek.as_mut() {
|
||
p.focused = true;
|
||
}
|
||
return Some(InputOutcome::Changed);
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
// Unfocused: non-vim printable focuses+types; vim swallows (focus via Enter/i/Tab).
|
||
if is_typing_char {
|
||
if vim_mode {
|
||
return Some(InputOutcome::Unchanged);
|
||
}
|
||
if let Some(p) = self.peek.as_mut() {
|
||
p.focused = true;
|
||
}
|
||
let _ = self.peek_reply_handle_key(key);
|
||
return Some(InputOutcome::Changed);
|
||
}
|
||
|
||
// Every OTHER key is CONSUMED (`Unchanged`) rather than left to
|
||
// fall through. The dispatch box is HIDDEN while the peek is open,
|
||
// so a `None` here would let editing chords (Backspace, Delete,
|
||
// `Ctrl+W`/`Ctrl+U`/`Ctrl+K`, Home/End, …) leak into and silently
|
||
// mutate the invisible new-session draft behind the panel.
|
||
// Dashboard-owned chords (`Ctrl+X` stop, `Shift+↑/↓` reorder, …),
|
||
// `Ctrl+/`, and `Ctrl+C`/`Ctrl+D` already returned above, so
|
||
// nothing reaching here has a dashboard or row-nav meaning;
|
||
// returning `Unchanged` (not `None`) still lets app-global
|
||
// shortcuts fire off the bubble-up.
|
||
Some(InputOutcome::Unchanged)
|
||
}
|
||
|
||
/// Resolve the dispatch input's send action for the given `attach`
|
||
/// flag. Shared by bare `Enter` (`attach == false`, stay on the
|
||
/// dashboard) and the `Ctrl+S` "send + open" chord (`attach ==
|
||
/// true`, walk into the detail view). Empty-prompt fallbacks mirror
|
||
/// the old Enter handler: open the selected row, or create from the
|
||
/// `[+ New Agent]` button. A `/command` always routes to the slash
|
||
/// dispatcher (there's no session to "open"), so `attach` only
|
||
/// affects the plain-dispatch path.
|
||
fn dispatch_send_action(&self, attach: bool) -> InputOutcome {
|
||
let text = self.dispatch.text().to_string();
|
||
let trimmed = text.trim();
|
||
if trimmed.is_empty() {
|
||
if let Some(id) = self.selected.clone() {
|
||
return InputOutcome::Action(Action::DashboardAttach(id));
|
||
}
|
||
if self.new_agent_button_focused {
|
||
return InputOutcome::Action(Action::DashboardCreateNewAgentWithDetail);
|
||
}
|
||
return InputOutcome::Unchanged;
|
||
}
|
||
if trimmed.starts_with('/') {
|
||
return InputOutcome::Action(Action::DashboardDispatchSlash { text });
|
||
}
|
||
InputOutcome::Action(Action::DashboardDispatch { text, attach })
|
||
}
|
||
|
||
fn handle_key(&mut self, key: &KeyEvent, registry: &ActionRegistry) -> InputOutcome {
|
||
// Resolve the registry binding up-front — the toast / stop-confirm
|
||
// clear below needs to know whether this key IS the stop key, and
|
||
// it must run before the peek intercept (the lookup itself is a
|
||
// pure read; the action is honoured further down).
|
||
//
|
||
// Vim-aware lookup: `lookup_with_mode` suppresses the bare-letter
|
||
// (`j`/`k`) nav bindings when vim-mode is OFF, so they type into
|
||
// the dispatch input instead of moving the row selection —
|
||
// matching the agent view's scrollback. In vim-mode they navigate
|
||
// (when the prompt is empty, per the `bare_letter_ok` gate below).
|
||
// Arrows / Ctrl combos always resolve.
|
||
let vim_mode = crate::appearance::cache::load_vim_mode();
|
||
let from_registry =
|
||
registry.lookup_with_mode(key, crate::actions::When::DashboardFocused, vim_mode);
|
||
|
||
// Clear `error_toast` at the TOP of the
|
||
// handler so any subsequent keypress dismisses the toast,
|
||
// regardless of which branch handles the key (including keys
|
||
// the peek panel consumes — peek is open by default for a
|
||
// selected row, so nav keys route through it).
|
||
//
|
||
// When the toast is cleared, the linked
|
||
// `stop_confirm` armed state is also cleared. The two state
|
||
// bits are semantically linked: the user saw "Press Ctrl+X
|
||
// again", that hint is now gone, so re-arm rather than let a
|
||
// stale confirm window silently close the wrong session.
|
||
//
|
||
// The clear is SKIPPED when the resolved
|
||
// action is `DashboardStop`. Without this skip, the second
|
||
// Ctrl+X press would wipe the just-armed `stop_confirm`
|
||
// before `dispatch_dashboard_stop` could observe it, and the
|
||
// session would never close (the dispatcher kept re-arming a
|
||
// fresh confirm on every press). The Ctrl+X path owns
|
||
// `stop_confirm` and `error_toast` end-to-end: the first
|
||
// press arms both, the second press observes them and closes.
|
||
let preserve_stop_state =
|
||
matches!(from_registry, Some(crate::actions::ActionId::DashboardStop));
|
||
if !preserve_stop_state {
|
||
self.error_toast = None;
|
||
// The disarm is NOT gated on `error_toast` being set (the
|
||
// Ctrl+X arm path deliberately plants no toast): a pending
|
||
// stop confirmation is bound to the row that was selected
|
||
// when Ctrl+X was pressed, so any other key — nav included —
|
||
// must disarm it. Otherwise the footer's "press again to
|
||
// close" hint lingers while the cursor moves to other agents.
|
||
self.stop_confirm = None;
|
||
}
|
||
|
||
// Shift+Tab while the peek is open cycles the PEEKED agent's live
|
||
// mode, not the new-session staged mode. The registry resolves all
|
||
// Shift+Tab encodings to `DashboardCycleMode`; re-route that to the
|
||
// peek-scoped action here so the cycle acts on the agent under the
|
||
// cursor (and the bottom-border badge updates to match). Outside the
|
||
// peek it still cycles the dispatch box's staged mode.
|
||
if self.peek.is_some()
|
||
&& matches!(
|
||
from_registry,
|
||
Some(crate::actions::ActionId::DashboardCycleMode)
|
||
)
|
||
{
|
||
return InputOutcome::Action(Action::DashboardPeekCycleMode);
|
||
}
|
||
|
||
// When the peek panel is open it owns the bare keys: the `❯ reply`
|
||
// line is a live editor, Up/Down switch the peeked agent, Enter
|
||
// sends / queues the reply, and Esc closes. Routing here first
|
||
// keeps peek typing from leaking into the (hidden) dispatch input
|
||
// or the row-nav / new-session machinery below. Keys the panel
|
||
// doesn't own (registry-bound dashboard chords like Ctrl+X stop
|
||
// or Shift+↑/↓ reorder) return `None` and fall through so the
|
||
// dashboard's registry actions and global shortcuts still fire.
|
||
if self.peek.is_some()
|
||
&& let Some(outcome) = self.handle_peek_key(key, from_registry)
|
||
{
|
||
return outcome;
|
||
}
|
||
|
||
let prompt_empty = self.dispatch.text().is_empty();
|
||
|
||
// Peek permission answering (digits 1–9) and
|
||
// all other peek input is handled up-front by `handle_peek_key`
|
||
// (the early return at the top of this function), so by the time
|
||
// execution reaches here the peek panel is guaranteed closed.
|
||
|
||
// ── Ctrl+V / Cmd+V paste ────────────────────────────────────────
|
||
// Read the pbpaste text once and route through the shared deferred
|
||
// paste pipeline: a file path wins synchronously, else the clipboard
|
||
// image/file-url probe defers off the event loop. Mirrors `AgentView`
|
||
// — without this, Ctrl+V on the dashboard did nothing useful.
|
||
if crate::input::key::is_paste_key(key) {
|
||
let clipboard_text = crate::app::actions::ClipboardTextRead::from_result(
|
||
crate::clipboard::system_clipboard_read_text(),
|
||
);
|
||
return self.handle_paste_key_deferred(clipboard_text, /* peek */ false);
|
||
}
|
||
|
||
// ── @-file-search intercept ─────────────────────────────────────
|
||
// The dispatch input offers a session-less `@` context picker
|
||
// rooted at the pager's launch cwd. While its dropdown is
|
||
// visible, the prompt widget owns Up/Down/Tab/Enter/Esc — route
|
||
// the key there BEFORE the dashboard's row-nav / Enter / Esc
|
||
// handlers, mirroring `agent_view::handle_prompt_key`.
|
||
if self.dispatch.file_search_visible() {
|
||
match self.dispatch.handle_key(key) {
|
||
crate::views::prompt_widget::PromptEvent::Edited => {
|
||
self.dispatch.refresh_slash(&self.models);
|
||
return InputOutcome::Changed;
|
||
}
|
||
crate::views::prompt_widget::PromptEvent::Ignored => {
|
||
// Not a picker key — fall through to normal handling.
|
||
}
|
||
}
|
||
}
|
||
|
||
// `from_registry` (resolved at the top of this
|
||
// handler) routes any `DashboardFocused` binding through the
|
||
// registry. Special cases (Esc cascade, Enter dispatch) are
|
||
// handled below because they require multi-tier behaviour
|
||
// (clear filter, clear input, exit) that a single registry
|
||
// action can't express. The bindings themselves ARE registered
|
||
// (so the help text and key picker pick them up); we just don't
|
||
// always honour the action's single-fire semantics for Esc.
|
||
|
||
// Collapsible section headers: when the cursor is on a section
|
||
// title, Right expands, Left collapses, Enter toggles (and, in
|
||
// vim mode with the LIST focused, `l` / `h` mirror Right / Left,
|
||
// matching the `j`/`k` nav parity). Up/Down nav reaches the
|
||
// header via the registry nav actions below.
|
||
//
|
||
// Gated on `prompt_empty || list_focused`: while the input is
|
||
// FOCUSED and holds text, Left/Right edit the draft and Enter
|
||
// dispatches it (a section header is never a reply target). The
|
||
// vim `l`/`h` arms additionally require `list_focused`, so they
|
||
// fold the section ONLY while the LIST is focused; when the input
|
||
// is focused they fall through to the widget and type literally
|
||
// (vim on or off), consistent with the honour-gate principle
|
||
// below (bare `Char(_)` types into the input unless the list is
|
||
// focused).
|
||
//
|
||
// Sits BELOW the toast clear above so a pending
|
||
// feedback toast is dismissed by collapse/expand keypresses
|
||
// like every other key (the toast clear must stay the first
|
||
// state mutation of the handler).
|
||
//
|
||
// Shared by the section and overflow blocks below.
|
||
let vim_fold = vim_mode && self.list_focused;
|
||
if let Some(section) = self.selected_section
|
||
&& (prompt_empty || self.list_focused)
|
||
&& key.modifiers.is_empty()
|
||
{
|
||
match key.code {
|
||
// Right/`l` expand, Left/`h` collapse (vim letters only while list-focused).
|
||
KeyCode::Right | KeyCode::Char('l')
|
||
if matches!(key.code, KeyCode::Right) || vim_fold =>
|
||
{
|
||
self.set_section_collapsed(section, false);
|
||
return InputOutcome::Changed;
|
||
}
|
||
KeyCode::Left | KeyCode::Char('h')
|
||
if matches!(key.code, KeyCode::Left) || vim_fold =>
|
||
{
|
||
self.set_section_collapsed(section, true);
|
||
return InputOutcome::Changed;
|
||
}
|
||
KeyCode::Enter => {
|
||
self.toggle_section(section);
|
||
return InputOutcome::Changed;
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
// Idle "N more" overflow: Enter toggles; Right/`l` reveal, Left/`h` re-fold.
|
||
if self.selected_idle_overflow
|
||
&& (prompt_empty || self.list_focused)
|
||
&& key.modifiers.is_empty()
|
||
{
|
||
match key.code {
|
||
KeyCode::Enter => {
|
||
self.toggle_idle_show_all();
|
||
return InputOutcome::Changed;
|
||
}
|
||
KeyCode::Right | KeyCode::Char('l')
|
||
if matches!(key.code, KeyCode::Right) || vim_fold =>
|
||
{
|
||
self.idle_show_all = true;
|
||
return InputOutcome::Changed;
|
||
}
|
||
KeyCode::Left | KeyCode::Char('h')
|
||
if matches!(key.code, KeyCode::Left) || vim_fold =>
|
||
{
|
||
self.idle_show_all = false;
|
||
return InputOutcome::Changed;
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
// Short-terminal open (peek suppressed). Right: empty prompt or
|
||
// list focus. Vim `l`: list focus only (same as `j`/`k`).
|
||
let open_row_detail = key.modifiers.is_empty()
|
||
&& match key.code {
|
||
KeyCode::Right => prompt_empty || self.list_focused,
|
||
KeyCode::Char('l') if vim_mode => self.list_focused && !self.search_mode,
|
||
_ => false,
|
||
};
|
||
if open_row_detail && let Some(id) = self.selected.clone() {
|
||
return InputOutcome::Action(Action::DashboardAttach(id));
|
||
}
|
||
|
||
// Slash-completion dropdown intercept — Up/Down/Ctrl+P/N/Tab/
|
||
// Enter/Esc steer the `/command` dropdown when it's open. Never
|
||
// in search mode (there the buffer is a filter query, not a
|
||
// command). Mirrors `agent_view::handle_prompt_key`. The model
|
||
// catalog snapshot seeded at dashboard-open backs the `/model`
|
||
// arg suggestions.
|
||
//
|
||
// `slash_accepted_send`: Enter accepted a terminal (no-arg) row and
|
||
// must fall through to dispatch — bypasses multiline Enter→newline.
|
||
let mut slash_accepted_send = false;
|
||
if self.dispatch.slash_open() && !self.search_mode {
|
||
match key.code {
|
||
KeyCode::Up => {
|
||
self.dispatch.slash_move_selection(-1);
|
||
return InputOutcome::Changed;
|
||
}
|
||
KeyCode::Down => {
|
||
self.dispatch.slash_move_selection(1);
|
||
return InputOutcome::Changed;
|
||
}
|
||
KeyCode::Char('p') if key.modifiers == KeyModifiers::CONTROL => {
|
||
self.dispatch.slash_move_selection(-1);
|
||
return InputOutcome::Changed;
|
||
}
|
||
KeyCode::Char('n') if key.modifiers == KeyModifiers::CONTROL => {
|
||
self.dispatch.slash_move_selection(1);
|
||
return InputOutcome::Changed;
|
||
}
|
||
KeyCode::Tab => {
|
||
// Accept records MRU + queues an off-thread persist internally.
|
||
self.dispatch.accept_slash_completion(&self.models);
|
||
return InputOutcome::Changed;
|
||
}
|
||
KeyCode::Esc => {
|
||
self.dispatch.slash_close();
|
||
return InputOutcome::Changed;
|
||
}
|
||
KeyCode::Enter if key.modifiers.is_empty() => {
|
||
// Accept the selected completion; if its insert text
|
||
// ends with a space the row "chains" (more input
|
||
// expected, e.g. `/model `), otherwise close the
|
||
// dropdown and fall through to the Enter handler which
|
||
// dispatches the slash command.
|
||
let snap = self.dispatch.slash_snapshot();
|
||
let chains = snap
|
||
.selection()
|
||
.is_some_and(|row| row.insert_text.ends_with(' '));
|
||
// Accept records MRU + queues an off-thread persist internally.
|
||
self.dispatch.accept_slash_completion(&self.models);
|
||
if chains {
|
||
return InputOutcome::Changed;
|
||
}
|
||
self.dispatch.slash_close();
|
||
slash_accepted_send = true;
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
// `Ctrl+/` toggles search mode. Repurposes the dispatch
|
||
// buffer as a live filter query (the prompt prefix flips to
|
||
// a yellow `Search:`). Entering starts a fresh query;
|
||
// toggling off cancels (clears the filter and restores the
|
||
// normal dispatch input). Handled before the registry
|
||
// lookup / Esc cascade so it works regardless of rebindings.
|
||
if key!('/', CONTROL).matches(key) {
|
||
if self.search_mode {
|
||
self.exit_search_mode();
|
||
} else {
|
||
self.enter_search_mode();
|
||
}
|
||
return InputOutcome::Changed;
|
||
}
|
||
|
||
// Esc precedence: search → peek → clear filter → unfocus the
|
||
// input (→ overview list) → deselect → exit dashboard. This sits
|
||
// between the registry lookup and the action emission because Esc
|
||
// is registered as `DashboardExit` but its multi-tier behaviour
|
||
// can't be expressed as a single action — we expand it here.
|
||
if matches!(key.code, KeyCode::Esc) {
|
||
// Search mode owns Esc first — cancel the search (clear
|
||
// filter + query) and return to the dispatch prompt.
|
||
if self.search_mode {
|
||
self.exit_search_mode();
|
||
return InputOutcome::Changed;
|
||
}
|
||
if self.peek.is_some() {
|
||
self.set_peek(None);
|
||
return InputOutcome::Changed;
|
||
}
|
||
// An active filter clears next — the empty-state message
|
||
// promises "press Esc to clear the filter", so this stays
|
||
// ahead of the focus/exit tiers regardless of which pane
|
||
// holds focus.
|
||
if self.filter.is_active() {
|
||
self.filter = Filter::None;
|
||
return InputOutcome::Changed;
|
||
}
|
||
// Esc on a focused dispatch input UNFOCUSES it: focus moves
|
||
// to the overview list so the user can navigate rows (↑/↓,
|
||
// j/k, Space) right away. Mirrors Tab's focus toggle. The
|
||
// typed draft is deliberately left intact — Esc never clears
|
||
// it (use Ctrl+U / Ctrl+C for that), and it is retained if
|
||
// you close and reopen the dashboard within the same app
|
||
// process (it is NOT persisted across an app restart —
|
||
// `PersistedDashboard` does not store the dispatch draft). A
|
||
// second Esc — now that the list holds focus — walks the
|
||
// back-out → exit cascade below.
|
||
if !self.list_focused {
|
||
self.list_focused = true;
|
||
// Re-engage selection-follow so the viewport tracks the
|
||
// cursor once the list takes focus (mirrors Tab).
|
||
self.clear_manual_scroll();
|
||
return InputOutcome::Changed;
|
||
}
|
||
// List focused: graduated back-out → exit. The deselect tier
|
||
// keeps the "reply vs new session" contract — a selected row
|
||
// makes the dispatch input reply to it, and deselecting flips
|
||
// it back to "new session" mode without leaving the dashboard.
|
||
if self.selected.is_some() {
|
||
// Deselect → focus the `[+ New Agent]` button so
|
||
// the cursor lands on a stable target instead of
|
||
// floating in `None`. The Enter-with-empty-prompt
|
||
// path keys off this to create-and-open, and the
|
||
// header paints the focused button in
|
||
// `accent_user` so the user can see where the
|
||
// cursor went.
|
||
self.focus_new_agent_button();
|
||
// Pure-viewport scroll state is bound to the
|
||
// cursor — once the cursor goes away, the next
|
||
// render frame should re-anchor without snapping
|
||
// to a stale offset.
|
||
self.manual_scroll_active = false;
|
||
return InputOutcome::Changed;
|
||
}
|
||
if self.selected_section.is_some() {
|
||
// Deselect the section header → focus the `[+ New Agent]`
|
||
// button, mirroring the row-deselect tier above.
|
||
self.focus_new_agent_button();
|
||
self.manual_scroll_active = false;
|
||
return InputOutcome::Changed;
|
||
}
|
||
if self.selected_idle_overflow {
|
||
// Deselect the Idle overflow toggle → focus the
|
||
// `[+ New Agent]` button, mirroring the section tier.
|
||
self.focus_new_agent_button();
|
||
self.manual_scroll_active = false;
|
||
return InputOutcome::Changed;
|
||
}
|
||
return InputOutcome::Action(Action::ExitDashboard);
|
||
}
|
||
|
||
// Focus-aware routing of registry actions (the two-focus model):
|
||
// - ↑/↓ navigate the overview when it is focused OR the input is
|
||
// empty (a convenience so you can browse without first
|
||
// pressing Tab); with non-empty input they move the caret.
|
||
// - Bare letters (`j`/`k` vim nav, Space, …) act only when the
|
||
// OVERVIEW is focused — in the input they type.
|
||
// (`j`/`k` additionally require vim-mode, gated upstream by
|
||
// `lookup_with_mode`.) In search mode every bare letter types
|
||
// into the query.
|
||
// - Shortcuts help (`?`) is the exception: same convenience as
|
||
// arrows (list focused or empty draft). Non-empty draft types.
|
||
// - Ctrl combos (pin/stop/group/…) and Shift+arrows (reorder)
|
||
// act regardless of focus — they can't be typed.
|
||
if let Some(id) = from_registry {
|
||
let honor = match key.code {
|
||
KeyCode::Up | KeyCode::Down if key.modifiers.is_empty() => {
|
||
self.list_focused || (prompt_empty && !self.search_mode)
|
||
}
|
||
KeyCode::Char(_)
|
||
if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT =>
|
||
{
|
||
if id == crate::actions::ActionId::DashboardShortcutsHelp {
|
||
self.list_focused || (prompt_empty && !self.search_mode)
|
||
} else {
|
||
self.list_focused && !self.search_mode
|
||
}
|
||
}
|
||
_ => true,
|
||
};
|
||
if honor && let Some(outcome) = dashboard_action_for_id(id, &mut self.error_toast) {
|
||
return outcome;
|
||
}
|
||
}
|
||
|
||
// `/` is no longer special — it types a literal `/` into the
|
||
// prompt (handled by the widget fall-through below). Filtering
|
||
// lives behind the explicit `Ctrl+/` search mode instead, so a
|
||
// dispatched prompt can start with `/`, `s:`, `a:`, or `#`
|
||
// without being silently swallowed as a filter.
|
||
|
||
// Ctrl+S = "send + open": dispatch the prompt (or open the
|
||
// selected row / create from the button) AND walk straight into
|
||
// the detail view. This is the chord that replaced Shift+Enter
|
||
// (now freed, with Alt+Enter, for newline insertion). No-op in
|
||
// search mode, where the buffer is a filter query, not a prompt.
|
||
if key!('s', CONTROL).matches(key) && !self.search_mode {
|
||
return self.dispatch_send_action(true);
|
||
}
|
||
|
||
// Ctrl+M: ToggleMultiline is PromptFocused only, so hardcode here.
|
||
if key!('m', CONTROL).matches(key) && !self.search_mode {
|
||
return InputOutcome::Action(Action::SetMultilineMode(!self.multiline_mode));
|
||
}
|
||
|
||
if matches!(key.code, KeyCode::Enter) {
|
||
// Overview focused: attach / create (or send if button + draft).
|
||
if self.list_focused && key.modifiers.is_empty() {
|
||
if let Some(id) = self.selected.clone() {
|
||
return InputOutcome::Action(Action::DashboardAttach(id));
|
||
}
|
||
if self.new_agent_button_focused {
|
||
if !self.dispatch.text().trim().is_empty() {
|
||
return self.dispatch_send_action(false);
|
||
}
|
||
return InputOutcome::Action(Action::DashboardCreateNewAgentWithDetail);
|
||
}
|
||
return InputOutcome::Unchanged;
|
||
}
|
||
let mod_enter = crate::input::is_mod_enter(key);
|
||
if self.search_mode {
|
||
// Confirm filter; clear query so dispatch returns to ❯.
|
||
self.search_mode = false;
|
||
self.dispatch.set_text("");
|
||
return InputOutcome::Changed;
|
||
}
|
||
// slash_accepted_send: no-arg slash accept must submit, not newline.
|
||
let enter_is_newline =
|
||
!slash_accepted_send && compose_enter_is_newline(self.multiline_mode, mod_enter);
|
||
// Expand paste/file chips only for real bare Enter. Apple Terminal
|
||
// rescue yields bare Enter while is_mod_enter is true — that must
|
||
// send/newline, not expand (peek already gates the same way).
|
||
if !mod_enter
|
||
&& matches!(
|
||
self.dispatch.try_element_interaction(key),
|
||
Some(crate::views::prompt_widget::ElementInteraction::Inlined)
|
||
)
|
||
{
|
||
self.dispatch.refresh_slash(&self.models);
|
||
return InputOutcome::Changed;
|
||
}
|
||
if enter_is_newline {
|
||
// fall through for newline
|
||
} else {
|
||
return self.dispatch_send_action(false);
|
||
}
|
||
}
|
||
|
||
// Shift+Tab (DashboardCycleMode) is resolved through the registry
|
||
// `from_registry` path above — its `ActionDef` carries the three
|
||
// terminal encodings (`BackTab`, `BackTab`+SHIFT, `Tab`+SHIFT) as
|
||
// default/alt keys, so no hardcoded intercept is needed here.
|
||
|
||
// Tab toggles focus between the dispatch input and the overview
|
||
// list (the vim-style way to reach j/k navigation). When the
|
||
// slash / `@` dropdowns are open the intercepts above already
|
||
// consumed Tab (accept completion), so this only fires otherwise.
|
||
if matches!(key.code, KeyCode::Tab) && key.modifiers.is_empty() {
|
||
self.list_focused = !self.list_focused;
|
||
// Re-engage selection-follow so the viewport tracks the
|
||
// cursor once the list takes focus.
|
||
self.clear_manual_scroll();
|
||
return InputOutcome::Changed;
|
||
}
|
||
|
||
// Overview focused: the input is inactive. A printable key hands
|
||
// focus back to the input so the user can start composing; `i`
|
||
// (vim) enters the input WITHOUT typing the `i`, other bare
|
||
// letters in vim are swallowed (normal-mode), and in non-vim any
|
||
// printable returns focus AND is typed by the widget below. Nav /
|
||
// command keys were already consumed above.
|
||
if self.list_focused {
|
||
if matches!(key.code, KeyCode::Char(_))
|
||
&& (key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT)
|
||
{
|
||
if vim_mode {
|
||
if key.code == KeyCode::Char('i') && key.modifiers.is_empty() {
|
||
self.list_focused = false;
|
||
return InputOutcome::Changed;
|
||
}
|
||
return InputOutcome::Unchanged;
|
||
}
|
||
self.list_focused = false;
|
||
// fall through to the widget so the char is typed.
|
||
} else {
|
||
// Non-printable (Backspace/Home/…) while the overview is
|
||
// focused must NOT leak into the inactive input.
|
||
return InputOutcome::Unchanged;
|
||
}
|
||
}
|
||
|
||
// (error_toast already cleared at top of handle_key.)
|
||
|
||
// Forward to the prompt widget (single-line).
|
||
let old = self.dispatch.text().to_string();
|
||
let _ = self.dispatch.handle_key(key);
|
||
let new = self.dispatch.text().to_string();
|
||
if old != new {
|
||
// Live-update the filter as the user types ONLY in search
|
||
// mode — the dispatch buffer is then the search query.
|
||
// Outside search mode the buffer is a dispatch prompt and
|
||
// never touches the filter (so `s:`/`a:`/`#`/`/` prefixes
|
||
// dispatch verbatim). `parse_filter` still honours the
|
||
// `a:`/`s:`/`#` prefixes WITHIN search mode for power
|
||
// users; plain text is a substring match.
|
||
let mut filter_changed = false;
|
||
if self.search_mode {
|
||
let trimmed = new.trim();
|
||
self.filter = if trimmed.is_empty() {
|
||
Filter::None
|
||
} else {
|
||
Filter::from_value(parse_filter(trimmed))
|
||
};
|
||
filter_changed = true;
|
||
} else {
|
||
// Outside search mode the buffer is a dispatch prompt;
|
||
// refresh the slash snapshot so the `/command` dropdown
|
||
// opens / updates (and `@` context) as the user types.
|
||
self.dispatch.refresh_slash(&self.models);
|
||
}
|
||
if filter_changed {
|
||
// Live filter edits reshape the visible row set; the
|
||
// user's prior wheel-scrolled position no longer
|
||
// points at a meaningful row. Re-engage the snap so
|
||
// the viewport tracks selection again.
|
||
self.manual_scroll_active = false;
|
||
}
|
||
InputOutcome::Changed
|
||
} else {
|
||
InputOutcome::Unchanged
|
||
}
|
||
}
|
||
|
||
fn handle_mouse(&mut self, mouse: &crossterm::event::MouseEvent) -> InputOutcome {
|
||
use crossterm::event::{MouseButton, MouseEventKind};
|
||
|
||
self.last_mouse_pos = Some((mouse.column, mouse.row));
|
||
|
||
// Update hover state when the mouse moves over a row or the
|
||
// header's `[+ New Agent]` button.
|
||
if matches!(mouse.kind, MouseEventKind::Moved) {
|
||
let mut changed = self
|
||
.new_agent_button_hit
|
||
.update_hover(mouse.column, mouse.row);
|
||
changed |= self.location_hit.update_hover(mouse.column, mouse.row);
|
||
|
||
// Slash / @-file dropdown hover wins over row hover so the
|
||
// completion list tracks the pointer while open (mirrors
|
||
// agent-view mouse handling in `app/mouse.rs`).
|
||
if let Some(dd_area) = self.slash_dropdown_items_area {
|
||
let has_scrollbar = self.slash_dropdown_hit.has_scrollbar;
|
||
let on_scrollbar =
|
||
has_scrollbar && mouse.column >= dd_area.x + dd_area.width.saturating_sub(2);
|
||
let new_hover =
|
||
if dd_area.contains((mouse.column, mouse.row).into()) && !on_scrollbar {
|
||
self.slash_dropdown_hit
|
||
.row_items
|
||
.get((mouse.row - dd_area.y) as usize)
|
||
.copied()
|
||
} else {
|
||
None
|
||
};
|
||
changed |= self.dispatch.set_slash_hovered(new_hover);
|
||
} else {
|
||
changed |= self.dispatch.set_slash_hovered(None);
|
||
}
|
||
if let Some(dd_area) = self.file_search_dropdown_items_area {
|
||
let result_count = if self.peek.is_some() {
|
||
self.peek_reply.file_search.result_count()
|
||
} else {
|
||
self.dispatch.file_search.result_count()
|
||
};
|
||
let scroll_offset = if self.peek.is_some() {
|
||
self.peek_reply.file_search.scroll_offset()
|
||
} else {
|
||
self.dispatch.file_search.scroll_offset()
|
||
};
|
||
let has_scrollbar = result_count > dd_area.height as usize;
|
||
let on_scrollbar =
|
||
has_scrollbar && mouse.column >= dd_area.x + dd_area.width.saturating_sub(2);
|
||
let new_dd_hover =
|
||
if dd_area.contains((mouse.column, mouse.row).into()) && !on_scrollbar {
|
||
Some((mouse.row - dd_area.y) as usize + scroll_offset)
|
||
} else {
|
||
None
|
||
};
|
||
changed |= self.dropdown_file_search_mut().set_hovered(new_dd_hover);
|
||
} else {
|
||
changed |= self.dispatch.file_search.set_hovered(None);
|
||
if self.peek.is_some() {
|
||
changed |= self.peek_reply.file_search.set_hovered(None);
|
||
}
|
||
}
|
||
|
||
let new_hover = self
|
||
.row_rects
|
||
.iter()
|
||
.find(|(_, r)| {
|
||
mouse.column >= r.x
|
||
&& mouse.column < r.x + r.width
|
||
&& mouse.row >= r.y
|
||
&& mouse.row < r.y + r.height
|
||
})
|
||
.map(|(id, _)| id.clone());
|
||
if new_hover != self.hovered_row {
|
||
self.hovered_row = new_hover;
|
||
changed = true;
|
||
}
|
||
// Section-header hover → the renderer brightens its text.
|
||
let new_hover_section = self
|
||
.section_rects
|
||
.iter()
|
||
.find(|(_, r)| {
|
||
mouse.column >= r.x
|
||
&& mouse.column < r.x + r.width
|
||
&& mouse.row >= r.y
|
||
&& mouse.row < r.y + r.height
|
||
})
|
||
.map(|(key, _)| *key);
|
||
if new_hover_section != self.hovered_section {
|
||
self.hovered_section = new_hover_section;
|
||
changed = true;
|
||
}
|
||
// Idle overflow row hover → the renderer brightens its text.
|
||
let new_hover_overflow = self.idle_overflow_rect.is_some_and(|r| {
|
||
mouse.column >= r.x
|
||
&& mouse.column < r.x + r.width
|
||
&& mouse.row >= r.y
|
||
&& mouse.row < r.y + r.height
|
||
});
|
||
if new_hover_overflow != self.hovered_idle_overflow {
|
||
self.hovered_idle_overflow = new_hover_overflow;
|
||
changed = true;
|
||
}
|
||
if changed {
|
||
return InputOutcome::Changed;
|
||
}
|
||
return InputOutcome::Unchanged;
|
||
}
|
||
|
||
// Click on the peek-panel close button.
|
||
if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left))
|
||
&& let Some(rect) = self.peek_close_rect
|
||
&& mouse.column >= rect.x
|
||
&& mouse.column < rect.x + rect.width
|
||
&& mouse.row >= rect.y
|
||
&& mouse.row < rect.y + rect.height
|
||
{
|
||
// `set_peek(None)` enforces the
|
||
// peek/close-rect invariant atomically.
|
||
self.set_peek(None);
|
||
return InputOutcome::Changed;
|
||
}
|
||
|
||
// Peek reply input — mouse interaction with the `❯ reply` row
|
||
// (or the reject-feedback slot in question mode), mirroring the
|
||
// dispatch box's click-to-focus plus the agent prompt's drag
|
||
// text selection:
|
||
// - a left click inside the recorded rect focuses the reply
|
||
// and forwards to the widget so the caret lands under the
|
||
// pointer (double/triple-click word/line selection included);
|
||
// - Drag/Up anywhere continue a selection drag that STARTED in
|
||
// the input (the textarea tracks its drag state internally;
|
||
// stray Drag/Up events with no active drag are no-ops), so
|
||
// dragging past the box edge keeps selecting.
|
||
if self.peek.is_some() {
|
||
match mouse.kind {
|
||
MouseEventKind::Down(MouseButton::Left) => {
|
||
if let Some(rect) = self.peek_reply_rect
|
||
&& mouse.column >= rect.x
|
||
&& mouse.column < rect.x + rect.width
|
||
&& mouse.row >= rect.y
|
||
&& mouse.row < rect.y + rect.height
|
||
{
|
||
if let Some(p) = self.peek.as_mut() {
|
||
p.focused = true;
|
||
}
|
||
let _ = self.peek_reply.handle_mouse(mouse);
|
||
let now = Instant::now();
|
||
if self.last_prompt_click.is_some_and(|last| {
|
||
now.duration_since(last).as_millis() < PROMPT_MULTI_CLICK_MS
|
||
}) {
|
||
let _ = self.peek_reply.expand_paste_element_at_cursor();
|
||
}
|
||
self.last_prompt_click = Some(now);
|
||
return InputOutcome::Changed;
|
||
}
|
||
}
|
||
MouseEventKind::Drag(MouseButton::Left) | MouseEventKind::Up(MouseButton::Left)
|
||
if !self.peek_reply.text().is_empty() =>
|
||
{
|
||
let _ = self.peek_reply.handle_mouse(mouse);
|
||
return InputOutcome::Changed;
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
// Same Drag/Up treatment for the dispatch box, gated off while a
|
||
// peek is open (the dispatch input is hidden then) and in search
|
||
// mode (the search line renders outside the textarea, matching
|
||
// the Down forwarding below).
|
||
if self.peek.is_none()
|
||
&& !self.search_mode
|
||
&& !self.dispatch.text().is_empty()
|
||
&& matches!(
|
||
mouse.kind,
|
||
MouseEventKind::Drag(MouseButton::Left) | MouseEventKind::Up(MouseButton::Left)
|
||
)
|
||
{
|
||
let _ = self.dispatch.handle_mouse(mouse);
|
||
return InputOutcome::Changed;
|
||
}
|
||
|
||
if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
|
||
// Slash dropdown click must run BEFORE row attach: the model
|
||
// list (and other arg suggestions) paints over agent rows,
|
||
// so without this a mouse click falls through to
|
||
// `DashboardAttach` and opens the underlying session.
|
||
if let Some(dd_area) = self.slash_dropdown_items_area
|
||
&& dd_area.contains((mouse.column, mouse.row).into())
|
||
{
|
||
let snap = self.dispatch.slash_snapshot();
|
||
let has_scrollbar = self.slash_dropdown_hit.has_scrollbar;
|
||
let on_scrollbar =
|
||
has_scrollbar && mouse.column >= dd_area.x + dd_area.width.saturating_sub(2);
|
||
|
||
if on_scrollbar {
|
||
let click_frac = (mouse.row - dd_area.y) as f64 / dd_area.height.max(1) as f64;
|
||
let target = (click_frac * snap.matches.len() as f64) as usize;
|
||
let max = snap.matches.len().saturating_sub(1);
|
||
let delta = target.min(max) as isize - snap.selected as isize;
|
||
self.dispatch.slash_move_selection(delta);
|
||
self.dispatch.slash_preview_current_selection();
|
||
} else if let Some(&item_idx) = self
|
||
.slash_dropdown_hit
|
||
.row_items
|
||
.get((mouse.row - dd_area.y) as usize)
|
||
{
|
||
self.dispatch.set_slash_hovered(Some(item_idx));
|
||
if self.dispatch.select_slash_hovered() {
|
||
self.dispatch.slash_commit_preview();
|
||
self.dispatch.accept_slash_completion(&self.models);
|
||
}
|
||
}
|
||
self.list_focused = false;
|
||
return InputOutcome::Changed;
|
||
}
|
||
|
||
// File-search (`@`) dropdown click — same priority as slash:
|
||
// absorb the hit so it never attaches a session row under the
|
||
// list. Uses the on-screen picker (peek reply while peeking,
|
||
// otherwise the dispatch box).
|
||
if let Some(dd_area) = self.file_search_dropdown_items_area
|
||
&& dd_area.contains((mouse.column, mouse.row).into())
|
||
{
|
||
let result_count = if self.peek.is_some() {
|
||
self.peek_reply.file_search.result_count()
|
||
} else {
|
||
self.dispatch.file_search.result_count()
|
||
};
|
||
let scroll_offset = if self.peek.is_some() {
|
||
self.peek_reply.file_search.scroll_offset()
|
||
} else {
|
||
self.dispatch.file_search.scroll_offset()
|
||
};
|
||
let has_scrollbar = result_count > dd_area.height as usize;
|
||
let on_scrollbar =
|
||
has_scrollbar && mouse.column >= dd_area.x + dd_area.width.saturating_sub(2);
|
||
|
||
if on_scrollbar {
|
||
let click_frac = (mouse.row - dd_area.y) as f64 / dd_area.height.max(1) as f64;
|
||
let target = (click_frac * result_count as f64) as usize;
|
||
let max = result_count.saturating_sub(1);
|
||
let selected = if self.peek.is_some() {
|
||
self.peek_reply.file_search.selected()
|
||
} else {
|
||
self.dispatch.file_search.selected()
|
||
};
|
||
self.dropdown_file_search_mut()
|
||
.move_selection(target.min(max) as isize - selected as isize);
|
||
} else {
|
||
let row_idx = (mouse.row - dd_area.y) as usize + scroll_offset;
|
||
let fs = self.dropdown_file_search_mut();
|
||
fs.set_hovered(Some(row_idx));
|
||
if fs.select_hovered() {
|
||
if self.peek.is_some() {
|
||
self.peek_reply.accept_file_search_result();
|
||
} else {
|
||
self.dispatch.accept_file_search_result();
|
||
}
|
||
}
|
||
}
|
||
self.list_focused = false;
|
||
return InputOutcome::Changed;
|
||
}
|
||
|
||
// Click on the `[+ New Agent]` button — same outcome
|
||
// as Enter-with-empty-prompt while the button is
|
||
// focused. Routed through the dispatcher so the
|
||
// create-session + view-switch sequence stays in one
|
||
// place. The hit test runs BEFORE the row-click check
|
||
// so the button can sit inside the header rect
|
||
// without competing for clicks.
|
||
if self.new_agent_button_hit.contains(mouse.column, mouse.row) {
|
||
self.focus_new_agent_button();
|
||
self.manual_scroll_active = false;
|
||
return InputOutcome::Action(Action::DashboardCreateNewAgentWithDetail);
|
||
}
|
||
|
||
// Click on the header location label → open the location
|
||
// picker. Sits next to the `[+ New Agent]` check since both
|
||
// are header affordances in separate columns.
|
||
if self.location_hit.contains(mouse.column, mouse.row) {
|
||
return InputOutcome::Action(Action::DashboardOpenLocationPicker);
|
||
}
|
||
|
||
// Click on a section header → select it and toggle collapse.
|
||
// Checked before the row hit-test (separate layout region).
|
||
if let Some(key) = self
|
||
.section_rects
|
||
.iter()
|
||
.find(|(_, r)| {
|
||
mouse.column >= r.x
|
||
&& mouse.column < r.x + r.width
|
||
&& mouse.row >= r.y
|
||
&& mouse.row < r.y + r.height
|
||
})
|
||
.map(|(k, _)| *k)
|
||
{
|
||
self.focus_section(key);
|
||
self.toggle_section(key);
|
||
self.manual_scroll_active = false;
|
||
return InputOutcome::Changed;
|
||
}
|
||
|
||
// Click on the Idle "N more" overflow row →
|
||
// focus it and flip show-all (mirrors the section-header
|
||
// click). Checked before the row hit-test; the overflow row
|
||
// is never in `row_rects`.
|
||
if let Some(rect) = self.idle_overflow_rect
|
||
&& mouse.column >= rect.x
|
||
&& mouse.column < rect.x + rect.width
|
||
&& mouse.row >= rect.y
|
||
&& mouse.row < rect.y + rect.height
|
||
{
|
||
self.focus_idle_overflow();
|
||
self.toggle_idle_show_all();
|
||
self.manual_scroll_active = false;
|
||
return InputOutcome::Changed;
|
||
}
|
||
|
||
// Single-click attaches (opens the
|
||
// conversation popup) immediately. A prior double-click-within-500ms
|
||
// design felt unresponsive: users tap a row
|
||
// and expect the conversation to open. Modern TUI tools
|
||
// (gh-dash, k9s, lazygit) all use click-to-open on list
|
||
// entries; keyboard navigation (↑/↓) is the way to
|
||
// browse without attaching.
|
||
if let Some(id) = self
|
||
.row_rects
|
||
.iter()
|
||
.find(|(_, r)| {
|
||
mouse.column >= r.x
|
||
&& mouse.column < r.x + r.width
|
||
&& mouse.row >= r.y
|
||
&& mouse.row < r.y + r.height
|
||
})
|
||
.map(|(id, _)| id.clone())
|
||
{
|
||
// Clicking a row is selection-driven, so re-engage
|
||
// the clamp's snap-to-selection by clearing the
|
||
// manual-scroll flag. Without this, a click after a
|
||
// wheel-scroll would jump the viewport on the next
|
||
// frame (the bias-up pull-back kicks in).
|
||
self.manual_scroll_active = false;
|
||
// `focus_row` also clears `new_agent_button_focused`
|
||
// so the two cursor states stay mutually exclusive
|
||
// (clicking a row while the button was focused
|
||
// hands the cursor over to the row).
|
||
self.focus_row(id.clone());
|
||
self.last_click = Some((id.clone(), Instant::now()));
|
||
return InputOutcome::Action(Action::DashboardAttach(id));
|
||
}
|
||
|
||
// Click anywhere on the dispatch input box focuses it —
|
||
// i.e. clears `list_focused`. This must work whether or not
|
||
// vim mode is on: in vim mode the overview steals the
|
||
// keyboard (j/k nav), so without this a mouse user clicking
|
||
// the box would be stranded with no caret. Checked after the
|
||
// button + row hit tests since those sit in separate layout
|
||
// regions and shouldn't compete.
|
||
if let Some(rect) = self.dispatch_rect
|
||
&& mouse.column >= rect.x
|
||
&& mouse.column < rect.x + rect.width
|
||
&& mouse.row >= rect.y
|
||
&& mouse.row < rect.y + rect.height
|
||
{
|
||
self.list_focused = false;
|
||
// Forward the click so the caret lands where the user
|
||
// clicked. Skipped in search mode, where the prompt
|
||
// renders its own single-line cursor with a `Search:`
|
||
// prefix rather than through the textarea.
|
||
if !self.search_mode {
|
||
let _ = self.dispatch.handle_mouse(mouse);
|
||
let now = Instant::now();
|
||
if self.last_prompt_click.is_some_and(|last| {
|
||
now.duration_since(last).as_millis() < PROMPT_MULTI_CLICK_MS
|
||
}) && self.dispatch.expand_paste_element_at_cursor()
|
||
{
|
||
self.dispatch.refresh_slash(&self.models);
|
||
}
|
||
self.last_prompt_click = Some(now);
|
||
}
|
||
return InputOutcome::Changed;
|
||
}
|
||
}
|
||
|
||
InputOutcome::Unchanged
|
||
}
|
||
|
||
/// Route input to the open location picker. Caller has confirmed
|
||
/// `location_picker.is_some()` (the gate in [`Self::handle_input`]).
|
||
/// Keys flow through the shared [`crate::views::picker`] handler (nav +
|
||
/// query editing) except Enter, which is intercepted here to apply the
|
||
/// chosen path. After any query edit the directory listing is
|
||
/// refreshed; mouse flows through the modal chrome then the rows.
|
||
fn handle_location_picker_input(&mut self, ev: &Event) -> InputOutcome {
|
||
// Mouse is handled separately so the `location_picker` borrow
|
||
// below doesn't overlap the `&mut self` mouse helper.
|
||
if let Event::Mouse(mouse) = ev {
|
||
return self.handle_location_picker_mouse(mouse);
|
||
}
|
||
|
||
let Some(lp) = self.location_picker.as_mut() else {
|
||
return InputOutcome::Unchanged;
|
||
};
|
||
|
||
if let Event::Key(key) = ev
|
||
&& key.kind != KeyEventKind::Release
|
||
&& key.code == KeyCode::Enter
|
||
&& key.modifiers.is_empty()
|
||
{
|
||
lp.refresh_suggestions();
|
||
return match lp.chosen_input() {
|
||
Some(input) => InputOutcome::Action(Action::DashboardChangeLocation { input }),
|
||
None => InputOutcome::Unchanged,
|
||
};
|
||
}
|
||
|
||
// Tab — shell-style completion: fill the input with the selected
|
||
// row's path (tilde-collapsed) plus a trailing `/` so the next
|
||
// listing drills into it. No-op when nothing is selected.
|
||
if let Event::Key(key) = ev
|
||
&& key.kind != KeyEventKind::Release
|
||
&& key.code == KeyCode::Tab
|
||
&& key.modifiers.is_empty()
|
||
{
|
||
let visible = lp.visible_candidates();
|
||
let Some(c) = visible.get(lp.picker.selected) else {
|
||
return InputOutcome::Unchanged;
|
||
};
|
||
let mut filled = crate::project_picker::sources::display_path(&c.path);
|
||
if !filled.ends_with('/') {
|
||
filled.push('/');
|
||
}
|
||
lp.picker.query = filled;
|
||
lp.picker.query_cursor = lp.picker.query.len();
|
||
lp.picker.selected = 0;
|
||
lp.picker.scroll_offset = None;
|
||
// The path changed — drop any stale "Not a directory" error.
|
||
lp.error = None;
|
||
lp.refresh_suggestions();
|
||
return InputOutcome::Changed;
|
||
}
|
||
|
||
let entry_count = lp.visible_candidates().len();
|
||
let config = location_picker_config();
|
||
let query_before = lp.picker.query.clone();
|
||
let outcome =
|
||
crate::views::picker::handle_picker_input(ev, &mut lp.picker, entry_count, &config);
|
||
// When the user edits the path, drop the stale validation error so a
|
||
// corrected (possibly valid) path isn't shown next to a red
|
||
// "Not a directory" left over from the previous failed attempt.
|
||
if lp.picker.query != query_before {
|
||
lp.error = None;
|
||
}
|
||
// The query may have changed (typing / backspace / Ctrl+U) — re-list
|
||
// the parent directory if its path-mode parent moved.
|
||
lp.refresh_suggestions();
|
||
match outcome {
|
||
crate::views::picker::PickerOutcome::Closed => {
|
||
InputOutcome::Action(Action::DashboardCloseLocationPicker)
|
||
}
|
||
crate::views::picker::PickerOutcome::Changed => InputOutcome::Changed,
|
||
_ => InputOutcome::Unchanged,
|
||
}
|
||
}
|
||
|
||
/// Mouse handling for the open location picker: modal chrome (close
|
||
/// button / click-outside) first, then content-row click (select +
|
||
/// apply) and hover (move the cursor).
|
||
fn handle_location_picker_mouse(
|
||
&mut self,
|
||
mouse: &crossterm::event::MouseEvent,
|
||
) -> InputOutcome {
|
||
use crate::views::modal_window::{ModalWindowOutcome, handle_modal_mouse};
|
||
use crossterm::event::{MouseButton, MouseEventKind};
|
||
|
||
let Some(lp) = self.location_picker.as_mut() else {
|
||
return InputOutcome::Unchanged;
|
||
};
|
||
|
||
match handle_modal_mouse(&mut lp.window, mouse.kind, mouse.column, mouse.row) {
|
||
ModalWindowOutcome::CloseRequested => {
|
||
return InputOutcome::Action(Action::DashboardCloseLocationPicker);
|
||
}
|
||
ModalWindowOutcome::Handled => return InputOutcome::Changed,
|
||
_ => {}
|
||
}
|
||
|
||
// Worktree toggle button on the path row: a left-click flips
|
||
// `worktree_mode`, which (when the location is applied) arms the
|
||
// dashboard's worktree dispatch.
|
||
if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left))
|
||
&& lp.worktree_hit.contains(mouse.column, mouse.row)
|
||
{
|
||
lp.worktree_mode = !lp.worktree_mode;
|
||
return InputOutcome::Changed;
|
||
}
|
||
|
||
// Map the cursor to a content row (visible-list index) without
|
||
// holding the `content_hits` borrow past the lookup.
|
||
let hit_idx: Option<usize> = lp.content_hits.as_ref().and_then(|hits| {
|
||
hits.item_rects.iter().enumerate().find_map(|(i, r)| {
|
||
let inside = mouse.column >= r.x
|
||
&& mouse.column < r.x + r.width
|
||
&& mouse.row >= r.y
|
||
&& mouse.row < r.y + r.height;
|
||
inside.then(|| hits.entry_indices.get(i).copied().unwrap_or(i))
|
||
})
|
||
});
|
||
|
||
match mouse.kind {
|
||
MouseEventKind::Down(MouseButton::Left) => {
|
||
if let Some(entry_idx) = hit_idx {
|
||
let visible = lp.visible_candidates();
|
||
if let Some(c) = visible.get(entry_idx) {
|
||
let input = c.path.to_string_lossy().into_owned();
|
||
return InputOutcome::Action(Action::DashboardChangeLocation { input });
|
||
}
|
||
}
|
||
InputOutcome::Unchanged
|
||
}
|
||
MouseEventKind::Moved => {
|
||
// Brighten the worktree button when hovered (its rect was
|
||
// set on the prior render).
|
||
let wt_changed = lp.worktree_hit.update_hover(mouse.column, mouse.row);
|
||
let row_changed = match hit_idx {
|
||
Some(entry_idx) if lp.picker.selected != entry_idx => {
|
||
lp.picker.selected = entry_idx;
|
||
true
|
||
}
|
||
_ => false,
|
||
};
|
||
if wt_changed || row_changed {
|
||
InputOutcome::Changed
|
||
} else {
|
||
InputOutcome::Unchanged
|
||
}
|
||
}
|
||
_ => InputOutcome::Unchanged,
|
||
}
|
||
}
|
||
|
||
/// Route input to the worktree-label dialog while it is open. Submit
|
||
/// confirms via [`Action::DashboardConfirmWorktree`] (the dispatcher
|
||
/// creates the worktree session and replays any stashed prompt); Esc /
|
||
/// Ctrl+C cancels, clearing the dialog and the stashed prompt. Caller
|
||
/// has confirmed `worktree_dialog.is_some()` (the gate in
|
||
/// [`Self::handle_input`]).
|
||
fn handle_worktree_dialog_input(&mut self, ev: &Event) -> InputOutcome {
|
||
use crate::app::app_view::NewWorktreeDialogOutcome;
|
||
let Some(dialog) = self.worktree_dialog.as_mut() else {
|
||
return InputOutcome::Unchanged;
|
||
};
|
||
let Event::Key(key) = ev else {
|
||
// Consume mouse / resize while the dialog is modal.
|
||
return InputOutcome::Unchanged;
|
||
};
|
||
if key.kind == KeyEventKind::Release {
|
||
return InputOutcome::Unchanged;
|
||
}
|
||
match dialog.handle_key(key) {
|
||
NewWorktreeDialogOutcome::Submitted(label) => {
|
||
self.worktree_dialog = None;
|
||
InputOutcome::Action(Action::DashboardConfirmWorktree { label })
|
||
}
|
||
NewWorktreeDialogOutcome::Cancelled => {
|
||
self.worktree_dialog = None;
|
||
// Don't silently discard the user's typed prompt — restore the
|
||
// stashed prompt (from the prompt-send path) to the dispatch
|
||
// input so they can resend it instead of losing it. Mirrors the
|
||
// restore in `dispatch_dashboard_confirm_worktree`'s not-a-repo
|
||
// error path. When the dialog was opened from the [+ New Agent]
|
||
// button there's no stash, so `take()` yields `None` and the
|
||
// input is left untouched.
|
||
if let Some(prompt) = self.pending_worktree_prompt.take() {
|
||
self.dispatch.restore(prompt);
|
||
}
|
||
InputOutcome::Changed
|
||
}
|
||
NewWorktreeDialogOutcome::Changed => InputOutcome::Changed,
|
||
NewWorktreeDialogOutcome::Unchanged => InputOutcome::Unchanged,
|
||
}
|
||
}
|
||
|
||
/// Route a top-level event to the open shortcuts cheatsheet
|
||
/// modal. Caller has already confirmed `shortcuts_modal.is_some()`
|
||
/// — that gate sits in [`Self::handle_input`] so the no-modal
|
||
/// fast path stays small. Returns an `InputOutcome::Action`
|
||
/// when the modal asked to close (so the dispatcher can clear
|
||
/// the field through the same channel that the close-button
|
||
/// click uses), `Changed` for any visual mutation, and
|
||
/// `Unchanged` otherwise. Mouse events route through
|
||
/// `shortcuts_help::handle_mouse`; key events go through the
|
||
/// chrome + picker pipeline via `handle_modal_key`.
|
||
fn handle_shortcuts_modal_input(&mut self, ev: &Event) -> InputOutcome {
|
||
use crate::views::shortcuts_help::{
|
||
ModalKeyOutcome, ShortcutsHelpOutcome, handle_modal_key, handle_mouse,
|
||
toggle_membership,
|
||
};
|
||
|
||
let Some(modal) = self.shortcuts_modal.as_mut() else {
|
||
return InputOutcome::Unchanged;
|
||
};
|
||
match ev {
|
||
Event::Key(key) if key.kind != KeyEventKind::Release => {
|
||
match handle_modal_key(
|
||
key,
|
||
&modal.entries,
|
||
&mut modal.state,
|
||
&mut modal.window,
|
||
modal.filter_active,
|
||
&modal.collapsed_sections,
|
||
&modal.expanded_ids,
|
||
&mut modal.mode,
|
||
/* compact */ false,
|
||
) {
|
||
ModalKeyOutcome::Close => {
|
||
InputOutcome::Action(Action::DashboardCloseShortcutsHelp)
|
||
}
|
||
ModalKeyOutcome::ToggleFilter => {
|
||
modal.filter_active = !modal.filter_active;
|
||
modal.state.selected = 0;
|
||
InputOutcome::Changed
|
||
}
|
||
ModalKeyOutcome::ToggleSection(idx) => {
|
||
toggle_membership(&mut modal.collapsed_sections, idx);
|
||
InputOutcome::Changed
|
||
}
|
||
ModalKeyOutcome::ToggleExpand(action_id) => {
|
||
toggle_membership(&mut modal.expanded_ids, action_id);
|
||
InputOutcome::Changed
|
||
}
|
||
ModalKeyOutcome::Changed => InputOutcome::Changed,
|
||
ModalKeyOutcome::Unchanged => InputOutcome::Unchanged,
|
||
}
|
||
}
|
||
Event::Mouse(mouse) => {
|
||
// Chrome first — the `[✗]` close button (click + hover
|
||
// brightening) and click-outside-to-close live on the
|
||
// modal-window chrome, not in the picker content. The
|
||
// picker's own `hit.close_button` is a dead
|
||
// `Rect::default()`, so skipping this step left the
|
||
// button inert. Mirrors `agent_view::handle_modal_mouse`.
|
||
match crate::views::modal_window::handle_modal_mouse(
|
||
&mut modal.window,
|
||
mouse.kind,
|
||
mouse.column,
|
||
mouse.row,
|
||
) {
|
||
crate::views::modal_window::ModalWindowOutcome::CloseRequested => {
|
||
return InputOutcome::Action(Action::DashboardCloseShortcutsHelp);
|
||
}
|
||
crate::views::modal_window::ModalWindowOutcome::Handled => {
|
||
return InputOutcome::Changed;
|
||
}
|
||
// Everything else (incl. wheel) belongs to the
|
||
// picker content below.
|
||
_ => {}
|
||
}
|
||
match handle_mouse(
|
||
mouse,
|
||
&modal.entries,
|
||
&mut modal.state,
|
||
modal.filter_active,
|
||
&modal.collapsed_sections,
|
||
&mut modal.mode,
|
||
) {
|
||
ShortcutsHelpOutcome::Close => {
|
||
InputOutcome::Action(Action::DashboardCloseShortcutsHelp)
|
||
}
|
||
ShortcutsHelpOutcome::ToggleFilter => {
|
||
modal.filter_active = !modal.filter_active;
|
||
modal.state.selected = 0;
|
||
InputOutcome::Changed
|
||
}
|
||
ShortcutsHelpOutcome::ToggleSection(idx) => {
|
||
toggle_membership(&mut modal.collapsed_sections, idx);
|
||
InputOutcome::Changed
|
||
}
|
||
// Unreachable today: handle_mouse never yields ToggleExpand (a row click opens detail); kept for exhaustiveness.
|
||
ShortcutsHelpOutcome::ToggleExpand(action_id) => {
|
||
toggle_membership(&mut modal.expanded_ids, action_id);
|
||
InputOutcome::Changed
|
||
}
|
||
ShortcutsHelpOutcome::Changed => InputOutcome::Changed,
|
||
ShortcutsHelpOutcome::Unchanged => InputOutcome::Unchanged,
|
||
}
|
||
}
|
||
_ => InputOutcome::Unchanged,
|
||
}
|
||
}
|
||
|
||
/// Clamp the cursor to a still-visible row when the previous
|
||
/// selection has disappeared. A deliberately-cleared selection
|
||
/// (`None`) is PRESERVED — the dashboard's
|
||
/// "no row selected → dispatch creates a new session,
|
||
/// row selected → dispatch replies to that agent" contract
|
||
/// hinges on `None` being a valid steady state. The previous
|
||
/// auto-promotion-to-first-row behaviour would have hijacked
|
||
/// the user's reply path the moment they pressed Esc to
|
||
/// deselect.
|
||
pub fn reanchor_selection(&mut self, rows: &[DashboardRow]) {
|
||
// Section-cursor validation — a selected section header can go
|
||
// stale without any grouping toggle: a `s:state` filter
|
||
// suppresses all state headers, and row churn (the last
|
||
// Working agent going idle) removes the header outright.
|
||
// Re-derive the focusable set the renderer is about to paint
|
||
// and, when the cursor's header is gone, move it to the
|
||
// `[+ New Agent]` button (mirroring `toggle_grouping`) so the
|
||
// footer hints and the Right/Left/Enter collapse keys never
|
||
// act on an invisible section.
|
||
let focusables = super::render::focusables(
|
||
rows,
|
||
self.grouping,
|
||
&self.filter,
|
||
&self.collapsed_sections,
|
||
self.idle_show_all,
|
||
self.search_mode,
|
||
);
|
||
if let Some(key) = self.selected_section {
|
||
let alive = focusables
|
||
.iter()
|
||
.any(|f| matches!(f, Focusable::Section(k) if *k == key));
|
||
if !alive {
|
||
self.focus_new_agent_button();
|
||
}
|
||
}
|
||
// Idle-overflow cursor validation — the "N more" toggle row
|
||
// disappears when the Idle group shrinks below the cap (or the
|
||
// user expands it, which removes the fold). A stranded cursor
|
||
// there would leave Enter/footer hints acting on nothing, so
|
||
// fall back to the `[+ New Agent]` button.
|
||
if self.selected_idle_overflow
|
||
&& !focusables
|
||
.iter()
|
||
.any(|f| matches!(f, Focusable::IdleOverflow))
|
||
{
|
||
self.focus_new_agent_button();
|
||
}
|
||
// Row-cursor visibility — a selected row can vanish WITHOUT
|
||
// leaving `rows`: state churn can migrate it into a collapsed
|
||
// section (e.g. a selected Idle row starts Working while
|
||
// "Working" is collapsed). The existence check below wouldn't
|
||
// catch that (the row is still in `rows`), leaving the cursor
|
||
// on an invisible row — footer hints and peek for a row you
|
||
// can't see, and ↑/↓ resetting to the top. Move the cursor to
|
||
// the section header that hid the row (the collapsed header is
|
||
// always visible) so the user is one `→`/`Enter` away from
|
||
// revealing it again.
|
||
if let Some(sel) = self.selected.clone() {
|
||
let visible = focusables
|
||
.iter()
|
||
.any(|f| matches!(f, Focusable::Row(id) if *id == sel));
|
||
if !visible
|
||
&& let Some(key) =
|
||
super::render::section_of_row(rows, self.grouping, &self.filter, &sel)
|
||
{
|
||
self.focus_section(key);
|
||
}
|
||
}
|
||
// Skip non-selectable placeholders ("… N more").
|
||
let selectable: Vec<&DashboardRow> =
|
||
rows.iter().filter(|r| !r.is_more_placeholder).collect();
|
||
if selectable.is_empty() {
|
||
self.selected = None;
|
||
return;
|
||
}
|
||
if let Some(sel) = self.selected.as_ref()
|
||
&& !selectable.iter().any(|r| r.id == *sel)
|
||
{
|
||
// The previously selected row was filtered out / closed
|
||
// / lost its parent. Drop the cursor — re-selecting is
|
||
// the user's job.
|
||
self.selected = None;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Strict Enter swap for compose surfaces (agent prompt parity).
|
||
/// Multiline off: Shift/Alt (or rescued) Enter → newline.
|
||
/// Multiline on: bare Enter → newline; Shift/Alt → send/create/open.
|
||
fn compose_enter_is_newline(multiline: bool, mod_enter: bool) -> bool {
|
||
multiline != mod_enter
|
||
}
|
||
|
||
/// Exhaustive map from `ActionId` → `InputOutcome` for
|
||
/// dashboard-focused actions. Adding a new `Dashboard*` `ActionId`
|
||
/// without wiring it here is a compile error (the `_` arm is gone).
|
||
///
|
||
/// Returns `None` for non-dashboard `ActionId`s; the caller falls
|
||
/// through to widget input.
|
||
///
|
||
/// Convention for the contributor adding a NEW
|
||
/// `ActionId` variant in the future: if the new variant is
|
||
/// dashboard-specific, add a `Some(...)` arm; otherwise add it to
|
||
/// the `None` "Non-dashboard" arm. The match below is exhaustive on
|
||
/// purpose — there is no `_` arm — so the compiler will fail the
|
||
/// build if you forget. A future refactor could carve a separate
|
||
/// `DashboardActionId` enum to make this constraint type-system-enforced,
|
||
/// but the current convention + exhaustive match is sufficient.
|
||
fn dashboard_action_for_id(
|
||
id: crate::actions::ActionId,
|
||
error_toast: &mut Option<String>,
|
||
) -> Option<InputOutcome> {
|
||
use crate::actions::ActionId;
|
||
match id {
|
||
ActionId::DashboardSelectNext => Some(InputOutcome::Action(Action::DashboardSelectNext)),
|
||
ActionId::DashboardSelectPrev => Some(InputOutcome::Action(Action::DashboardSelectPrev)),
|
||
ActionId::DashboardTogglePin => Some(InputOutcome::Action(Action::DashboardTogglePin)),
|
||
ActionId::DashboardBeginRename => Some(InputOutcome::Action(Action::DashboardBeginRename)),
|
||
ActionId::DashboardStop => Some(InputOutcome::Action(Action::DashboardStop)),
|
||
ActionId::DashboardCycleMode => Some(InputOutcome::Action(Action::DashboardCycleMode)),
|
||
ActionId::DashboardToggleAutoApprove => {
|
||
Some(InputOutcome::Action(Action::DashboardToggleAutoApprove))
|
||
}
|
||
ActionId::DashboardToggleWorktree => {
|
||
Some(InputOutcome::Action(Action::DashboardToggleWorktree))
|
||
}
|
||
ActionId::DashboardOpenLocationPicker => {
|
||
Some(InputOutcome::Action(Action::DashboardOpenLocationPicker))
|
||
}
|
||
ActionId::DashboardToggleGrouping => {
|
||
Some(InputOutcome::Action(Action::DashboardToggleGrouping))
|
||
}
|
||
ActionId::DashboardReorderUp => Some(InputOutcome::Action(Action::DashboardReorderUp)),
|
||
ActionId::DashboardReorderDown => Some(InputOutcome::Action(Action::DashboardReorderDown)),
|
||
ActionId::DashboardShortcutsHelp => {
|
||
// Open a real cheatsheet modal (mirroring the
|
||
// agent view's `ActiveModal::ShortcutsHelp`) instead
|
||
// of stuffing a single-line hint into the dispatch
|
||
// input via `error_toast`. The previous toast bled
|
||
// through the prompt-placeholder slot, which the user
|
||
// expected to be reserved for *their* typing target.
|
||
// The modal opens via the action dispatcher so the
|
||
// build-entries side-effect lives next to other
|
||
// dashboard dispatchers in `app::dispatch`.
|
||
let _ = error_toast;
|
||
Some(InputOutcome::Action(Action::DashboardOpenShortcutsHelp))
|
||
}
|
||
ActionId::DashboardExit => {
|
||
// Esc is handled in the cascade above. A user-rebind of
|
||
// exit (e.g. F1) lands here.
|
||
Some(InputOutcome::Action(Action::ExitDashboard))
|
||
}
|
||
// Non-dashboard ActionIds: fall through to the widget. This
|
||
// is the ONLY arm that should ever match; the compiler will
|
||
// flag a missing case when a new Dashboard* action is added.
|
||
ActionId::SendPrompt
|
||
| ActionId::InterjectPrompt
|
||
| ActionId::ScrollUp
|
||
| ActionId::ScrollDown
|
||
| ActionId::PageUp
|
||
| ActionId::PageDown
|
||
| ActionId::HalfPageUp
|
||
| ActionId::HalfPageDown
|
||
| ActionId::GotoTop
|
||
| ActionId::GotoBottom
|
||
| ActionId::SelectNext
|
||
| ActionId::SelectPrev
|
||
| ActionId::NextTurn
|
||
| ActionId::PrevTurn
|
||
| ActionId::NextResponse
|
||
| ActionId::PrevResponse
|
||
| ActionId::Collapse
|
||
| ActionId::Expand
|
||
| ActionId::ToggleFold
|
||
| ActionId::ToggleExpandAll
|
||
| ActionId::ExpandAllThinking
|
||
| ActionId::ToggleRaw
|
||
| ActionId::ToggleMouseCapture
|
||
| ActionId::NextModel
|
||
| ActionId::CancelTurn
|
||
| ActionId::ToggleYolo
|
||
| ActionId::ToggleMultiline
|
||
| ActionId::FocusPrompt
|
||
| ActionId::FocusScrollback
|
||
| ActionId::CopyBlockContent
|
||
| ActionId::CopyBlockMeta
|
||
| ActionId::OpenBlockViewer
|
||
| ActionId::OpenNextLink
|
||
| ActionId::OpenPrevLink
|
||
| ActionId::ToggleTodos
|
||
| ActionId::ToggleTasks
|
||
| ActionId::ToggleQueue
|
||
| ActionId::OpenSessions
|
||
| ActionId::OpenExtensions
|
||
| ActionId::SendToBackground
|
||
| ActionId::CycleMode
|
||
| ActionId::BashMode
|
||
| ActionId::Rewind
|
||
| ActionId::KillBgTask
|
||
| ActionId::DumpInputLog
|
||
| ActionId::Quit
|
||
| ActionId::NewSession
|
||
| ActionId::ExitSession
|
||
| ActionId::NewSessionInWorktree
|
||
| ActionId::CommandPalette
|
||
| ActionId::ModelPicker
|
||
| ActionId::ShortcutsHelp
|
||
| ActionId::OpenSettings
|
||
| ActionId::OpenDashboard
|
||
// Overlay actions are intercepted at the AppView level
|
||
// before they reach the dashboard's own input loop; they
|
||
// can never arrive here.
|
||
| ActionId::DashboardOverlayExit
|
||
| ActionId::DashboardOverlayPrev
|
||
| ActionId::DashboardOverlayNext
|
||
| ActionId::DashboardOverlayStop => None,
|
||
}
|
||
}
|
||
|
||
fn handle_rename_key(draft: &mut RenameDraft, key: &KeyEvent) -> InputOutcome {
|
||
use crate::input::key::is_altgr;
|
||
// Reject Ctrl/Alt-modified character keys so
|
||
// Ctrl+R / Ctrl+A / Ctrl+V don't smuggle a bare letter into the
|
||
// draft. Ctrl+C is explicitly mapped to cancel.
|
||
if key.modifiers.contains(KeyModifiers::CONTROL)
|
||
&& !is_altgr(key.modifiers)
|
||
&& let KeyCode::Char(c) = key.code
|
||
{
|
||
if c == 'c' {
|
||
return InputOutcome::Action(Action::DashboardCancelRename);
|
||
}
|
||
return InputOutcome::Unchanged;
|
||
}
|
||
if key.modifiers.contains(KeyModifiers::ALT) && !is_altgr(key.modifiers) {
|
||
return InputOutcome::Unchanged;
|
||
}
|
||
match key.code {
|
||
KeyCode::Esc => InputOutcome::Action(Action::DashboardCancelRename),
|
||
KeyCode::Enter => InputOutcome::Action(Action::DashboardCommitRename),
|
||
KeyCode::Backspace => {
|
||
draft.draft.pop();
|
||
InputOutcome::Action(Action::DashboardRenameInput(draft.draft.clone()))
|
||
}
|
||
KeyCode::Char(c) => {
|
||
// Reject control characters and zero-width chars.
|
||
if c.is_control() {
|
||
return InputOutcome::Unchanged;
|
||
}
|
||
// Cap at 100 chars to match the worktree dialog input.
|
||
if draft.draft.chars().count() < 100 {
|
||
draft.draft.push(c);
|
||
}
|
||
InputOutcome::Action(Action::DashboardRenameInput(draft.draft.clone()))
|
||
}
|
||
_ => InputOutcome::Unchanged,
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Filter parser (edge case 11)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Parse a filter expression from the dispatch input.
|
||
///
|
||
/// Rules per edge case 11:
|
||
/// - `a:` (empty) → no-op (clear filter).
|
||
/// - `a:<name>` → match by agent label (case-insensitive substring).
|
||
/// - `s:` (empty) → substring match.
|
||
/// - `s:<state>` → match by row state; accepts synonyms
|
||
/// `needs-input`/`needs_input`/`needsinput`/`blocked`/`completed`/
|
||
/// `failed`/`idle`/`working`. Unknown values fall back to substring.
|
||
/// - `#<n>` → Phase 2 stub: treat as substring filter (PR support is
|
||
/// out of scope; we still match against label + cwd so users see
|
||
/// nothing useless on screen).
|
||
/// - Anything else → substring on label + cwd.
|
||
pub fn parse_filter(text: &str) -> FilterValue {
|
||
let trimmed = text.trim();
|
||
if let Some(rest) = trimmed.strip_prefix("a:") {
|
||
let rest = rest.trim();
|
||
if rest.is_empty() {
|
||
FilterValue::None
|
||
} else {
|
||
FilterValue::Agent(rest.to_string())
|
||
}
|
||
} else if let Some(rest) = trimmed.strip_prefix("s:") {
|
||
let rest = rest.trim();
|
||
if rest.is_empty() {
|
||
// `s:` empty is consistent with `a:` empty:
|
||
// both clear the filter.
|
||
FilterValue::None
|
||
} else if let Some(rs) = parse_row_state_token(rest) {
|
||
FilterValue::State(rs)
|
||
} else {
|
||
// Unknown state token falls back to substring
|
||
// on the full `s:foobar` so the user sees feedback (their
|
||
// typed text is matched against labels) AND realises the
|
||
// state path didn't take effect.
|
||
FilterValue::Substring(rest.to_string())
|
||
}
|
||
} else if let Some(rest) = trimmed.strip_prefix('#') {
|
||
// `#<n>` keeps the `#` in the substring needle so
|
||
// it never matches arbitrary digits in labels. PR filtering
|
||
// is reserved for a future revision.
|
||
FilterValue::Substring(format!("#{rest}"))
|
||
} else {
|
||
FilterValue::Substring(trimmed.to_string())
|
||
}
|
||
}
|
||
|
||
/// Parse a `RowState` from a user token. Accepts common synonyms.
|
||
pub fn parse_row_state_token(s: &str) -> Option<RowState> {
|
||
let normalised: String = s
|
||
.chars()
|
||
.filter_map(|c| {
|
||
if c == '-' || c == '_' || c == ' ' {
|
||
None
|
||
} else {
|
||
Some(c.to_ascii_lowercase())
|
||
}
|
||
})
|
||
.collect();
|
||
match normalised.as_str() {
|
||
"needsinput" | "needs" | "input" => Some(RowState::NeedsInput),
|
||
"working" | "busy" | "running" => Some(RowState::Working),
|
||
"idle" => Some(RowState::Idle),
|
||
"inactive" | "dormant" => Some(RowState::Inactive),
|
||
"completed" | "done" => Some(RowState::Completed),
|
||
"failed" | "errored" | "cancelled" | "canceled" => Some(RowState::Failed),
|
||
"blocked" | "paused" => Some(RowState::Blocked),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Persistence I/O
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Read the persisted `[dashboard].enabled` flag (defaults to `true`).
|
||
///
|
||
/// Lenient: any error or unparseable value returns `None`, which the
|
||
/// caller interprets as "use the default".
|
||
pub fn load_persisted_enabled() -> Option<bool> {
|
||
let path = config_path()?;
|
||
let content = std::fs::read_to_string(&path).ok()?;
|
||
let doc: toml_edit::DocumentMut = content.parse().ok()?;
|
||
doc.get("dashboard")
|
||
.and_then(|d| d.get("enabled"))
|
||
.and_then(|v| v.as_bool())
|
||
}
|
||
|
||
/// Load the full persisted dashboard from `~/.kigi/config.toml`.
|
||
///
|
||
/// Returns `None` only when the file is missing or completely unreadable.
|
||
/// Malformed individual fields fall back to defaults silently (edge case
|
||
/// 12).
|
||
pub fn load_persisted() -> Option<PersistedDashboard> {
|
||
let path = config_path()?;
|
||
load_persisted_from_path(&path)
|
||
}
|
||
|
||
/// Path-taking variant of [`load_persisted`] so the
|
||
/// on-disk round-trip can be exercised in tests against a `tempfile::TempDir`.
|
||
/// Falls back to defaults when the table or individual fields are
|
||
/// malformed.
|
||
pub fn load_persisted_from_path(path: &std::path::Path) -> Option<PersistedDashboard> {
|
||
let content = std::fs::read_to_string(path).ok()?;
|
||
let doc: toml_edit::DocumentMut = content.parse().ok()?;
|
||
let table = doc.get("dashboard")?;
|
||
let enabled = table
|
||
.get("enabled")
|
||
.and_then(|v| v.as_bool())
|
||
.unwrap_or(true);
|
||
let grouping = table
|
||
.get("grouping")
|
||
.and_then(|v| v.as_str())
|
||
.and_then(|s| match s {
|
||
"directory" | "dir" => Some(Grouping::Directory),
|
||
"state" => Some(Grouping::State),
|
||
_ => None,
|
||
})
|
||
.unwrap_or(Grouping::State);
|
||
let pinned: BTreeSet<PersistedRowId> = match table.get("pinned") {
|
||
Some(item) => parse_persist_keys(item),
|
||
None => BTreeSet::new(),
|
||
};
|
||
let reorder: Vec<PersistedRowId> = match table.get("reorder") {
|
||
Some(item) => parse_persist_key_list(item),
|
||
None => Vec::new(),
|
||
};
|
||
Some(PersistedDashboard {
|
||
enabled,
|
||
grouping,
|
||
pinned,
|
||
reorder,
|
||
})
|
||
}
|
||
|
||
/// Synchronous, blocking write of the persisted dashboard config.
|
||
/// Spawned from [`crate::app::actions::Effect`] handlers via `spawn_blocking`.
|
||
///
|
||
/// Short-circuits when `read_config_document_for_edit`
|
||
/// returns `None`. That helper returns `None` only when the file is
|
||
/// non-empty AND unparseable, meaning we MUST NOT overwrite it (the
|
||
/// file may contain user data we cannot interpret). Without this
|
||
/// guard, a single dashboard pin would clobber every other table in
|
||
/// `~/.kigi/config.toml` (`[ui]`, `[hints]`, `[mcpServers]`, …).
|
||
///
|
||
/// Atomic write via `<path>.dashboard.tmp.<pid>`
|
||
/// + rename, so concurrent readers never observe a half-truncated file.
|
||
pub fn write_persisted(p: &PersistedDashboard) -> std::io::Result<()> {
|
||
let path = config_path()
|
||
.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no grok home"))?;
|
||
write_persisted_to_path(&path, p)
|
||
}
|
||
|
||
/// Path-taking variant of [`write_persisted`] so the
|
||
/// on-disk round-trip can be exercised in tests against a
|
||
/// `tempfile::TempDir`.
|
||
pub fn write_persisted_to_path(
|
||
path: &std::path::Path,
|
||
p: &PersistedDashboard,
|
||
) -> std::io::Result<()> {
|
||
if let Some(parent) = path.parent() {
|
||
std::fs::create_dir_all(parent)?;
|
||
}
|
||
let mut doc = match crate::config_toml_edit::read_config_document_for_edit(path) {
|
||
Some(d) => d,
|
||
None => {
|
||
// File exists but is unparseable. Refuse to overwrite —
|
||
// doing so would erase every other table.
|
||
tracing::warn!(
|
||
path = %path.display(),
|
||
"refusing to persist dashboard: config.toml is non-empty and unparseable"
|
||
);
|
||
return Ok(());
|
||
}
|
||
};
|
||
let dash = doc.entry("dashboard").or_insert(toml_edit::table());
|
||
let Some(t) = dash.as_table_mut() else {
|
||
return Ok(());
|
||
};
|
||
t["enabled"] = toml_edit::value(p.enabled);
|
||
t["grouping"] = toml_edit::value(match p.grouping {
|
||
Grouping::State => "state",
|
||
Grouping::Directory => "directory",
|
||
});
|
||
let mut pin_arr = toml_edit::Array::new();
|
||
for id in &p.pinned {
|
||
pin_arr.push(id.to_key());
|
||
}
|
||
t["pinned"] = toml_edit::value(pin_arr);
|
||
let mut reorder_arr = toml_edit::Array::new();
|
||
for id in &p.reorder {
|
||
reorder_arr.push(id.to_key());
|
||
}
|
||
t["reorder"] = toml_edit::value(reorder_arr);
|
||
// The onboarding hint was removed — drop the stale table so old
|
||
// configs don't carry a dead `[dashboard.onboarding]` key forever.
|
||
t.remove("onboarding");
|
||
atomic_write(path, doc.to_string().as_bytes())
|
||
}
|
||
|
||
/// Atomic write via `<path>.dashboard.tmp.<pid>` + `rename`. Concurrent
|
||
/// readers see either the old file or the new file, never a partial
|
||
/// truncated copy that would parse as `None` and trigger the
|
||
/// catastrophic clobber on the next writer.
|
||
///
|
||
/// The bytes are explicitly `sync_all()`'d
|
||
/// before the rename, so a power loss between the syscall return and
|
||
/// the OS flush cannot leave behind a renamed-but-zero-length file.
|
||
///
|
||
/// Also fsync the parent directory after the rename
|
||
/// so the metadata change (the rename itself) is durable across power
|
||
/// loss on filesystems where the directory entry isn't implicitly
|
||
/// synced by the file's `sync_all`. Best-effort: a failure to open or
|
||
/// fsync the parent is logged at `debug` but does not propagate (the
|
||
/// rename itself succeeded; durability of the directory entry is the
|
||
/// only loss).
|
||
fn atomic_write(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> {
|
||
use std::io::Write;
|
||
let pid = std::process::id();
|
||
let tmp = path.with_extension(format!("toml.dashboard.tmp.{pid}"));
|
||
{
|
||
let mut f = std::fs::File::create(&tmp)?;
|
||
f.write_all(bytes)?;
|
||
// Ensure the bytes are physically on disk before the rename.
|
||
// Without this, the rename can complete and the file system
|
||
// can later observe the renamed inode pointing at zeroed data.
|
||
f.sync_all()?;
|
||
}
|
||
std::fs::rename(&tmp, path)?;
|
||
// Parent directory fsync (defense in depth on
|
||
// unusual filesystems / network mounts).
|
||
if let Some(parent) = path.parent()
|
||
&& let Ok(dir) = std::fs::File::open(parent)
|
||
{
|
||
// sync_all on a directory is allowed on Unix and is a no-op
|
||
// on platforms that don't support it. Swallow errors: the
|
||
// rename succeeded, and the durability question is moot if
|
||
// the OS won't fsync the directory.
|
||
let _ = dir.sync_all();
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn config_path() -> Option<PathBuf> {
|
||
let home = kigi_shell::util::kigi_home::kigi_home();
|
||
Some(home.join("config.toml"))
|
||
}
|
||
|
||
/// Cap parsed entry count to keep a corrupted
|
||
/// or malicious config.toml from ballooning allocations.
|
||
const MAX_PERSISTED_ENTRIES: usize = 256;
|
||
/// Cap persist-key string length so a malformed
|
||
/// entry doesn't carry around megabytes.
|
||
const MAX_PERSIST_KEY_LEN: usize = 1024;
|
||
|
||
fn parse_persist_keys(item: &toml_edit::Item) -> BTreeSet<PersistedRowId> {
|
||
let Some(arr) = item.as_array() else {
|
||
tracing::warn!("dashboard.pinned must be an array; ignoring");
|
||
return BTreeSet::new();
|
||
};
|
||
let mut out = BTreeSet::new();
|
||
for v in arr.iter().take(MAX_PERSISTED_ENTRIES) {
|
||
if let Some(s) = v.as_str()
|
||
&& s.len() <= MAX_PERSIST_KEY_LEN
|
||
&& let Some(id) = PersistedRowId::from_key(s)
|
||
{
|
||
out.insert(id);
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
fn parse_persist_key_list(item: &toml_edit::Item) -> Vec<PersistedRowId> {
|
||
let Some(arr) = item.as_array() else {
|
||
tracing::warn!("dashboard.reorder must be an array; ignoring");
|
||
return Vec::new();
|
||
};
|
||
arr.iter()
|
||
.take(MAX_PERSISTED_ENTRIES)
|
||
.filter_map(|v| {
|
||
v.as_str()
|
||
.filter(|s| s.len() <= MAX_PERSIST_KEY_LEN)
|
||
.and_then(PersistedRowId::from_key)
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Helper: relative path display
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Compact a `Path` for display against `$HOME`, returning a `String`.
|
||
///
|
||
/// Used by the row renderer + filter substring search to keep cwd
|
||
/// matching consistent.
|
||
///
|
||
/// When `cwd == home`, `strip_prefix` returns an
|
||
/// empty `Path` and the previous `format!("~/{}", ...)` produced
|
||
/// `"~/"` (trailing slash). The empty-rest branch now collapses to
|
||
/// the bare `"~"`.
|
||
pub fn compact_cwd(cwd: &Path, home: Option<&str>) -> String {
|
||
if let Some(h) = home
|
||
&& let Ok(rest) = cwd.strip_prefix(h)
|
||
{
|
||
if rest.as_os_str().is_empty() {
|
||
return "~".to_string();
|
||
}
|
||
return format!("~/{}", rest.display());
|
||
}
|
||
cwd.display().to_string()
|
||
}
|
||
|
||
#[cfg(test)]
|
||
impl DashboardState {
|
||
/// Test-only: clone the state for tests that can't move ownership.
|
||
/// `PromptWidget` doesn't impl Clone so we cheat by constructing
|
||
/// a fresh state with the same selection.
|
||
pub(crate) fn clone_for_test(&self) -> Self {
|
||
let mut s = Self::new();
|
||
s.selected = self.selected.clone();
|
||
s.pinned = self.pinned.clone();
|
||
s.reorder = self.reorder.clone();
|
||
s.grouping = self.grouping;
|
||
s.filter = self.filter.clone();
|
||
s
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
/// `set_error_toast` prefixes the message with the error glyph
|
||
/// (`✗`/`x`) so the verbatim-rendering badge marks it as an error.
|
||
#[test]
|
||
fn set_error_toast_prefixes_error_glyph() {
|
||
let mut state = DashboardState::new();
|
||
state.set_error_toast("boom");
|
||
assert_eq!(
|
||
state.error_toast.as_deref(),
|
||
Some(format!("{} boom", crate::glyphs::ballot_x()).as_str()),
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn filter_parser_agent_prefix() {
|
||
match parse_filter("a:reviewer") {
|
||
FilterValue::Agent(s) => assert_eq!(s, "reviewer"),
|
||
other => panic!("expected agent filter, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn filter_parser_empty_agent_clears() {
|
||
assert!(matches!(parse_filter("a:"), FilterValue::None));
|
||
assert!(matches!(parse_filter("a: "), FilterValue::None));
|
||
}
|
||
|
||
#[test]
|
||
fn filter_parser_state_known() {
|
||
assert!(matches!(
|
||
parse_filter("s:needs-input"),
|
||
FilterValue::State(RowState::NeedsInput)
|
||
));
|
||
assert!(matches!(
|
||
parse_filter("s:needs_input"),
|
||
FilterValue::State(RowState::NeedsInput)
|
||
));
|
||
assert!(matches!(
|
||
parse_filter("s:needsinput"),
|
||
FilterValue::State(RowState::NeedsInput)
|
||
));
|
||
assert!(matches!(
|
||
parse_filter("s:working"),
|
||
FilterValue::State(RowState::Working)
|
||
));
|
||
assert!(matches!(
|
||
parse_filter("s:idle"),
|
||
FilterValue::State(RowState::Idle)
|
||
));
|
||
assert!(matches!(
|
||
parse_filter("s:blocked"),
|
||
FilterValue::State(RowState::Blocked)
|
||
));
|
||
assert!(matches!(
|
||
parse_filter("s:completed"),
|
||
FilterValue::State(RowState::Completed)
|
||
));
|
||
assert!(matches!(
|
||
parse_filter("s:failed"),
|
||
FilterValue::State(RowState::Failed)
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn filter_parser_state_unknown_falls_back_to_substring() {
|
||
match parse_filter("s:foobar") {
|
||
FilterValue::Substring(s) => assert_eq!(s, "foobar"),
|
||
other => panic!("expected substring fallback, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
/// `s:` empty now mirrors `a:` empty: returns None,
|
||
/// not Substring("").
|
||
#[test]
|
||
fn filter_parser_state_empty_is_none() {
|
||
match parse_filter("s:") {
|
||
FilterValue::None => {}
|
||
other => panic!("expected None, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
/// `#<n>` keeps the `#` in the substring needle so it
|
||
/// never matches arbitrary digits.
|
||
#[test]
|
||
fn filter_parser_pr_prefix_keeps_hash() {
|
||
match parse_filter("#42") {
|
||
FilterValue::Substring(s) => assert_eq!(s, "#42"),
|
||
other => panic!("expected substring fallback, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn filter_parser_free_text() {
|
||
match parse_filter("auth flow") {
|
||
FilterValue::Substring(s) => assert_eq!(s, "auth flow"),
|
||
other => panic!("expected substring filter, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn persisted_row_id_round_trip_top_level() {
|
||
let id = PersistedRowId::TopLevel {
|
||
session_id: "sess-abc".into(),
|
||
};
|
||
let key = id.to_key();
|
||
assert_eq!(key, "top:sess-abc");
|
||
assert_eq!(PersistedRowId::from_key(&key), Some(id));
|
||
}
|
||
|
||
#[test]
|
||
fn persisted_row_id_round_trip_subagent() {
|
||
let id = PersistedRowId::Subagent {
|
||
parent_session_id: "p-1".into(),
|
||
child_session_id: "c-1".into(),
|
||
};
|
||
let key = id.to_key();
|
||
assert_eq!(key, "sub:p-1:c-1");
|
||
assert_eq!(PersistedRowId::from_key(&key), Some(id));
|
||
}
|
||
|
||
#[test]
|
||
fn persisted_row_id_subagent_with_colon_in_child_id() {
|
||
// The child session id portion may itself contain colons; we
|
||
// only split on the first colon after `sub:<parent>:`.
|
||
let raw = "sub:parent-x:abc:def:ghi";
|
||
let parsed = PersistedRowId::from_key(raw).unwrap();
|
||
match parsed {
|
||
PersistedRowId::Subagent {
|
||
parent_session_id,
|
||
child_session_id,
|
||
} => {
|
||
assert_eq!(parent_session_id, "parent-x");
|
||
assert_eq!(child_session_id, "abc:def:ghi");
|
||
}
|
||
_ => panic!("expected subagent variant"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn persisted_row_id_invalid() {
|
||
assert!(PersistedRowId::from_key("garbage").is_none());
|
||
// top:<empty> rejected.
|
||
assert!(PersistedRowId::from_key("top:").is_none());
|
||
// sub: with no parent rejected.
|
||
assert!(PersistedRowId::from_key("sub::child").is_none());
|
||
// sub:<parent> with no child rejected.
|
||
assert!(PersistedRowId::from_key("sub:parent:").is_none());
|
||
// sub: with no colon between parent/child rejected.
|
||
assert!(PersistedRowId::from_key("sub:foo").is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn group_priority_ordering() {
|
||
assert!(RowState::NeedsInput.group_priority() > RowState::Working.group_priority());
|
||
assert!(RowState::Working.group_priority() > RowState::Idle.group_priority());
|
||
assert!(RowState::Idle.group_priority() > RowState::Completed.group_priority());
|
||
assert!(RowState::Completed.group_priority() > RowState::Failed.group_priority());
|
||
}
|
||
|
||
#[test]
|
||
fn compact_cwd_strips_home() {
|
||
let p = Path::new("/Users/alice/projects/grok");
|
||
assert_eq!(compact_cwd(p, Some("/Users/alice")), "~/projects/grok");
|
||
}
|
||
|
||
#[test]
|
||
fn compact_cwd_no_home_match() {
|
||
let p = Path::new("/var/tmp/x");
|
||
assert_eq!(compact_cwd(p, Some("/Users/alice")), "/var/tmp/x");
|
||
}
|
||
|
||
/// When `cwd == home`, return bare `"~"` (no
|
||
/// trailing slash). This test pins the behaviour.
|
||
#[test]
|
||
fn compact_cwd_path_equals_home() {
|
||
let p = Path::new("/Users/alice");
|
||
assert_eq!(compact_cwd(p, Some("/Users/alice")), "~");
|
||
}
|
||
|
||
// Vacuous `rename_draft_caps_at_100_chars` deleted —
|
||
// covered by the substantive `rename_at_cap_drops_extra_char` and
|
||
// `rename_under_cap_appends` tests below, which assert exact
|
||
// character at the cap boundary.
|
||
|
||
/// Edge case 20: stale row ids in `pinned` / `reorder` are dropped
|
||
/// on `gc_stale_refs`.
|
||
#[test]
|
||
fn gc_drops_stale_ids() {
|
||
let mut state = DashboardState::new();
|
||
state.pinned.insert(DashboardRowId::TopLevel(AgentId(7)));
|
||
state.reorder.push(DashboardRowId::TopLevel(AgentId(7)));
|
||
// Alive predicate says agent 7 no longer exists.
|
||
state.gc_stale_refs(&|_| false);
|
||
assert!(state.pinned.is_empty());
|
||
assert!(state.reorder.is_empty());
|
||
}
|
||
|
||
/// Lenient parsing: malformed `pinned` (string instead of array)
|
||
/// is silently dropped. Edge case 12.
|
||
#[test]
|
||
fn parse_persist_keys_rejects_non_array() {
|
||
let s = r#"pinned = "not-an-array""#;
|
||
let doc: toml_edit::DocumentMut = s.parse().unwrap();
|
||
let item = doc.get("pinned").unwrap();
|
||
let out = parse_persist_keys(item);
|
||
assert!(out.is_empty());
|
||
}
|
||
|
||
/// Lenient parsing: malformed `reorder` (table instead of array)
|
||
/// is silently dropped.
|
||
#[test]
|
||
fn parse_persist_key_list_rejects_non_array() {
|
||
let s = "[reorder]\nx = 1";
|
||
let doc: toml_edit::DocumentMut = s.parse().unwrap();
|
||
let item = doc.get("reorder").unwrap();
|
||
let out = parse_persist_key_list(item);
|
||
assert!(out.is_empty());
|
||
}
|
||
|
||
/// Lenient parsing: array entries that aren't strings get dropped.
|
||
#[test]
|
||
fn parse_persist_keys_skips_non_string_entries() {
|
||
let s = "pinned = [1, 2, \"top:sess-7\", false]";
|
||
let doc: toml_edit::DocumentMut = s.parse().unwrap();
|
||
let item = doc.get("pinned").unwrap();
|
||
let out = parse_persist_keys(item);
|
||
assert_eq!(out.len(), 1);
|
||
assert!(out.contains(&PersistedRowId::TopLevel {
|
||
session_id: "sess-7".into(),
|
||
}));
|
||
}
|
||
|
||
/// Array entries past `MAX_PERSISTED_ENTRIES` are dropped.
|
||
#[test]
|
||
fn parse_persist_keys_caps_entry_count() {
|
||
let many: Vec<String> = (0..(MAX_PERSISTED_ENTRIES * 2))
|
||
.map(|i| format!("\"top:s-{i}\""))
|
||
.collect();
|
||
let s = format!("pinned = [{}]", many.join(","));
|
||
let doc: toml_edit::DocumentMut = s.parse().unwrap();
|
||
let item = doc.get("pinned").unwrap();
|
||
let out = parse_persist_keys(item);
|
||
assert!(out.len() <= MAX_PERSISTED_ENTRIES);
|
||
}
|
||
|
||
/// Each persisted key value is capped in length.
|
||
#[test]
|
||
fn parse_persist_keys_rejects_overlong_strings() {
|
||
let huge = format!("\"top:{}\"", "a".repeat(MAX_PERSIST_KEY_LEN + 10));
|
||
let s = format!("pinned = [{huge}]");
|
||
let doc: toml_edit::DocumentMut = s.parse().unwrap();
|
||
let item = doc.get("pinned").unwrap();
|
||
let out = parse_persist_keys(item);
|
||
assert!(out.is_empty());
|
||
}
|
||
|
||
/// Pin/unpin toggling works against the in-memory set.
|
||
#[test]
|
||
fn pin_unpin_toggle() {
|
||
let mut state = DashboardState::new();
|
||
let id = DashboardRowId::TopLevel(AgentId(1));
|
||
state.selected = Some(id.clone());
|
||
assert!(state.pinned.is_empty());
|
||
state.toggle_pin_selected();
|
||
assert!(state.pinned.contains(&id));
|
||
state.toggle_pin_selected();
|
||
assert!(state.pinned.is_empty());
|
||
}
|
||
|
||
/// Grouping toggle round-trips State → Directory → State.
|
||
#[test]
|
||
fn grouping_toggles() {
|
||
let mut state = DashboardState::new();
|
||
assert_eq!(state.grouping, Grouping::State);
|
||
state.toggle_grouping();
|
||
assert_eq!(state.grouping, Grouping::Directory);
|
||
state.toggle_grouping();
|
||
assert_eq!(state.grouping, Grouping::State);
|
||
}
|
||
|
||
/// `Ctrl+G` (the rebound grouping chord) emits `DashboardToggleGrouping`,
|
||
/// and `Ctrl+S` no longer toggles grouping — it's now "send + open".
|
||
#[test]
|
||
fn ctrl_g_toggles_grouping_ctrl_s_does_not() {
|
||
use crate::app::actions::Action;
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
let mut state = DashboardState::new();
|
||
let ctrl_g = KeyEvent::new(KeyCode::Char('g'), KeyModifiers::CONTROL);
|
||
assert!(
|
||
matches!(
|
||
state.handle_key(&ctrl_g, ®),
|
||
InputOutcome::Action(Action::DashboardToggleGrouping)
|
||
),
|
||
"Ctrl+G must emit DashboardToggleGrouping",
|
||
);
|
||
|
||
// Ctrl+S on the empty `[+ New Agent]` button is "send + open"
|
||
// (create + detail), NOT a grouping toggle.
|
||
let ctrl_s = KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL);
|
||
assert!(
|
||
matches!(
|
||
state.handle_key(&ctrl_s, ®),
|
||
InputOutcome::Action(Action::DashboardCreateNewAgentWithDetail)
|
||
),
|
||
"Ctrl+S must be send+open, not grouping",
|
||
);
|
||
}
|
||
|
||
/// Edge case 4: selection survives a row refresh as long as the
|
||
/// underlying `DashboardRowId` is still present.
|
||
#[test]
|
||
fn reanchor_selection_keeps_existing_id() {
|
||
let mut state = DashboardState::new();
|
||
let id1 = DashboardRowId::TopLevel(AgentId(1));
|
||
state.selected = Some(id1.clone());
|
||
let rows = vec![super::super::row::DashboardRow {
|
||
id: id1.clone(),
|
||
label: "r1".to_string(),
|
||
subtitle: None,
|
||
state: RowState::Idle,
|
||
activity: None,
|
||
secondary_line: None,
|
||
cwd_display: String::new(),
|
||
cwd: std::path::PathBuf::from("/"),
|
||
last_change_at: std::time::SystemTime::now(),
|
||
pinned: false,
|
||
is_active: false,
|
||
badges: Vec::new(),
|
||
context_pct: None,
|
||
indent: 0,
|
||
parent_label: None,
|
||
is_more_placeholder: false,
|
||
more_count: 0,
|
||
}];
|
||
state.reanchor_selection(&rows);
|
||
assert_eq!(state.selected, Some(id1));
|
||
}
|
||
|
||
/// When the previous selection has disappeared,
|
||
/// `reanchor_selection` now drops the cursor to `None`
|
||
/// instead of auto-promoting to the first row. The "no row
|
||
/// selected → dispatch creates a new session" contract
|
||
/// depends on `None` being a stable steady state — a stale
|
||
/// agent vanishing must not silently re-arm the reply path
|
||
/// against whatever happens to be at the top.
|
||
#[test]
|
||
fn reanchor_selection_drops_to_none_when_previous_disappeared() {
|
||
let mut state = DashboardState::new();
|
||
state.selected = Some(DashboardRowId::TopLevel(AgentId(99)));
|
||
let id1 = DashboardRowId::TopLevel(AgentId(1));
|
||
let rows = vec![super::super::row::DashboardRow {
|
||
id: id1.clone(),
|
||
label: "r1".to_string(),
|
||
subtitle: None,
|
||
state: RowState::Idle,
|
||
activity: None,
|
||
secondary_line: None,
|
||
cwd_display: String::new(),
|
||
cwd: std::path::PathBuf::from("/"),
|
||
last_change_at: std::time::SystemTime::now(),
|
||
pinned: false,
|
||
is_active: false,
|
||
badges: Vec::new(),
|
||
context_pct: None,
|
||
indent: 0,
|
||
parent_label: None,
|
||
is_more_placeholder: false,
|
||
more_count: 0,
|
||
}];
|
||
state.reanchor_selection(&rows);
|
||
assert_eq!(
|
||
state.selected, None,
|
||
"stale selection must drop to None so the new-session path stays reachable",
|
||
);
|
||
}
|
||
|
||
/// Real on-disk round-trip via the new
|
||
/// `write_persisted_to_path` / `load_persisted_from_path` helpers
|
||
/// plus `tempfile::TempDir`.
|
||
#[test]
|
||
fn persisted_on_disk_round_trip() {
|
||
use std::collections::BTreeSet;
|
||
let tmp = tempfile::tempdir().unwrap();
|
||
let path = tmp.path().join("config.toml");
|
||
let mut pinned: BTreeSet<PersistedRowId> = BTreeSet::new();
|
||
pinned.insert(PersistedRowId::TopLevel {
|
||
session_id: "sess-3".into(),
|
||
});
|
||
let reorder = vec![PersistedRowId::Subagent {
|
||
parent_session_id: "sess-3".into(),
|
||
child_session_id: "child-1".into(),
|
||
}];
|
||
let p = PersistedDashboard {
|
||
enabled: false,
|
||
grouping: Grouping::Directory,
|
||
pinned: pinned.clone(),
|
||
reorder: reorder.clone(),
|
||
};
|
||
write_persisted_to_path(&path, &p).unwrap();
|
||
let loaded = load_persisted_from_path(&path).expect("must load back");
|
||
assert!(!loaded.enabled);
|
||
assert_eq!(loaded.grouping, Grouping::Directory);
|
||
assert_eq!(loaded.pinned, pinned);
|
||
assert_eq!(loaded.reorder, reorder);
|
||
}
|
||
|
||
/// The onboarding hint was removed — a stale `[dashboard.onboarding]`
|
||
/// table left by an older version is dropped on the next write.
|
||
#[test]
|
||
fn persisted_write_drops_stale_onboarding_table() {
|
||
let tmp = tempfile::tempdir().unwrap();
|
||
let path = tmp.path().join("config.toml");
|
||
std::fs::write(
|
||
&path,
|
||
"[dashboard]\nenabled = true\n\n[dashboard.onboarding]\ndismissed = true\n",
|
||
)
|
||
.unwrap();
|
||
write_persisted_to_path(&path, &PersistedDashboard::defaults()).unwrap();
|
||
let after = std::fs::read_to_string(&path).unwrap();
|
||
assert!(
|
||
!after.contains("onboarding"),
|
||
"stale onboarding table must be removed, got: {after:?}"
|
||
);
|
||
}
|
||
|
||
/// Pre-populated `[hints]` table survives the dashboard write
|
||
/// (the guarantee — we never clobber unrelated tables).
|
||
#[test]
|
||
fn persisted_write_preserves_other_tables() {
|
||
let tmp = tempfile::tempdir().unwrap();
|
||
let path = tmp.path().join("config.toml");
|
||
std::fs::write(
|
||
&path,
|
||
"[hints]\nproject_picker_disabled = true\n\n[ui]\ncompact_mode = false\n",
|
||
)
|
||
.unwrap();
|
||
let p = PersistedDashboard {
|
||
enabled: true,
|
||
grouping: Grouping::State,
|
||
pinned: BTreeSet::new(),
|
||
reorder: Vec::new(),
|
||
};
|
||
write_persisted_to_path(&path, &p).unwrap();
|
||
let after = std::fs::read_to_string(&path).unwrap();
|
||
assert!(after.contains("[hints]"));
|
||
assert!(after.contains("project_picker_disabled = true"));
|
||
assert!(after.contains("[ui]"));
|
||
assert!(after.contains("compact_mode = false"));
|
||
assert!(after.contains("[dashboard]"));
|
||
}
|
||
|
||
/// Garbage `enabled` value falls back to defaults at load.
|
||
#[test]
|
||
fn persisted_load_garbage_enabled_falls_back() {
|
||
let tmp = tempfile::tempdir().unwrap();
|
||
let path = tmp.path().join("config.toml");
|
||
std::fs::write(&path, "[dashboard]\nenabled = \"garbage\"\n").unwrap();
|
||
let loaded = load_persisted_from_path(&path).expect("section present");
|
||
// Garbage → default `true`.
|
||
assert!(loaded.enabled);
|
||
assert_eq!(loaded.grouping, Grouping::State);
|
||
assert!(loaded.pinned.is_empty());
|
||
}
|
||
|
||
/// Write refuses to clobber a non-empty
|
||
/// unparseable file (round-trip with file containing garbage).
|
||
#[test]
|
||
fn persisted_write_refuses_unparseable_file() {
|
||
let tmp = tempfile::tempdir().unwrap();
|
||
let path = tmp.path().join("config.toml");
|
||
std::fs::write(&path, "this is :: not :: valid :: toml :: at all").unwrap();
|
||
let p = PersistedDashboard {
|
||
enabled: true,
|
||
grouping: Grouping::Directory,
|
||
pinned: BTreeSet::new(),
|
||
reorder: Vec::new(),
|
||
};
|
||
// write_persisted_to_path returns Ok(()) but does NOT overwrite.
|
||
write_persisted_to_path(&path, &p).unwrap();
|
||
let after = std::fs::read_to_string(&path).unwrap();
|
||
assert!(
|
||
after.starts_with("this is"),
|
||
"file must be preserved verbatim; got: {after:?}"
|
||
);
|
||
}
|
||
|
||
/// gc_stale_refs preserves the order of remaining
|
||
/// reorder entries.
|
||
#[test]
|
||
fn gc_preserves_order_of_remaining_reorder_entries() {
|
||
let mut state = DashboardState::new();
|
||
let alive1 = DashboardRowId::TopLevel(AgentId(1));
|
||
let alive2 = DashboardRowId::TopLevel(AgentId(2));
|
||
let alive3 = DashboardRowId::TopLevel(AgentId(3));
|
||
let stale99 = DashboardRowId::TopLevel(AgentId(99));
|
||
let stale100 = DashboardRowId::TopLevel(AgentId(100));
|
||
state.reorder = vec![
|
||
alive1.clone(),
|
||
stale99.clone(),
|
||
alive2.clone(),
|
||
stale100.clone(),
|
||
alive3.clone(),
|
||
];
|
||
let alive_set: std::collections::HashSet<_> =
|
||
[alive1.clone(), alive2.clone(), alive3.clone()]
|
||
.into_iter()
|
||
.collect();
|
||
state.gc_stale_refs(&|id| alive_set.contains(id));
|
||
assert_eq!(state.reorder, vec![alive1, alive2, alive3]);
|
||
}
|
||
|
||
/// Two different pinned rows coexist.
|
||
#[test]
|
||
fn pin_two_different_rows_coexist() {
|
||
let mut state = DashboardState::new();
|
||
let id_a = DashboardRowId::TopLevel(AgentId(1));
|
||
let id_b = DashboardRowId::TopLevel(AgentId(2));
|
||
state.selected = Some(id_a.clone());
|
||
state.toggle_pin_selected();
|
||
state.selected = Some(id_b.clone());
|
||
state.toggle_pin_selected();
|
||
assert!(state.pinned.contains(&id_a));
|
||
assert!(state.pinned.contains(&id_b));
|
||
assert_eq!(state.pinned.len(), 2);
|
||
}
|
||
|
||
/// Grouping toggle idempotency over multiple cycles.
|
||
#[test]
|
||
fn grouping_toggles_three_times() {
|
||
let mut state = DashboardState::new();
|
||
let start = state.grouping;
|
||
state.toggle_grouping();
|
||
state.toggle_grouping();
|
||
state.toggle_grouping();
|
||
assert_ne!(state.grouping, start);
|
||
state.toggle_grouping();
|
||
assert_eq!(state.grouping, start);
|
||
}
|
||
|
||
/// parse_row_state_token covers all documented synonyms.
|
||
#[test]
|
||
fn parse_row_state_token_all_synonyms() {
|
||
for (s, expected) in [
|
||
("needs-input", RowState::NeedsInput),
|
||
("needs_input", RowState::NeedsInput),
|
||
("needsinput", RowState::NeedsInput),
|
||
("needs", RowState::NeedsInput),
|
||
("input", RowState::NeedsInput),
|
||
("NEEDS-INPUT", RowState::NeedsInput),
|
||
("working", RowState::Working),
|
||
("busy", RowState::Working),
|
||
("running", RowState::Working),
|
||
("idle", RowState::Idle),
|
||
("IDLE", RowState::Idle),
|
||
("inactive", RowState::Inactive),
|
||
("dormant", RowState::Inactive),
|
||
("completed", RowState::Completed),
|
||
("done", RowState::Completed),
|
||
("failed", RowState::Failed),
|
||
("errored", RowState::Failed),
|
||
("cancelled", RowState::Failed),
|
||
("canceled", RowState::Failed),
|
||
("blocked", RowState::Blocked),
|
||
("paused", RowState::Blocked),
|
||
] {
|
||
assert_eq!(parse_row_state_token(s), Some(expected), "input={s}");
|
||
}
|
||
// Empty and whitespace return None.
|
||
assert_eq!(parse_row_state_token(""), None);
|
||
assert_eq!(parse_row_state_token(" "), None);
|
||
assert_eq!(parse_row_state_token("nonsense"), None);
|
||
}
|
||
|
||
/// Rename cap is honored exactly.
|
||
#[test]
|
||
fn rename_at_cap_drops_extra_char() {
|
||
let mut draft = RenameDraft {
|
||
row: DashboardRowId::TopLevel(AgentId(0)),
|
||
draft: "a".repeat(100),
|
||
};
|
||
let key = KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE);
|
||
let _ = handle_rename_key(&mut draft, &key);
|
||
assert_eq!(draft.draft.chars().count(), 100);
|
||
assert!(
|
||
draft.draft.ends_with('a'),
|
||
"char at cap should NOT be replaced: got {:?}",
|
||
draft.draft
|
||
);
|
||
}
|
||
|
||
/// under-cap appends correctly.
|
||
#[test]
|
||
fn rename_under_cap_appends() {
|
||
let mut draft = RenameDraft {
|
||
row: DashboardRowId::TopLevel(AgentId(0)),
|
||
draft: "a".repeat(99),
|
||
};
|
||
let key = KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE);
|
||
let _ = handle_rename_key(&mut draft, &key);
|
||
assert_eq!(draft.draft.chars().count(), 100);
|
||
assert!(draft.draft.ends_with('b'));
|
||
}
|
||
|
||
/// Ctrl+letter in rename mode rejected (does not type
|
||
/// the bare letter into the draft); Ctrl+C cancels.
|
||
#[test]
|
||
fn rename_rejects_ctrl_chars() {
|
||
let mut draft = RenameDraft {
|
||
row: DashboardRowId::TopLevel(AgentId(0)),
|
||
draft: "hello".to_string(),
|
||
};
|
||
let ctrl_r = KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL);
|
||
let outcome = handle_rename_key(&mut draft, &ctrl_r);
|
||
assert!(matches!(outcome, InputOutcome::Unchanged));
|
||
assert_eq!(draft.draft, "hello", "draft must not gain 'r'");
|
||
// Ctrl+C → cancel.
|
||
let ctrl_c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
|
||
let outcome = handle_rename_key(&mut draft, &ctrl_c);
|
||
assert!(matches!(
|
||
outcome,
|
||
InputOutcome::Action(crate::app::actions::Action::DashboardCancelRename)
|
||
));
|
||
}
|
||
|
||
/// Esc-cancelling the worktree-label dialog must restore the stashed
|
||
/// prompt (from the prompt-send path) to the dispatch input instead of
|
||
/// silently discarding the user's typed text. Mirrors the restore in
|
||
/// `dispatch_dashboard_confirm_worktree`'s not-a-repo error path.
|
||
#[test]
|
||
fn worktree_dialog_cancel_restores_stashed_prompt_state() {
|
||
let mut state = DashboardState::new();
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
// Simulate the prompt-send path: the prompt is stashed, the dialog
|
||
// is opened, and the dispatch input is cleared.
|
||
state.dispatch.set_text("fix the bug ");
|
||
let draft_end = state.dispatch.text().len();
|
||
state.dispatch.set_cursor(draft_end);
|
||
state.dispatch.insert_image(peek_test_image()).unwrap();
|
||
state.pending_worktree_prompt = Some(state.dispatch.stash());
|
||
state.worktree_dialog = Some(crate::app::app_view::NewWorktreeDialogState::new());
|
||
state.dispatch.set_text("");
|
||
|
||
let outcome = state.handle_input(
|
||
&Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)),
|
||
®,
|
||
);
|
||
|
||
assert!(matches!(outcome, InputOutcome::Changed));
|
||
assert!(state.worktree_dialog.is_none(), "Esc closes the dialog");
|
||
assert!(
|
||
state.pending_worktree_prompt.is_none(),
|
||
"the stash must be consumed",
|
||
);
|
||
assert_eq!(
|
||
state.dispatch.text(),
|
||
"fix the bug [Image #1] ",
|
||
"the stashed prompt must be restored to the dispatch input",
|
||
);
|
||
assert_eq!(state.dispatch.drain_images().len(), 1);
|
||
}
|
||
|
||
/// Cancelling the dialog when it was opened from the `[+ New Agent]`
|
||
/// button (no stashed prompt) leaves the dispatch input untouched.
|
||
#[test]
|
||
fn worktree_dialog_cancel_without_stash_leaves_input_empty() {
|
||
let mut state = DashboardState::new();
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
state.worktree_dialog = Some(crate::app::app_view::NewWorktreeDialogState::new());
|
||
state.dispatch.set_text("");
|
||
|
||
let _ = state.handle_input(
|
||
&Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)),
|
||
®,
|
||
);
|
||
|
||
assert!(state.worktree_dialog.is_none());
|
||
assert!(state.pending_worktree_prompt.is_none());
|
||
assert_eq!(
|
||
state.dispatch.text(),
|
||
"",
|
||
"no stash → dispatch input stays empty",
|
||
);
|
||
}
|
||
|
||
/// `Ctrl+W` resolves to the worktree-toggle action (which is what puts it
|
||
/// in the dashboard cheatsheet and lets the dispatcher git-gate it). The
|
||
/// actual flag flip + non-git guard live in
|
||
/// `dispatch_dashboard_toggle_worktree` and are covered there.
|
||
#[test]
|
||
fn ctrl_w_emits_toggle_worktree_action() {
|
||
let mut state = DashboardState::new();
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
|
||
let outcome = state.handle_input(
|
||
&Event::Key(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL)),
|
||
®,
|
||
);
|
||
assert!(
|
||
matches!(
|
||
outcome,
|
||
InputOutcome::Action(Action::DashboardToggleWorktree)
|
||
),
|
||
"Ctrl+W must resolve to the DashboardToggleWorktree action",
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------
|
||
// handle_key tests (Esc cascade, Enter routing).
|
||
// ---------------------------------------------------------------
|
||
|
||
fn make_state_with_selection() -> DashboardState {
|
||
let mut s = DashboardState::new();
|
||
s.selected = Some(DashboardRowId::TopLevel(AgentId(0)));
|
||
s
|
||
}
|
||
|
||
fn peek_fields_for_test(response_type: &str) -> super::super::peek::PeekFields {
|
||
super::super::peek::PeekFields {
|
||
label: "x".to_string(),
|
||
time_ago: String::new(),
|
||
response_type: response_type.to_string(),
|
||
last_user_message: None,
|
||
last_agent_lines: Vec::new(),
|
||
last_response_truncated: false,
|
||
question: None,
|
||
options: Vec::new(),
|
||
request_id: None,
|
||
reject_option: None,
|
||
}
|
||
}
|
||
|
||
/// edge case 13: Esc closes peek first.
|
||
#[test]
|
||
fn esc_closes_peek_first() {
|
||
let mut state = make_state_with_selection();
|
||
state.peek = Some(super::super::peek::PeekPanelState::new(
|
||
DashboardRowId::TopLevel(AgentId(0)),
|
||
peek_fields_for_test("Idle"),
|
||
));
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
|
||
let outcome = state.handle_key(&key, ®);
|
||
assert!(matches!(outcome, InputOutcome::Changed));
|
||
assert!(state.peek.is_none());
|
||
}
|
||
|
||
fn state_with_open_peek() -> DashboardState {
|
||
// PeekPanelState::new seeds focus from load_vim_mode(); pin off so
|
||
// older tests that assume a focused reply don't depend on config /
|
||
// process cache. Vim tests set true and restore themselves.
|
||
crate::appearance::cache::set_vim_mode(false);
|
||
let mut s = make_state_with_selection();
|
||
s.peek = Some(super::super::peek::PeekPanelState::new(
|
||
DashboardRowId::TopLevel(AgentId(0)),
|
||
peek_fields_for_test("Idle"),
|
||
));
|
||
s
|
||
}
|
||
|
||
/// Regression: with the peek open but the reply UNFOCUSED (Tab → row
|
||
/// nav), generic editing chords must NOT leak into the hidden new-session
|
||
/// dispatch draft behind the panel. Backspace / Delete are consumed
|
||
/// (`Unchanged`) instead of falling through to the hidden dispatch widget.
|
||
/// (Ctrl+W is NOT tested here — it's a registry-bound dashboard chord, the
|
||
/// worktree toggle, so like Ctrl+X it intentionally falls through to fire
|
||
/// its action with the peek open.)
|
||
#[test]
|
||
fn peek_unfocused_editing_chords_do_not_leak_to_dispatch() {
|
||
let mut state = state_with_open_peek();
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
// Hidden new-session draft, caret at END (where Backspace bites;
|
||
// set_text alone parks it at 0).
|
||
state.dispatch.set_text("hidden draft");
|
||
state.dispatch.set_cursor(state.dispatch.text().len());
|
||
// Tab → unfocus the reply (it becomes a row-nav surface).
|
||
let _ = state.handle_key(&KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE), ®);
|
||
assert!(!state.peek.as_ref().unwrap().focused, "Tab must unfocus");
|
||
|
||
for key in [
|
||
KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE),
|
||
KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE),
|
||
] {
|
||
let outcome = state.handle_key(&key, ®);
|
||
assert!(
|
||
matches!(outcome, InputOutcome::Unchanged),
|
||
"{key:?} must be consumed (Unchanged) with the peek open, got {outcome:?}",
|
||
);
|
||
}
|
||
assert_eq!(
|
||
state.dispatch.text(),
|
||
"hidden draft",
|
||
"editing chords must NOT leak into the hidden dispatch draft",
|
||
);
|
||
assert!(
|
||
!state.peek.as_ref().unwrap().focused,
|
||
"consumed editing chords must not grab focus",
|
||
);
|
||
}
|
||
|
||
/// While the peek panel is open, bare printable keys type into the
|
||
/// `❯ reply` input (not the hidden dispatch box).
|
||
#[test]
|
||
fn peek_typing_edits_reply_buffer() {
|
||
let mut state = state_with_open_peek();
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
for c in ['h', 'i'] {
|
||
let key = KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE);
|
||
let _ = state.handle_key(&key, ®);
|
||
}
|
||
assert_eq!(state.peek_reply.text(), "hi");
|
||
// The dispatch (new-session) buffer is untouched.
|
||
assert!(state.dispatch.text().is_empty());
|
||
}
|
||
|
||
/// Enter with a typed reply emits `DashboardPeekReply` (no attach);
|
||
/// Ctrl+S ("send + open") sets `attach=true`.
|
||
#[test]
|
||
fn peek_enter_with_text_emits_reply() {
|
||
use crate::app::actions::Action;
|
||
let mut state = state_with_open_peek();
|
||
state.peek_reply.set_text("ship it");
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
|
||
match state.handle_key(&enter, ®) {
|
||
InputOutcome::Action(Action::DashboardPeekReply { text, attach, row }) => {
|
||
assert_eq!(text, "ship it");
|
||
assert!(!attach);
|
||
assert_eq!(row, DashboardRowId::TopLevel(AgentId(0)));
|
||
}
|
||
other => panic!("expected DashboardPeekReply, got {other:?}"),
|
||
}
|
||
|
||
let ctrl_s = KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL);
|
||
match state.handle_key(&ctrl_s, ®) {
|
||
InputOutcome::Action(Action::DashboardPeekReply { attach, text, .. }) => {
|
||
assert!(attach, "Ctrl+S must set attach=true (send + open)");
|
||
assert_eq!(text, "ship it");
|
||
}
|
||
other => panic!("expected DashboardPeekReply, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
fn peek_test_image() -> crate::prompt_images::PastedImage {
|
||
crate::prompt_images::PastedImage {
|
||
element_id: kigi_ratatui_textarea::ElementId::from_raw(0),
|
||
display_number: 0,
|
||
mime_type: "image/png".into(),
|
||
dimensions: Some((10, 10)),
|
||
byte_len: 16,
|
||
encoded_bytes: Some(vec![0u8; 16].into()),
|
||
source_path: Some(std::path::PathBuf::from(
|
||
"/Users/somebody/very/long/path/screenshot.png",
|
||
)),
|
||
staged_temp_path: None,
|
||
session_image_path: None,
|
||
preview: crate::prompt_images::PromptImagePreview::default(),
|
||
}
|
||
}
|
||
|
||
fn write_test_png(dir: &std::path::Path) -> std::path::PathBuf {
|
||
let img: image::ImageBuffer<image::Rgba<u8>, Vec<u8>> =
|
||
image::ImageBuffer::from_pixel(16, 16, image::Rgba([255, 0, 0, 255]));
|
||
let path = dir.join("shot.png");
|
||
img.save_with_format(&path, image::ImageFormat::Png)
|
||
.unwrap();
|
||
path
|
||
}
|
||
|
||
fn test_png_bytes() -> Vec<u8> {
|
||
let img: image::ImageBuffer<image::Rgba<u8>, Vec<u8>> =
|
||
image::ImageBuffer::from_pixel(16, 16, image::Rgba([0, 128, 255, 255]));
|
||
let mut buf = Vec::new();
|
||
img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png)
|
||
.unwrap();
|
||
buf
|
||
}
|
||
|
||
/// The Ctrl/Cmd+V chord path attaches a clipboard image to the peek
|
||
/// reply (image wins over the caption) without leaking into dispatch. Drives
|
||
/// the real deferred entry point + completion.
|
||
#[test]
|
||
fn peek_paste_key_clipboard_image_wins_over_text() {
|
||
let mut state = state_with_open_peek();
|
||
cmd_v_image(&mut state, Some("ignored text"));
|
||
assert_eq!(state.peek_reply.images.len(), 1);
|
||
let text = state.peek_reply.text();
|
||
assert!(text.contains("[Image #1]"), "got {text:?}");
|
||
assert!(
|
||
!text.contains("ignored text"),
|
||
"text won over image: {text:?}"
|
||
);
|
||
assert!(state.dispatch.images.is_empty() && state.dispatch.text().is_empty());
|
||
}
|
||
|
||
/// In question mode the chord path must not defer/attach a clipboard
|
||
/// image — the reply is text-only on the wire.
|
||
#[test]
|
||
fn peek_paste_key_question_mode_blocks_clipboard_image() {
|
||
let mut state = state_with_open_peek();
|
||
if let Some(p) = state.peek.as_mut() {
|
||
p.focused = true;
|
||
p.question = Some("Allow?".into());
|
||
p.options = vec![("yes".into(), "Yes".into()), ("no".into(), "No".into())];
|
||
p.reject_option = Some(1);
|
||
p.selected_option = Some(1);
|
||
}
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
// Even with a raster on the pasteboard, question mode must not probe.
|
||
crate::clipboard::set_clipboard_probe_hook(
|
||
crate::clipboard::ClipboardProbeHook::with_raster(None),
|
||
);
|
||
let _ = state.handle_input(&ctrl_v_event(), ®);
|
||
let deferred = deferred_probe_target(&state).is_some();
|
||
crate::clipboard::clear_clipboard_probe_hook();
|
||
assert!(!deferred, "question mode must not defer an image probe");
|
||
assert!(state.peek_reply.images.is_empty());
|
||
assert!(!state.peek_reply.text().contains("[Image #"));
|
||
}
|
||
|
||
/// A whitespace-only chord paste inserts no text into the reply. Trimmed-empty
|
||
/// routes to the FileUrlsThenImage probe (to catch an image-only pasteboard),
|
||
/// so it defers rather than inserting spaces.
|
||
#[test]
|
||
fn peek_paste_key_whitespace_only_is_unchanged() {
|
||
let mut state = state_with_open_peek();
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
crate::clipboard::set_clipboard_probe_hook(
|
||
crate::clipboard::ClipboardProbeHook::no_raster(Some(" ")),
|
||
);
|
||
let _ = state.handle_input(&ctrl_v_event(), ®);
|
||
crate::clipboard::clear_clipboard_probe_hook();
|
||
assert!(state.peek_reply.text().is_empty());
|
||
}
|
||
|
||
/// An empty-clipboard chord defers a probe (to catch a Finder file-url /
|
||
/// image-only pasteboard) without inserting text.
|
||
#[test]
|
||
fn peek_paste_key_empty_clipboard_defers_probe() {
|
||
let mut state = state_with_open_peek();
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
crate::clipboard::set_clipboard_probe_hook(
|
||
crate::clipboard::ClipboardProbeHook::no_raster(None),
|
||
);
|
||
let _ = state.handle_input(&ctrl_v_event(), ®);
|
||
let target = deferred_probe_target(&state);
|
||
crate::clipboard::clear_clipboard_probe_hook();
|
||
assert!(
|
||
matches!(
|
||
target,
|
||
Some(crate::app::actions::ClipboardPasteTarget::DashboardPeek { .. })
|
||
),
|
||
"empty pbpaste still probes for a file-url / image via the peek target"
|
||
);
|
||
assert!(state.peek_reply.text().is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn dashboard_failed_text_read_is_carried_into_deferred_context() {
|
||
let mut state = DashboardState::new();
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
crate::clipboard::set_clipboard_probe_hook(crate::clipboard::ClipboardProbeHook {
|
||
text_read_failed: true,
|
||
..crate::clipboard::ClipboardProbeHook::snapshot_unavailable()
|
||
});
|
||
|
||
let _ = state.handle_input(&ctrl_v_event(), ®);
|
||
let ctx = deferred_probe_ctx(&state).expect("failed text read must probe attachments");
|
||
crate::clipboard::clear_clipboard_probe_hook();
|
||
|
||
assert!(ctx.source.text_read_failed());
|
||
}
|
||
|
||
/// A real path-image paste routed through `handle_input`
|
||
/// attaches an `[Image #N]` chip on the peek reply (not the hidden
|
||
/// dispatch input), with the source path stripped for a clean chip.
|
||
#[test]
|
||
fn peek_path_paste_routes_image_to_reply_not_dispatch() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let png = write_test_png(dir.path());
|
||
let mut state = state_with_open_peek();
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
// Trailing newline skips the clipboard probe so the test is hermetic.
|
||
let paste = format!("{}\n", png.display());
|
||
let outcome = state.handle_input(&Event::Paste(paste), ®);
|
||
assert!(matches!(outcome, InputOutcome::Changed));
|
||
let text = state.peek_reply.text();
|
||
assert!(text.contains("[Image #1]"), "expected chip, got {text:?}");
|
||
assert!(
|
||
!text.contains("shot.png"),
|
||
"chip must not embed the source path, got {text:?}"
|
||
);
|
||
assert_eq!(state.peek_reply.images.len(), 1);
|
||
assert!(
|
||
state.dispatch.text().is_empty() && state.dispatch.images.is_empty(),
|
||
"image must not leak into the hidden dispatch input"
|
||
);
|
||
}
|
||
|
||
/// In question mode the reply is text-only on the wire — a
|
||
/// path-image paste must NOT become an image chip.
|
||
#[test]
|
||
fn peek_question_mode_path_paste_stays_text_only() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let png = write_test_png(dir.path());
|
||
let mut state = state_with_open_peek();
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
if let Some(p) = state.peek.as_mut() {
|
||
p.focused = true;
|
||
p.question = Some("Allow?".into());
|
||
p.options = vec![("yes".into(), "Yes".into()), ("no".into(), "No".into())];
|
||
p.reject_option = Some(1);
|
||
p.selected_option = Some(1);
|
||
}
|
||
let paste = format!("{}\n", png.display());
|
||
let _ = state.handle_input(&Event::Paste(paste), ®);
|
||
assert!(state.peek_reply.images.is_empty());
|
||
assert!(!state.peek_reply.text().contains("[Image #"));
|
||
}
|
||
|
||
/// `clear_peek_reply` drops image state with the draft text.
|
||
#[test]
|
||
fn clear_peek_reply_clears_images() {
|
||
let mut state = state_with_open_peek();
|
||
let _ = state.attach_peek_pasted_image(peek_test_image());
|
||
assert!(!state.peek_reply.images.is_empty());
|
||
state.clear_peek_reply();
|
||
assert!(state.peek_reply.text().is_empty());
|
||
assert!(state.peek_reply.images.is_empty());
|
||
}
|
||
|
||
/// Enter with an image chip on the reply emits `DashboardPeekReply`
|
||
/// carrying the chip placeholder text (images drain at dispatch time).
|
||
#[test]
|
||
fn peek_enter_with_image_emits_reply() {
|
||
use crate::app::actions::Action;
|
||
let mut state = state_with_open_peek();
|
||
let _ = state.attach_peek_pasted_image(peek_test_image());
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
|
||
match state.handle_key(&enter, ®) {
|
||
InputOutcome::Action(Action::DashboardPeekReply { text, attach, .. }) => {
|
||
assert!(text.contains("[Image #1]"), "got {text:?}");
|
||
assert!(!attach);
|
||
}
|
||
other => panic!("expected DashboardPeekReply, got {other:?}"),
|
||
}
|
||
// Action does not drain images — still on the widget until dispatch.
|
||
assert_eq!(state.peek_reply.images.len(), 1);
|
||
}
|
||
|
||
/// Question/permission feedback is text-only — an image chip
|
||
/// left on the reply must not leak its `[Image #N]` token into the
|
||
/// submitted feedback text.
|
||
#[test]
|
||
fn peek_question_feedback_strips_image_placeholder() {
|
||
use crate::app::actions::Action;
|
||
let mut state = state_with_open_peek();
|
||
state.peek_reply.set_text("no thanks");
|
||
state.peek_reply.set_cursor(state.peek_reply.text().len());
|
||
let _ = state.attach_peek_pasted_image(peek_test_image());
|
||
assert!(state.peek_reply.text().contains("[Image #1]"));
|
||
if let Some(p) = state.peek.as_mut() {
|
||
p.focused = true;
|
||
p.question = Some("Allow?".into());
|
||
p.options = vec![("yes".into(), "Yes".into()), ("no".into(), "No".into())];
|
||
p.reject_option = Some(1);
|
||
p.selected_option = Some(1);
|
||
p.request_id = Some(7);
|
||
}
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
|
||
match state.handle_key(&enter, ®) {
|
||
InputOutcome::Action(Action::DashboardPermissionFollowup { text, .. }) => {
|
||
assert!(!text.contains("[Image #"), "image token leaked: {text:?}");
|
||
assert!(
|
||
text.contains("no thanks"),
|
||
"feedback text dropped: {text:?}"
|
||
);
|
||
}
|
||
other => panic!("expected DashboardPermissionFollowup, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
/// The Ask "Other" fallthrough (image-only draft, no typed text)
|
||
/// must also strip the `[Image #N]` token from the freeform answer.
|
||
#[test]
|
||
fn peek_ask_other_image_only_strips_placeholder() {
|
||
use crate::app::actions::Action;
|
||
let mut state = state_with_open_peek();
|
||
let _ = state.attach_peek_pasted_image(peek_test_image());
|
||
assert!(state.peek_reply.text().contains("[Image #1]"));
|
||
if let Some(p) = state.peek.as_mut() {
|
||
p.focused = true;
|
||
p.question = Some("Which?".into());
|
||
p.options = vec![("a".into(), "A".into()), ("other".into(), "Other".into())];
|
||
p.reject_option = Some(1);
|
||
p.selected_option = Some(1);
|
||
p.request_id = None; // Ask tool (not a permission)
|
||
}
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
|
||
match state.handle_key(&enter, ®) {
|
||
InputOutcome::Action(Action::DashboardQuestionAnswer { freeform, .. }) => {
|
||
assert!(
|
||
!freeform.contains("[Image #"),
|
||
"image token leaked: {freeform:?}"
|
||
);
|
||
}
|
||
other => panic!("expected DashboardQuestionAnswer, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
/// Shift+Enter / Alt+Enter insert a newline into the focused peek
|
||
/// reply (multiline compose) instead of sending — the reply text
|
||
/// grows and no action is emitted.
|
||
#[test]
|
||
fn peek_shift_enter_inserts_newline() {
|
||
let mut state = state_with_open_peek();
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
state.peek_reply.set_text("line one");
|
||
// Caret at end so the newline appends.
|
||
let shift_enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT);
|
||
let outcome = state.handle_key(&shift_enter, ®);
|
||
assert!(
|
||
matches!(outcome, InputOutcome::Changed),
|
||
"Shift+Enter must edit the reply, got {outcome:?}",
|
||
);
|
||
assert!(
|
||
state.peek_reply.text().contains('\n'),
|
||
"Shift+Enter must insert a newline, got {:?}",
|
||
state.peek_reply.text(),
|
||
);
|
||
// Alt+Enter does the same.
|
||
let alt_enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT);
|
||
let _ = state.handle_key(&alt_enter, ®);
|
||
assert_eq!(state.peek_reply.text().matches('\n').count(), 2);
|
||
}
|
||
|
||
/// With multiline_mode on, peek bare Enter inserts a newline; Shift+Enter sends.
|
||
#[test]
|
||
fn peek_multiline_mode_swaps_enter() {
|
||
use crate::app::actions::Action;
|
||
let mut state = state_with_open_peek();
|
||
state.multiline_mode = true;
|
||
if let Some(p) = state.peek.as_mut() {
|
||
p.focused = true;
|
||
}
|
||
state.peek_reply.set_text("line one");
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
|
||
let bare = state.handle_key(&KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), ®);
|
||
assert!(
|
||
matches!(bare, InputOutcome::Changed),
|
||
"bare Enter in multiline peek must insert newline, got {bare:?}"
|
||
);
|
||
assert!(
|
||
state.peek_reply.text().contains('\n'),
|
||
"got {:?}",
|
||
state.peek_reply.text()
|
||
);
|
||
|
||
let shift = state.handle_key(&KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT), ®);
|
||
match shift {
|
||
InputOutcome::Action(Action::DashboardPeekReply {
|
||
text,
|
||
attach: false,
|
||
..
|
||
}) => {
|
||
assert!(text.contains("line one"), "peek reply text: {text:?}");
|
||
}
|
||
other => panic!("Shift+Enter in multiline peek must send, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
/// Enter with an empty reply opens the peeked agent rather than
|
||
/// sending an empty prompt.
|
||
#[test]
|
||
fn peek_enter_empty_opens_agent() {
|
||
use crate::app::actions::Action;
|
||
let mut state = state_with_open_peek();
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
|
||
match state.handle_key(&enter, ®) {
|
||
InputOutcome::Action(Action::DashboardAttach(row)) => {
|
||
assert_eq!(row, DashboardRowId::TopLevel(AgentId(0)));
|
||
}
|
||
other => panic!("expected DashboardAttach, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
/// Right arrow on a selected agent (peek open) opens its detail view
|
||
/// — the mirror of the agent overlay's Left-arrow back-out.
|
||
#[test]
|
||
fn peek_right_arrow_opens_agent() {
|
||
use crate::app::actions::Action;
|
||
let mut state = state_with_open_peek();
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
let right = KeyEvent::new(KeyCode::Right, KeyModifiers::NONE);
|
||
match state.handle_key(&right, ®) {
|
||
InputOutcome::Action(Action::DashboardAttach(row)) => {
|
||
assert_eq!(row, DashboardRowId::TopLevel(AgentId(0)));
|
||
}
|
||
other => panic!("expected DashboardAttach, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
/// Right arrow with a non-empty FOCUSED reply moves the caret within
|
||
/// the draft instead of opening the agent — the reply text is
|
||
/// preserved and no attach is emitted.
|
||
#[test]
|
||
fn peek_right_arrow_with_text_moves_caret_not_open() {
|
||
use crate::app::actions::Action;
|
||
let mut state = state_with_open_peek();
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
state.peek_reply.set_text("draft");
|
||
let right = KeyEvent::new(KeyCode::Right, KeyModifiers::NONE);
|
||
let outcome = state.handle_key(&right, ®);
|
||
assert!(
|
||
!matches!(outcome, InputOutcome::Action(Action::DashboardAttach(_))),
|
||
"Right with a focused non-empty reply must NOT open the agent, got {outcome:?}",
|
||
);
|
||
assert_eq!(
|
||
state.peek_reply.text(),
|
||
"draft",
|
||
"Right must leave the reply draft intact",
|
||
);
|
||
}
|
||
|
||
/// Up/Down switch the peeked agent (the panel follows the selection
|
||
/// cursor).
|
||
#[test]
|
||
fn peek_arrows_switch_selected_agent() {
|
||
use crate::app::actions::Action;
|
||
let mut state = state_with_open_peek();
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
// Empty reply → arrows are a navigation surface.
|
||
let down = KeyEvent::new(KeyCode::Down, KeyModifiers::NONE);
|
||
assert!(matches!(
|
||
state.handle_key(&down, ®),
|
||
InputOutcome::Action(Action::DashboardSelectNext)
|
||
));
|
||
let up = KeyEvent::new(KeyCode::Up, KeyModifiers::NONE);
|
||
assert!(matches!(
|
||
state.handle_key(&up, ®),
|
||
InputOutcome::Action(Action::DashboardSelectPrev)
|
||
));
|
||
}
|
||
|
||
/// With a non-empty FOCUSED reply, bare Up/Down move the caret
|
||
/// WITHIN the reply text (multi-line draft) instead of switching the
|
||
/// peeked agent — they edit, never emit a `DashboardSelect*` action,
|
||
/// and leave the draft text untouched.
|
||
#[test]
|
||
fn peek_arrows_move_caret_when_reply_has_content() {
|
||
let mut state = state_with_open_peek();
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
// Two-line draft (caret at the start after set_text).
|
||
state.peek_reply.set_text("line one\nline two");
|
||
|
||
// Down must NOT switch agents — it moves the caret down a line.
|
||
let down = state.handle_key(&KeyEvent::new(KeyCode::Down, KeyModifiers::NONE), ®);
|
||
assert!(
|
||
!matches!(down, InputOutcome::Action(_)),
|
||
"Down with reply content must edit the caret, not switch agents, got {down:?}",
|
||
);
|
||
// Up likewise stays within the input.
|
||
let up = state.handle_key(&KeyEvent::new(KeyCode::Up, KeyModifiers::NONE), ®);
|
||
assert!(
|
||
!matches!(up, InputOutcome::Action(_)),
|
||
"Up with reply content must edit the caret, not switch agents, got {up:?}",
|
||
);
|
||
// The draft is untouched (caret moves only).
|
||
assert_eq!(state.peek_reply.text(), "line one\nline two");
|
||
}
|
||
|
||
/// An UNFOCUSED peek (Tab → row nav) keeps Up/Down as agent-switch
|
||
/// even with reply content — the reply isn't the active surface.
|
||
#[test]
|
||
fn peek_arrows_switch_agent_when_unfocused_despite_content() {
|
||
use crate::app::actions::Action;
|
||
let mut state = state_with_open_peek();
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
state.peek_reply.set_text("a draft");
|
||
state.peek.as_mut().unwrap().focused = false; // Tab → row nav
|
||
assert!(matches!(
|
||
state.handle_key(&KeyEvent::new(KeyCode::Down, KeyModifiers::NONE), ®),
|
||
InputOutcome::Action(Action::DashboardSelectNext)
|
||
));
|
||
}
|
||
|
||
/// The (peek-less) dispatch input mirrors the peek: with content,
|
||
/// bare Up/Down move the caret within the text (no `DashboardSelect*`
|
||
/// emitted); with an EMPTY prompt they navigate the row list (browse
|
||
/// convenience).
|
||
#[test]
|
||
fn dispatch_arrows_move_caret_with_content_navigate_when_empty() {
|
||
use crate::app::actions::Action;
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
|
||
// Empty prompt → Up/Down navigate the list.
|
||
let mut empty = make_state_with_selection();
|
||
assert!(empty.dispatch.text().is_empty());
|
||
assert!(matches!(
|
||
empty.handle_key(&KeyEvent::new(KeyCode::Down, KeyModifiers::NONE), ®),
|
||
InputOutcome::Action(Action::DashboardSelectNext)
|
||
));
|
||
|
||
// Non-empty multi-line prompt → Up/Down edit the caret, never
|
||
// switching the selected row.
|
||
let mut typed = make_state_with_selection();
|
||
typed.dispatch.set_text("line one\nline two");
|
||
let down = typed.handle_key(&KeyEvent::new(KeyCode::Down, KeyModifiers::NONE), ®);
|
||
assert!(
|
||
!matches!(
|
||
down,
|
||
InputOutcome::Action(Action::DashboardSelectNext | Action::DashboardSelectPrev)
|
||
),
|
||
"Down with dispatch content must move the caret, not the list, got {down:?}",
|
||
);
|
||
let up = typed.handle_key(&KeyEvent::new(KeyCode::Up, KeyModifiers::NONE), ®);
|
||
assert!(
|
||
!matches!(
|
||
up,
|
||
InputOutcome::Action(Action::DashboardSelectNext | Action::DashboardSelectPrev)
|
||
),
|
||
"Up with dispatch content must move the caret, not the list, got {up:?}",
|
||
);
|
||
}
|
||
|
||
/// Space closes the peek when the reply is empty (quick toggle) but
|
||
/// types a space once the user has started composing.
|
||
#[test]
|
||
fn peek_space_types_into_reply() {
|
||
let mut state = state_with_open_peek();
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
// The peek is tied to selection now, so Space is plain text (no
|
||
// close) — Esc unselects instead.
|
||
for c in ['h', 'i'] {
|
||
let _ = state.handle_key(&KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE), ®);
|
||
}
|
||
let space = KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE);
|
||
let _ = state.handle_key(&space, ®);
|
||
let _ = state.handle_key(&KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE), ®);
|
||
assert_eq!(state.peek_reply.text(), "hi y");
|
||
// Peek stays open while the row is selected.
|
||
assert!(state.peek.is_some());
|
||
}
|
||
|
||
/// Esc unselects: with an empty draft it clears the selection and
|
||
/// focuses the `[+ New Agent]` button (the new-session entry); a
|
||
/// typed draft is cleared first.
|
||
#[test]
|
||
fn peek_esc_clears_draft_then_unselects() {
|
||
let mut state = state_with_open_peek();
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
state.peek_reply.set_text("draft");
|
||
let esc = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
|
||
// First Esc clears the draft, keeps the peek + selection.
|
||
let _ = state.handle_key(&esc, ®);
|
||
assert!(state.peek_reply.text().is_empty());
|
||
assert!(state.selected.is_some());
|
||
// Second Esc unselects → focuses the + New Agent button + closes peek.
|
||
let _ = state.handle_key(&esc, ®);
|
||
assert!(state.peek.is_none());
|
||
assert!(state.selected.is_none());
|
||
assert!(state.new_agent_button_focused);
|
||
}
|
||
|
||
/// Ctrl-modified chords are never TYPED into the reply: non-bound
|
||
/// editing chords (Ctrl+A → caret-to-start) are delegated to the
|
||
/// widget as edits, while registry-bound dashboard chords (Ctrl+X
|
||
/// stop, Ctrl+T pin, …) fall through so they keep firing with the
|
||
/// peek open.
|
||
#[test]
|
||
fn peek_ctrl_keys_fall_through_not_typed() {
|
||
use crate::app::actions::Action;
|
||
let mut state = state_with_open_peek();
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
let ctrl_a = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL);
|
||
let _ = state.handle_key(&ctrl_a, ®);
|
||
// 'a' must not have been typed into the reply (Ctrl+A is the
|
||
// caret-to-start editing chord, not text input).
|
||
assert!(state.peek_reply.text().is_empty());
|
||
|
||
// A registry-bound dashboard chord still falls through to its
|
||
// action with the peek open (Ctrl+T → pin toggle).
|
||
let ctrl_t = KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL);
|
||
assert!(
|
||
matches!(
|
||
state.handle_key(&ctrl_t, ®),
|
||
InputOutcome::Action(Action::DashboardTogglePin)
|
||
),
|
||
"registry-bound Ctrl+T must keep firing with the peek open",
|
||
);
|
||
assert!(state.peek_reply.text().is_empty());
|
||
}
|
||
|
||
/// Ctrl+C / Ctrl+D bubble up as `Unchanged` so the app-global
|
||
/// quit handler fires — they are not typed into the reply or
|
||
/// swallowed by the peek.
|
||
#[test]
|
||
fn peek_ctrl_c_d_bubble_to_global_quit() {
|
||
let mut state = state_with_open_peek();
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
let ctrl_c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
|
||
assert!(matches!(
|
||
state.handle_key(&ctrl_c, ®),
|
||
InputOutcome::Unchanged
|
||
));
|
||
let ctrl_d = KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL);
|
||
assert!(matches!(
|
||
state.handle_key(&ctrl_d, ®),
|
||
InputOutcome::Unchanged
|
||
));
|
||
// The peek stays open and nothing was typed.
|
||
assert!(state.peek.is_some());
|
||
assert!(state.peek_reply.text().is_empty());
|
||
}
|
||
|
||
/// Question picker flow: no option is selected by default (arrows switch
|
||
/// agents, Enter opens). A number key selects (and toggles) an option;
|
||
/// then `↑`/`↓` move within the options (spilling to the prev/next agent
|
||
/// at the edges) and `Enter` answers the selected option.
|
||
#[test]
|
||
fn peek_arrows_navigate_options_and_enter_answers() {
|
||
use crate::app::actions::Action;
|
||
let mut state = make_state_with_selection();
|
||
let mut f = peek_fields_for_test("Awaiting your input");
|
||
f.question = Some("Allow Edit?".into());
|
||
f.options = vec![
|
||
("allow".into(), "Allow".into()),
|
||
("deny".into(), "Deny".into()),
|
||
];
|
||
f.request_id = Some(7);
|
||
state.peek = Some(super::super::peek::PeekPanelState::new(
|
||
DashboardRowId::TopLevel(AgentId(0)),
|
||
f,
|
||
));
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
|
||
// Default: nothing selected → Down switches to the next agent.
|
||
let down = KeyEvent::new(KeyCode::Down, KeyModifiers::NONE);
|
||
assert!(matches!(
|
||
state.handle_key(&down, ®),
|
||
InputOutcome::Action(Action::DashboardSelectNext)
|
||
));
|
||
assert_eq!(state.peek.as_ref().unwrap().selected_option, None);
|
||
|
||
// Pressing `1` selects the first option (and focuses the picker).
|
||
let one = KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE);
|
||
assert!(matches!(
|
||
state.handle_key(&one, ®),
|
||
InputOutcome::Changed
|
||
));
|
||
assert_eq!(state.peek.as_ref().unwrap().selected_option, Some(0));
|
||
|
||
// Now Down moves within the options to option 1.
|
||
assert!(matches!(
|
||
state.handle_key(&down, ®),
|
||
InputOutcome::Changed
|
||
));
|
||
assert_eq!(state.peek.as_ref().unwrap().selected_option, Some(1));
|
||
// Down again at the LAST option spills out to the next agent; the
|
||
// selection is left unchanged.
|
||
assert!(matches!(
|
||
state.handle_key(&down, ®),
|
||
InputOutcome::Action(Action::DashboardSelectNext)
|
||
));
|
||
assert_eq!(state.peek.as_ref().unwrap().selected_option, Some(1));
|
||
|
||
// Enter answers the selected option (index 1 → "deny").
|
||
let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
|
||
match state.handle_key(&enter, ®) {
|
||
InputOutcome::Action(Action::DashboardPermissionSelect {
|
||
request_id,
|
||
option_id,
|
||
..
|
||
}) => {
|
||
assert_eq!(request_id, 7);
|
||
assert_eq!(option_id.0.as_ref(), "deny");
|
||
}
|
||
other => panic!("expected DashboardPermissionSelect, got {other:?}"),
|
||
}
|
||
|
||
// Up moves back toward the first option.
|
||
let up = KeyEvent::new(KeyCode::Up, KeyModifiers::NONE);
|
||
let _ = state.handle_key(&up, ®);
|
||
assert_eq!(state.peek.as_ref().unwrap().selected_option, Some(0));
|
||
// Up again at the FIRST option spills out to the previous row.
|
||
assert!(matches!(
|
||
state.handle_key(&up, ®),
|
||
InputOutcome::Action(Action::DashboardSelectPrev)
|
||
));
|
||
assert_eq!(state.peek.as_ref().unwrap().selected_option, Some(0));
|
||
|
||
// Pressing `1` again toggles the selection off → back to navigation,
|
||
// where Enter opens the agent in detail.
|
||
assert!(matches!(
|
||
state.handle_key(&one, ®),
|
||
InputOutcome::Changed
|
||
));
|
||
assert_eq!(state.peek.as_ref().unwrap().selected_option, None);
|
||
assert!(matches!(
|
||
state.handle_key(&enter, ®),
|
||
InputOutcome::Action(Action::DashboardAttach(_))
|
||
));
|
||
}
|
||
|
||
/// Right mirrors Enter on the question-picker navigation surface:
|
||
/// with the panel focused and NO option selected, a bare Right opens
|
||
/// the peeked row in detail — just like Enter. Regression guard: the
|
||
/// `question_mode` block ends in a modal catch-all that returns
|
||
/// `Unchanged`, so without an explicit Right arm Enter opened but
|
||
/// Right did nothing (an inconsistent dead key).
|
||
#[test]
|
||
fn peek_right_arrow_opens_agent_in_focused_question_picker() {
|
||
use crate::app::actions::Action;
|
||
let mut state = make_state_with_selection();
|
||
let mut f = peek_fields_for_test("Awaiting your input");
|
||
f.question = Some("Allow Edit?".into());
|
||
f.options = vec![
|
||
("allow".into(), "Allow".into()),
|
||
("deny".into(), "Deny".into()),
|
||
];
|
||
f.request_id = Some(7);
|
||
state.peek = Some(super::super::peek::PeekPanelState::new(
|
||
DashboardRowId::TopLevel(AgentId(0)),
|
||
f,
|
||
));
|
||
// Pin focused: PeekPanelState::new seeds from load_vim_mode().
|
||
state.peek.as_mut().unwrap().focused = true;
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
assert_eq!(state.peek.as_ref().unwrap().selected_option, None);
|
||
|
||
let right = KeyEvent::new(KeyCode::Right, KeyModifiers::NONE);
|
||
match state.handle_key(&right, ®) {
|
||
InputOutcome::Action(Action::DashboardAttach(row)) => {
|
||
assert_eq!(row, DashboardRowId::TopLevel(AgentId(0)));
|
||
}
|
||
other => panic!(
|
||
"Right in the focused question picker must open the agent (DashboardAttach), got {other:?}",
|
||
),
|
||
}
|
||
}
|
||
|
||
/// The reject ("No") option accepts inline free-text feedback:
|
||
/// typing on it composes a message and `Enter` sends the rejection
|
||
/// with that feedback. Typing on a non-reject option is consumed.
|
||
#[test]
|
||
fn peek_reject_option_accepts_typed_feedback() {
|
||
use crate::app::actions::Action;
|
||
let mut state = make_state_with_selection();
|
||
let mut f = peek_fields_for_test("Awaiting your input");
|
||
f.question = Some("Allow Edit?".into());
|
||
f.options = vec![
|
||
("allow".into(), "Allow".into()),
|
||
("reject".into(), "No".into()),
|
||
];
|
||
f.request_id = Some(9);
|
||
f.reject_option = Some(1);
|
||
state.peek = Some(super::super::peek::PeekPanelState::new(
|
||
DashboardRowId::TopLevel(AgentId(0)),
|
||
f,
|
||
));
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
|
||
// With no option selected, typing a letter is consumed — no feedback
|
||
// composed and it doesn't leak into the reply buffer.
|
||
let _ = state.handle_key(&KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE), ®);
|
||
assert!(state.peek_reply.text().is_empty());
|
||
|
||
// Select the reject option (index 1 → key `2`), then type feedback.
|
||
let _ = state.handle_key(&KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE), ®);
|
||
assert_eq!(state.peek.as_ref().unwrap().selected_option, Some(1));
|
||
for c in ['n', 'o', 'p', 'e'] {
|
||
let _ = state.handle_key(&KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE), ®);
|
||
}
|
||
assert_eq!(state.peek_reply.text(), "nope");
|
||
|
||
// Enter sends the rejection with the typed feedback.
|
||
match state.handle_key(&KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), ®) {
|
||
InputOutcome::Action(Action::DashboardPermissionFollowup {
|
||
request_id, text, ..
|
||
}) => {
|
||
assert_eq!(request_id, 9);
|
||
assert_eq!(text, "nope");
|
||
}
|
||
other => panic!("expected DashboardPermissionFollowup, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
/// `clear_peek_reply` (used on every lifecycle clear — row change,
|
||
/// open/close, send) wipes the undo history too, so `Ctrl+Z` can't
|
||
/// resurrect a draft typed for a DIFFERENT agent onto the newly
|
||
/// peeked one. Regression for the cross-agent mis-send hole that a
|
||
/// bare `set_text("")` left open (set_text records an undoable
|
||
/// `Replace` checkpoint).
|
||
#[test]
|
||
fn peek_clear_wipes_undo_so_ctrl_z_cannot_resurrect_draft() {
|
||
let mut state = state_with_open_peek();
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
for c in "secret for A".chars() {
|
||
let _ = state.handle_key(&KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE), ®);
|
||
}
|
||
assert_eq!(state.peek_reply.text(), "secret for A");
|
||
// Simulate the row-change / lifecycle clear.
|
||
state.clear_peek_reply();
|
||
assert!(state.peek_reply.text().is_empty());
|
||
// Ctrl+Z while the (now different-agent) reply is focused must
|
||
// NOT bring the old draft back.
|
||
let _ = state.handle_key(
|
||
&KeyEvent::new(KeyCode::Char('z'), KeyModifiers::CONTROL),
|
||
®,
|
||
);
|
||
assert!(
|
||
state.peek_reply.text().is_empty(),
|
||
"undo must not resurrect a cleared cross-agent draft, got {:?}",
|
||
state.peek_reply.text(),
|
||
);
|
||
}
|
||
|
||
/// Typing `@` in the peek reply activates the session-less file
|
||
/// context picker (rooted at the launch cwd, like the dispatch box),
|
||
/// so the `@` dropdown can stream in and render above the panel.
|
||
#[test]
|
||
fn peek_typing_at_activates_file_search() {
|
||
let mut state = state_with_open_peek();
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
assert!(
|
||
state.peek_reply.file_search.context().is_none(),
|
||
"no @-context before typing @",
|
||
);
|
||
for c in ['@', 's', 'r', 'c'] {
|
||
let _ = state.handle_key(&KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE), ®);
|
||
}
|
||
assert_eq!(state.peek_reply.text(), "@src");
|
||
assert!(
|
||
state.peek_reply.file_search.context().is_some(),
|
||
"typing @ must activate the reply's file-search picker",
|
||
);
|
||
}
|
||
|
||
/// Mouse-wheel scrolling over the `@` dropdown must drive the SAME
|
||
/// picker that is rendered: the peek reply's while the panel is
|
||
/// open, the dispatch box's otherwise. (Regression: the wheel
|
||
/// intercept hardcoded `dispatch.file_search`, so scrolling the peek
|
||
/// dropdown moved the hidden dispatch selection while the visible
|
||
/// list stayed put.) Uses `context()` as a cheap observable for
|
||
/// "which picker" — `@`-context is set synchronously, unlike the
|
||
/// async results `is_visible()` needs.
|
||
#[test]
|
||
fn dropdown_file_search_follows_peek_state() {
|
||
// Peek open → the picker behind the dropdown is the reply's.
|
||
let mut open = state_with_open_peek();
|
||
open.peek_reply.file_search.update_context("@a", 2);
|
||
assert!(
|
||
open.dropdown_file_search_mut().context().is_some(),
|
||
"with the peek open, wheel scrolling must target the reply's picker",
|
||
);
|
||
|
||
// Peek closed → it's the dispatch box's.
|
||
let mut closed = DashboardState::new();
|
||
closed.dispatch.file_search.update_context("@b", 2);
|
||
assert!(closed.peek.is_none());
|
||
assert!(
|
||
closed.dropdown_file_search_mut().context().is_some(),
|
||
"with the peek closed, wheel scrolling must target the dispatch picker",
|
||
);
|
||
}
|
||
|
||
/// The reply's `@` picker roots LAZILY at the peeked agent's cwd: a
|
||
/// bare cursor move (navigation) never retargets the daemon (no
|
||
/// thread churn), and the retarget lands on the first composing
|
||
/// keystroke, deduped so a same-cwd agent switch is free.
|
||
#[test]
|
||
fn peek_reply_file_search_retargets_lazily_on_compose() {
|
||
let mut state = state_with_open_peek();
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
// The render pass records the peeked agent's cwd; simulate it.
|
||
state.set_peek_reply_target_cwd(Some(PathBuf::from("/work/repo-a")));
|
||
assert_eq!(state.peek_reply_cwd, None, "daemon not retargeted yet");
|
||
|
||
// Bare Down on an EMPTY reply switches agents — must NOT retarget.
|
||
let _ = state.handle_key(&KeyEvent::new(KeyCode::Down, KeyModifiers::NONE), ®);
|
||
assert_eq!(
|
||
state.peek_reply_cwd, None,
|
||
"navigation must not spawn a matcher daemon",
|
||
);
|
||
|
||
// First composing keystroke retargets to the recorded cwd.
|
||
let _ = state.handle_key(&KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE), ®);
|
||
assert_eq!(
|
||
state.peek_reply_cwd.as_deref(),
|
||
Some(Path::new("/work/repo-a")),
|
||
"first compose must root the picker at the peeked agent's cwd",
|
||
);
|
||
|
||
// Switching to a different-cwd agent retargets once on next compose.
|
||
state.set_peek_reply_target_cwd(Some(PathBuf::from("/work/repo-b")));
|
||
let _ = state.handle_key(&KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE), ®);
|
||
assert_eq!(
|
||
state.peek_reply_cwd.as_deref(),
|
||
Some(Path::new("/work/repo-b")),
|
||
"a cwd change retargets on the next compose",
|
||
);
|
||
}
|
||
|
||
/// In question mode the `❯ reply` line is hidden, so a paste must NOT
|
||
/// silently fill the (invisible) reply buffer unless the reject /
|
||
/// "Other" free-text option is the selected one. (Regression: paste
|
||
/// used to land in `peek_reply` regardless and resurface later.)
|
||
#[test]
|
||
fn peek_paste_in_question_mode_gated_on_reject_selection() {
|
||
let mut state = make_state_with_selection();
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
let mut f = peek_fields_for_test("Awaiting your input");
|
||
f.question = Some("Allow Edit?".into());
|
||
f.options = vec![
|
||
("allow".into(), "Allow".into()),
|
||
("reject".into(), "No".into()),
|
||
];
|
||
f.request_id = Some(9);
|
||
f.reject_option = Some(1);
|
||
state.peek = Some(super::super::peek::PeekPanelState::new(
|
||
DashboardRowId::TopLevel(AgentId(0)),
|
||
f,
|
||
));
|
||
|
||
// No option selected → paste is dropped (would be invisible).
|
||
let outcome = state.handle_input(&Event::Paste("ignored".to_string()), ®);
|
||
assert!(matches!(outcome, InputOutcome::Unchanged));
|
||
assert!(
|
||
state.peek_reply.text().is_empty(),
|
||
"paste must not fill the hidden reply when no reject option is selected",
|
||
);
|
||
|
||
// Select the reject option → paste now lands in the feedback field.
|
||
let _ = state.handle_key(&KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE), ®);
|
||
assert_eq!(state.peek.as_ref().unwrap().selected_option, Some(1));
|
||
let outcome = state.handle_input(&Event::Paste("real feedback".to_string()), ®);
|
||
assert!(matches!(outcome, InputOutcome::Changed));
|
||
assert_eq!(state.peek_reply.text(), "real feedback");
|
||
}
|
||
|
||
/// The Ask tool (`AskUserQuestion`) is answered from the peek too:
|
||
/// selecting an option emits `DashboardQuestionAnswer { option_idx }`,
|
||
/// and the "Other" free-text row emits it with `option_idx: None` +
|
||
/// the typed text. (Ask questions carry no `request_id`.)
|
||
#[test]
|
||
fn peek_ask_question_answer_routing() {
|
||
use crate::app::actions::Action;
|
||
let mut state = make_state_with_selection();
|
||
let mut f = peek_fields_for_test("Awaiting your input");
|
||
f.question = Some("Which approach?".into());
|
||
// Two real options + an appended "Other" free-text row.
|
||
f.options = vec![
|
||
("Redis".into(), "Redis".into()),
|
||
("In-memory".into(), "In-memory".into()),
|
||
("__other__".into(), "Other".into()),
|
||
];
|
||
f.reject_option = Some(2);
|
||
f.request_id = None; // ← marks it as an ask question, not a permission
|
||
state.peek = Some(super::super::peek::PeekPanelState::new(
|
||
DashboardRowId::TopLevel(AgentId(0)),
|
||
f,
|
||
));
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
|
||
// Select the first option (key `1`), then Enter answers by index.
|
||
let _ = state.handle_key(&KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE), ®);
|
||
assert_eq!(state.peek.as_ref().unwrap().selected_option, Some(0));
|
||
let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
|
||
match state.handle_key(&enter, ®) {
|
||
InputOutcome::Action(Action::DashboardQuestionAnswer {
|
||
option_idx,
|
||
freeform,
|
||
..
|
||
}) => {
|
||
assert_eq!(option_idx, Some(0));
|
||
assert!(freeform.is_empty());
|
||
}
|
||
other => panic!("expected DashboardQuestionAnswer, got {other:?}"),
|
||
}
|
||
|
||
// Select the "Other" row (index 2 → key `3`), type free-text, Enter
|
||
// → freeform answer.
|
||
let _ = state.handle_key(&KeyEvent::new(KeyCode::Char('3'), KeyModifiers::NONE), ®);
|
||
assert_eq!(state.peek.as_ref().unwrap().selected_option, Some(2));
|
||
for c in ['s', 'q', 'l'] {
|
||
let _ = state.handle_key(&KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE), ®);
|
||
}
|
||
match state.handle_key(&enter, ®) {
|
||
InputOutcome::Action(Action::DashboardQuestionAnswer {
|
||
option_idx,
|
||
freeform,
|
||
..
|
||
}) => {
|
||
assert_eq!(option_idx, None);
|
||
assert_eq!(freeform, "sql");
|
||
}
|
||
other => panic!("expected DashboardQuestionAnswer(Other), got {other:?}"),
|
||
}
|
||
}
|
||
|
||
/// Tab toggles peek reply focus; unfocused printable re-focuses and types (non-vim).
|
||
#[test]
|
||
fn peek_tab_toggles_focus_and_typing_refocuses() {
|
||
crate::appearance::cache::set_vim_mode(false);
|
||
let mut state = state_with_open_peek();
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
assert!(state.peek.as_ref().unwrap().focused);
|
||
let tab = KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE);
|
||
let _ = state.handle_key(&tab, ®);
|
||
assert!(!state.peek.as_ref().unwrap().focused, "Tab must unfocus");
|
||
// Typing while unfocused re-focuses and composes.
|
||
let _ = state.handle_key(&KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE), ®);
|
||
assert!(
|
||
state.peek.as_ref().unwrap().focused,
|
||
"typing must re-focus the reply"
|
||
);
|
||
assert_eq!(state.peek_reply.text(), "y");
|
||
}
|
||
|
||
/// Vim: peek reply starts unfocused; j navigates; Enter focuses (no attach).
|
||
#[test]
|
||
fn vim_peek_opens_unfocused_jk_nav_enter_focuses() {
|
||
let mut state = state_with_open_peek();
|
||
// Fixture pins vim off; re-enable and rebuild so the panel is
|
||
// born unfocused under vim.
|
||
crate::appearance::cache::set_vim_mode(true);
|
||
state.peek = Some(super::super::peek::PeekPanelState::new(
|
||
DashboardRowId::TopLevel(AgentId(0)),
|
||
peek_fields_for_test("Idle"),
|
||
));
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
assert!(
|
||
!state.peek.as_ref().unwrap().focused,
|
||
"vim peek must not auto-focus the reply"
|
||
);
|
||
let j = state.handle_key(&KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE), ®);
|
||
assert!(
|
||
matches!(j, InputOutcome::Action(Action::DashboardSelectNext)),
|
||
"vim j on unfocused peek must navigate, got {j:?}"
|
||
);
|
||
assert!(
|
||
state.peek_reply.text().is_empty(),
|
||
"j must not type into the reply, got {:?}",
|
||
state.peek_reply.text()
|
||
);
|
||
let enter = state.handle_key(&KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), ®);
|
||
assert!(matches!(enter, InputOutcome::Changed));
|
||
assert!(
|
||
state.peek.as_ref().unwrap().focused,
|
||
"Enter must focus the peek reply in vim mode"
|
||
);
|
||
let _ = state.handle_key(&KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE), ®);
|
||
assert_eq!(state.peek_reply.text(), "j");
|
||
crate::appearance::cache::set_vim_mode(false);
|
||
}
|
||
|
||
/// Vim unfocused: `i` focuses without inserting; other printables are swallowed.
|
||
#[test]
|
||
fn vim_peek_unfocused_i_focuses_printable_swallowed() {
|
||
let mut state = state_with_open_peek();
|
||
crate::appearance::cache::set_vim_mode(true);
|
||
state.peek = Some(super::super::peek::PeekPanelState::new(
|
||
DashboardRowId::TopLevel(AgentId(0)),
|
||
peek_fields_for_test("Idle"),
|
||
));
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
assert!(!state.peek.as_ref().unwrap().focused);
|
||
|
||
let x = state.handle_key(&KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE), ®);
|
||
assert!(
|
||
matches!(x, InputOutcome::Unchanged),
|
||
"vim unfocused printable must be swallowed, got {x:?}"
|
||
);
|
||
assert!(
|
||
!state.peek.as_ref().unwrap().focused,
|
||
"swallowed key must not focus the reply"
|
||
);
|
||
assert!(
|
||
state.peek_reply.text().is_empty(),
|
||
"swallowed key must not type, got {:?}",
|
||
state.peek_reply.text()
|
||
);
|
||
|
||
let i = state.handle_key(&KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE), ®);
|
||
assert!(matches!(i, InputOutcome::Changed));
|
||
assert!(
|
||
state.peek.as_ref().unwrap().focused,
|
||
"i must focus the peek reply"
|
||
);
|
||
assert!(
|
||
state.peek_reply.text().is_empty(),
|
||
"i must not be inserted, got {:?}",
|
||
state.peek_reply.text()
|
||
);
|
||
crate::appearance::cache::set_vim_mode(false);
|
||
}
|
||
|
||
/// Vim: apply_fields row change clears peek reply focus.
|
||
#[test]
|
||
fn vim_peek_row_change_unfocuses_reply() {
|
||
let mut state = state_with_open_peek();
|
||
crate::appearance::cache::set_vim_mode(true);
|
||
state.peek.as_mut().unwrap().focused = true;
|
||
let other = DashboardRowId::TopLevel(AgentId(99));
|
||
let fields = super::super::peek::PeekFields {
|
||
label: "other".into(),
|
||
time_ago: String::new(),
|
||
response_type: "Idle".into(),
|
||
last_user_message: None,
|
||
last_agent_lines: vec![],
|
||
last_response_truncated: false,
|
||
question: None,
|
||
options: vec![],
|
||
request_id: None,
|
||
reject_option: None,
|
||
};
|
||
let changed = state.peek.as_mut().unwrap().apply_fields(other, fields);
|
||
assert!(changed);
|
||
assert!(
|
||
!state.peek.as_ref().unwrap().focused,
|
||
"vim row change must unfocus the reply"
|
||
);
|
||
crate::appearance::cache::set_vim_mode(false);
|
||
}
|
||
|
||
/// Non-registry editing chords reach the reply widget while the
|
||
/// peek is focused: Ctrl+A moves the caret to the start and Ctrl+K
|
||
/// kills to end-of-line — the full `PromptWidget` editing surface,
|
||
/// not the old bare-char-only editor.
|
||
#[test]
|
||
fn peek_editing_chords_reach_reply_widget() {
|
||
let mut state = state_with_open_peek();
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
for c in ['a', 'b', 'c'] {
|
||
let _ = state.handle_key(&KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE), ®);
|
||
}
|
||
assert_eq!(state.peek_reply.text(), "abc");
|
||
// Ctrl+A → caret to line start (consumed by the widget, NOT a
|
||
// dashboard action and NOT typed).
|
||
let _ = state.handle_key(
|
||
&KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL),
|
||
®,
|
||
);
|
||
assert_eq!(state.peek_reply.cursor(), 0, "Ctrl+A must move the caret");
|
||
// Ctrl+K → kill to end of line.
|
||
let _ = state.handle_key(
|
||
&KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL),
|
||
®,
|
||
);
|
||
assert!(
|
||
state.peek_reply.text().is_empty(),
|
||
"Ctrl+K must kill to end of line, got {:?}",
|
||
state.peek_reply.text(),
|
||
);
|
||
// The hidden dispatch input was never touched.
|
||
assert!(state.dispatch.text().is_empty());
|
||
}
|
||
|
||
/// Drag-selecting text in the dispatch box works like the peek
|
||
/// reply: Down inside the rect anchors the drag, Drag extends the
|
||
/// textarea selection, and Up finishes it — Drag/Up are forwarded
|
||
/// even when the pointer leaves the box.
|
||
#[test]
|
||
fn dispatch_mouse_drag_selects_text() {
|
||
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
|
||
use ratatui::buffer::Buffer;
|
||
|
||
let style = crate::views::prompt_widget::PromptStyle {
|
||
focused: true,
|
||
show_prefix: true,
|
||
vpad_top: 0,
|
||
chrome: false,
|
||
..crate::views::prompt_widget::PromptStyle::default()
|
||
};
|
||
let mut state = DashboardState::new();
|
||
state.dispatch.set_text("hello world");
|
||
let rect = Rect::new(2, 1, 60, 1);
|
||
state.dispatch_rect = Some(rect);
|
||
let mut buf = Buffer::empty(Rect::new(0, 0, 80, 10));
|
||
let _ = state.dispatch.draw(&mut buf, rect, None, &style, None);
|
||
for (kind, column) in [
|
||
(MouseEventKind::Down(MouseButton::Left), 4),
|
||
(MouseEventKind::Drag(MouseButton::Left), 12),
|
||
// The drag continues past the right edge of the box and is
|
||
// released there; the selection must keep extending.
|
||
(MouseEventKind::Drag(MouseButton::Left), 70),
|
||
(MouseEventKind::Up(MouseButton::Left), 70),
|
||
] {
|
||
let _ = state.handle_mouse(&MouseEvent {
|
||
kind,
|
||
column,
|
||
row: 1,
|
||
modifiers: KeyModifiers::NONE,
|
||
});
|
||
}
|
||
assert_eq!(
|
||
state.dispatch.textarea.selection_range(),
|
||
Some(0..11),
|
||
"dispatch drag must extend the textarea selection like the peek reply",
|
||
);
|
||
}
|
||
|
||
/// A left click inside the recorded reply rect focuses the reply
|
||
/// input (mirrors the dispatch box's click-to-focus) and routes the
|
||
/// event to the widget.
|
||
#[test]
|
||
fn peek_mouse_click_on_reply_rect_focuses() {
|
||
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
|
||
let mut state = state_with_open_peek();
|
||
state.peek.as_mut().unwrap().focused = false;
|
||
state.peek_reply_rect = Some(Rect::new(2, 10, 40, 1));
|
||
let click = MouseEvent {
|
||
kind: MouseEventKind::Down(MouseButton::Left),
|
||
column: 5,
|
||
row: 10,
|
||
modifiers: KeyModifiers::NONE,
|
||
};
|
||
let outcome = state.handle_mouse(&click);
|
||
assert!(matches!(outcome, InputOutcome::Changed));
|
||
assert!(
|
||
state.peek.as_ref().unwrap().focused,
|
||
"a click on the reply rect must focus the reply input",
|
||
);
|
||
// A click outside the rect (on no row) leaves focus alone.
|
||
state.peek.as_mut().unwrap().focused = false;
|
||
let miss = MouseEvent {
|
||
kind: MouseEventKind::Down(MouseButton::Left),
|
||
column: 70,
|
||
row: 20,
|
||
modifiers: KeyModifiers::NONE,
|
||
};
|
||
let _ = state.handle_mouse(&miss);
|
||
assert!(
|
||
!state.peek.as_ref().unwrap().focused,
|
||
"a click outside the reply rect must not grab focus",
|
||
);
|
||
}
|
||
|
||
/// Esc never wipes a typed dispatch draft. On a focused input the
|
||
/// first Esc unfocuses (blurs) to the overview list so the user can
|
||
/// navigate; the draft is left intact. A later Esc exits, still
|
||
/// keeping the draft (retained across a same-process close/reopen of
|
||
/// the dashboard; not persisted across an app restart).
|
||
#[test]
|
||
fn esc_preserves_dispatch_text() {
|
||
let mut state = DashboardState::new();
|
||
state.dispatch.set_text("fix the bug");
|
||
assert!(!state.list_focused, "input focused by default");
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
|
||
// First Esc blurs the input → list focus; draft preserved.
|
||
let first = state.handle_key(&key, ®);
|
||
assert!(matches!(first, InputOutcome::Changed));
|
||
assert!(state.list_focused, "Esc unfocuses the input");
|
||
assert_eq!(state.dispatch.text(), "fix the bug");
|
||
// Second Esc (list focused, nothing selected) exits — draft kept.
|
||
let second = state.handle_key(&key, ®);
|
||
assert!(matches!(
|
||
second,
|
||
InputOutcome::Action(Action::ExitDashboard)
|
||
));
|
||
assert_eq!(state.dispatch.text(), "fix the bug");
|
||
}
|
||
|
||
/// Esc on a focused input blurs to the list without touching the
|
||
/// draft, even when a row is selected (the selection survives the
|
||
/// blur and is only backed out of by a later Esc).
|
||
#[test]
|
||
fn esc_blurs_input_keeps_draft_and_selection() {
|
||
let mut state = make_state_with_selection();
|
||
state.dispatch.set_text("hello");
|
||
assert!(!state.list_focused, "input focused by default");
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
|
||
let outcome = state.handle_key(&key, ®);
|
||
assert!(matches!(outcome, InputOutcome::Changed));
|
||
assert!(state.list_focused, "Esc unfocuses the input");
|
||
assert!(state.selected.is_some(), "selection preserved on blur");
|
||
assert_eq!(state.dispatch.text(), "hello", "draft is preserved");
|
||
}
|
||
|
||
/// edge case 13: Esc with empty input + active filter
|
||
/// clears filter.
|
||
#[test]
|
||
fn esc_clears_active_filter() {
|
||
let mut state = make_state_with_selection();
|
||
state.filter = Filter::Agent("reviewer".into());
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
|
||
let outcome = state.handle_key(&key, ®);
|
||
assert!(matches!(outcome, InputOutcome::Changed));
|
||
assert!(matches!(state.filter, Filter::None));
|
||
}
|
||
|
||
/// Esc with nothing to back out of: the first Esc unfocuses the
|
||
/// input (→ overview list), the second exits the dashboard. The
|
||
/// focus tier sits above exit so a focused input always blurs first.
|
||
#[test]
|
||
fn esc_with_nothing_to_clear_blurs_then_exits() {
|
||
let mut state = DashboardState::new();
|
||
assert!(!state.list_focused, "input focused by default");
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
|
||
let first = state.handle_key(&key, ®);
|
||
assert!(matches!(first, InputOutcome::Changed));
|
||
assert!(state.list_focused, "first Esc unfocuses the input");
|
||
let second = state.handle_key(&key, ®);
|
||
assert!(matches!(
|
||
second,
|
||
InputOutcome::Action(Action::ExitDashboard)
|
||
));
|
||
}
|
||
|
||
/// With the list focused and a row selected, Esc DESELECTS instead
|
||
/// of exiting. The user's contract hinges on this: a selected row
|
||
/// turns the dispatch input into "reply to this agent"; deselecting
|
||
/// flips it back to "create a new session" without leaving the
|
||
/// dashboard. (From a focused input Esc would blur first; here we
|
||
/// start already on the list.)
|
||
#[test]
|
||
fn esc_with_selection_deselects() {
|
||
let mut state = make_state_with_selection();
|
||
state.list_focused = true;
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
|
||
let outcome = state.handle_key(&key, ®);
|
||
assert!(
|
||
matches!(outcome, InputOutcome::Changed),
|
||
"Esc on a selected dashboard must report `Changed`, got {outcome:?}",
|
||
);
|
||
assert!(
|
||
state.selected.is_none(),
|
||
"Esc must clear `selected` so the next dispatch reaches the new-session path",
|
||
);
|
||
assert!(
|
||
state.new_agent_button_focused,
|
||
"Esc-deselect must focus the `[+ New Agent]` button as the new cursor target",
|
||
);
|
||
}
|
||
|
||
/// Enter with an empty prompt while the button is
|
||
/// focused emits `DashboardCreateNewAgentWithDetail`. The
|
||
/// state handler returns the action; the dispatcher then
|
||
/// spawns the session + switches to its detail view.
|
||
#[test]
|
||
fn enter_on_focused_button_with_empty_prompt_emits_create_with_detail() {
|
||
use crate::app::actions::Action;
|
||
let mut state = DashboardState::new();
|
||
// Fresh state defaults to button-focused; pin that
|
||
// precondition so a future regression doesn't quietly
|
||
// flip the default away from the button.
|
||
assert!(state.new_agent_button_focused);
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
|
||
let outcome = state.handle_key(&key, ®);
|
||
assert!(
|
||
matches!(
|
||
outcome,
|
||
InputOutcome::Action(Action::DashboardCreateNewAgentWithDetail),
|
||
),
|
||
"Enter on focused button with empty prompt must emit \
|
||
DashboardCreateNewAgentWithDetail, got: {outcome:?}",
|
||
);
|
||
}
|
||
|
||
/// Enter with a NON-empty prompt while the button is
|
||
/// focused emits `DashboardDispatch` (the regular new-session
|
||
/// path). Detail view does NOT open: the user wanted to fire
|
||
/// off a session and keep working in the dashboard.
|
||
#[test]
|
||
fn enter_on_focused_button_with_non_empty_prompt_emits_dispatch() {
|
||
use crate::app::actions::Action;
|
||
let mut state = DashboardState::new();
|
||
state.dispatch.set_text("queue a task");
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
|
||
let outcome = state.handle_key(&key, ®);
|
||
match outcome {
|
||
InputOutcome::Action(Action::DashboardDispatch { text, attach }) => {
|
||
assert_eq!(text, "queue a task");
|
||
assert!(!attach, "plain Enter must keep `attach=false`");
|
||
}
|
||
other => panic!(
|
||
"Enter on focused button with non-empty prompt must emit \
|
||
DashboardDispatch (NOT CreateNewAgentWithDetail), got: {other:?}",
|
||
),
|
||
}
|
||
}
|
||
|
||
/// Ctrl+S ("send + open") on focused button + non-empty prompt
|
||
/// emits `DashboardDispatch { attach: true }` so the
|
||
/// dispatcher's new-session arm switches view AND sets
|
||
/// `attached_agent`. The state handler doesn't know about attach
|
||
/// semantics — it just forwards the chord through the payload.
|
||
#[test]
|
||
fn ctrl_s_on_focused_button_with_text_emits_dispatch_with_attach_true() {
|
||
use crate::app::actions::Action;
|
||
let mut state = DashboardState::new();
|
||
state.dispatch.set_text("send and open");
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
let key = KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL);
|
||
let outcome = state.handle_key(&key, ®);
|
||
match outcome {
|
||
InputOutcome::Action(Action::DashboardDispatch { text, attach }) => {
|
||
assert_eq!(text, "send and open");
|
||
assert!(attach, "Ctrl+S must set `attach=true`");
|
||
}
|
||
other => panic!(
|
||
"Ctrl+S on focused button with text must emit \
|
||
DashboardDispatch {{ attach: true }}, got: {other:?}",
|
||
),
|
||
}
|
||
}
|
||
|
||
/// Enter on a row-selected dashboard with an EMPTY
|
||
/// prompt emits `DashboardAttach(row_id)` so the dispatcher
|
||
/// opens the detail view without sending anything.
|
||
#[test]
|
||
fn enter_on_row_selected_empty_prompt_emits_attach() {
|
||
use crate::app::actions::Action;
|
||
let mut state = make_state_with_selection();
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
|
||
let outcome = state.handle_key(&key, ®);
|
||
match outcome {
|
||
InputOutcome::Action(Action::DashboardAttach(id)) => {
|
||
assert_eq!(id, DashboardRowId::TopLevel(AgentId(0)));
|
||
}
|
||
other => panic!(
|
||
"Enter on row + empty prompt must emit DashboardAttach, \
|
||
got: {other:?}",
|
||
),
|
||
}
|
||
}
|
||
|
||
/// Enter on a row-selected dashboard with TYPED text
|
||
/// emits `DashboardDispatch { attach: false }` so the
|
||
/// dispatcher's reply arm sends without leaving the
|
||
/// dashboard.
|
||
#[test]
|
||
fn enter_on_row_selected_with_text_emits_dispatch_no_attach() {
|
||
use crate::app::actions::Action;
|
||
let mut state = make_state_with_selection();
|
||
state.dispatch.set_text("reply to selected");
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
|
||
let outcome = state.handle_key(&key, ®);
|
||
match outcome {
|
||
InputOutcome::Action(Action::DashboardDispatch { text, attach }) => {
|
||
assert_eq!(text, "reply to selected");
|
||
assert!(!attach);
|
||
}
|
||
other => panic!(
|
||
"Enter on row + text must emit DashboardDispatch {{ attach: false }}, \
|
||
got: {other:?}",
|
||
),
|
||
}
|
||
}
|
||
|
||
/// Ctrl+S ("send + open") on a row-selected dashboard with TYPED
|
||
/// text emits `DashboardDispatch { attach: true }`.
|
||
#[test]
|
||
fn ctrl_s_on_row_selected_with_text_emits_dispatch_with_attach() {
|
||
use crate::app::actions::Action;
|
||
let mut state = make_state_with_selection();
|
||
state.dispatch.set_text("reply and open");
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
let key = KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL);
|
||
let outcome = state.handle_key(&key, ®);
|
||
match outcome {
|
||
InputOutcome::Action(Action::DashboardDispatch { text, attach }) => {
|
||
assert_eq!(text, "reply and open");
|
||
assert!(attach, "Ctrl+S must set `attach=true`");
|
||
}
|
||
other => panic!(
|
||
"Ctrl+S on row + text must emit DashboardDispatch {{ attach: true }}, \
|
||
got: {other:?}",
|
||
),
|
||
}
|
||
}
|
||
|
||
/// Ctrl+S ("send + open") on focused button with EMPTY prompt
|
||
/// behaves like plain Enter — emits `CreateNewAgentWithDetail`.
|
||
/// There's nothing to "send" so the chord collapses to:
|
||
/// the only sensible interpretation is "create + open
|
||
/// detail", which the unmodified Enter already does.
|
||
#[test]
|
||
fn ctrl_s_on_focused_button_with_empty_prompt_emits_create_with_detail() {
|
||
use crate::app::actions::Action;
|
||
let mut state = DashboardState::new();
|
||
assert!(state.new_agent_button_focused);
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
let key = KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL);
|
||
let outcome = state.handle_key(&key, ®);
|
||
assert!(
|
||
matches!(
|
||
outcome,
|
||
InputOutcome::Action(Action::DashboardCreateNewAgentWithDetail),
|
||
),
|
||
"Ctrl+S on focused button with empty prompt must collapse to \
|
||
CreateNewAgentWithDetail, got: {outcome:?}",
|
||
);
|
||
}
|
||
|
||
/// Full Esc cascade from a focused input with a row selected:
|
||
/// blur (→ list) → deselect (→ `[+ New Agent]`) → exit. Pins the
|
||
/// tier ordering, catching a regression that would skip any tier.
|
||
#[test]
|
||
fn esc_cascade_blurs_then_deselects_then_exits() {
|
||
let mut state = make_state_with_selection();
|
||
assert!(!state.list_focused, "input focused by default");
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
|
||
// 1: blur the input to the list (selection survives).
|
||
let first = state.handle_key(&key, ®);
|
||
assert!(matches!(first, InputOutcome::Changed));
|
||
assert!(state.list_focused, "first Esc unfocuses the input");
|
||
assert!(state.selected.is_some(), "selection survives the blur");
|
||
// 2: deselect the row.
|
||
let second = state.handle_key(&key, ®);
|
||
assert!(matches!(second, InputOutcome::Changed));
|
||
assert!(state.selected.is_none());
|
||
assert!(state.new_agent_button_focused);
|
||
// 3: exit.
|
||
let third = state.handle_key(&key, ®);
|
||
assert!(
|
||
matches!(third, InputOutcome::Action(Action::ExitDashboard)),
|
||
"third Esc must exit the dashboard, got {third:?}",
|
||
);
|
||
}
|
||
|
||
/// New contract: a `a:` / `s:` / `#` prefix is NO LONGER treated
|
||
/// as a filter on Enter — filtering is the explicit `Ctrl+/`
|
||
/// search mode now, so a prompt that merely starts with a prefix
|
||
/// dispatches verbatim. This pins the bug fix: prefixed prompts
|
||
/// must not be silently swallowed as filters.
|
||
#[test]
|
||
fn enter_with_prefix_text_dispatches_not_filters() {
|
||
let mut state = make_state_with_selection();
|
||
state.dispatch.set_text("a:reviewer please refactor");
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
|
||
let outcome = state.handle_key(&key, ®);
|
||
match outcome {
|
||
InputOutcome::Action(Action::DashboardDispatch { text, attach }) => {
|
||
assert_eq!(text, "a:reviewer please refactor");
|
||
assert!(!attach);
|
||
}
|
||
other => panic!("prefix text must DISPATCH, not filter, got {other:?}"),
|
||
}
|
||
assert!(
|
||
matches!(state.filter, Filter::None),
|
||
"dispatch must not set a filter",
|
||
);
|
||
}
|
||
|
||
/// edge case 21: Enter on free text dispatches.
|
||
/// Assert the payload matches the typed text and that
|
||
/// `attach` is false (no Shift modifier). A regression that
|
||
/// dispatched a different string (or swallowed the input) would
|
||
/// be invisible to a `matches!` assertion that ignores the
|
||
/// payload fields.
|
||
#[test]
|
||
fn enter_with_free_text_dispatches() {
|
||
let mut state = make_state_with_selection();
|
||
let typed = "write some tests";
|
||
state.dispatch.set_text(typed);
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
|
||
let outcome = state.handle_key(&key, ®);
|
||
match outcome {
|
||
InputOutcome::Action(Action::DashboardDispatch { text, attach }) => {
|
||
assert_eq!(text, typed, "Dispatch payload must echo the typed text");
|
||
assert!(!attach, "Plain Enter must not set attach=true");
|
||
}
|
||
other => panic!("expected DashboardDispatch, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
// -----------------------------------------------------------------
|
||
// Reconciled dispatch-input features: slash commands, Alt+Enter
|
||
// multiline, vim-gated j/k, and paste — layered on top of the
|
||
// reply-mode / search-mode base.
|
||
// -----------------------------------------------------------------
|
||
|
||
/// A `/command` Enter routes through the session-less slash
|
||
/// dispatcher instead of becoming a new session's prompt.
|
||
#[test]
|
||
fn slash_command_on_enter_dispatches_slash() {
|
||
let mut state = make_state_with_selection();
|
||
state.dispatch.set_text("/help");
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
|
||
match state.handle_key(&key, ®) {
|
||
InputOutcome::Action(Action::DashboardDispatchSlash { text }) => {
|
||
assert_eq!(text, "/help");
|
||
}
|
||
other => panic!("expected DashboardDispatchSlash, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
/// Alt+Enter AND Shift+Enter insert a newline (multiline compose)
|
||
/// in the dispatch input rather than dispatching — "send + open"
|
||
/// moved to Ctrl+S so both Enter-modifier chords are free for
|
||
/// newlines (matching the agent prompt).
|
||
#[test]
|
||
fn alt_and_shift_enter_insert_newline_not_dispatch() {
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
for modifier in [KeyModifiers::ALT, KeyModifiers::SHIFT] {
|
||
let mut state = make_state_with_selection();
|
||
for ch in "hi".chars() {
|
||
let _ =
|
||
state.handle_key(&KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE), ®);
|
||
}
|
||
let outcome = state.handle_key(&KeyEvent::new(KeyCode::Enter, modifier), ®);
|
||
assert!(
|
||
!matches!(outcome, InputOutcome::Action(_)),
|
||
"{modifier:?}+Enter must not dispatch, got {outcome:?}"
|
||
);
|
||
assert_eq!(
|
||
state.dispatch.text(),
|
||
"hi\n",
|
||
"{modifier:?}+Enter must insert a newline"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn compose_enter_is_newline_matrix() {
|
||
// Strict swap: (multiline, mod_enter) → is_newline
|
||
assert!(!compose_enter_is_newline(false, false));
|
||
assert!(compose_enter_is_newline(false, true));
|
||
assert!(compose_enter_is_newline(true, false));
|
||
assert!(!compose_enter_is_newline(true, true));
|
||
}
|
||
|
||
/// With multiline_mode on, bare Enter inserts a newline (does not
|
||
/// dispatch) and Shift/Alt+Enter send — the agent-prompt swap.
|
||
#[test]
|
||
fn multiline_mode_swaps_enter_and_shift_enter() {
|
||
use crate::app::actions::Action;
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
let mut state = make_state_with_selection();
|
||
state.multiline_mode = true;
|
||
state.dispatch.set_text("line one");
|
||
|
||
let bare = state.handle_key(&KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), ®);
|
||
assert!(
|
||
matches!(bare, InputOutcome::Changed),
|
||
"bare Enter in multiline must insert newline, got {bare:?}"
|
||
);
|
||
assert!(
|
||
state.dispatch.text().contains('\n'),
|
||
"bare Enter must insert a newline, got {:?}",
|
||
state.dispatch.text()
|
||
);
|
||
|
||
// After newline, Shift+Enter should dispatch the draft.
|
||
let shift = state.handle_key(&KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT), ®);
|
||
match shift {
|
||
InputOutcome::Action(Action::DashboardDispatch {
|
||
text,
|
||
attach: false,
|
||
}) => {
|
||
assert!(text.contains("line one"), "dispatch text: {text:?}");
|
||
}
|
||
other => panic!("Shift+Enter in multiline must dispatch, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
/// Multiline empty bare Enter inserts a newline (strict swap); Shift+Enter
|
||
/// open/create/attach.
|
||
#[test]
|
||
fn multiline_mode_empty_bare_enter_is_newline() {
|
||
use crate::app::actions::Action;
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
let mut state = make_state_with_selection();
|
||
state.multiline_mode = true;
|
||
state.dispatch.set_text("");
|
||
let bare = state.handle_key(&KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), ®);
|
||
assert!(
|
||
matches!(bare, InputOutcome::Changed),
|
||
"empty bare Enter in multiline must insert newline, got {bare:?}"
|
||
);
|
||
assert!(
|
||
state.dispatch.text().contains('\n'),
|
||
"got {:?}",
|
||
state.dispatch.text()
|
||
);
|
||
|
||
state.dispatch.set_text("");
|
||
match state.handle_key(&KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT), ®) {
|
||
InputOutcome::Action(Action::DashboardAttach(id)) => {
|
||
assert_eq!(id, DashboardRowId::TopLevel(AgentId(0)));
|
||
}
|
||
other => panic!("empty Shift+Enter in multiline must attach, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
/// Ctrl+M toggles multiline via SetMultilineMode (same chord as agent).
|
||
#[test]
|
||
fn ctrl_m_toggles_multiline_mode() {
|
||
use crate::app::actions::Action;
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
let mut state = DashboardState::new();
|
||
assert!(!state.multiline_mode);
|
||
let outcome = state.handle_key(
|
||
&KeyEvent::new(KeyCode::Char('m'), KeyModifiers::CONTROL),
|
||
®,
|
||
);
|
||
match outcome {
|
||
InputOutcome::Action(Action::SetMultilineMode(true)) => {}
|
||
other => panic!("Ctrl+M must emit SetMultilineMode(true), got {other:?}"),
|
||
}
|
||
state.multiline_mode = true;
|
||
let outcome = state.handle_key(
|
||
&KeyEvent::new(KeyCode::Char('m'), KeyModifiers::CONTROL),
|
||
®,
|
||
);
|
||
match outcome {
|
||
InputOutcome::Action(Action::SetMultilineMode(false)) => {}
|
||
other => panic!("Ctrl+M when on must emit SetMultilineMode(false), got {other:?}"),
|
||
}
|
||
}
|
||
|
||
/// Bare `?` opens shortcuts when the draft is empty (input-focused) or
|
||
/// the list is focused; types into a non-empty draft.
|
||
#[test]
|
||
fn question_mark_honor_gate_matches_empty_or_list_focus() {
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
let question = KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE);
|
||
|
||
// Default: input-focused, empty → help.
|
||
let mut state = DashboardState::new();
|
||
assert!(!state.list_focused);
|
||
assert!(matches!(
|
||
state.handle_key(&question, ®),
|
||
InputOutcome::Action(Action::DashboardOpenShortcutsHelp)
|
||
));
|
||
assert!(state.dispatch.text().is_empty());
|
||
|
||
// Non-empty draft → type.
|
||
state.dispatch.set_text("hello");
|
||
let _ = state.handle_key(&question, ®);
|
||
assert!(
|
||
state.dispatch.text().contains('?'),
|
||
"non-empty draft: `?` must type, got {:?}",
|
||
state.dispatch.text()
|
||
);
|
||
|
||
// List-focused with leftover draft → help, draft untouched.
|
||
state.list_focused = true;
|
||
state.dispatch.set_text("leftover");
|
||
assert!(matches!(
|
||
state.handle_key(&question, ®),
|
||
InputOutcome::Action(Action::DashboardOpenShortcutsHelp)
|
||
));
|
||
assert_eq!(state.dispatch.text(), "leftover");
|
||
|
||
// Empty peek reply → help; non-empty peek reply → type.
|
||
let mut peek = state_with_open_peek();
|
||
assert!(matches!(
|
||
peek.handle_key(&question, ®),
|
||
InputOutcome::Action(Action::DashboardOpenShortcutsHelp)
|
||
));
|
||
assert!(peek.peek_reply.text().is_empty());
|
||
peek.peek_reply.set_text("draft");
|
||
let _ = peek.handle_key(&question, ®);
|
||
assert!(
|
||
peek.peek_reply.text().contains('?'),
|
||
"non-empty peek reply: `?` must type, got {:?}",
|
||
peek.peek_reply.text()
|
||
);
|
||
}
|
||
|
||
/// vim-mode OFF — `j`/`k` type into the dispatch input (they are
|
||
/// NOT hijacked as row navigation). Mirrors the agent scrollback.
|
||
#[test]
|
||
fn vim_off_jk_type_into_input() {
|
||
crate::appearance::cache::set_vim_mode(false);
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
let mut state = DashboardState::new();
|
||
for ch in ['j', 'k'] {
|
||
let _ = state.handle_key(&KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE), ®);
|
||
}
|
||
assert_eq!(
|
||
state.dispatch.text(),
|
||
"jk",
|
||
"vim-off j/k must type into the input, not navigate"
|
||
);
|
||
}
|
||
|
||
/// vim-mode ON + the overview list focused (via Tab) — `j`/`k`
|
||
/// navigate the row list. In the input focus they type (covered by
|
||
/// `vim_on_jk_type_into_input_when_focused`).
|
||
#[test]
|
||
fn vim_on_jk_navigate_when_list_focused() {
|
||
crate::appearance::cache::set_vim_mode(true);
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
let mut state = DashboardState::new();
|
||
state.list_focused = true;
|
||
let j = state.handle_key(&KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE), ®);
|
||
assert!(
|
||
matches!(j, InputOutcome::Action(Action::DashboardSelectNext)),
|
||
"vim j must select the next row, got {j:?}"
|
||
);
|
||
let k = state.handle_key(&KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE), ®);
|
||
assert!(
|
||
matches!(k, InputOutcome::Action(Action::DashboardSelectPrev)),
|
||
"vim k must select the previous row, got {k:?}"
|
||
);
|
||
crate::appearance::cache::set_vim_mode(false);
|
||
}
|
||
|
||
/// vim-mode ON but the INPUT focused (the default) — `j`/`k` type
|
||
/// into the dispatch prompt; navigation requires Tab to the overview
|
||
/// first. This is the "distinct focus areas" contract.
|
||
#[test]
|
||
fn vim_on_jk_type_into_input_when_focused() {
|
||
crate::appearance::cache::set_vim_mode(true);
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
let mut state = DashboardState::new();
|
||
assert!(!state.list_focused, "input focused by default");
|
||
for ch in ['j', 'k'] {
|
||
let _ = state.handle_key(&KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE), ®);
|
||
}
|
||
assert_eq!(
|
||
state.dispatch.text(),
|
||
"jk",
|
||
"input-focused vim j/k must type, not navigate"
|
||
);
|
||
crate::appearance::cache::set_vim_mode(false);
|
||
}
|
||
|
||
/// Tab toggles the two-focus model: input bar ↔ overview list.
|
||
#[test]
|
||
fn tab_toggles_input_and_list_focus() {
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
let mut state = make_state_with_selection();
|
||
assert!(!state.list_focused, "input focused by default");
|
||
let a = state.handle_key(&KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE), ®);
|
||
assert!(matches!(a, InputOutcome::Changed));
|
||
assert!(state.list_focused, "Tab focuses the overview list");
|
||
let b = state.handle_key(&KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE), ®);
|
||
assert!(matches!(b, InputOutcome::Changed));
|
||
assert!(!state.list_focused, "Tab again returns focus to the input");
|
||
}
|
||
|
||
/// Shift+Tab emits `DashboardCycleMode` regardless of how the terminal
|
||
/// encodes it — `BackTab` (with or without a SHIFT modifier) or
|
||
/// `Tab`+SHIFT. Guards the regression where the registry's exact-modifier
|
||
/// `key!(BackTab)` lookup silently failed on `BackTab`+SHIFT.
|
||
#[test]
|
||
fn shift_tab_emits_cycle_mode_for_all_encodings() {
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
for key in [
|
||
KeyEvent::new(KeyCode::BackTab, KeyModifiers::NONE),
|
||
KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT),
|
||
KeyEvent::new(KeyCode::Tab, KeyModifiers::SHIFT),
|
||
] {
|
||
let mut state = DashboardState::new();
|
||
let outcome = state.handle_key(&key, ®);
|
||
assert!(
|
||
matches!(outcome, InputOutcome::Action(Action::DashboardCycleMode)),
|
||
"Shift+Tab ({key:?}) must emit DashboardCycleMode, got {outcome:?}",
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Multiline must not treat Shift+Tab as the submit chord (is_mod_enter
|
||
/// requires KeyCode::Enter).
|
||
#[test]
|
||
fn multiline_shift_tab_cycles_mode_with_non_empty_draft() {
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
for key in [
|
||
KeyEvent::new(KeyCode::BackTab, KeyModifiers::NONE),
|
||
KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT),
|
||
KeyEvent::new(KeyCode::Tab, KeyModifiers::SHIFT),
|
||
] {
|
||
let mut state = DashboardState::new();
|
||
state.multiline_mode = true;
|
||
state.dispatch.set_text("draft text");
|
||
let outcome = state.handle_key(&key, ®);
|
||
assert!(
|
||
matches!(outcome, InputOutcome::Action(Action::DashboardCycleMode)),
|
||
"multiline + {key:?} must DashboardCycleMode, not send, got {outcome:?}",
|
||
);
|
||
assert_eq!(
|
||
state.dispatch.text(),
|
||
"draft text",
|
||
"draft must not be consumed by Shift+Tab"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Shift+↑/↓ emits the reorder actions even with the peek open (the
|
||
/// default state when a row is selected) and regardless of focus. Guards
|
||
/// the reorder keybinding end-to-end through `handle_key`.
|
||
#[test]
|
||
fn shift_arrows_emit_reorder_with_peek_open() {
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
for (code, expected) in [
|
||
(KeyCode::Up, Action::DashboardReorderUp),
|
||
(KeyCode::Down, Action::DashboardReorderDown),
|
||
] {
|
||
let mut state = make_state_with_selection();
|
||
// Peek is shown by default when a row is selected.
|
||
state.peek = Some(super::super::peek::PeekPanelState::new(
|
||
DashboardRowId::TopLevel(AgentId(0)),
|
||
peek_fields_for_test("Idle"),
|
||
));
|
||
let outcome = state.handle_key(&KeyEvent::new(code, KeyModifiers::SHIFT), ®);
|
||
assert!(
|
||
matches!(&outcome, InputOutcome::Action(a) if std::mem::discriminant(a) == std::mem::discriminant(&expected)),
|
||
"Shift+{code:?} must emit {expected:?}, got {outcome:?}",
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Shift+Tab cycles the PEEKED agent's live mode while the peek is
|
||
/// open (emitting `DashboardPeekCycleMode`), but the new-session
|
||
/// staged mode (`DashboardCycleMode`) when no peek is shown.
|
||
#[test]
|
||
fn shift_tab_cycles_peeked_agent_mode_when_peek_open() {
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
for key in [
|
||
KeyEvent::new(KeyCode::BackTab, KeyModifiers::NONE),
|
||
KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT),
|
||
KeyEvent::new(KeyCode::Tab, KeyModifiers::SHIFT),
|
||
] {
|
||
let mut state = make_state_with_selection();
|
||
state.peek = Some(super::super::peek::PeekPanelState::new(
|
||
DashboardRowId::TopLevel(AgentId(0)),
|
||
peek_fields_for_test("Idle"),
|
||
));
|
||
let outcome = state.handle_key(&key, ®);
|
||
assert!(
|
||
matches!(
|
||
outcome,
|
||
InputOutcome::Action(Action::DashboardPeekCycleMode)
|
||
),
|
||
"Shift+Tab ({key:?}) with peek open must emit DashboardPeekCycleMode, got {outcome:?}",
|
||
);
|
||
}
|
||
// No peek → still the new-session staged-mode cycle.
|
||
let mut state = DashboardState::new();
|
||
let outcome = state.handle_key(&KeyEvent::new(KeyCode::BackTab, KeyModifiers::NONE), ®);
|
||
assert!(
|
||
matches!(outcome, InputOutcome::Action(Action::DashboardCycleMode)),
|
||
"Shift+Tab without peek must emit DashboardCycleMode, got {outcome:?}",
|
||
);
|
||
}
|
||
|
||
/// Overview focused: Enter opens the focused row; Esc backs out of
|
||
/// the selection (focuses `[+ New Agent]`) and STAYS on the list —
|
||
/// it no longer returns to the input (Tab / `i` do that now).
|
||
#[test]
|
||
fn list_focus_enter_opens_and_esc_backs_out() {
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
let mut state = make_state_with_selection();
|
||
state.list_focused = true;
|
||
let enter = state.handle_key(&KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), ®);
|
||
assert!(
|
||
matches!(enter, InputOutcome::Action(Action::DashboardAttach(_))),
|
||
"Enter on the focused overview must open the selected row, got {enter:?}"
|
||
);
|
||
let esc = state.handle_key(&KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), ®);
|
||
assert!(matches!(esc, InputOutcome::Changed));
|
||
assert!(
|
||
state.list_focused,
|
||
"Esc stays on the list; Tab / i return focus to the input"
|
||
);
|
||
assert!(state.selected.is_none(), "Esc backs out of the selection");
|
||
assert!(state.new_agent_button_focused);
|
||
}
|
||
|
||
/// Regression for the Esc-blur draft-loss path: with the list focused
|
||
/// (e.g. after Esc unfocuses the input) on the `[+ New Agent]`
|
||
/// button, Enter must SEND a typed draft rather than create an empty
|
||
/// session and silently drop it. An empty draft still
|
||
/// creates-with-detail.
|
||
#[test]
|
||
fn list_focus_enter_on_button_sends_draft_else_creates() {
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
|
||
|
||
// Draft present → dispatch it (no loss), staying on the dashboard.
|
||
let mut state = DashboardState::new();
|
||
assert!(state.new_agent_button_focused);
|
||
state.dispatch.set_text("fix the bug");
|
||
state.list_focused = true; // e.g. after an Esc blur
|
||
match state.handle_key(&key, ®) {
|
||
InputOutcome::Action(Action::DashboardDispatch { text, attach }) => {
|
||
assert_eq!(text, "fix the bug");
|
||
assert!(!attach, "Enter sends + stays on the dashboard");
|
||
}
|
||
other => panic!("Enter with a draft must dispatch it, got {other:?}"),
|
||
}
|
||
|
||
// Empty draft → create-with-detail (unchanged behavior).
|
||
let mut empty = DashboardState::new();
|
||
empty.list_focused = true;
|
||
assert!(matches!(
|
||
empty.handle_key(&key, ®),
|
||
InputOutcome::Action(Action::DashboardCreateNewAgentWithDetail)
|
||
));
|
||
}
|
||
|
||
/// Overview focused: a non-nav printable key hands focus back to the
|
||
/// input. In vim mode `i` enters the input without typing the `i`.
|
||
#[test]
|
||
fn vim_i_returns_focus_to_input_without_typing() {
|
||
crate::appearance::cache::set_vim_mode(true);
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
let mut state = make_state_with_selection();
|
||
state.list_focused = true;
|
||
let outcome =
|
||
state.handle_key(&KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE), ®);
|
||
assert!(matches!(outcome, InputOutcome::Changed));
|
||
assert!(!state.list_focused, "i focuses the input");
|
||
assert!(
|
||
state.dispatch.text().is_empty(),
|
||
"i must NOT be typed, got {:?}",
|
||
state.dispatch.text()
|
||
);
|
||
crate::appearance::cache::set_vim_mode(false);
|
||
}
|
||
|
||
/// A multi-line bracketed paste keeps its full raw text (what gets
|
||
/// dispatched) while collapsing to a single chip element in the
|
||
/// textarea (rendered folded as `[Pasted: N lines]`).
|
||
#[test]
|
||
fn multiline_paste_folds_into_dispatch_input() {
|
||
let mut state = DashboardState::new();
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
let pasted = "line one\nline two\nline three\nline four";
|
||
let _ = state.handle_input(&Event::Paste(pasted.to_string()), ®);
|
||
assert_eq!(state.dispatch.text(), pasted, "raw paste text is preserved");
|
||
assert_eq!(
|
||
state.dispatch.textarea.elements().len(),
|
||
1,
|
||
"multi-line paste must collapse to one chip element"
|
||
);
|
||
}
|
||
|
||
/// Enter with the caret on a paste chip expands it instead of
|
||
/// dispatching (agent prompt parity).
|
||
#[test]
|
||
fn enter_on_dispatch_paste_chip_expands() {
|
||
let mut state = DashboardState::new();
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
let pasted = "line one\nline two\nline three\nline four";
|
||
let _ = state.handle_input(&Event::Paste(pasted.to_string()), ®);
|
||
state.dispatch.set_cursor(0);
|
||
let outcome = state.handle_key(&KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), ®);
|
||
assert!(
|
||
matches!(outcome, InputOutcome::Changed),
|
||
"Enter on chip must expand, got {outcome:?}"
|
||
);
|
||
assert!(
|
||
state.dispatch.textarea.elements().is_empty(),
|
||
"chip must be inlined"
|
||
);
|
||
assert_eq!(state.dispatch.text(), pasted);
|
||
}
|
||
|
||
/// Enter with the caret right after a paste chip still dispatches
|
||
/// (preview shows there; expand is on-chip only).
|
||
#[test]
|
||
fn enter_after_dispatch_paste_chip_dispatches() {
|
||
let mut state = DashboardState::new();
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
let pasted = "line one\nline two\nline three\nline four";
|
||
let _ = state.handle_input(&Event::Paste(pasted.to_string()), ®);
|
||
// handle_paste leaves the cursor after the chip.
|
||
match state.handle_key(&KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), ®) {
|
||
InputOutcome::Action(Action::DashboardDispatch { text, .. }) => {
|
||
assert_eq!(text, pasted);
|
||
}
|
||
other => panic!("Enter after chip must dispatch, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
/// Peek reply: Enter on a paste chip expands rather than sending.
|
||
#[test]
|
||
fn enter_on_peek_reply_paste_chip_expands() {
|
||
let mut state = state_with_open_peek();
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
state.peek.as_mut().unwrap().focused = true;
|
||
let pasted = "a\nb\nc";
|
||
// Peek reply is compact → 2-line threshold; 3 lines still chips.
|
||
let _ = state.handle_input(&Event::Paste(pasted.to_string()), ®);
|
||
assert_eq!(state.peek_reply.textarea.elements().len(), 1);
|
||
state.peek_reply.set_cursor(0);
|
||
let outcome = state.handle_key(&KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), ®);
|
||
assert!(
|
||
matches!(outcome, InputOutcome::Changed),
|
||
"Enter on peek chip must expand, got {outcome:?}"
|
||
);
|
||
assert!(state.peek_reply.textarea.elements().is_empty());
|
||
assert_eq!(state.peek_reply.text(), pasted);
|
||
}
|
||
|
||
/// Multiline peek still expands paste chips before treating bare Enter
|
||
/// as a newline (dispatch + agent order).
|
||
#[test]
|
||
fn multiline_peek_enter_on_paste_chip_expands() {
|
||
let mut state = state_with_open_peek();
|
||
state.multiline_mode = true;
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
state.peek.as_mut().unwrap().focused = true;
|
||
let pasted = "a\nb\nc";
|
||
let _ = state.handle_input(&Event::Paste(pasted.to_string()), ®);
|
||
assert_eq!(state.peek_reply.textarea.elements().len(), 1);
|
||
state.peek_reply.set_cursor(0);
|
||
let outcome = state.handle_key(&KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), ®);
|
||
assert!(
|
||
matches!(outcome, InputOutcome::Changed),
|
||
"multiline Enter on peek chip must expand, got {outcome:?}"
|
||
);
|
||
assert!(
|
||
state.peek_reply.textarea.elements().is_empty(),
|
||
"chip must be inlined, not left as an element"
|
||
);
|
||
assert_eq!(state.peek_reply.text(), pasted);
|
||
assert!(
|
||
!state.peek_reply.text().contains("\n\n"),
|
||
"must expand, not insert an extra newline: {:?}",
|
||
state.peek_reply.text()
|
||
);
|
||
}
|
||
|
||
/// Enter on an image chip still dispatches — dashboard has no image
|
||
/// viewer, so ImagePreview must not swallow the key.
|
||
#[test]
|
||
fn enter_on_dispatch_image_chip_dispatches() {
|
||
let mut state = DashboardState::new();
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
let _ = state.attach_pasted_image(peek_test_image());
|
||
state.dispatch.set_cursor(0);
|
||
match state.handle_key(&KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), ®) {
|
||
InputOutcome::Action(Action::DashboardDispatch { text, .. }) => {
|
||
assert!(text.contains("[Image #1]"), "got {text:?}");
|
||
}
|
||
other => panic!("Enter on image chip must dispatch, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn set_peek_clears_prompt_click_timer() {
|
||
let mut state = DashboardState::new();
|
||
state.last_prompt_click = Some(Instant::now());
|
||
state.set_peek(Some(super::super::peek::PeekPanelState::new(
|
||
DashboardRowId::TopLevel(AgentId(0)),
|
||
peek_fields_for_test("Idle"),
|
||
)));
|
||
assert!(
|
||
state.last_prompt_click.is_none(),
|
||
"opening peek must clear the shared double-click timer"
|
||
);
|
||
state.last_prompt_click = Some(Instant::now());
|
||
state.set_peek(None);
|
||
assert!(
|
||
state.last_prompt_click.is_none(),
|
||
"closing peek must clear the shared double-click timer"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn peek_row_change_clears_prompt_click_timer() {
|
||
let mut state = state_with_open_peek();
|
||
state.last_prompt_click = Some(Instant::now());
|
||
state.clear_peek_reply();
|
||
assert!(
|
||
state.last_prompt_click.is_none(),
|
||
"row change / clear_peek_reply must clear double-click timer"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn enter_on_reject_feedback_paste_chip_expands() {
|
||
let mut state = DashboardState::new();
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
let mut f = peek_fields_for_test("Awaiting your input");
|
||
f.question = Some("Allow Edit?".into());
|
||
f.options = vec![
|
||
("allow".into(), "Allow".into()),
|
||
("no".into(), "No, reject".into()),
|
||
];
|
||
f.reject_option = Some(1);
|
||
f.request_id = Some(7);
|
||
state.set_peek(Some(super::super::peek::PeekPanelState::new(
|
||
DashboardRowId::TopLevel(AgentId(0)),
|
||
f,
|
||
)));
|
||
if let Some(p) = state.peek.as_mut() {
|
||
p.focused = true;
|
||
p.selected_option = Some(1);
|
||
}
|
||
let pasted = "reason line one\nreason line two\nreason line three";
|
||
let _ = state.handle_input(&Event::Paste(pasted.to_string()), ®);
|
||
assert_eq!(state.peek_reply.textarea.elements().len(), 1);
|
||
state.peek_reply.set_cursor(0);
|
||
let outcome = state.handle_key(&KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), ®);
|
||
assert!(
|
||
matches!(outcome, InputOutcome::Changed),
|
||
"Enter on reject freeform paste chip must expand, got {outcome:?}"
|
||
);
|
||
assert!(state.peek_reply.textarea.elements().is_empty());
|
||
assert_eq!(state.peek_reply.text(), pasted);
|
||
}
|
||
|
||
#[test]
|
||
fn wrap_host_image_none_paste_not_inserted_as_text() {
|
||
let mut state = DashboardState::new();
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
let outcome = state.handle_input(
|
||
&Event::Paste(crate::wrap_clipboard_image::MAGIC_NONE.to_string()),
|
||
®,
|
||
);
|
||
assert!(matches!(outcome, InputOutcome::Unchanged));
|
||
assert!(state.dispatch.text().is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn wrap_host_image_none_paste_not_inserted_into_peek_reply() {
|
||
let mut state = DashboardState::new();
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
state.focus_row(DashboardRowId::TopLevel(AgentId(0)));
|
||
state.set_peek(Some(super::super::peek::PeekPanelState::new(
|
||
DashboardRowId::TopLevel(AgentId(0)),
|
||
peek_fields_for_test("Idle"),
|
||
)));
|
||
let outcome = state.handle_input(
|
||
&Event::Paste(crate::wrap_clipboard_image::MAGIC_NONE.to_string()),
|
||
®,
|
||
);
|
||
assert!(matches!(outcome, InputOutcome::Unchanged));
|
||
assert!(state.peek_reply.text().is_empty());
|
||
assert!(state.dispatch.text().is_empty());
|
||
}
|
||
|
||
/// Wrap host-image bracketed paste with peek open must attach to
|
||
/// `peek_reply`, not the hidden new-session dispatch input.
|
||
#[test]
|
||
fn wrap_host_image_paste_with_peek_open_goes_to_reply_not_dispatch() {
|
||
let mut state = state_with_open_peek();
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
let png = test_png_bytes();
|
||
let b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &png);
|
||
let paste = format!(
|
||
"{}\nimage/png\n{b64}",
|
||
crate::wrap_clipboard_image::MAGIC_IMG
|
||
);
|
||
let outcome = state.handle_input(&Event::Paste(paste), ®);
|
||
assert!(matches!(outcome, InputOutcome::Changed));
|
||
let text = state.peek_reply.text();
|
||
assert!(text.contains("[Image #1]"), "got {text:?}");
|
||
assert_eq!(state.peek_reply.images.len(), 1);
|
||
assert!(
|
||
state.dispatch.images.is_empty() && state.dispatch.text().is_empty(),
|
||
"wrap image must not leak into hidden dispatch"
|
||
);
|
||
assert!(
|
||
state.peek.as_ref().unwrap().focused,
|
||
"wrap image paste must focus the reply"
|
||
);
|
||
}
|
||
|
||
/// Question mode is text-only on the wire — wrap host-image must not
|
||
/// attach to peek reply (or leak into dispatch).
|
||
#[test]
|
||
fn wrap_host_image_paste_question_mode_blocks_attach() {
|
||
let mut state = state_with_open_peek();
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
if let Some(p) = state.peek.as_mut() {
|
||
p.focused = true;
|
||
p.question = Some("Allow?".into());
|
||
p.options = vec![("yes".into(), "Yes".into()), ("no".into(), "No".into())];
|
||
p.reject_option = Some(1);
|
||
p.selected_option = Some(1);
|
||
}
|
||
let png = test_png_bytes();
|
||
let b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &png);
|
||
let paste = format!(
|
||
"{}\nimage/png\n{b64}",
|
||
crate::wrap_clipboard_image::MAGIC_IMG
|
||
);
|
||
let outcome = state.handle_input(&Event::Paste(paste), ®);
|
||
assert!(matches!(outcome, InputOutcome::Unchanged));
|
||
assert!(state.peek_reply.images.is_empty());
|
||
assert!(!state.peek_reply.text().contains("[Image #"));
|
||
assert!(state.dispatch.images.is_empty() && state.dispatch.text().is_empty());
|
||
}
|
||
|
||
/// A bracketed paste while the peek panel is open lands in the
|
||
/// peek's `❯ reply` widget — NOT the hidden new-session dispatch
|
||
/// input. (Regression: terminals with bracketed paste deliver
|
||
/// Cmd/Ctrl+V as `Event::Paste`, which used to fall through to
|
||
/// the dispatch arm and silently fill the box behind the
|
||
/// panel.) A multi-line paste folds into a single `[Pasted: N
|
||
/// lines]` chip (the reply widget is compact → 2-line threshold)
|
||
/// while preserving the raw text for the eventual send, and pasting
|
||
/// focuses the input like the Ctrl/Cmd+V chord does.
|
||
#[test]
|
||
fn bracketed_paste_with_peek_open_goes_to_reply() {
|
||
let mut state = DashboardState::new();
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
state.focus_row(DashboardRowId::TopLevel(AgentId(0)));
|
||
state.set_peek(Some(super::super::peek::PeekPanelState::new(
|
||
DashboardRowId::TopLevel(AgentId(0)),
|
||
peek_fields_for_test("Idle"),
|
||
)));
|
||
// Unfocused (Tab → row nav) — paste must still target the reply
|
||
// and re-focus it ("pasting implies an intent to reply").
|
||
state.peek.as_mut().unwrap().focused = false;
|
||
let outcome = state.handle_input(&Event::Paste("hello\nworld".to_string()), ®);
|
||
assert!(matches!(outcome, InputOutcome::Changed));
|
||
assert_eq!(
|
||
state.peek_reply.text(),
|
||
"hello\nworld",
|
||
"paste must land in the reply with its raw text preserved",
|
||
);
|
||
assert_eq!(
|
||
state.peek_reply.textarea.elements().len(),
|
||
1,
|
||
"a 2-line paste must fold into one [Pasted: N lines] chip (compact threshold)",
|
||
);
|
||
assert!(
|
||
state.peek.as_ref().unwrap().focused,
|
||
"paste must focus the reply input"
|
||
);
|
||
assert!(
|
||
state.dispatch.text().is_empty(),
|
||
"paste must NOT leak into the hidden dispatch input, got {:?}",
|
||
state.dispatch.text(),
|
||
);
|
||
}
|
||
|
||
/// A pasted image attaches as a clean `[Image #N]` chip with no
|
||
/// embedded source path (the path would blow out the single-line
|
||
/// dispatch box and the dispatched session's scrollback).
|
||
#[test]
|
||
fn pasted_image_chip_omits_full_path() {
|
||
let mut state = DashboardState::new();
|
||
let pasted = crate::prompt_images::PastedImage {
|
||
element_id: kigi_ratatui_textarea::ElementId::from_raw(0),
|
||
display_number: 0,
|
||
mime_type: "image/png".into(),
|
||
dimensions: Some((10, 10)),
|
||
byte_len: 16,
|
||
encoded_bytes: Some(vec![0u8; 16].into()),
|
||
source_path: Some(std::path::PathBuf::from(
|
||
"/Users/somebody/very/long/path/screenshot.png",
|
||
)),
|
||
staged_temp_path: None,
|
||
session_image_path: None,
|
||
preview: crate::prompt_images::PromptImagePreview::default(),
|
||
};
|
||
let _ = state.attach_pasted_image(pasted);
|
||
let text = state.dispatch.text();
|
||
assert!(
|
||
text.contains("[Image #1]"),
|
||
"expected a clean chip, got {text:?}"
|
||
);
|
||
assert!(
|
||
!text.contains("screenshot.png"),
|
||
"chip must not embed the source path, got {text:?}"
|
||
);
|
||
assert_eq!(
|
||
state.dispatch.images[0].source_path.as_deref(),
|
||
Some(std::path::Path::new(
|
||
"/Users/somebody/very/long/path/screenshot.png"
|
||
))
|
||
);
|
||
}
|
||
|
||
// -----------------------------------------------------------------
|
||
// The clipboard raster/file-url probe (osascript) + image decode +
|
||
// session persist run OFF the event loop. A paste that would probe
|
||
// enqueues a `ProbeClipboardAttachment` effect and returns without an
|
||
// inline probe (`clipboard_probe_call_count() == 0`); the chip
|
||
// attaches later via `complete_clipboard_attachment_paste`. Snapshot /
|
||
// support are faked via the test-only seam; plain text with no
|
||
// raster stays fully synchronous (no defer).
|
||
// -----------------------------------------------------------------
|
||
|
||
fn probe_image_data() -> crate::clipboard::ImageData {
|
||
crate::clipboard::ImageData {
|
||
data: test_png_bytes(),
|
||
mime_type: "image/png".into(),
|
||
}
|
||
}
|
||
|
||
fn ctrl_v_event() -> Event {
|
||
Event::Key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL))
|
||
}
|
||
|
||
/// Target of the enqueued deferred-probe effect, if any.
|
||
fn deferred_probe_target(
|
||
state: &DashboardState,
|
||
) -> Option<crate::app::actions::ClipboardPasteTarget> {
|
||
deferred_probe_ctx(state).map(|ctx| ctx.target)
|
||
}
|
||
|
||
/// The `ClipboardPasteContext` of the enqueued deferred-probe effect, if any.
|
||
fn deferred_probe_ctx(
|
||
state: &DashboardState,
|
||
) -> Option<crate::app::actions::ClipboardPasteContext> {
|
||
state.pending_effects.iter().find_map(|e| match e {
|
||
crate::app::actions::Effect::ProbeClipboardAttachment { ctx, .. } => Some(ctx.clone()),
|
||
_ => None,
|
||
})
|
||
}
|
||
|
||
/// A ready-to-insert (already decoded) pasted image for completion tests.
|
||
fn completion_pasted_image() -> crate::prompt_images::PastedImage {
|
||
crate::prompt_images::from_clipboard_data(&probe_image_data())
|
||
}
|
||
|
||
/// A `ClipboardPasteContext` for driving `complete_clipboard_attachment_paste`
|
||
/// directly (image-wins Cmd+V: carries the caption, inserts on a no-image
|
||
/// miss). The peek target stamps the row `state_with_open_peek` peeks.
|
||
fn completion_ctx(
|
||
clipboard_text: Option<&str>,
|
||
peek: bool,
|
||
) -> crate::app::actions::ClipboardPasteContext {
|
||
crate::app::actions::ClipboardPasteContext {
|
||
target: if peek {
|
||
crate::app::actions::ClipboardPasteTarget::DashboardPeek {
|
||
row: DashboardRowId::TopLevel(AgentId(0)),
|
||
}
|
||
} else {
|
||
crate::app::actions::ClipboardPasteTarget::DashboardDispatch
|
||
},
|
||
source: crate::app::actions::ClipboardPasteSource::ClipboardKey {
|
||
text: crate::app::actions::ClipboardTextRead::Success(
|
||
clipboard_text.map(str::to_owned),
|
||
),
|
||
tip_showing: false,
|
||
},
|
||
}
|
||
}
|
||
|
||
/// Drive a real Cmd+V that finds a raster (defers), then complete the probe
|
||
/// with a decoded image — the full shipped deferred image-paste path. The
|
||
/// caller sets `state` up so `handle_input` routes to the intended surface
|
||
/// (peek open → reply; peek closed → dispatch).
|
||
fn cmd_v_image(state: &mut DashboardState, clipboard_text: Option<&str>) {
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
crate::clipboard::set_clipboard_probe_hook(crate::clipboard::ClipboardProbeHook {
|
||
text: clipboard_text.map(str::to_owned),
|
||
..crate::clipboard::ClipboardProbeHook::with_raster(None)
|
||
});
|
||
let _ = state.handle_input(&ctrl_v_event(), ®);
|
||
let ctx = deferred_probe_ctx(state).expect("an image paste must defer a probe");
|
||
crate::clipboard::clear_clipboard_probe_hook();
|
||
state.complete_clipboard_attachment_paste(
|
||
ctx,
|
||
crate::app::actions::ProbedAttachment::Image(completion_pasted_image()),
|
||
None,
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn dispatch_bracketed_image_paste_defers_probe() {
|
||
let mut state = DashboardState::new();
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
// Empty bracketed paste + a raster on the pasteboard.
|
||
crate::clipboard::set_clipboard_probe_hook(
|
||
crate::clipboard::ClipboardProbeHook::with_raster(None),
|
||
);
|
||
let _ = state.handle_input(&Event::Paste(String::new()), ®);
|
||
let calls = crate::clipboard::clipboard_probe_call_count();
|
||
let target = deferred_probe_target(&state);
|
||
crate::clipboard::clear_clipboard_probe_hook();
|
||
|
||
assert_eq!(calls, 0, "probe must NOT run inline on the event loop");
|
||
assert!(
|
||
matches!(
|
||
target,
|
||
Some(crate::app::actions::ClipboardPasteTarget::DashboardDispatch)
|
||
),
|
||
"a dispatch-targeted probe effect must be enqueued"
|
||
);
|
||
assert!(
|
||
state.dispatch.images.is_empty(),
|
||
"chip attaches on completion, not inline"
|
||
);
|
||
}
|
||
|
||
/// Regression: an IME commit delivered as bracketed paste (Otty)
|
||
/// must not attach the unrelated clipboard image.
|
||
#[test]
|
||
fn dispatch_bracketed_paste_stamps_ctx_bracketed() {
|
||
let mut state = DashboardState::new();
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
crate::clipboard::set_clipboard_probe_hook(
|
||
crate::clipboard::ClipboardProbeHook::with_raster(None),
|
||
);
|
||
let _ = state.handle_input(&Event::Paste("中".to_owned()), ®);
|
||
let ctx = deferred_probe_ctx(&state);
|
||
crate::clipboard::clear_clipboard_probe_hook();
|
||
|
||
let ctx = ctx.expect("a bracketed paste with a raster must defer a probe");
|
||
assert!(
|
||
ctx.source.is_bracketed(),
|
||
"bracketed source must let the probe verify payload origin"
|
||
);
|
||
assert_eq!(ctx.source.text(), Some("中"));
|
||
}
|
||
|
||
#[test]
|
||
fn dispatch_cmd_v_probe_ctx_not_bracketed() {
|
||
let mut state = DashboardState::new();
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
crate::clipboard::set_clipboard_probe_hook(crate::clipboard::ClipboardProbeHook {
|
||
text: Some("caption".to_owned()),
|
||
..crate::clipboard::ClipboardProbeHook::with_raster(None)
|
||
});
|
||
let _ = state.handle_input(&ctrl_v_event(), ®);
|
||
let ctx = deferred_probe_ctx(&state);
|
||
crate::clipboard::clear_clipboard_probe_hook();
|
||
|
||
let ctx = ctx.expect("a Cmd+V with a raster must defer a probe");
|
||
assert!(
|
||
!ctx.source.is_bracketed(),
|
||
"Cmd+V source must remain a CLIPBOARD-key read"
|
||
);
|
||
}
|
||
|
||
/// Bracketed caption + raster: image wins across the deferral boundary — the
|
||
/// caption is NOT inserted synchronously (it is carried into the effect and
|
||
/// dropped when the probe returns an image), so the dashboard bracketed path
|
||
/// attaches exactly one thing: the image, never image + caption.
|
||
#[test]
|
||
fn dispatch_bracketed_caption_image_wins_no_double_insert() {
|
||
let mut state = DashboardState::new();
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
crate::clipboard::set_clipboard_probe_hook(
|
||
crate::clipboard::ClipboardProbeHook::with_raster(None),
|
||
);
|
||
let _ = state.handle_input(&Event::Paste("a caption".to_string()), ®);
|
||
let ctx = deferred_probe_ctx(&state);
|
||
crate::clipboard::clear_clipboard_probe_hook();
|
||
|
||
let ctx = ctx.expect("caption + raster must defer a probe");
|
||
assert_eq!(
|
||
state.dispatch.text(),
|
||
"",
|
||
"caption must not be inserted synchronously (image wins)"
|
||
);
|
||
assert_eq!(ctx.source.text(), Some("a caption"));
|
||
assert!(matches!(
|
||
ctx.source,
|
||
crate::app::actions::ClipboardPasteSource::BracketedDeferred { .. }
|
||
));
|
||
|
||
// Probe finds the image → image wins, caption dropped (no double insert).
|
||
state.complete_clipboard_attachment_paste(
|
||
ctx,
|
||
crate::app::actions::ProbedAttachment::Image(completion_pasted_image()),
|
||
None,
|
||
);
|
||
assert_eq!(state.dispatch.images.len(), 1);
|
||
assert!(state.dispatch.text().contains("[Image #1]"));
|
||
assert!(!state.dispatch.text().contains("a caption"));
|
||
}
|
||
|
||
#[test]
|
||
fn dispatch_paste_key_image_defers_probe() {
|
||
let mut state = DashboardState::new();
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
crate::clipboard::set_clipboard_probe_hook(
|
||
crate::clipboard::ClipboardProbeHook::with_raster(None),
|
||
);
|
||
let _ = state.handle_input(&ctrl_v_event(), ®);
|
||
let calls = crate::clipboard::clipboard_probe_call_count();
|
||
let target = deferred_probe_target(&state);
|
||
crate::clipboard::clear_clipboard_probe_hook();
|
||
|
||
assert_eq!(calls, 0, "probe must NOT run inline on the event loop");
|
||
assert!(matches!(
|
||
target,
|
||
Some(crate::app::actions::ClipboardPasteTarget::DashboardDispatch)
|
||
));
|
||
assert!(state.dispatch.images.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn peek_bracketed_image_paste_defers_probe() {
|
||
let mut state = state_with_open_peek();
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
crate::clipboard::set_clipboard_probe_hook(
|
||
crate::clipboard::ClipboardProbeHook::with_raster(None),
|
||
);
|
||
let _ = state.handle_input(&Event::Paste(String::new()), ®);
|
||
let calls = crate::clipboard::clipboard_probe_call_count();
|
||
let target = deferred_probe_target(&state);
|
||
crate::clipboard::clear_clipboard_probe_hook();
|
||
|
||
assert_eq!(calls, 0, "probe must NOT run inline on the event loop");
|
||
assert!(
|
||
matches!(
|
||
target,
|
||
Some(crate::app::actions::ClipboardPasteTarget::DashboardPeek { .. })
|
||
),
|
||
"a peek-targeted probe effect must be enqueued"
|
||
);
|
||
assert!(state.peek_reply.images.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn peek_paste_key_image_defers_probe() {
|
||
let mut state = state_with_open_peek();
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
crate::clipboard::set_clipboard_probe_hook(
|
||
crate::clipboard::ClipboardProbeHook::with_raster(None),
|
||
);
|
||
let _ = state.handle_input(&ctrl_v_event(), ®);
|
||
let calls = crate::clipboard::clipboard_probe_call_count();
|
||
let target = deferred_probe_target(&state);
|
||
crate::clipboard::clear_clipboard_probe_hook();
|
||
|
||
assert_eq!(calls, 0, "probe must NOT run inline on the event loop");
|
||
assert!(matches!(
|
||
target,
|
||
Some(crate::app::actions::ClipboardPasteTarget::DashboardPeek { .. })
|
||
));
|
||
assert!(state.peek_reply.images.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn dispatch_text_paste_no_raster_stays_synchronous() {
|
||
let mut state = DashboardState::new();
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
// Non-empty non-URL text with no raster: the snapshot gate skips the
|
||
// probe entirely, so nothing is deferred and the text inserts inline.
|
||
crate::clipboard::set_clipboard_probe_hook(
|
||
crate::clipboard::ClipboardProbeHook::no_raster(None),
|
||
);
|
||
let outcome = state.handle_input(&Event::Paste("hello world".to_string()), ®);
|
||
let calls = crate::clipboard::clipboard_probe_call_count();
|
||
let deferred = deferred_probe_target(&state).is_some();
|
||
crate::clipboard::clear_clipboard_probe_hook();
|
||
|
||
assert!(matches!(outcome, InputOutcome::Changed));
|
||
assert_eq!(calls, 0, "no inline probe");
|
||
assert!(
|
||
!deferred,
|
||
"plain text with no raster must not defer a probe"
|
||
);
|
||
assert_eq!(
|
||
state.dispatch.text(),
|
||
"hello world",
|
||
"text inserted synchronously"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn dispatch_path_paste_attaches_inline_without_deferring() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let png = write_test_png(dir.path());
|
||
let mut state = DashboardState::new();
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
// Even with a raster snapshot, a pasted file path resolves inline and
|
||
// must NOT also enqueue a probe (no double insert).
|
||
crate::clipboard::set_clipboard_probe_hook(
|
||
crate::clipboard::ClipboardProbeHook::with_raster(None),
|
||
);
|
||
let outcome = state.handle_input(&Event::Paste(png.display().to_string()), ®);
|
||
let calls = crate::clipboard::clipboard_probe_call_count();
|
||
let deferred = deferred_probe_target(&state).is_some();
|
||
crate::clipboard::clear_clipboard_probe_hook();
|
||
|
||
assert!(matches!(outcome, InputOutcome::Changed));
|
||
assert_eq!(calls, 0, "no inline probe");
|
||
assert!(
|
||
!deferred,
|
||
"a resolved path paste must not also defer a probe"
|
||
);
|
||
assert_eq!(
|
||
state.dispatch.images.len(),
|
||
1,
|
||
"path attached as a chip inline"
|
||
);
|
||
assert!(state.dispatch.text().contains("[Image #1]"));
|
||
}
|
||
|
||
#[test]
|
||
fn completion_attaches_image_to_dispatch() {
|
||
let mut state = DashboardState::new();
|
||
let completion = state.complete_clipboard_attachment_paste(
|
||
completion_ctx(Some("caption"), false),
|
||
crate::app::actions::ProbedAttachment::Image(completion_pasted_image()),
|
||
None,
|
||
);
|
||
assert_eq!(
|
||
completion,
|
||
crate::app::actions::ClipboardPasteCompletion::Handled
|
||
);
|
||
assert_eq!(state.dispatch.images.len(), 1);
|
||
assert!(state.dispatch.text().contains("[Image #1]"));
|
||
// Image wins: the carried caption is NOT also inserted (no double).
|
||
assert!(!state.dispatch.text().contains("caption"));
|
||
}
|
||
|
||
#[test]
|
||
fn completion_attaches_image_to_peek() {
|
||
let mut state = state_with_open_peek();
|
||
state.complete_clipboard_attachment_paste(
|
||
completion_ctx(None, true),
|
||
crate::app::actions::ProbedAttachment::Image(completion_pasted_image()),
|
||
None,
|
||
);
|
||
assert_eq!(state.peek_reply.images.len(), 1);
|
||
assert!(state.peek_reply.text().contains("[Image #1]"));
|
||
assert!(
|
||
state.dispatch.images.is_empty(),
|
||
"peek completion must not leak into dispatch"
|
||
);
|
||
}
|
||
|
||
/// A no-image miss on an image-wins dispatch Cmd+V inserts the carried
|
||
/// caption instead — deferring the probe must not lose a text-only paste.
|
||
#[test]
|
||
fn completion_inserts_caption_on_no_image_miss() {
|
||
let mut state = DashboardState::new();
|
||
let completion = state.complete_clipboard_attachment_paste(
|
||
completion_ctx(Some("just some text"), false),
|
||
crate::app::actions::ProbedAttachment::NoRaster,
|
||
None,
|
||
);
|
||
assert_eq!(
|
||
completion,
|
||
crate::app::actions::ClipboardPasteCompletion::Handled
|
||
);
|
||
assert!(state.dispatch.images.is_empty());
|
||
assert_eq!(state.dispatch.text(), "just some text");
|
||
}
|
||
|
||
#[test]
|
||
fn dashboard_deferred_bracketed_text_survives_failed_or_dropped_probe() {
|
||
use crate::app::actions::{
|
||
ClipboardPasteCompletion, ClipboardPasteFailure, ProbedAttachment,
|
||
};
|
||
for (probe, expected) in [
|
||
(
|
||
ProbedAttachment::ProbeFailed,
|
||
ClipboardPasteCompletion::Failed(ClipboardPasteFailure::AttachmentRead),
|
||
),
|
||
(
|
||
ProbedAttachment::ProbeDropped,
|
||
ClipboardPasteCompletion::Dropped,
|
||
),
|
||
] {
|
||
let mut state = DashboardState::new();
|
||
let mut ctx = completion_ctx(None, false);
|
||
ctx.source = crate::app::actions::ClipboardPasteSource::BracketedDeferred {
|
||
text: "bracketed text".to_owned(),
|
||
};
|
||
|
||
let completion = state.complete_clipboard_attachment_paste(ctx, probe, None);
|
||
|
||
assert_eq!(completion, expected);
|
||
assert_eq!(state.dispatch.text(), "bracketed text");
|
||
}
|
||
}
|
||
|
||
/// A no-image miss on a peek Cmd+V must NOT buffer the caption into the
|
||
/// hidden reply if the peeked agent raised a question during the probe window
|
||
/// (the reply is text-only on the wire in question mode).
|
||
#[test]
|
||
fn completion_peek_caption_dropped_in_question_mode() {
|
||
let mut state = state_with_open_peek();
|
||
state.paste_probe_in_flight = 1;
|
||
if let Some(p) = state.peek.as_mut() {
|
||
p.question = Some("Allow?".into());
|
||
}
|
||
let completion = state.complete_clipboard_attachment_paste(
|
||
completion_ctx(Some("some caption"), true),
|
||
crate::app::actions::ProbedAttachment::NoRaster,
|
||
None,
|
||
);
|
||
assert_eq!(
|
||
completion,
|
||
crate::app::actions::ClipboardPasteCompletion::Dropped
|
||
);
|
||
assert!(
|
||
state.peek_reply.text().is_empty(),
|
||
"the caption must be dropped in question mode, not buffered into the hidden reply"
|
||
);
|
||
assert_eq!(state.paste_probe_in_flight, 0);
|
||
}
|
||
|
||
#[test]
|
||
fn completion_attaches_file_url_to_dispatch() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let png = write_test_png(dir.path());
|
||
let mut state = DashboardState::new();
|
||
let completion = state.complete_clipboard_attachment_paste(
|
||
completion_ctx(None, false),
|
||
crate::app::actions::ProbedAttachment::NoRaster,
|
||
Some(png.display().to_string()),
|
||
);
|
||
assert_eq!(
|
||
completion,
|
||
crate::app::actions::ClipboardPasteCompletion::Handled
|
||
);
|
||
assert_eq!(
|
||
state.dispatch.images.len(),
|
||
1,
|
||
"file URL attached as a chip"
|
||
);
|
||
assert!(state.dispatch.text().contains("[Image #1]"));
|
||
}
|
||
|
||
/// A peek completion arriving after the panel closed drops the attachment
|
||
/// instead of inserting into the now-hidden reply buffer.
|
||
#[test]
|
||
fn completion_peek_dropped_when_panel_closed() {
|
||
let mut state = DashboardState::new(); // no peek open
|
||
state.paste_probe_in_flight = 1;
|
||
let completion = state.complete_clipboard_attachment_paste(
|
||
completion_ctx(None, true),
|
||
crate::app::actions::ProbedAttachment::Image(completion_pasted_image()),
|
||
None,
|
||
);
|
||
assert_eq!(
|
||
completion,
|
||
crate::app::actions::ClipboardPasteCompletion::Dropped
|
||
);
|
||
assert!(
|
||
state.peek_reply.images.is_empty(),
|
||
"a closed peek must not receive the deferred image"
|
||
);
|
||
assert_eq!(
|
||
state.paste_probe_in_flight, 0,
|
||
"the in-flight count is still decremented so a stashed send can drain"
|
||
);
|
||
}
|
||
|
||
/// A peek completion arriving after the panel moved to ANOTHER row drops
|
||
/// the attachment (never lands in a different agent's reply), and a peek
|
||
/// send stashed for the old row is dropped at drain time — with a toast —
|
||
/// instead of replying to the newly peeked agent.
|
||
#[test]
|
||
fn completion_peek_dropped_when_row_changed() {
|
||
let mut state = state_with_open_peek(); // peeks TopLevel(AgentId(0))
|
||
state.paste_probe_in_flight = 1;
|
||
state.deferred_peek_send = Some(DeferredPeekSend {
|
||
row: DashboardRowId::TopLevel(AgentId(0)),
|
||
attach: false,
|
||
});
|
||
// The user moves the peek to a different row during the probe window.
|
||
if let Some(p) = state.peek.as_mut() {
|
||
p.row = DashboardRowId::TopLevel(AgentId(7));
|
||
}
|
||
state.complete_clipboard_attachment_paste(
|
||
completion_ctx(None, true),
|
||
crate::app::actions::ProbedAttachment::Image(completion_pasted_image()),
|
||
None,
|
||
);
|
||
assert!(
|
||
state.peek_reply.images.is_empty(),
|
||
"a retargeted peek must not receive the deferred image"
|
||
);
|
||
assert_eq!(state.paste_probe_in_flight, 0);
|
||
let actions = state.take_deferred_sends_after_paste();
|
||
assert!(
|
||
actions.is_empty(),
|
||
"the stale peek stash must not reissue to the new row: {actions:?}"
|
||
);
|
||
assert!(state.deferred_peek_send.is_none(), "the stash is consumed");
|
||
assert!(
|
||
state.error_toast.is_some(),
|
||
"dropping the stashed reply must be announced with a toast"
|
||
);
|
||
}
|
||
|
||
/// A question arriving on the peeked row mid-probe makes the reply
|
||
/// text-only: an Image completion for that row must be discarded WITH a
|
||
/// toast (not silently no-opped by the attach helper's question guard).
|
||
#[test]
|
||
fn completion_peek_image_discarded_when_question_arrives() {
|
||
let mut state = state_with_open_peek();
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
// Cmd+V in NORMAL mode → the probe defers against the peeked row.
|
||
crate::clipboard::set_clipboard_probe_hook(
|
||
crate::clipboard::ClipboardProbeHook::with_raster(None),
|
||
);
|
||
let _ = state.handle_input(&ctrl_v_event(), ®);
|
||
let ctx = deferred_probe_ctx(&state).expect("an image paste must defer a probe");
|
||
crate::clipboard::clear_clipboard_probe_hook();
|
||
assert_eq!(state.paste_probe_in_flight, 1);
|
||
// A permission question arrives on the SAME row before completion.
|
||
if let Some(p) = state.peek.as_mut() {
|
||
p.question = Some("Allow?".into());
|
||
}
|
||
let completion = state.complete_clipboard_attachment_paste(
|
||
ctx,
|
||
crate::app::actions::ProbedAttachment::Image(completion_pasted_image()),
|
||
None,
|
||
);
|
||
assert_eq!(
|
||
completion,
|
||
crate::app::actions::ClipboardPasteCompletion::Dropped
|
||
);
|
||
assert!(
|
||
state.peek_reply.images.is_empty(),
|
||
"the image must not attach to a question-mode reply"
|
||
);
|
||
assert!(!state.peek_reply.text().contains("[Image #"));
|
||
assert!(
|
||
state.error_toast.is_some(),
|
||
"discarding the deferred image must be announced with a toast"
|
||
);
|
||
assert_eq!(state.paste_probe_in_flight, 0);
|
||
}
|
||
|
||
#[test]
|
||
fn completion_reports_full_miss_for_unreadable_file_url() {
|
||
let mut state = DashboardState::new();
|
||
let completion = state.complete_clipboard_attachment_paste(
|
||
completion_ctx(None, false),
|
||
crate::app::actions::ProbedAttachment::NoRaster,
|
||
Some("file:///definitely/missing/xai-primary-paste.png".to_owned()),
|
||
);
|
||
|
||
assert_eq!(
|
||
completion,
|
||
crate::app::actions::ClipboardPasteCompletion::FullMiss
|
||
);
|
||
assert!(state.dispatch.text().is_empty());
|
||
assert!(state.dispatch.images.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn completion_reports_failed_probe() {
|
||
let mut state = DashboardState::new();
|
||
let completion = state.complete_clipboard_attachment_paste(
|
||
completion_ctx(None, false),
|
||
crate::app::actions::ProbedAttachment::ProbeFailed,
|
||
None,
|
||
);
|
||
|
||
assert_eq!(
|
||
completion,
|
||
crate::app::actions::ClipboardPasteCompletion::Failed(
|
||
crate::app::actions::ClipboardPasteFailure::AttachmentRead,
|
||
)
|
||
);
|
||
}
|
||
|
||
/// Same guard for the file-url completion arm: Finder file-URL chips are
|
||
/// attachments too and must be discarded loudly in question mode.
|
||
#[test]
|
||
fn completion_peek_file_urls_discarded_when_question_arrives() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let png = write_test_png(dir.path());
|
||
let mut state = state_with_open_peek();
|
||
state.paste_probe_in_flight = 1;
|
||
if let Some(p) = state.peek.as_mut() {
|
||
p.question = Some("Allow?".into());
|
||
}
|
||
state.complete_clipboard_attachment_paste(
|
||
completion_ctx(None, true),
|
||
crate::app::actions::ProbedAttachment::NoRaster,
|
||
Some(png.display().to_string()),
|
||
);
|
||
assert!(
|
||
state.peek_reply.images.is_empty(),
|
||
"file-url chips must not attach to a question-mode reply"
|
||
);
|
||
assert!(
|
||
state.error_toast.is_some(),
|
||
"discarding the deferred file-url chips must be announced with a toast"
|
||
);
|
||
assert_eq!(state.paste_probe_in_flight, 0);
|
||
}
|
||
|
||
/// A peek send stashed in normal mode must NOT reissue once a question
|
||
/// owns the panel — the reply dispatch would silently queue a prompt and
|
||
/// wipe the draft behind the dialog. It is dropped with a toast and the
|
||
/// draft stays in the widget.
|
||
#[test]
|
||
fn stashed_peek_reply_dropped_when_question_active() {
|
||
let mut state = state_with_open_peek(); // peeks TopLevel(AgentId(0))
|
||
state.peek_reply.set_text("please look");
|
||
state.deferred_peek_send = Some(DeferredPeekSend {
|
||
row: DashboardRowId::TopLevel(AgentId(0)),
|
||
attach: false,
|
||
});
|
||
if let Some(p) = state.peek.as_mut() {
|
||
p.question = Some("Allow?".into());
|
||
}
|
||
let actions = state.take_deferred_sends_after_paste();
|
||
assert!(
|
||
actions.is_empty(),
|
||
"the stashed reply must not reissue into question mode: {actions:?}"
|
||
);
|
||
assert!(state.deferred_peek_send.is_none(), "the stash is consumed");
|
||
assert!(
|
||
state.error_toast.is_some(),
|
||
"dropping the stashed reply must be announced with a toast"
|
||
);
|
||
assert_eq!(
|
||
state.peek_reply.text(),
|
||
"please look",
|
||
"the draft stays in the widget for after the question"
|
||
);
|
||
}
|
||
|
||
// -----------------------------------------------------------------
|
||
// `/` literal + `Ctrl+/` search mode (replaces the old `/`→filter
|
||
// behaviour that silently swallowed prompts starting with a
|
||
// filter prefix).
|
||
// -----------------------------------------------------------------
|
||
|
||
/// `/` types a literal slash into the prompt — it no longer
|
||
/// enters a filter mode. (Filtering moved to `Ctrl+/`.)
|
||
#[test]
|
||
fn slash_types_literal_not_filter() {
|
||
let mut state = DashboardState::new();
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
let ev = Event::Key(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE));
|
||
let _ = state.handle_input(&ev, ®);
|
||
assert_eq!(state.dispatch.text(), "/", "/ must type a literal slash");
|
||
assert!(!state.search_mode, "/ must NOT enter search mode");
|
||
assert!(
|
||
matches!(state.filter, Filter::None),
|
||
"/ must NOT set a filter",
|
||
);
|
||
}
|
||
|
||
/// `Ctrl+/` toggles search mode on, then off.
|
||
#[test]
|
||
fn ctrl_slash_toggles_search_mode() {
|
||
let mut state = DashboardState::new();
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
let ctrl_slash = Event::Key(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::CONTROL));
|
||
let o1 = state.handle_input(&ctrl_slash, ®);
|
||
assert!(matches!(o1, InputOutcome::Changed));
|
||
assert!(state.search_mode, "Ctrl+/ must enter search mode");
|
||
let o2 = state.handle_input(&ctrl_slash, ®);
|
||
assert!(matches!(o2, InputOutcome::Changed));
|
||
assert!(!state.search_mode, "Ctrl+/ again must exit search mode");
|
||
}
|
||
|
||
/// In search mode the dispatch buffer is a live filter query;
|
||
/// Enter CONFIRMS (keeps the filter, leaves search, clears query).
|
||
#[test]
|
||
fn search_mode_typing_filters_live_and_enter_confirms() {
|
||
let mut state = DashboardState::new();
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
state.enter_search_mode();
|
||
for ch in "auth".chars() {
|
||
let ev = Event::Key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
|
||
let _ = state.handle_input(&ev, ®);
|
||
}
|
||
assert_eq!(state.dispatch.text(), "auth");
|
||
assert!(
|
||
matches!(&state.filter, Filter::Substring(s) if s == "auth"),
|
||
"typing in search mode must update the filter live, got {:?}",
|
||
state.filter,
|
||
);
|
||
let enter = Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||
let outcome = state.handle_input(&enter, ®);
|
||
assert!(matches!(outcome, InputOutcome::Changed));
|
||
assert!(!state.search_mode, "Enter must leave search mode");
|
||
assert!(
|
||
matches!(&state.filter, Filter::Substring(s) if s == "auth"),
|
||
"Enter must KEEP the filter applied",
|
||
);
|
||
assert!(
|
||
state.dispatch.text().is_empty(),
|
||
"query buffer cleared after confirm",
|
||
);
|
||
}
|
||
|
||
/// Esc in search mode CANCELS: clears the filter and exits.
|
||
#[test]
|
||
fn search_mode_esc_cancels_and_clears_filter() {
|
||
let mut state = DashboardState::new();
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
state.enter_search_mode();
|
||
state.dispatch.set_text("auth");
|
||
state.filter = Filter::Substring("auth".into());
|
||
let esc = Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||
let outcome = state.handle_input(&esc, ®);
|
||
assert!(matches!(outcome, InputOutcome::Changed));
|
||
assert!(!state.search_mode, "Esc must exit search mode");
|
||
assert!(
|
||
matches!(state.filter, Filter::None),
|
||
"Esc must clear the filter",
|
||
);
|
||
assert!(state.dispatch.text().is_empty());
|
||
}
|
||
|
||
/// In search mode, bare letters that are normally nav shortcuts
|
||
/// (j/k) type into the query instead of navigating.
|
||
#[test]
|
||
fn search_mode_bare_letter_types_into_query() {
|
||
let mut state = DashboardState::new();
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
state.enter_search_mode();
|
||
for ch in "jk".chars() {
|
||
let ev = Event::Key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
|
||
let _ = state.handle_input(&ev, ®);
|
||
}
|
||
assert_eq!(
|
||
state.dispatch.text(),
|
||
"jk",
|
||
"j/k must type in search mode, not navigate",
|
||
);
|
||
}
|
||
|
||
/// edge case 21: Enter on empty input attaches.
|
||
#[test]
|
||
fn enter_with_empty_input_attaches() {
|
||
let state = make_state_with_selection();
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
|
||
let outcome = state.clone_for_test().handle_key(&key, ®);
|
||
assert!(matches!(
|
||
outcome,
|
||
InputOutcome::Action(Action::DashboardAttach(_))
|
||
));
|
||
}
|
||
|
||
/// Single left-click on a row attaches the
|
||
/// conversation immediately. The previous double-click-required
|
||
/// design felt unresponsive (user explicitly reported "click
|
||
/// does not respond or do anything properly"). Mouse handling
|
||
/// now mirrors click-to-open list semantics from gh-dash / k9s.
|
||
#[test]
|
||
fn single_click_on_row_attaches_immediately() {
|
||
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
|
||
use ratatui::layout::Rect;
|
||
let mut state = make_state_with_selection();
|
||
// Populate row_rects so the click lookup finds a row.
|
||
let row_id = state
|
||
.selected
|
||
.as_ref()
|
||
.cloned()
|
||
.expect("seed state has a selection");
|
||
let rect = Rect {
|
||
x: 0,
|
||
y: 5,
|
||
width: 80,
|
||
height: 1,
|
||
};
|
||
state.row_rects = vec![(row_id.clone(), rect)];
|
||
let click = MouseEvent {
|
||
kind: MouseEventKind::Down(MouseButton::Left),
|
||
column: 10,
|
||
row: 5,
|
||
modifiers: crossterm::event::KeyModifiers::NONE,
|
||
};
|
||
let outcome = state.handle_mouse(&click);
|
||
match outcome {
|
||
InputOutcome::Action(Action::DashboardAttach(id)) => {
|
||
assert_eq!(id, row_id, "click must attach the clicked row, got {id:?}");
|
||
}
|
||
other => panic!("expected DashboardAttach on single click, got {other:?}"),
|
||
}
|
||
// The clicked row also becomes selected so a follow-up Esc
|
||
// returns the cursor to where the user clicked.
|
||
assert_eq!(state.selected.as_ref(), Some(&row_id));
|
||
}
|
||
|
||
/// Clicking on an empty cell (no row at that
|
||
/// position) is a no-op. Prevents accidental attaches when the
|
||
/// user clicks between rows or in the header area.
|
||
#[test]
|
||
fn click_on_empty_area_is_no_op() {
|
||
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
|
||
let mut state = make_state_with_selection();
|
||
// Empty row_rects → no row to find at the click position.
|
||
state.row_rects = Vec::new();
|
||
let click = MouseEvent {
|
||
kind: MouseEventKind::Down(MouseButton::Left),
|
||
column: 10,
|
||
row: 5,
|
||
modifiers: crossterm::event::KeyModifiers::NONE,
|
||
};
|
||
let outcome = state.handle_mouse(&click);
|
||
assert!(
|
||
matches!(outcome, InputOutcome::Unchanged),
|
||
"click on empty area must be a no-op, got {outcome:?}",
|
||
);
|
||
}
|
||
|
||
/// Clicking a model in the dashboard `/model` slash dropdown must
|
||
/// accept the completion and must NOT attach the session row that
|
||
/// sits under the same screen coordinates (click-through bug).
|
||
#[test]
|
||
fn slash_model_dropdown_click_selects_model_not_session_row() {
|
||
use agent_client_protocol as acp;
|
||
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
|
||
use indexmap::IndexMap;
|
||
use ratatui::layout::Rect;
|
||
|
||
let mut state = make_state_with_selection();
|
||
let row_id = state
|
||
.selected
|
||
.as_ref()
|
||
.cloned()
|
||
.expect("seed state has a selection");
|
||
|
||
// Session row occupies the same Y as the model dropdown item.
|
||
state.row_rects = vec![(
|
||
row_id.clone(),
|
||
Rect {
|
||
x: 0,
|
||
y: 5,
|
||
width: 80,
|
||
height: 1,
|
||
},
|
||
)];
|
||
// Slash dropdown overlays that row (as `render_slash_dropdown` does).
|
||
state.slash_dropdown_items_area = Some(Rect {
|
||
x: 2,
|
||
y: 5,
|
||
width: 40,
|
||
height: 4,
|
||
});
|
||
state.slash_dropdown_hit = crate::views::slash_dropdown::RenderedDropdown {
|
||
row_items: vec![0, 1, 2, 3],
|
||
has_scrollbar: false,
|
||
};
|
||
|
||
// Seed a model catalog and open `/model ` so arg suggestions exist.
|
||
let model_id = acp::ModelId::new("beta-model");
|
||
let mut available = IndexMap::new();
|
||
available.insert(
|
||
model_id.clone(),
|
||
acp::ModelInfo::new(model_id.clone(), "Beta Model"),
|
||
);
|
||
available.insert(
|
||
acp::ModelId::new("alpha-model"),
|
||
acp::ModelInfo::new(acp::ModelId::new("alpha-model"), "Alpha Model"),
|
||
);
|
||
state.models.update_catalog(available, Some(model_id));
|
||
// Mirror how the real dashboard types into the dispatch box:
|
||
// caret at end so `/model ` is in the args phase.
|
||
state.dispatch.set_text("/model ");
|
||
let end = state.dispatch.text().len();
|
||
state.dispatch.textarea.set_cursor(end);
|
||
state.dispatch.refresh_slash(&state.models);
|
||
let snap = state.dispatch.slash_snapshot();
|
||
assert!(
|
||
!snap.matches.is_empty(),
|
||
"expected model arg suggestions for /model "
|
||
);
|
||
|
||
let click = MouseEvent {
|
||
kind: MouseEventKind::Down(MouseButton::Left),
|
||
column: 10,
|
||
row: 5, // inside BOTH dropdown and row_rects
|
||
modifiers: crossterm::event::KeyModifiers::NONE,
|
||
};
|
||
let outcome = state.handle_mouse(&click);
|
||
assert!(
|
||
!matches!(outcome, InputOutcome::Action(Action::DashboardAttach(_))),
|
||
"model-list click must not attach session, got {outcome:?}"
|
||
);
|
||
assert!(
|
||
matches!(outcome, InputOutcome::Changed),
|
||
"dropdown click should be consumed as Changed, got {outcome:?}"
|
||
);
|
||
let text = state.dispatch.text();
|
||
assert!(
|
||
text.contains("alpha-model")
|
||
|| text.contains("beta-model")
|
||
|| text.contains("Alpha")
|
||
|| text.contains("Beta"),
|
||
"dispatch should contain accepted model completion, got {text:?}"
|
||
);
|
||
assert!(
|
||
!state.list_focused,
|
||
"clicking the dropdown focuses the input"
|
||
);
|
||
}
|
||
|
||
/// Hover over the open slash dropdown updates `slash_hovered` so the
|
||
/// list tracks the pointer (agent-view parity).
|
||
#[test]
|
||
fn slash_dropdown_mouse_move_sets_hover() {
|
||
use agent_client_protocol as acp;
|
||
use crossterm::event::{MouseEvent, MouseEventKind};
|
||
use indexmap::IndexMap;
|
||
use ratatui::layout::Rect;
|
||
|
||
let mut state = DashboardState::new();
|
||
state.slash_dropdown_items_area = Some(Rect {
|
||
x: 2,
|
||
y: 5,
|
||
width: 40,
|
||
height: 4,
|
||
});
|
||
state.slash_dropdown_hit = crate::views::slash_dropdown::RenderedDropdown {
|
||
row_items: vec![0, 1, 2, 3],
|
||
has_scrollbar: false,
|
||
};
|
||
let model_id = acp::ModelId::new("hover-model");
|
||
let mut available = IndexMap::new();
|
||
available.insert(
|
||
model_id.clone(),
|
||
acp::ModelInfo::new(model_id.clone(), "Hover Model"),
|
||
);
|
||
state.models.update_catalog(available, Some(model_id));
|
||
state.dispatch.set_text("/model ");
|
||
let end = state.dispatch.text().len();
|
||
state.dispatch.textarea.set_cursor(end);
|
||
state.dispatch.refresh_slash(&state.models);
|
||
assert!(
|
||
!state.dispatch.slash_snapshot().matches.is_empty(),
|
||
"expected model suggestions so hover can land on a row"
|
||
);
|
||
|
||
let move_ev = MouseEvent {
|
||
kind: MouseEventKind::Moved,
|
||
column: 10,
|
||
row: 5,
|
||
modifiers: crossterm::event::KeyModifiers::NONE,
|
||
};
|
||
let _ = state.handle_mouse(&move_ev);
|
||
assert_eq!(
|
||
state.dispatch.slash_hovered(),
|
||
Some(0),
|
||
"pointer over first dropdown row should set hover index 0"
|
||
);
|
||
}
|
||
|
||
/// With a section header selected and the prompt empty, Right
|
||
/// expands, Left collapses, and Enter toggles it.
|
||
#[test]
|
||
fn section_keys_collapse_expand_and_toggle() {
|
||
let mut state = DashboardState::new();
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
let key_sec = SectionKey::State(RowState::Working);
|
||
state.focus_section(key_sec);
|
||
assert_eq!(state.selected_section, Some(key_sec));
|
||
|
||
let _ = state.handle_key(&KeyEvent::new(KeyCode::Left, KeyModifiers::NONE), ®);
|
||
assert!(state.is_section_collapsed(key_sec), "Left must collapse");
|
||
|
||
let _ = state.handle_key(&KeyEvent::new(KeyCode::Right, KeyModifiers::NONE), ®);
|
||
assert!(!state.is_section_collapsed(key_sec), "Right must expand");
|
||
|
||
let _ = state.handle_key(&KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), ®);
|
||
assert!(
|
||
state.is_section_collapsed(key_sec),
|
||
"Enter toggles → collapsed"
|
||
);
|
||
let _ = state.handle_key(&KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), ®);
|
||
assert!(
|
||
!state.is_section_collapsed(key_sec),
|
||
"Enter toggles → expanded"
|
||
);
|
||
}
|
||
|
||
/// A freshly-constructed dashboard starts with the "Inactive"
|
||
/// (roster-only) section collapsed by default — and no other
|
||
/// section. Expanding it is one keypress away (and survives reopen
|
||
/// within the process; see `collapsed_sections` docs).
|
||
#[test]
|
||
fn inactive_section_collapsed_by_default() {
|
||
let state = DashboardState::new();
|
||
assert!(
|
||
state.is_section_collapsed(SectionKey::State(RowState::Inactive)),
|
||
"Inactive must start collapsed",
|
||
);
|
||
for other in [
|
||
RowState::NeedsInput,
|
||
RowState::Working,
|
||
RowState::Idle,
|
||
RowState::Completed,
|
||
RowState::Failed,
|
||
RowState::Blocked,
|
||
] {
|
||
assert!(
|
||
!state.is_section_collapsed(SectionKey::State(other)),
|
||
"{other:?} must start expanded",
|
||
);
|
||
}
|
||
assert!(
|
||
!state.is_section_collapsed(SectionKey::Pinned),
|
||
"Pinned must start expanded",
|
||
);
|
||
}
|
||
|
||
/// Enter on the Idle overflow toggle flips `idle_show_all`; Right
|
||
/// reveals, Left re-folds (with an empty prompt).
|
||
#[test]
|
||
fn idle_overflow_enter_and_arrows_toggle_show_all() {
|
||
let mut state = DashboardState::new();
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
state.focus_idle_overflow();
|
||
assert!(state.selected_idle_overflow);
|
||
assert!(!state.idle_show_all, "starts capped");
|
||
|
||
let _ = state.handle_key(&KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), ®);
|
||
assert!(state.idle_show_all, "Enter reveals");
|
||
let _ = state.handle_key(&KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), ®);
|
||
assert!(!state.idle_show_all, "Enter re-folds");
|
||
|
||
let _ = state.handle_key(&KeyEvent::new(KeyCode::Right, KeyModifiers::NONE), ®);
|
||
assert!(state.idle_show_all, "Right reveals");
|
||
let _ = state.handle_key(&KeyEvent::new(KeyCode::Left, KeyModifiers::NONE), ®);
|
||
assert!(!state.idle_show_all, "Left re-folds");
|
||
}
|
||
|
||
/// vim mode ON with the LIST focused — `l` / `h` on the Idle overflow
|
||
/// toggle reveal / re-fold the folded agents (mirroring the section
|
||
/// keys). vim mode ON with the INPUT focused — they type into the
|
||
/// dispatch prompt instead of toggling.
|
||
#[test]
|
||
fn idle_overflow_vim_hl_focus_gated() {
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
|
||
// vim ON + LIST focused — `l`/`h` toggle show-all.
|
||
crate::appearance::cache::set_vim_mode(true);
|
||
let mut state = DashboardState::new();
|
||
state.focus_idle_overflow();
|
||
state.list_focused = true;
|
||
let _ = state.handle_key(&KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE), ®);
|
||
let show_all_after_l = state.idle_show_all;
|
||
let _ = state.handle_key(&KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE), ®);
|
||
let show_all_after_h = state.idle_show_all;
|
||
crate::appearance::cache::set_vim_mode(false);
|
||
assert!(show_all_after_l, "list-focused vim `l` must reveal");
|
||
assert!(!show_all_after_h, "list-focused vim `h` must re-fold");
|
||
|
||
// vim ON + INPUT focused (list_focused == false) + empty draft —
|
||
// `h`/`l` must type into the prompt, never toggle show-all.
|
||
crate::appearance::cache::set_vim_mode(true);
|
||
let mut state = DashboardState::new();
|
||
state.focus_idle_overflow();
|
||
// Input focused is the default; leave list_focused == false.
|
||
let _ = state.handle_key(&KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE), ®);
|
||
let show_all_after_input_h = state.idle_show_all;
|
||
let _ = state.handle_key(&KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE), ®);
|
||
let show_all_after_input_l = state.idle_show_all;
|
||
let typed = state.dispatch.text().to_string();
|
||
crate::appearance::cache::set_vim_mode(false);
|
||
assert!(
|
||
!show_all_after_input_h && !show_all_after_input_l,
|
||
"input-focused vim `h`/`l` must not toggle show-all",
|
||
);
|
||
assert_eq!(
|
||
typed, "hl",
|
||
"input-focused vim `h`/`l` must type into the dispatch input",
|
||
);
|
||
}
|
||
|
||
/// With the list focused and the Idle overflow toggle selected, Esc
|
||
/// focuses the `[+ New Agent]` button (mirroring the section / row
|
||
/// deselect tiers), rather than exiting.
|
||
#[test]
|
||
fn idle_overflow_esc_focuses_new_agent_button() {
|
||
let mut state = DashboardState::new();
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
state.focus_idle_overflow();
|
||
state.list_focused = true;
|
||
let _ = state.handle_key(&KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), ®);
|
||
assert!(state.new_agent_button_focused, "Esc focuses the button");
|
||
assert!(
|
||
!state.selected_idle_overflow,
|
||
"Esc clears the overflow cursor"
|
||
);
|
||
}
|
||
|
||
/// The overflow cursor is mutually exclusive with the other three
|
||
/// cursor targets — focusing any of them clears it.
|
||
#[test]
|
||
fn focusing_other_targets_clears_idle_overflow() {
|
||
let mut state = DashboardState::new();
|
||
state.focus_idle_overflow();
|
||
state.focus_new_agent_button();
|
||
assert!(
|
||
!state.selected_idle_overflow,
|
||
"button focus clears overflow"
|
||
);
|
||
|
||
state.focus_idle_overflow();
|
||
state.focus_section(SectionKey::State(RowState::Working));
|
||
assert!(
|
||
!state.selected_idle_overflow,
|
||
"section focus clears overflow"
|
||
);
|
||
|
||
state.focus_idle_overflow();
|
||
state.focus_row(DashboardRowId::TopLevel(crate::app::agent::AgentId(0)));
|
||
assert!(!state.selected_idle_overflow, "row focus clears overflow");
|
||
}
|
||
|
||
/// `reanchor_selection` drops a stale overflow cursor (the toggle row
|
||
/// vanished because the Idle group is no longer capped) onto the
|
||
/// `[+ New Agent]` button.
|
||
#[test]
|
||
fn reanchor_clears_stale_idle_overflow_cursor() {
|
||
let mut state = DashboardState::new();
|
||
state.focus_idle_overflow();
|
||
// No rows at all → no overflow focusable exists.
|
||
state.reanchor_selection(&[]);
|
||
assert!(
|
||
state.new_agent_button_focused,
|
||
"a stranded overflow cursor must fall back to the button",
|
||
);
|
||
assert!(!state.selected_idle_overflow);
|
||
}
|
||
|
||
/// With the list focused and a section header selected, Esc focuses
|
||
/// the `[+ New Agent]` button (mirroring the row-deselect tier),
|
||
/// rather than exiting.
|
||
#[test]
|
||
fn section_esc_focuses_new_agent_button() {
|
||
let mut state = DashboardState::new();
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
state.focus_section(SectionKey::State(RowState::Working));
|
||
state.list_focused = true;
|
||
let _ = state.handle_key(&KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), ®);
|
||
assert!(
|
||
state.new_agent_button_focused,
|
||
"Esc on a section must focus [+ New Agent]",
|
||
);
|
||
assert!(
|
||
state.selected_section.is_none(),
|
||
"Esc must clear the section cursor",
|
||
);
|
||
}
|
||
|
||
/// Clicking a section header selects it and toggles its collapse.
|
||
#[test]
|
||
fn section_click_selects_and_toggles() {
|
||
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
|
||
let mut state = DashboardState::new();
|
||
let key_sec = SectionKey::State(RowState::Idle);
|
||
// Simulate a rendered header hit rect (render rebuilds these).
|
||
state.section_rects.push((key_sec, Rect::new(0, 0, 40, 1)));
|
||
let click = MouseEvent {
|
||
kind: MouseEventKind::Down(MouseButton::Left),
|
||
column: 5,
|
||
row: 0,
|
||
modifiers: KeyModifiers::NONE,
|
||
};
|
||
let _ = state.handle_mouse(&click);
|
||
assert_eq!(
|
||
state.selected_section,
|
||
Some(key_sec),
|
||
"click selects the section",
|
||
);
|
||
assert!(state.is_section_collapsed(key_sec), "click collapses");
|
||
// A second click expands it again (rect persists between renders).
|
||
let _ = state.handle_mouse(&click);
|
||
assert!(!state.is_section_collapsed(key_sec), "second click expands");
|
||
}
|
||
|
||
/// Moving the mouse over a section header sets `hovered_section`;
|
||
/// moving off clears it.
|
||
#[test]
|
||
fn section_hover_sets_and_clears_hovered_section() {
|
||
use crossterm::event::{MouseEvent, MouseEventKind};
|
||
let mut state = DashboardState::new();
|
||
let key_sec = SectionKey::State(RowState::Idle);
|
||
state.section_rects.push((key_sec, Rect::new(0, 0, 40, 1)));
|
||
let over = MouseEvent {
|
||
kind: MouseEventKind::Moved,
|
||
column: 5,
|
||
row: 0,
|
||
modifiers: KeyModifiers::NONE,
|
||
};
|
||
let _ = state.handle_mouse(&over);
|
||
assert_eq!(state.hovered_section, Some(key_sec));
|
||
let off = MouseEvent {
|
||
kind: MouseEventKind::Moved,
|
||
column: 5,
|
||
row: 5,
|
||
modifiers: KeyModifiers::NONE,
|
||
};
|
||
let _ = state.handle_mouse(&off);
|
||
assert_eq!(state.hovered_section, None);
|
||
}
|
||
|
||
/// `focus_section` / `focus_row` / `focus_new_agent_button` keep the
|
||
/// three cursor targets mutually exclusive.
|
||
#[test]
|
||
fn cursor_targets_are_mutually_exclusive() {
|
||
let mut state = DashboardState::new();
|
||
state.focus_section(SectionKey::Pinned);
|
||
assert_eq!(state.selected_section, Some(SectionKey::Pinned));
|
||
assert!(state.selected.is_none());
|
||
assert!(!state.new_agent_button_focused);
|
||
|
||
state.focus_row(DashboardRowId::TopLevel(crate::app::agent::AgentId(0)));
|
||
assert!(
|
||
state.selected_section.is_none(),
|
||
"focus_row clears the section"
|
||
);
|
||
|
||
state.focus_section(SectionKey::Pinned);
|
||
state.focus_new_agent_button();
|
||
assert!(
|
||
state.selected_section.is_none(),
|
||
"focus_new_agent_button clears the section",
|
||
);
|
||
}
|
||
|
||
/// The cheatsheet's `[✗]` chrome close button is clickable and
|
||
/// hover-tracked — mouse events must route through
|
||
/// `modal_window::handle_modal_mouse` before the picker content
|
||
/// (whose own close rect is a dead `Rect::default()`).
|
||
#[test]
|
||
fn shortcuts_modal_close_button_clicks_and_hovers() {
|
||
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
|
||
let mut state = DashboardState::new();
|
||
let entries = Vec::new();
|
||
let picker = crate::views::shortcuts_help::build_initial_picker_state(&entries);
|
||
let mut modal = Box::new(ShortcutsModalState {
|
||
entries,
|
||
state: picker,
|
||
window: Default::default(),
|
||
filter_active: false,
|
||
collapsed_sections: Default::default(),
|
||
expanded_ids: std::collections::HashSet::new(),
|
||
mode: crate::views::shortcuts_help::ShortcutsHelpMode::Browse,
|
||
});
|
||
// Simulate the rect render_close_button records each frame.
|
||
modal.window.close_button_rect = Some(Rect::new(70, 2, 5, 1));
|
||
state.shortcuts_modal = Some(modal);
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
|
||
// Hover over the button → tracked + repaint requested.
|
||
let over = Event::Mouse(MouseEvent {
|
||
kind: MouseEventKind::Moved,
|
||
column: 72,
|
||
row: 2,
|
||
modifiers: KeyModifiers::NONE,
|
||
});
|
||
assert!(matches!(
|
||
state.handle_input(&over, ®),
|
||
InputOutcome::Changed
|
||
));
|
||
assert!(
|
||
state
|
||
.shortcuts_modal
|
||
.as_ref()
|
||
.is_some_and(|m| m.window.close_hovered),
|
||
"hovering [✗] must set close_hovered",
|
||
);
|
||
|
||
// Click on the button → close action.
|
||
let click = Event::Mouse(MouseEvent {
|
||
kind: MouseEventKind::Down(MouseButton::Left),
|
||
column: 72,
|
||
row: 2,
|
||
modifiers: KeyModifiers::NONE,
|
||
});
|
||
assert!(
|
||
matches!(
|
||
state.handle_input(&click, ®),
|
||
InputOutcome::Action(Action::DashboardCloseShortcutsHelp)
|
||
),
|
||
"clicking [✗] must request close",
|
||
);
|
||
}
|
||
|
||
/// An inline-expand keypress toggles the selected row's id in and out of
|
||
/// `expanded_ids` (on→off→on) through the shared `toggle_membership`.
|
||
#[test]
|
||
fn shortcuts_modal_key_toggles_inline_expand() {
|
||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||
let mut state = DashboardState::new();
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
let entries = crate::views::shortcuts_help::build_entries(
|
||
&[
|
||
crate::actions::When::DashboardFocused,
|
||
crate::actions::When::Always,
|
||
],
|
||
®,
|
||
true,
|
||
);
|
||
let picker = crate::views::shortcuts_help::build_initial_picker_state(&entries);
|
||
let mut modal = Box::new(ShortcutsModalState {
|
||
entries,
|
||
state: picker,
|
||
window: Default::default(),
|
||
filter_active: false,
|
||
collapsed_sections: Default::default(),
|
||
expanded_ids: std::collections::HashSet::new(),
|
||
mode: crate::views::shortcuts_help::ShortcutsHelpMode::Browse,
|
||
});
|
||
// Land on the first registry-backed hint (the section header is row 0).
|
||
modal.state.selected = 1;
|
||
state.shortcuts_modal = Some(modal);
|
||
|
||
let right = || Event::Key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE));
|
||
let expanded_len =
|
||
|s: &DashboardState| s.shortcuts_modal.as_ref().unwrap().expanded_ids.len();
|
||
|
||
assert!(matches!(
|
||
state.handle_input(&right(), ®),
|
||
InputOutcome::Changed
|
||
));
|
||
assert_eq!(expanded_len(&state), 1, "first press expands the row");
|
||
state.handle_input(&right(), ®);
|
||
assert_eq!(expanded_len(&state), 0, "second press collapses it");
|
||
state.handle_input(&right(), ®);
|
||
assert_eq!(expanded_len(&state), 1, "third press expands it again");
|
||
}
|
||
|
||
/// Opening the detail page and returning must leave every browse-list field
|
||
/// (selection, query, filter, collapsed, expanded) exactly as it was.
|
||
#[test]
|
||
fn shortcuts_modal_detail_round_trip_preserves_browse_state() {
|
||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||
let mut state = DashboardState::new();
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
let entries = crate::views::shortcuts_help::build_entries(
|
||
&[
|
||
crate::actions::When::DashboardFocused,
|
||
crate::actions::When::Always,
|
||
],
|
||
®,
|
||
true,
|
||
);
|
||
let picker = crate::views::shortcuts_help::build_initial_picker_state(&entries);
|
||
let mut modal = Box::new(ShortcutsModalState {
|
||
entries,
|
||
state: picker,
|
||
window: Default::default(),
|
||
filter_active: false,
|
||
collapsed_sections: std::collections::HashSet::from([6usize]),
|
||
expanded_ids: std::collections::HashSet::from([
|
||
crate::views::shortcuts_help::ExpandKey::Action(crate::actions::ActionId::Quit),
|
||
]),
|
||
mode: crate::views::shortcuts_help::ShortcutsHelpMode::Browse,
|
||
});
|
||
modal.state.selected = 1; // first registry-backed hint
|
||
state.shortcuts_modal = Some(modal);
|
||
|
||
let snapshot = |s: &DashboardState| {
|
||
let m = s.shortcuts_modal.as_ref().unwrap();
|
||
(
|
||
m.state.selected,
|
||
m.state.query.clone(),
|
||
m.filter_active,
|
||
m.collapsed_sections.clone(),
|
||
m.expanded_ids.clone(),
|
||
)
|
||
};
|
||
let before = snapshot(&state);
|
||
|
||
let enter = Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||
state.handle_input(&enter, ®);
|
||
assert!(
|
||
state.shortcuts_modal.as_ref().unwrap().mode.is_detail(),
|
||
"Enter on a registry hint opens the detail page",
|
||
);
|
||
|
||
let esc = Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||
state.handle_input(&esc, ®);
|
||
assert!(
|
||
state.shortcuts_modal.as_ref().unwrap().mode.is_browse(),
|
||
"Esc returns to the browse list",
|
||
);
|
||
assert_eq!(
|
||
before,
|
||
snapshot(&state),
|
||
"detail round-trip must preserve all browse-list state",
|
||
);
|
||
}
|
||
|
||
/// Collapse/expand keypresses on a section header dismiss a pending
|
||
/// feedback toast like every other key (the invariant) —
|
||
/// the intercept must sit BELOW the handler's toast-clear tier.
|
||
#[test]
|
||
fn section_keys_clear_pending_toast() {
|
||
let mut state = DashboardState::new();
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
let key_sec = SectionKey::State(RowState::Working);
|
||
state.focus_section(key_sec);
|
||
state.set_error_toast("boom");
|
||
let _ = state.handle_key(&KeyEvent::new(KeyCode::Left, KeyModifiers::NONE), ®);
|
||
assert!(state.is_section_collapsed(key_sec), "Left must collapse");
|
||
assert!(
|
||
state.error_toast.is_none(),
|
||
"a collapse keypress must dismiss the pending toast",
|
||
);
|
||
}
|
||
|
||
/// An armed stop confirmation is bound to the row that was selected
|
||
/// when `Ctrl+X` was pressed — any other key (nav included) must
|
||
/// disarm it, otherwise the footer's "press again to close" hint
|
||
/// lingers while the cursor moves to other agents. The disarm must
|
||
/// NOT depend on `error_toast` (the Ctrl+X arm path plants none).
|
||
#[test]
|
||
fn nav_key_disarms_pending_stop_confirm() {
|
||
let mut state = DashboardState::new();
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
state.focus_row(DashboardRowId::TopLevel(AgentId(0)));
|
||
state.stop_confirm = Some((DashboardRowId::TopLevel(AgentId(0)), Instant::now()));
|
||
assert!(state.error_toast.is_none(), "arm path plants no toast");
|
||
let _ = state.handle_key(&KeyEvent::new(KeyCode::Down, KeyModifiers::NONE), ®);
|
||
assert!(
|
||
state.stop_confirm.is_none(),
|
||
"a nav keypress must disarm the pending stop confirm",
|
||
);
|
||
|
||
// Control — Ctrl+X itself preserves the armed confirm so the
|
||
// dispatcher can observe it and close.
|
||
state.stop_confirm = Some((DashboardRowId::TopLevel(AgentId(0)), Instant::now()));
|
||
let _ = state.handle_key(
|
||
&KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
|
||
®,
|
||
);
|
||
assert!(
|
||
state.stop_confirm.is_some(),
|
||
"Ctrl+X must preserve the armed confirm for the dispatcher",
|
||
);
|
||
|
||
// The actual repro path: peek is open by default for a selected
|
||
// row, and `handle_peek_key` CONSUMES Up/Down (agent switch) —
|
||
// the disarm must sit above that intercept or nav keys never
|
||
// reach it and the footer hint lingers.
|
||
state.stop_confirm = Some((DashboardRowId::TopLevel(AgentId(0)), Instant::now()));
|
||
state.peek = Some(super::super::peek::PeekPanelState::new(
|
||
DashboardRowId::TopLevel(AgentId(0)),
|
||
peek_fields_for_test("Idle"),
|
||
));
|
||
let _ = state.handle_key(&KeyEvent::new(KeyCode::Down, KeyModifiers::NONE), ®);
|
||
assert!(
|
||
state.stop_confirm.is_none(),
|
||
"a nav keypress consumed by the peek panel must still disarm the confirm",
|
||
);
|
||
}
|
||
|
||
/// Section header selected while the LIST is focused — the input is
|
||
/// inactive, so Enter / Left / Right operate on the section even
|
||
/// when a draft is sitting in the (unfocused) dispatch input.
|
||
/// (With the input focused, text flips those keys to draft editing
|
||
/// / dispatch — covered by the gate's `prompt_empty` arm.)
|
||
#[test]
|
||
fn section_keys_work_with_draft_when_list_focused() {
|
||
let mut state = DashboardState::new();
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
let key_sec = SectionKey::State(RowState::Working);
|
||
state.focus_section(key_sec);
|
||
state.list_focused = true;
|
||
state.dispatch.set_text("draft for a new agent");
|
||
|
||
let _ = state.handle_key(&KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), ®);
|
||
assert!(
|
||
state.is_section_collapsed(key_sec),
|
||
"list-focused Enter must toggle the section despite the draft",
|
||
);
|
||
let _ = state.handle_key(&KeyEvent::new(KeyCode::Right, KeyModifiers::NONE), ®);
|
||
assert!(
|
||
!state.is_section_collapsed(key_sec),
|
||
"list-focused Right must expand despite the draft",
|
||
);
|
||
let _ = state.handle_key(&KeyEvent::new(KeyCode::Left, KeyModifiers::NONE), ®);
|
||
assert!(
|
||
state.is_section_collapsed(key_sec),
|
||
"list-focused Left must collapse despite the draft",
|
||
);
|
||
assert_eq!(
|
||
state.dispatch.text(),
|
||
"draft for a new agent",
|
||
"the inactive input's draft must survive untouched",
|
||
);
|
||
}
|
||
|
||
/// vim `l` opens detail on list-focused rows; focused dispatch types `l`.
|
||
#[test]
|
||
fn vim_l_row_attach_and_input_focus() {
|
||
use crate::app::actions::Action;
|
||
use crate::views::dashboard::DashboardRowId;
|
||
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
let id = DashboardRowId::TopLevel(crate::app::agent::AgentId(42));
|
||
let l = KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE);
|
||
|
||
crate::appearance::cache::set_vim_mode(true);
|
||
let mut state = DashboardState::new();
|
||
state.focus_row(id.clone());
|
||
state.list_focused = true;
|
||
match state.handle_key(&l, ®) {
|
||
InputOutcome::Action(Action::DashboardAttach(row)) => assert_eq!(row, id),
|
||
other => panic!("list-focused vim `l` must attach, got {other:?}"),
|
||
}
|
||
|
||
let mut state = DashboardState::new();
|
||
state.focus_row(id);
|
||
state.list_focused = false;
|
||
let outcome = state.handle_key(&l, ®);
|
||
crate::appearance::cache::set_vim_mode(false);
|
||
assert!(
|
||
!matches!(outcome, InputOutcome::Action(Action::DashboardAttach(_))),
|
||
"input-focused vim `l` must not attach, got {outcome:?}",
|
||
);
|
||
assert_eq!(state.dispatch.text(), "l");
|
||
}
|
||
|
||
/// vim `l` on peek: unfocused attaches; focused empty reply types `l`.
|
||
#[test]
|
||
fn vim_l_peek_attach_and_focused_type() {
|
||
use crate::app::actions::Action;
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
let l = KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE);
|
||
|
||
// state_with_open_peek pins vim off for seed focus; enable after.
|
||
let mut state = state_with_open_peek();
|
||
crate::appearance::cache::set_vim_mode(true);
|
||
state.peek.as_mut().unwrap().focused = false;
|
||
match state.handle_key(&l, ®) {
|
||
InputOutcome::Action(Action::DashboardAttach(row)) => {
|
||
assert_eq!(row, DashboardRowId::TopLevel(AgentId(0)));
|
||
}
|
||
other => panic!("unfocused peek vim `l` must attach, got {other:?}"),
|
||
}
|
||
|
||
let mut state = state_with_open_peek();
|
||
crate::appearance::cache::set_vim_mode(true);
|
||
assert!(state.peek.as_ref().unwrap().focused);
|
||
let outcome = state.handle_key(&l, ®);
|
||
let reply = state.peek_reply.text().to_string();
|
||
crate::appearance::cache::set_vim_mode(false);
|
||
assert!(
|
||
!matches!(outcome, InputOutcome::Action(Action::DashboardAttach(_))),
|
||
"focused reply vim `l` must not attach, got {outcome:?}",
|
||
);
|
||
assert_eq!(reply, "l");
|
||
}
|
||
|
||
/// List-focused vim `h`/`l` fold sections; input-focused or vim-off type.
|
||
#[test]
|
||
fn section_vim_hl_collapse_expand() {
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
let key_sec = SectionKey::State(RowState::Working);
|
||
|
||
// vim ON + LIST focused — `h`/`l` fold the section.
|
||
crate::appearance::cache::set_vim_mode(true);
|
||
let mut state = DashboardState::new();
|
||
state.focus_section(key_sec);
|
||
state.list_focused = true;
|
||
let _ = state.handle_key(&KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE), ®);
|
||
let collapsed_after_h = state.is_section_collapsed(key_sec);
|
||
let _ = state.handle_key(&KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE), ®);
|
||
let collapsed_after_l = state.is_section_collapsed(key_sec);
|
||
// Reset before asserting so a failure can't leak vim state
|
||
// into another test sharing this thread's cache.
|
||
crate::appearance::cache::set_vim_mode(false);
|
||
assert!(collapsed_after_h, "list-focused vim `h` must collapse");
|
||
assert!(!collapsed_after_l, "list-focused vim `l` must expand");
|
||
|
||
// vim ON + INPUT focused (list_focused == false) + empty draft —
|
||
// `h`/`l` must type into the prompt, never fold the section.
|
||
crate::appearance::cache::set_vim_mode(true);
|
||
let mut state = DashboardState::new();
|
||
state.focus_section(key_sec);
|
||
// Input focused is the default; leave list_focused == false.
|
||
let _ = state.handle_key(&KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE), ®);
|
||
let collapsed_after_input_h = state.is_section_collapsed(key_sec);
|
||
let _ = state.handle_key(&KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE), ®);
|
||
let collapsed_after_input_l = state.is_section_collapsed(key_sec);
|
||
let typed = state.dispatch.text().to_string();
|
||
crate::appearance::cache::set_vim_mode(false);
|
||
assert!(
|
||
!collapsed_after_input_h && !collapsed_after_input_l,
|
||
"input-focused vim `h`/`l` must not fold the section",
|
||
);
|
||
assert_eq!(
|
||
typed, "hl",
|
||
"input-focused vim `h`/`l` must type into the dispatch input",
|
||
);
|
||
|
||
// vim OFF — bare letters are dispatch-input edits, never
|
||
// collapse keys, even with a section header selected.
|
||
let mut state = DashboardState::new();
|
||
state.focus_section(key_sec);
|
||
let _ = state.handle_key(&KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE), ®);
|
||
assert!(
|
||
!state.is_section_collapsed(key_sec),
|
||
"vim-off `l` must not collapse",
|
||
);
|
||
assert_eq!(
|
||
state.dispatch.text(),
|
||
"l",
|
||
"vim-off `l` must type into the dispatch input",
|
||
);
|
||
}
|
||
|
||
/// Build a minimal top-level row for the reanchor tests.
|
||
fn reanchor_test_row(id: usize, state: RowState) -> super::super::row::DashboardRow {
|
||
super::super::row::DashboardRow {
|
||
id: DashboardRowId::TopLevel(AgentId(id)),
|
||
label: format!("r{id}"),
|
||
subtitle: None,
|
||
state,
|
||
activity: None,
|
||
secondary_line: None,
|
||
cwd_display: String::new(),
|
||
cwd: std::path::PathBuf::from("/"),
|
||
last_change_at: std::time::SystemTime::now(),
|
||
pinned: false,
|
||
is_active: false,
|
||
badges: Vec::new(),
|
||
context_pct: None,
|
||
indent: 0,
|
||
parent_label: None,
|
||
is_more_placeholder: false,
|
||
more_count: 0,
|
||
}
|
||
}
|
||
|
||
/// A selected section header whose section no longer exists (row
|
||
/// churn removed its last row) is moved to the `[+ New Agent]`
|
||
/// button by `reanchor_selection`, so the footer hints and the
|
||
/// collapse keys never act on an invisible header.
|
||
#[test]
|
||
fn reanchor_moves_stale_section_cursor_to_button() {
|
||
let mut state = DashboardState::new();
|
||
state.focus_section(SectionKey::State(RowState::Idle));
|
||
// Only a Working row remains — the Idle header is gone.
|
||
let rows = vec![reanchor_test_row(1, RowState::Working)];
|
||
state.reanchor_selection(&rows);
|
||
assert!(
|
||
state.selected_section.is_none(),
|
||
"stale section cursor must be cleared",
|
||
);
|
||
assert!(
|
||
state.new_agent_button_focused,
|
||
"cursor must move to the [+ New Agent] button",
|
||
);
|
||
}
|
||
|
||
/// A `s:state` filter suppresses ALL state headers — a section
|
||
/// cursor left over from before the filter must be re-anchored
|
||
/// even though the section's rows still exist.
|
||
#[test]
|
||
fn reanchor_moves_section_cursor_when_state_filter_hides_headers() {
|
||
let mut state = DashboardState::new();
|
||
state.focus_section(SectionKey::State(RowState::Working));
|
||
state.filter = Filter::State(RowState::Working);
|
||
let rows = vec![reanchor_test_row(1, RowState::Working)];
|
||
state.reanchor_selection(&rows);
|
||
assert!(
|
||
state.selected_section.is_none(),
|
||
"headers are suppressed under a state filter — the section cursor must clear",
|
||
);
|
||
assert!(state.new_agent_button_focused);
|
||
}
|
||
|
||
/// A section cursor whose header is still on screen is untouched.
|
||
#[test]
|
||
fn reanchor_keeps_live_section_cursor() {
|
||
let mut state = DashboardState::new();
|
||
state.focus_section(SectionKey::State(RowState::Working));
|
||
let rows = vec![reanchor_test_row(1, RowState::Working)];
|
||
state.reanchor_selection(&rows);
|
||
assert_eq!(
|
||
state.selected_section,
|
||
Some(SectionKey::State(RowState::Working)),
|
||
"a live section cursor must survive reanchoring",
|
||
);
|
||
assert!(!state.new_agent_button_focused);
|
||
}
|
||
|
||
/// A selected row that state churn migrated INTO a collapsed
|
||
/// section (still present in `rows`, but hidden by the collapse)
|
||
/// moves the cursor onto the section header that hid it — never an
|
||
/// invisible row with live footer hints / peek.
|
||
#[test]
|
||
fn reanchor_moves_collapse_hidden_row_cursor_to_its_header() {
|
||
let mut state = DashboardState::new();
|
||
state.set_section_collapsed(SectionKey::State(RowState::Working), true);
|
||
// The row was selected while (say) Idle; it has since started
|
||
// Working — and "Working" is collapsed.
|
||
state.focus_row(DashboardRowId::TopLevel(AgentId(1)));
|
||
let rows = vec![reanchor_test_row(1, RowState::Working)];
|
||
state.reanchor_selection(&rows);
|
||
assert!(
|
||
state.selected.is_none(),
|
||
"the hidden row must not keep the cursor",
|
||
);
|
||
assert_eq!(
|
||
state.selected_section,
|
||
Some(SectionKey::State(RowState::Working)),
|
||
"the cursor must land on the header that hides the row",
|
||
);
|
||
assert!(!state.new_agent_button_focused);
|
||
}
|
||
|
||
/// Same churn scenario for the Pinned block: a selected pinned row
|
||
/// hidden by a collapsed "Pinned" section re-anchors to that header.
|
||
#[test]
|
||
fn reanchor_moves_collapse_hidden_pinned_row_to_pinned_header() {
|
||
let mut state = DashboardState::new();
|
||
state.set_section_collapsed(SectionKey::Pinned, true);
|
||
state.focus_row(DashboardRowId::TopLevel(AgentId(1)));
|
||
let mut row = reanchor_test_row(1, RowState::Idle);
|
||
row.pinned = true;
|
||
state.reanchor_selection(&[row]);
|
||
assert!(state.selected.is_none());
|
||
assert_eq!(
|
||
state.selected_section,
|
||
Some(SectionKey::Pinned),
|
||
"a collapse-hidden pinned row must re-anchor to the Pinned header",
|
||
);
|
||
}
|
||
|
||
/// A subagent row hidden by its PARENT's collapsed section
|
||
/// re-anchors to the parent's state header (subagents render under
|
||
/// the parent's group).
|
||
#[test]
|
||
fn reanchor_moves_collapse_hidden_subagent_to_parent_header() {
|
||
let mut state = DashboardState::new();
|
||
state.set_section_collapsed(SectionKey::State(RowState::Working), true);
|
||
let child_id = DashboardRowId::Subagent {
|
||
parent: AgentId(1),
|
||
child_session_id: "child-1".into(),
|
||
};
|
||
state.focus_row(child_id.clone());
|
||
let parent = reanchor_test_row(1, RowState::Working);
|
||
let mut child = reanchor_test_row(2, RowState::Working);
|
||
child.id = child_id;
|
||
child.indent = 1;
|
||
state.reanchor_selection(&[parent, child]);
|
||
assert!(state.selected.is_none());
|
||
assert_eq!(
|
||
state.selected_section,
|
||
Some(SectionKey::State(RowState::Working)),
|
||
"a hidden subagent must re-anchor to its parent's header",
|
||
);
|
||
}
|
||
|
||
/// A selected row stays selected when a DIFFERENT section is
|
||
/// collapsed (its own section is still expanded).
|
||
#[test]
|
||
fn reanchor_keeps_row_cursor_when_other_section_collapsed() {
|
||
let mut state = DashboardState::new();
|
||
state.set_section_collapsed(SectionKey::State(RowState::Idle), true);
|
||
let sel = DashboardRowId::TopLevel(AgentId(1));
|
||
state.focus_row(sel.clone());
|
||
let rows = vec![
|
||
reanchor_test_row(1, RowState::Working),
|
||
reanchor_test_row(2, RowState::Idle),
|
||
];
|
||
state.reanchor_selection(&rows);
|
||
assert_eq!(
|
||
state.selected,
|
||
Some(sel),
|
||
"a visible row's cursor must survive reanchoring",
|
||
);
|
||
assert!(state.selected_section.is_none());
|
||
}
|
||
|
||
/// Under a `s:state` filter headers are suppressed, so collapse
|
||
/// never hides rows — a leftover collapsed flag must NOT steal the
|
||
/// row cursor (the row is visible).
|
||
#[test]
|
||
fn reanchor_keeps_row_cursor_under_state_filter_despite_collapsed_flag() {
|
||
let mut state = DashboardState::new();
|
||
state.set_section_collapsed(SectionKey::State(RowState::Working), true);
|
||
state.filter = Filter::State(RowState::Working);
|
||
let sel = DashboardRowId::TopLevel(AgentId(1));
|
||
state.focus_row(sel.clone());
|
||
let rows = vec![reanchor_test_row(1, RowState::Working)];
|
||
state.reanchor_selection(&rows);
|
||
assert_eq!(
|
||
state.selected,
|
||
Some(sel),
|
||
"headers (and thus collapse) are suppressed under a state filter — \
|
||
the visible row must keep the cursor",
|
||
);
|
||
assert!(state.selected_section.is_none());
|
||
}
|
||
|
||
/// Clicking anywhere on the dispatch input box focuses the input
|
||
/// (clears `list_focused`). This must hold in vim mode too — there
|
||
/// the overview owns the keyboard (j/k nav), so a mouse user who
|
||
/// Tabbed or vim-navigated into the list would otherwise be stuck
|
||
/// with no way to click back into the prompt.
|
||
#[test]
|
||
fn click_on_dispatch_box_focuses_input_in_both_modes() {
|
||
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
|
||
use ratatui::layout::Rect;
|
||
let box_rect = Rect {
|
||
x: 0,
|
||
y: 0,
|
||
width: 80,
|
||
height: 3,
|
||
};
|
||
let click = MouseEvent {
|
||
kind: MouseEventKind::Down(MouseButton::Left),
|
||
column: 10,
|
||
row: 1,
|
||
modifiers: crossterm::event::KeyModifiers::NONE,
|
||
};
|
||
for vim in [false, true] {
|
||
crate::appearance::cache::set_vim_mode(vim);
|
||
let mut state = DashboardState::new();
|
||
// Overview focused (as if via Tab / vim nav).
|
||
state.list_focused = true;
|
||
// Box rect as recorded by `render_dashboard`.
|
||
state.dispatch_rect = Some(box_rect);
|
||
let outcome = state.handle_mouse(&click);
|
||
let focused_input = !state.list_focused;
|
||
// Reset before asserting so a failure can't leak vim state
|
||
// into another test sharing this thread's cache.
|
||
crate::appearance::cache::set_vim_mode(false);
|
||
assert!(
|
||
matches!(outcome, InputOutcome::Changed),
|
||
"vim={vim}: click on dispatch box must report Changed, got {outcome:?}",
|
||
);
|
||
assert!(
|
||
focused_input,
|
||
"vim={vim}: click on dispatch box must focus the input (clear list_focused)",
|
||
);
|
||
}
|
||
}
|
||
|
||
/// A click that lands outside the dispatch box (with no row or
|
||
/// button underneath) does not steal focus into the input.
|
||
#[test]
|
||
fn click_outside_dispatch_box_leaves_focus() {
|
||
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
|
||
use ratatui::layout::Rect;
|
||
let mut state = DashboardState::new();
|
||
state.list_focused = true;
|
||
// Box occupies the first 3 rows; click well below it.
|
||
state.dispatch_rect = Some(Rect {
|
||
x: 0,
|
||
y: 0,
|
||
width: 80,
|
||
height: 3,
|
||
});
|
||
let click = MouseEvent {
|
||
kind: MouseEventKind::Down(MouseButton::Left),
|
||
column: 10,
|
||
row: 20,
|
||
modifiers: crossterm::event::KeyModifiers::NONE,
|
||
};
|
||
let outcome = state.handle_mouse(&click);
|
||
assert!(
|
||
matches!(outcome, InputOutcome::Unchanged),
|
||
"click below the dispatch box must be a no-op, got {outcome:?}",
|
||
);
|
||
assert!(
|
||
state.list_focused,
|
||
"click outside the dispatch box must not move focus to the input",
|
||
);
|
||
}
|
||
|
||
/// Registry walk — `Ctrl+\\` is reserved for dashboard
|
||
/// navigation. It's bound to `OpenDashboard` (global, `When::Always`) and
|
||
/// to `DashboardOverlayExit` (`When::DashboardOverlay`) — disjoint
|
||
/// contexts that both route to the dashboard (the overlay intercept maps
|
||
/// both to `DashboardOverlayExit`). No OTHER, unrelated action may claim
|
||
/// it. Notably it must NOT be the worktree toggle's key (that's Ctrl+W).
|
||
#[test]
|
||
fn ctrl_backslash_only_bound_to_dashboard_navigation() {
|
||
use crate::actions::ActionId;
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
let mut bound_to_ctrl_backslash: Vec<ActionId> = Vec::new();
|
||
for def in reg.all() {
|
||
let mut keys = vec![def.default_key];
|
||
keys.extend_from_slice(&def.alt_keys);
|
||
for k in keys {
|
||
let s = k.display().to_string();
|
||
if s.eq_ignore_ascii_case("Ctrl+\\") {
|
||
bound_to_ctrl_backslash.push(def.id);
|
||
}
|
||
}
|
||
}
|
||
bound_to_ctrl_backslash.sort_by_key(|id| format!("{id:?}"));
|
||
assert_eq!(
|
||
bound_to_ctrl_backslash,
|
||
vec![ActionId::DashboardOverlayExit, ActionId::OpenDashboard],
|
||
"Ctrl+\\ must be bound only to the two dashboard-navigation actions",
|
||
);
|
||
}
|
||
|
||
/// Direct unit test for `clamp_viewport`: when the
|
||
/// total row count shrinks below the current offset, the offset
|
||
/// is pulled in to keep `max_offset = total - viewport_h`.
|
||
#[test]
|
||
fn clamp_viewport_pulls_offset_when_rows_shrink() {
|
||
let mut s = DashboardState::new();
|
||
s.viewport_offset = 10;
|
||
// No selection, viewport=5, total=8 → max_offset = 3.
|
||
s.clamp_viewport(None, 5, 8);
|
||
assert_eq!(s.viewport_offset, 3);
|
||
}
|
||
|
||
/// Direct unit test for `clamp_viewport`: a selection
|
||
/// below the visible window snaps the offset down so the row is
|
||
/// visible at the bottom edge.
|
||
#[test]
|
||
fn clamp_viewport_snaps_to_selection() {
|
||
let mut s = DashboardState::new();
|
||
s.viewport_offset = 0;
|
||
// Selection at line 12, viewport=5, total=20.
|
||
// 12 >= offset + 5 (0 + 5) → offset = 12 + 1 - 5 = 8.
|
||
s.clamp_viewport(Some(12), 5, 20);
|
||
assert_eq!(s.viewport_offset, 8);
|
||
}
|
||
|
||
/// Selection above the window pulls the offset up to
|
||
/// keep the selection visible at the top edge.
|
||
#[test]
|
||
fn clamp_viewport_snaps_offset_up_when_selection_scrolls_above() {
|
||
let mut s = DashboardState::new();
|
||
s.viewport_offset = 10;
|
||
s.clamp_viewport(Some(2), 5, 20);
|
||
// sel_idx < offset → offset = sel_idx (2).
|
||
assert_eq!(s.viewport_offset, 2);
|
||
}
|
||
|
||
/// zero-height viewport: clamp returns 0 (no visible
|
||
/// rows means nothing to scroll to).
|
||
#[test]
|
||
fn clamp_viewport_handles_zero_viewport_height() {
|
||
let mut s = DashboardState::new();
|
||
s.viewport_offset = 5;
|
||
s.clamp_viewport(Some(3), 0, 10);
|
||
// viewport_h = 0 → no snap-to-selection; max_offset = total -
|
||
// 0 = 10, so offset stays at 5.
|
||
assert_eq!(s.viewport_offset, 5);
|
||
}
|
||
|
||
// -----------------------------------------------------------------
|
||
// Mouse wheel decoupled from selection
|
||
// -----------------------------------------------------------------
|
||
|
||
/// `handle_scroll` flags `manual_scroll_active` so the next
|
||
/// `clamp_viewport` knows to skip the snap-to-selection
|
||
/// pull-back.
|
||
#[test]
|
||
fn handle_scroll_sets_manual_scroll_active() {
|
||
let mut s = DashboardState::new();
|
||
assert!(!s.manual_scroll_active);
|
||
s.handle_scroll(3);
|
||
assert!(s.manual_scroll_active);
|
||
assert_eq!(s.viewport_offset, 3);
|
||
}
|
||
|
||
/// `handle_scroll(0)` is a no-op — neither the offset nor the
|
||
/// flag changes. Without this, a stray zero-line accumulator
|
||
/// flush would clobber the snap state.
|
||
#[test]
|
||
fn handle_scroll_zero_lines_is_noop() {
|
||
let mut s = DashboardState::new();
|
||
s.viewport_offset = 4;
|
||
s.handle_scroll(0);
|
||
assert!(!s.manual_scroll_active);
|
||
assert_eq!(s.viewport_offset, 4);
|
||
}
|
||
|
||
/// Negative scrolls (wheel up) move the offset toward the top
|
||
/// AND still flag the viewport as user-driven.
|
||
#[test]
|
||
fn handle_scroll_negative_moves_offset_up() {
|
||
let mut s = DashboardState::new();
|
||
s.viewport_offset = 10;
|
||
s.handle_scroll(-4);
|
||
assert!(s.manual_scroll_active);
|
||
assert_eq!(s.viewport_offset, 6);
|
||
}
|
||
|
||
/// Saturating arithmetic guards the upper-edge: scrolling up
|
||
/// when already at 0 stays at 0 (no underflow panic) and still
|
||
/// flips the manual-scroll flag.
|
||
#[test]
|
||
fn handle_scroll_saturates_at_zero() {
|
||
let mut s = DashboardState::new();
|
||
s.viewport_offset = 2;
|
||
s.handle_scroll(-99);
|
||
assert_eq!(s.viewport_offset, 0);
|
||
assert!(s.manual_scroll_active);
|
||
}
|
||
|
||
/// THE FIX: when the user has manually scrolled, the
|
||
/// snap-to-selection in `clamp_viewport` is skipped so the
|
||
/// viewport doesn't get yanked back to the selected row. Without
|
||
/// this skip, scrolling past the cursor was a no-op visually
|
||
/// (the renderer snapped it back next frame).
|
||
#[test]
|
||
fn clamp_viewport_skips_snap_when_manual_scroll_active() {
|
||
let mut s = DashboardState::new();
|
||
s.manual_scroll_active = true;
|
||
// viewport_h=5, total=50, selection at line 0 — without the
|
||
// skip, the snap would pull offset back to 0.
|
||
s.viewport_offset = 20;
|
||
s.clamp_viewport(Some(0), 5, 50);
|
||
assert_eq!(
|
||
s.viewport_offset, 20,
|
||
"manual_scroll_active must suppress the snap-to-selection pull-back",
|
||
);
|
||
}
|
||
|
||
/// The manual-scroll flag does NOT disable the bounds clamp —
|
||
/// scrolling past the bottom edge still stops at `max_offset`.
|
||
/// Otherwise wheel acceleration would let the user park the
|
||
/// viewport on an entirely empty band below the last row.
|
||
#[test]
|
||
fn clamp_viewport_still_clamps_max_offset_when_manual_scroll_active() {
|
||
let mut s = DashboardState::new();
|
||
s.manual_scroll_active = true;
|
||
s.viewport_offset = 100;
|
||
s.clamp_viewport(Some(0), 5, 20);
|
||
// max_offset = 20 - 5 = 15.
|
||
assert_eq!(s.viewport_offset, 15);
|
||
}
|
||
|
||
/// Clearing the manual-scroll flag re-engages the
|
||
/// snap-to-selection on the next clamp, restoring the keyboard-
|
||
/// nav contract.
|
||
#[test]
|
||
fn clear_manual_scroll_re_engages_snap_to_selection() {
|
||
let mut s = DashboardState::new();
|
||
s.manual_scroll_active = true;
|
||
s.viewport_offset = 20;
|
||
s.clear_manual_scroll();
|
||
assert!(!s.manual_scroll_active);
|
||
s.clamp_viewport(Some(0), 5, 50);
|
||
// With the flag cleared, the snap pulls the offset back so
|
||
// selection at line 0 is visible.
|
||
assert_eq!(s.viewport_offset, 0);
|
||
}
|
||
|
||
/// Env var force-disables.
|
||
///
|
||
/// Guard the env-var mutation with `serial_test`'s
|
||
/// per-key serial lock. The `KIGI_AGENT_DASHBOARD` key means this
|
||
/// test runs serially with any other test that decorates itself
|
||
/// with `#[serial_test::serial(KIGI_AGENT_DASHBOARD)]` — see the
|
||
/// `dispatch_open_dashboard`-calling tests in `app::dispatch`.
|
||
/// A function-local `Mutex` would only serialize
|
||
/// against itself; readers in other tests
|
||
/// could still observe the transient `0` value.
|
||
#[serial_test::serial(KIGI_AGENT_DASHBOARD)]
|
||
#[test]
|
||
fn env_var_force_disables() {
|
||
// SAFETY: the test temporarily mutates a process-wide env var.
|
||
// `serial_test`'s lock ensures no other test marked with the
|
||
// same `KIGI_AGENT_DASHBOARD` key reads it concurrently.
|
||
unsafe { std::env::set_var("KIGI_AGENT_DASHBOARD", "0") };
|
||
assert!(!super::super::dashboard_enabled());
|
||
unsafe { std::env::remove_var("KIGI_AGENT_DASHBOARD") };
|
||
}
|
||
|
||
// ── Location picker ─────────────────────────────────────────────
|
||
|
||
fn location_candidate(path: &str, label: &str) -> LocationCandidate {
|
||
LocationCandidate {
|
||
path: PathBuf::from(path),
|
||
label: label.to_string(),
|
||
detail: path.to_string(),
|
||
worktree: None,
|
||
}
|
||
}
|
||
|
||
/// Build a picker over `recents` with a fixed base cwd and no worktrees.
|
||
fn location_picker(recents: Vec<LocationCandidate>) -> LocationPickerState {
|
||
LocationPickerState::new(
|
||
recents,
|
||
PathBuf::from("/base"),
|
||
std::collections::HashMap::new(),
|
||
)
|
||
}
|
||
|
||
/// Build a picker with a worktree index for tagging suggestions.
|
||
fn location_picker_with_worktrees(
|
||
recents: Vec<LocationCandidate>,
|
||
worktrees: std::collections::HashMap<PathBuf, String>,
|
||
) -> LocationPickerState {
|
||
LocationPickerState::new(recents, PathBuf::from("/base"), worktrees)
|
||
}
|
||
|
||
fn visible_labels(lp: &LocationPickerState) -> Vec<String> {
|
||
lp.visible_candidates()
|
||
.into_iter()
|
||
.map(|c| c.label)
|
||
.collect()
|
||
}
|
||
|
||
#[test]
|
||
fn location_visible_empty_query_shows_all_recents() {
|
||
let lp = location_picker(vec![
|
||
location_candidate("/home/me/alpha", "alpha"),
|
||
location_candidate("/home/me/beta", "beta"),
|
||
]);
|
||
assert_eq!(visible_labels(&lp), vec!["alpha", "beta"]);
|
||
}
|
||
|
||
#[test]
|
||
fn location_visible_filters_recents_by_substring() {
|
||
let mut lp = location_picker(vec![
|
||
location_candidate("/home/me/alpha", "alpha"),
|
||
location_candidate("/home/me/beta", "beta"),
|
||
]);
|
||
lp.picker.query = "bet".to_string();
|
||
assert_eq!(visible_labels(&lp), vec!["beta"]);
|
||
}
|
||
|
||
#[test]
|
||
fn location_query_is_path_detection() {
|
||
let mut lp = location_picker(vec![]);
|
||
for q in ["/abs", "~/x", "rel/sub", "~"] {
|
||
lp.picker.query = q.to_string();
|
||
assert!(lp.query_is_path(), "`{q}` should be path mode");
|
||
}
|
||
for q in ["", "alpha", "bet"] {
|
||
lp.picker.query = q.to_string();
|
||
assert!(!lp.query_is_path(), "`{q}` should be recents mode");
|
||
}
|
||
}
|
||
|
||
/// Windows drive-prefix detection is platform-independent (the
|
||
/// `cfg!(windows)` gate on its use is exercised only on Windows, but the
|
||
/// predicate itself must be correct everywhere).
|
||
#[test]
|
||
fn windows_drive_prefix_detection() {
|
||
assert!(has_windows_drive_prefix("C:\\Users\\me"));
|
||
assert!(has_windows_drive_prefix("d:/projects"));
|
||
assert!(has_windows_drive_prefix("Z:"));
|
||
assert!(!has_windows_drive_prefix("/usr/local"));
|
||
assert!(!has_windows_drive_prefix("~/x"));
|
||
assert!(!has_windows_drive_prefix("C"));
|
||
assert!(!has_windows_drive_prefix("1:\\nope"));
|
||
}
|
||
|
||
#[test]
|
||
fn location_chosen_input_falls_back_to_typed_path() {
|
||
let mut lp = location_picker(vec![location_candidate("/home/me/alpha", "alpha")]);
|
||
// A path with no matching suggestion → the raw typed path is used.
|
||
lp.picker.query = "/no/such/dir".to_string();
|
||
assert_eq!(lp.chosen_input().as_deref(), Some("/no/such/dir"));
|
||
}
|
||
|
||
#[test]
|
||
fn location_chosen_input_uses_selected_recent() {
|
||
let mut lp = location_picker(vec![
|
||
location_candidate("/home/me/alpha", "alpha"),
|
||
location_candidate("/home/me/beta", "beta"),
|
||
]);
|
||
lp.picker.selected = 1;
|
||
assert_eq!(lp.chosen_input().as_deref(), Some("/home/me/beta"));
|
||
}
|
||
|
||
#[test]
|
||
fn location_path_completion_lists_filters_and_hides_dotdirs() {
|
||
let tmp = tempfile::tempdir().unwrap();
|
||
std::fs::create_dir(tmp.path().join("alpha")).unwrap();
|
||
std::fs::create_dir(tmp.path().join("beta")).unwrap();
|
||
std::fs::create_dir(tmp.path().join(".hidden")).unwrap();
|
||
let mut lp = location_picker(vec![]);
|
||
|
||
// Trailing slash → list the (non-hidden) subdirs.
|
||
lp.picker.query = format!("{}/", tmp.path().display());
|
||
lp.refresh_suggestions();
|
||
let labels = visible_labels(&lp);
|
||
assert!(labels.contains(&"alpha".to_string()), "got: {labels:?}");
|
||
assert!(labels.contains(&"beta".to_string()), "got: {labels:?}");
|
||
assert!(
|
||
!labels.contains(&".hidden".to_string()),
|
||
"dotdirs hidden unless the partial starts with `.`, got: {labels:?}",
|
||
);
|
||
|
||
// Prefix filter on the final segment.
|
||
lp.picker.query = format!("{}/al", tmp.path().display());
|
||
lp.refresh_suggestions();
|
||
assert_eq!(visible_labels(&lp), vec!["alpha"]);
|
||
|
||
// A leading dot in the partial reveals dot-directories.
|
||
lp.picker.query = format!("{}/.h", tmp.path().display());
|
||
lp.refresh_suggestions();
|
||
assert_eq!(visible_labels(&lp), vec![".hidden"]);
|
||
}
|
||
|
||
#[test]
|
||
fn location_path_completion_tags_worktree_subdirs() {
|
||
let tmp = tempfile::tempdir().unwrap();
|
||
std::fs::create_dir(tmp.path().join("wt")).unwrap();
|
||
std::fs::create_dir(tmp.path().join("plain")).unwrap();
|
||
// Index `wt` as a managed worktree (keys are canonical paths).
|
||
let canon = dunce::canonicalize(tmp.path()).unwrap_or_else(|_| tmp.path().to_path_buf());
|
||
let mut worktrees = std::collections::HashMap::new();
|
||
worktrees.insert(canon.join("wt"), "my-feature".to_string());
|
||
|
||
let mut lp = location_picker_with_worktrees(vec![], worktrees);
|
||
lp.picker.query = format!("{}/", tmp.path().display());
|
||
lp.refresh_suggestions();
|
||
|
||
let visible = lp.visible_candidates();
|
||
let wt = visible.iter().find(|c| c.label == "wt").expect("wt listed");
|
||
assert_eq!(wt.worktree.as_deref(), Some("my-feature"));
|
||
let plain = visible
|
||
.iter()
|
||
.find(|c| c.label == "plain")
|
||
.expect("plain listed");
|
||
assert_eq!(plain.worktree, None);
|
||
}
|
||
|
||
/// A worktree directory that is itself a symlink still gets tagged:
|
||
/// the index key is the canonical (real) path, so `read_subdirs` must
|
||
/// canonicalize the entry (resolving the symlink), not just join the
|
||
/// name to the canonical parent.
|
||
#[cfg(unix)]
|
||
#[test]
|
||
fn location_path_completion_tags_symlinked_worktree() {
|
||
let real = tempfile::tempdir().unwrap(); // the real worktree target
|
||
let parent = tempfile::tempdir().unwrap(); // the dir we list
|
||
std::os::unix::fs::symlink(real.path(), parent.path().join("link")).unwrap();
|
||
|
||
// Index keyed by the real (canonical) path, as the worktree DB is.
|
||
let real_canon = dunce::canonicalize(real.path()).unwrap();
|
||
let mut worktrees = std::collections::HashMap::new();
|
||
worktrees.insert(real_canon, "linked-wt".to_string());
|
||
|
||
let mut lp = location_picker_with_worktrees(vec![], worktrees);
|
||
lp.picker.query = format!("{}/", parent.path().display());
|
||
lp.refresh_suggestions();
|
||
|
||
let link = lp
|
||
.visible_candidates()
|
||
.into_iter()
|
||
.find(|c| c.label == "link")
|
||
.expect("symlinked dir listed");
|
||
assert_eq!(link.worktree.as_deref(), Some("linked-wt"));
|
||
}
|
||
|
||
#[test]
|
||
fn click_on_location_label_opens_picker() {
|
||
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
|
||
let mut state = DashboardState::new();
|
||
state.location_hit.set(Some(Rect {
|
||
x: 0,
|
||
y: 0,
|
||
width: 20,
|
||
height: 1,
|
||
}));
|
||
let click = MouseEvent {
|
||
kind: MouseEventKind::Down(MouseButton::Left),
|
||
column: 3,
|
||
row: 0,
|
||
modifiers: KeyModifiers::NONE,
|
||
};
|
||
assert!(matches!(
|
||
state.handle_mouse(&click),
|
||
InputOutcome::Action(Action::DashboardOpenLocationPicker)
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn ctrl_l_opens_location_picker() {
|
||
let mut state = DashboardState::new();
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
let key = KeyEvent::new(KeyCode::Char('l'), KeyModifiers::CONTROL);
|
||
assert!(matches!(
|
||
state.handle_input(&Event::Key(key), ®),
|
||
InputOutcome::Action(Action::DashboardOpenLocationPicker)
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn location_picker_esc_closes() {
|
||
// Pin vim-mode off; this test asserts the non-vim picker path.
|
||
crate::appearance::cache::set_vim_mode(false);
|
||
let mut state = DashboardState::new();
|
||
state.location_picker = Some(location_picker(vec![location_candidate("/tmp", "tmp")]));
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
let esc = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
|
||
assert!(matches!(
|
||
state.handle_input(&Event::Key(esc), ®),
|
||
InputOutcome::Action(Action::DashboardCloseLocationPicker)
|
||
));
|
||
}
|
||
|
||
/// Editing the path clears a stale "Not a directory" error so it
|
||
/// doesn't linger next to a corrected (possibly valid) input.
|
||
#[test]
|
||
fn location_picker_edit_clears_error() {
|
||
let mut state = DashboardState::new();
|
||
let mut lp = location_picker(vec![]);
|
||
lp.error = Some("Not a directory: /bad".to_string());
|
||
state.location_picker = Some(lp);
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
|
||
// Typing a character edits the query → the error is dropped.
|
||
let key = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE);
|
||
let _ = state.handle_input(&Event::Key(key), ®);
|
||
assert!(
|
||
state.location_picker.as_ref().unwrap().error.is_none(),
|
||
"editing the path must clear the stale error",
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn location_picker_enter_selects_recent() {
|
||
let mut state = DashboardState::new();
|
||
state.location_picker = Some(location_picker(vec![
|
||
location_candidate("/home/me/alpha", "alpha"),
|
||
location_candidate("/home/me/beta", "beta"),
|
||
]));
|
||
state.location_picker.as_mut().unwrap().picker.selected = 1;
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
|
||
match state.handle_input(&Event::Key(enter), ®) {
|
||
InputOutcome::Action(Action::DashboardChangeLocation { input }) => {
|
||
assert_eq!(input, "/home/me/beta");
|
||
}
|
||
other => panic!("expected DashboardChangeLocation, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn location_picker_tab_fills_selected_path() {
|
||
let mut state = DashboardState::new();
|
||
state.location_picker = Some(location_picker(vec![
|
||
// Paths outside $HOME so `display_path` leaves them absolute,
|
||
// keeping the assertion independent of the test machine's home.
|
||
location_candidate("/opt/projects/alpha", "alpha"),
|
||
location_candidate("/opt/projects/beta", "beta"),
|
||
]));
|
||
state.location_picker.as_mut().unwrap().picker.selected = 1;
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
let tab = KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE);
|
||
assert!(matches!(
|
||
state.handle_input(&Event::Key(tab), ®),
|
||
InputOutcome::Changed
|
||
));
|
||
let lp = state.location_picker.as_ref().unwrap();
|
||
assert_eq!(lp.picker.query, "/opt/projects/beta/");
|
||
assert_eq!(lp.picker.query_cursor, lp.picker.query.len());
|
||
}
|
||
|
||
#[test]
|
||
fn location_path_completion_enter_uses_selected_subdir() {
|
||
let tmp = tempfile::tempdir().unwrap();
|
||
std::fs::create_dir(tmp.path().join("alpha")).unwrap();
|
||
let mut lp = location_picker(vec![]);
|
||
lp.picker.query = format!("{}/al", tmp.path().display());
|
||
lp.refresh_suggestions();
|
||
|
||
let mut state = DashboardState::new();
|
||
state.location_picker = Some(lp);
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
|
||
match state.handle_input(&Event::Key(enter), ®) {
|
||
InputOutcome::Action(Action::DashboardChangeLocation { input }) => {
|
||
let expected = tmp.path().join("alpha").to_string_lossy().into_owned();
|
||
assert_eq!(input, expected);
|
||
}
|
||
other => panic!("expected DashboardChangeLocation, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn location_picker_typed_path_no_match_uses_raw_query() {
|
||
let mut state = DashboardState::new();
|
||
state.location_picker = Some(location_picker(vec![location_candidate(
|
||
"/home/me/alpha",
|
||
"alpha",
|
||
)]));
|
||
let reg = crate::actions::ActionRegistry::defaults();
|
||
// A guaranteed-absent home path → no suggestion → the raw query is used.
|
||
for c in "~/__nope_zzz__".chars() {
|
||
let key = KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE);
|
||
let _ = state.handle_input(&Event::Key(key), ®);
|
||
}
|
||
let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
|
||
match state.handle_input(&Event::Key(enter), ®) {
|
||
InputOutcome::Action(Action::DashboardChangeLocation { input }) => {
|
||
assert_eq!(input, "~/__nope_zzz__");
|
||
}
|
||
other => panic!("expected DashboardChangeLocation, got {other:?}"),
|
||
}
|
||
}
|
||
}
|