Sampler / inference (PRD F3):
- kimi_compat.rs: single adaptation point for the Kimi chat/completions
dialect (thinking-field mapping, model_id stripping, empty-content
tool-call message fix, stream_options.include_usage), with kimi-cli
source citations
- Rate-limit handling reworked for Kimi/Moonshot semantics; UA kigi/{version}
- /models replaces the xAI models-v2 endpoint everywhere; idle model
refresh carries X-Msh-* device headers only (X-XAI-Token-Auth and
x-grok-client-mode/CLIENT_MODE_HEADER machinery deleted)
Cloud-surface excision (PRD §5, zero-egress):
- remote/ conversations lane, cli-chat-proxy-types crate, prod/ dir,
share command, credit bar: deleted (single local session lane;
paginate() replaces merge_and_paginate)
- Subscription/tier gate stack deleted end-to-end: AppView
gate/tier/team/ZDR fields, app/subscription.rs watch loop,
dispatch/billing.rs paywall + SuperGrok upsell, free-usage-exhausted
chain, tier-restricted commands, GateInfo, RemoteSettings gate fields,
SettingsUpdateNotification gate fields
- /privacy + coding-data-sharing setting deleted (backed by a dead xAI
RPC; Kigi is zero-egress — nothing to share or retain remotely)
Auth UX correctness (user-reported):
- Device-flow fixtures now mirror the live Kimi payload shape
(https://www.kimi.com/code/authorize_device?user_code=..., verified
against auth.kimi.com); the fabricated auth.kimi.com/device?code=...
URLs are gone
- open_browser_detached is a no-op under cfg(test): unit tests drove
wiremock fixture URLs into the real browser (root cause of the
"garbage mock link" ABCD-1234 tabs)
- Welcome/pager-minimal rebrand: Grok Build -> Kigi, grok.com ->
kimi.com, "Sign in to Grok" -> "Sign in to Kimi"
2244 lines
94 KiB
Rust
2244 lines
94 KiB
Rust
//! Dashboard dispatchers: attach, overlays, rows, renames, and permissions.
|
||
|
||
use super::ctx::{show_welcome, surface_yolo_launch_block_notice};
|
||
use super::modes::{dispatch_cycle_mode_and_sync, set_yolo_mode, yolo_enable_blocked};
|
||
use super::permissions::resolve_permission_queue_transition;
|
||
use super::queue::maybe_drain_queue;
|
||
use super::router::dispatch;
|
||
use super::session::lifecycle::{
|
||
dispatch_new_session_inner_with_id, dispatch_new_worktree_session,
|
||
};
|
||
use super::session::load::dispatch_load_session;
|
||
use super::session::load::focus_if_session_already_open;
|
||
use super::session::modal::dispatch_sessions_confirm_close;
|
||
use super::turn::dispatch_cancel_turn;
|
||
use crate::app::actions::{Action, Effect};
|
||
use crate::app::agent::AgentId;
|
||
use crate::app::agent_view::AgentView;
|
||
use crate::app::app_view::{ActiveView, AppView, TrustState};
|
||
use agent_client_protocol as acp;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Agent Dashboard dispatchers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Build a `DashboardState` from the persisted layout (pins / reorder /
|
||
/// grouping), loading + caching `app.dashboard_persisted` on first use. Used
|
||
/// both to materialize the real dashboard and to compute a correct cycle order
|
||
/// before the dashboard has been opened.
|
||
fn dashboard_state_from_persisted(app: &mut AppView) -> crate::views::dashboard::DashboardState {
|
||
use crate::views::dashboard::{DashboardState, load_persisted};
|
||
if app.dashboard_persisted.is_none() {
|
||
app.dashboard_persisted = load_persisted();
|
||
}
|
||
let persisted = app
|
||
.dashboard_persisted
|
||
.clone()
|
||
.unwrap_or_else(crate::views::dashboard::PersistedDashboard::defaults);
|
||
let resolver = crate::views::dashboard::SessionIdResolver::from_agents(&app.agents);
|
||
DashboardState::from_persisted(&persisted, &resolver)
|
||
}
|
||
|
||
pub(super) fn ensure_dashboard_state(app: &mut AppView) {
|
||
if app.dashboard.is_some() {
|
||
return;
|
||
}
|
||
let mut state = dashboard_state_from_persisted(app);
|
||
state.gc_stale_refs(&dashboard_alive_fn(&app.agents));
|
||
state.adopt_slash_mru(app.slash_mru.clone());
|
||
state.set_screen_mode(app.screen_mode);
|
||
state.set_recap_visible(app.session_recap_available);
|
||
state.set_restricted_commands(&[]);
|
||
app.dashboard = Some(state);
|
||
}
|
||
|
||
/// Configure the dashboard for display: snapshot app-wide state (cwd, models,
|
||
/// plugins, `default_yolo`) and reset per-session staging. Shared by
|
||
/// `dispatch_open_dashboard` and the overlay-cycle path; no-op if unallocated.
|
||
fn configure_dashboard_state(app: &mut AppView) {
|
||
let bootstrap_commands = app.bootstrap_acp_commands.clone();
|
||
let models = app.models.clone();
|
||
let disable_plugins = app.appearance.disable_plugins;
|
||
let default_yolo = app.default_yolo;
|
||
let cwd = app.cwd.clone();
|
||
let cwd_has_git_ancestor = app.cwd_has_git_ancestor;
|
||
let has_agents = !app.agents.is_empty();
|
||
if let Some(d) = app.dashboard.as_mut() {
|
||
d.close_popup();
|
||
d.location_picker = None;
|
||
d.cwd = cwd.clone();
|
||
d.cwd_has_git_ancestor = cwd_has_git_ancestor;
|
||
d.dispatch_worktree = false;
|
||
d.worktree_dialog = None;
|
||
d.pending_worktree_prompt = None;
|
||
d.pending_worktree_attach = false;
|
||
d.focus_new_agent_button();
|
||
d.list_focused = has_agents;
|
||
d.dispatch.file_search.retarget(&cwd);
|
||
// Tool gating disabled (None): the dashboard has no agent toolset.
|
||
d.dispatch
|
||
.slash_controller
|
||
.registry_mut()
|
||
.set_plugins_visible(!disable_plugins);
|
||
d.dispatch
|
||
.sync_acp_commands(&bootstrap_commands, None, &models);
|
||
d.models = models;
|
||
d.pending_model = None;
|
||
d.pending_mode = if default_yolo {
|
||
crate::views::dashboard::DashboardDispatchMode::AlwaysApprove
|
||
} else {
|
||
crate::views::dashboard::DashboardDispatchMode::Normal
|
||
};
|
||
}
|
||
}
|
||
|
||
/// Open the dashboard view. Respects the
|
||
/// [`crate::views::dashboard::dashboard_enabled`] feature flag (env var
|
||
/// override + persisted setting). The dashboard is independent of leader
|
||
/// mode: it renders local sessions from `app.agents` and, when connected
|
||
/// via a leader, additionally polls the leader roster (see the roster-poll
|
||
/// gate in the event loop).
|
||
pub(super) fn dispatch_open_dashboard(app: &mut AppView) -> Vec<Effect> {
|
||
use crate::views::dashboard::dashboard_enabled;
|
||
|
||
if !dashboard_enabled() {
|
||
app.show_toast("Agent dashboard is disabled in this configuration");
|
||
return vec![];
|
||
}
|
||
// Gate behind auth. Until login completes, the
|
||
// backend rejects new sessions; activating the dashboard view
|
||
// visually dismisses the auth UI. Toast and stay put.
|
||
if !matches!(app.auth_state, crate::app::app_view::AuthState::Done) {
|
||
app.show_toast("Sign in to open the dashboard");
|
||
return vec![];
|
||
}
|
||
// Same rationale for folder trust: opening the dashboard would visually
|
||
// dismiss the trust question with the folder still unanswered. Toast and
|
||
// stay put (mirrors the auth gate above) so the question is resolved first.
|
||
if matches!(app.trust_state, TrustState::Pending { .. }) {
|
||
app.show_toast("Answer the folder-trust question to open the dashboard");
|
||
return vec![];
|
||
}
|
||
// Edge case 24: idempotent toggle — opening from the dashboard view
|
||
// itself just closes.
|
||
//
|
||
// Ctrl+\ is now a single-shot toggle between the
|
||
// agent view and the dashboard view. The previous design used
|
||
// Ctrl+\ as a 3-state cascade (open dashboard → close popup →
|
||
// exit dashboard) which fought the user's mental model of
|
||
// "Ctrl+\ flips views". With auto-attach landing a popup on every
|
||
// open, the close-popup step would have eaten the user's expected
|
||
// exit press. Now Ctrl+\ always exits when already in the
|
||
// dashboard; closing the popup-only stays bound to Esc inside the
|
||
// popup mouse/key cascade.
|
||
if matches!(app.active_view, ActiveView::AgentDashboard) {
|
||
return dispatch_exit_dashboard(app);
|
||
}
|
||
// Preserve in-memory state across reopen.
|
||
// `app.dashboard.is_some()` means we've previously initialised
|
||
// it; preserve the user's filter / dispatch text / hover /
|
||
// selection. Otherwise seed from persisted state.
|
||
if app.dashboard.is_none() {
|
||
ensure_dashboard_state(app);
|
||
} else if let Some(d) = app.dashboard.as_mut() {
|
||
// Subsequent reopen — just gc dead ids; in-memory state stays.
|
||
d.gc_stale_refs(&dashboard_alive_fn(&app.agents));
|
||
d.set_recap_visible(app.session_recap_available);
|
||
d.set_restricted_commands(&[]);
|
||
}
|
||
// Refresh each local agent's git context (branch / worktree / label)
|
||
// from disk so the row subtitles show the LATEST branch and worktree
|
||
// name, not a value that may have gone stale since the last
|
||
// `git_head_changed` notification. Runs on open (a deliberate user
|
||
// action), never per frame; `compute_cwd_git_info` returns `None` for
|
||
// non-repo cwds so existing values are left intact rather than cleared.
|
||
let agent_cwds: Vec<(AgentId, std::path::PathBuf)> = app
|
||
.agents
|
||
.iter()
|
||
.map(|(id, a)| (*id, a.session.cwd.clone()))
|
||
.collect();
|
||
for (id, cwd) in agent_cwds {
|
||
if let Some(info) = crate::git_info::compute_cwd_git_info(&cwd)
|
||
&& let Some(agent) = app.agents.get_mut(&id)
|
||
{
|
||
agent.current_branch = info.branch;
|
||
agent.is_worktree = info.is_worktree;
|
||
agent.main_repo = info.main_repo;
|
||
agent.worktree_label = info.worktree_label;
|
||
}
|
||
}
|
||
// The previous "auto-attach popup overlay" path
|
||
// showed BOTH the dashboard (as a top banner) AND the focused
|
||
// agent (as a bottom popup) on every `/dashboard` open. The
|
||
// stacked layout was confusing — the user couldn't tell which
|
||
// view owned the prompt, and the popup's keybindings (Enter
|
||
// to send, etc.) had subtle input-routing bugs. Dashboard
|
||
// open now shows ONLY the dashboard; pressing Enter on a row
|
||
// switches the whole view to the agent's fullscreen view
|
||
// (handled in `dispatch_dashboard_attach`).
|
||
//
|
||
// Always open in NEW-SESSION mode: focus the `[+ New Agent]` button
|
||
// (no row selected) so typing a prompt + Enter dispatches a brand
|
||
// new agent. Reply mode is opt-in — the user navigates (↑/↓ or j/k)
|
||
// or clicks a row to select it, which arms "reply to that agent".
|
||
//
|
||
// Previously the dashboard pre-seeded `selected` to the agent the
|
||
// user came from, which silently armed reply mode: a prompt typed
|
||
// right after opening went to the old agent instead of spawning a
|
||
// new one, AND the reply path never clears `selected`, so EVERY
|
||
// subsequent dispatch kept replying to the same agent — the user
|
||
// got "stuck to the same agent" and couldn't quickly dispatch new
|
||
// sessions. New-session is the dashboard's primary gesture, so it is
|
||
// the default; reply stays one explicit selection away.
|
||
//
|
||
configure_dashboard_state(app);
|
||
app.active_view = ActiveView::AgentDashboard;
|
||
// Outside leader mode there is no live leader roster to poll, so the
|
||
// dashboard would only show this process's in-memory agents. Seed it with
|
||
// the local on-disk session list (dormant/idle sessions) so it isn't empty;
|
||
// the event loop keeps it fresh on the roster-poll timer while open.
|
||
if !app.leader_mode {
|
||
app.dashboard_sessions_loading = true;
|
||
return vec![Effect::FetchDashboardSessions];
|
||
}
|
||
vec![]
|
||
}
|
||
|
||
/// Helper: produce a closure that answers "does this DashboardRowId
|
||
/// still exist in `agents`?". Static lifetime not possible (closures
|
||
/// borrow), so callers pass `&app.agents`.
|
||
fn dashboard_alive_fn(
|
||
agents: &indexmap::IndexMap<AgentId, AgentView>,
|
||
) -> impl Fn(&crate::views::dashboard::DashboardRowId) -> bool + '_ {
|
||
move |id| match id {
|
||
crate::views::dashboard::DashboardRowId::TopLevel(a) => agents.contains_key(a),
|
||
crate::views::dashboard::DashboardRowId::Subagent {
|
||
parent,
|
||
child_session_id,
|
||
} => agents
|
||
.get(parent)
|
||
.is_some_and(|a| a.subagent_sessions.contains_key(child_session_id)),
|
||
// Roster-only rows are not locally hosted; they aren't tracked by
|
||
// `agents` and are never persisted, so treat them as not alive for
|
||
// pinned/reorder GC purposes.
|
||
crate::views::dashboard::DashboardRowId::Roster { .. } => false,
|
||
}
|
||
}
|
||
|
||
pub(super) fn dispatch_exit_dashboard(app: &mut AppView) -> Vec<Effect> {
|
||
// Also clear any popup attachment so a fresh
|
||
// reopen lands on the row list, not on a stale popup
|
||
// (`close_popup()` atomically clears the hit
|
||
// rects too.)
|
||
if let Some(d) = app.dashboard.as_mut() {
|
||
d.close_popup();
|
||
}
|
||
// Return to either Welcome or the most recently active agent.
|
||
if let Some(id) = app.agents.keys().next().copied() {
|
||
app.active_view = ActiveView::Agent(id);
|
||
surface_yolo_launch_block_notice(app, id);
|
||
} else {
|
||
show_welcome(app);
|
||
}
|
||
vec![]
|
||
}
|
||
|
||
pub(super) fn dispatch_dashboard_attach(
|
||
app: &mut AppView,
|
||
id: crate::views::dashboard::DashboardRowId,
|
||
) -> Vec<Effect> {
|
||
use crate::views::dashboard::DashboardRowId;
|
||
// Attach is a fullscreen view switch AND signals
|
||
// "session-overlay mode" via `attached_agent`. The agent view
|
||
// takes the full screen and the renderer wraps it in a bordered
|
||
// frame with `[Prev] [Next] [✗]` affordances at the top right
|
||
// (mirrors the subagent fullscreen takeover). Input goes
|
||
// straight to the agent because `active_view = Agent(id)`, so
|
||
// Enter/Shift+Tab/etc. all work as in any regular agent view.
|
||
//
|
||
// Attaching re-targets the overlay — an overlay stop-confirm armed
|
||
// on a previously attached agent (legacy popup row-click path
|
||
// reaches here without a key press) must not follow the user in.
|
||
clear_pending_overlay_stop(app);
|
||
match id {
|
||
DashboardRowId::TopLevel(agent_id) => {
|
||
if !app.agents.contains_key(&agent_id) {
|
||
if let Some(d) = app.dashboard.as_mut() {
|
||
d.set_error_toast("Session no longer exists");
|
||
}
|
||
return vec![];
|
||
}
|
||
if let Some(agent) = app.agents.get_mut(&agent_id) {
|
||
// Drop any prior subagent takeover so the agent
|
||
// view paints the parent, not whatever subagent
|
||
// the user last opened fullscreen.
|
||
agent.active_subagent = None;
|
||
}
|
||
if let Some(d) = app.dashboard.as_mut() {
|
||
// `focus_row` (not a bare `selected` assignment) so the
|
||
// section cursor / button focus are cleared — exactly one
|
||
// cursor target stays active.
|
||
d.focus_row(DashboardRowId::TopLevel(agent_id));
|
||
// Signal session-overlay mode: render the agent
|
||
// wrapped in the bordered frame with cycle/close
|
||
// affordances at the top right.
|
||
d.attached_agent = Some(agent_id);
|
||
}
|
||
app.active_view = ActiveView::Agent(agent_id);
|
||
surface_yolo_launch_block_notice(app, agent_id);
|
||
}
|
||
DashboardRowId::Subagent {
|
||
parent,
|
||
child_session_id,
|
||
} => {
|
||
// If the subagent has finished/disappeared
|
||
// between row build and attach, toast and stay put rather
|
||
// than silently switching to the parent.
|
||
let alive = app
|
||
.agents
|
||
.get(&parent)
|
||
.is_some_and(|a| a.subagent_sessions.contains_key(&child_session_id));
|
||
if !alive {
|
||
if let Some(d) = app.dashboard.as_mut() {
|
||
d.set_error_toast("Subagent no longer running");
|
||
}
|
||
return vec![];
|
||
}
|
||
if let Some(agent) = app.agents.get_mut(&parent)
|
||
&& agent.subagent_views.contains_key(&child_session_id)
|
||
{
|
||
// Mirror `open_subagent_fullscreen`: load the resume-deferred
|
||
// child transcript on demand so the dashboard shows full history.
|
||
crate::app::subagent::ensure_subagent_child_replayed(agent, &child_session_id);
|
||
agent.active_subagent = Some(child_session_id.clone());
|
||
}
|
||
let row_id = DashboardRowId::Subagent {
|
||
parent,
|
||
child_session_id,
|
||
};
|
||
if let Some(d) = app.dashboard.as_mut() {
|
||
d.focus_row(row_id.clone());
|
||
d.attached_agent = Some(parent);
|
||
}
|
||
app.active_view = ActiveView::Agent(parent);
|
||
surface_yolo_launch_block_notice(app, parent);
|
||
}
|
||
DashboardRowId::Roster { session_id } => {
|
||
// A roster row is a leader-hosted session this client is not
|
||
// locally attached to. Attaching issues an ACP `session/load`,
|
||
// which the leader join-not-steal-subscribes: it adds this
|
||
// client to the session's subscriber set (replay + live
|
||
// broadcast) instead of stealing ownership. Mirror the session
|
||
// picker's strict resume path — inserting a
|
||
// leader-hosted, possibly cross-cwd session into THIS cwd's
|
||
// recent list would be wrong.
|
||
|
||
// Resolve cwd + origin first (also drives kind for focus-if-open).
|
||
let (session_cwd, conversation_entry) = app
|
||
.leader_roster
|
||
.iter()
|
||
.chain(app.dashboard_local_sessions.iter())
|
||
.find(|e| e.session_id == session_id)
|
||
.map(|e| {
|
||
let is_conversation = e.origin.kind == "conversation";
|
||
(
|
||
// Conversation rows have no cwd to re-home into.
|
||
(!is_conversation).then(|| std::path::PathBuf::from(&e.cwd)),
|
||
is_conversation,
|
||
)
|
||
})
|
||
.unwrap_or((None, false));
|
||
|
||
// Already local (e.g. double-click after the row converted): focus only.
|
||
if focus_if_session_already_open(app, session_id.as_str(), conversation_entry).is_some()
|
||
{
|
||
return vec![];
|
||
}
|
||
|
||
// Mirror the picker resume path: allocate a new local agent and
|
||
// emit `Effect::LoadSession` (strict load). `dispatch_load_session`
|
||
// already sets `app.active_view` to the new agent. Conversation-
|
||
// origin roster rows carry the conversation-entry bit so they take
|
||
// the direct chat load, never local/GCS resolution.
|
||
let effects = dispatch_load_session(app, session_id, session_cwd, conversation_entry);
|
||
if let Some(new_id) = effects.iter().find_map(|e| match e {
|
||
Effect::LoadSession { agent_id, .. } => Some(*agent_id),
|
||
_ => None,
|
||
}) && let Some(d) = app.dashboard.as_mut()
|
||
{
|
||
d.focus_row(DashboardRowId::TopLevel(new_id));
|
||
d.attached_agent = Some(new_id);
|
||
}
|
||
return effects;
|
||
}
|
||
}
|
||
vec![]
|
||
}
|
||
|
||
/// Exit the dashboard's session-overlay: dismiss the bordered
|
||
/// chrome and return to the dashboard view. Mirrors the popup
|
||
/// `[✗]` close from the older design but applied to the new
|
||
/// fullscreen-with-frame layout.
|
||
pub(super) fn dispatch_dashboard_overlay_exit(app: &mut AppView) -> Vec<Effect> {
|
||
if let Some(d) = app.dashboard.as_mut() {
|
||
d.close_popup();
|
||
}
|
||
// Leaving the overlay by mouse (`[Dashboard]` click) doesn't pass
|
||
// through the key-press disarm in `handle_input`, so an armed
|
||
// overlay stop-confirm would otherwise survive the exit and let a
|
||
// later Ctrl+X on the dashboard close a session without the
|
||
// two-press ritual. The confirm is bound to "this overlay, this
|
||
// agent" — clear it on the way out.
|
||
clear_pending_overlay_stop(app);
|
||
app.active_view = ActiveView::AgentDashboard;
|
||
vec![]
|
||
}
|
||
|
||
/// Disarm a pending overlay stop-confirm (see
|
||
/// [`dispatch_dashboard_overlay_stop`]). Called from every overlay
|
||
/// navigation that can happen WITHOUT a key press (mouse clicks on
|
||
/// `[Dashboard]` / `[‹]` / `[›]`); key presses already disarm via the
|
||
/// pending-action fast path in `AppView::handle_input`.
|
||
fn clear_pending_overlay_stop(app: &mut AppView) {
|
||
if app
|
||
.pending_action
|
||
.as_ref()
|
||
.is_some_and(|p| matches!(p.action, Action::DashboardOverlayStop))
|
||
{
|
||
app.pending_action = None;
|
||
}
|
||
}
|
||
|
||
/// Confirmed stop from inside the dashboard's session-overlay — the
|
||
/// second Ctrl+X press within the confirm window. Canonical state
|
||
/// machine for overlay Ctrl+X (the intercept in
|
||
/// `app_view::handle_input` and the `DashboardOverlayStop` def both
|
||
/// point here):
|
||
///
|
||
/// - First press, turn RUNNING → `Action::CancelTurn` (the agent
|
||
/// view's Ctrl+C behaviour: keep-subagents prompt, prompt rewind).
|
||
/// Never arms, so mashing Ctrl+X to stop a turn can't close the
|
||
/// session.
|
||
/// - First press, any other state (idle, command in flight, cancel
|
||
/// pending) → arms `AppView::pending_action` with the dashboard's
|
||
/// 2s `STOP_CONFIRM_WINDOW`; the shortcuts bar paints "press Ctrl+x
|
||
/// again to close this session". Cancel can't help in the non-idle
|
||
/// variants of this arm — `dispatch_cancel_turn` no-ops unless a
|
||
/// turn is running, and command cancellation isn't implemented (see
|
||
/// `handle_agent_action`'s `CancelTurn` TODO) — so the two-press
|
||
/// close is the only termination the user can reach, matching the
|
||
/// dashboard list's Ctrl+X which arms even while busy.
|
||
/// - Second press inside the window lands here via the pending-action
|
||
/// fast path; any other key disarms via that same path. A turn that
|
||
/// STARTED inside the window (queued prompt drained, user sent one)
|
||
/// downgrades the confirmed press to a cancel instead of closing
|
||
/// work in flight.
|
||
///
|
||
/// Mirrors `dispatch_dashboard_stop`'s second press, except the user
|
||
/// is INSIDE the session being closed: the view returns to the
|
||
/// dashboard instead of falling back to another agent.
|
||
pub(super) fn dispatch_dashboard_overlay_stop(app: &mut AppView) -> Vec<Effect> {
|
||
let Some(id) = app.dashboard.as_ref().and_then(|d| d.attached_agent) else {
|
||
return vec![];
|
||
};
|
||
if app
|
||
.agents
|
||
.get(&id)
|
||
.is_some_and(|a| a.session.state.is_turn_running())
|
||
{
|
||
return dispatch_cancel_turn(app);
|
||
}
|
||
// Land on the dashboard BEFORE closing: `dispatch_sessions_confirm_close`
|
||
// only re-targets the active view when the closed agent IS the
|
||
// active view, so switching first keeps it from picking a fallback
|
||
// agent. This also means a refused close (e.g. "Cannot close the
|
||
// only session") still returns to the dashboard — with the refusal
|
||
// toast surfaced there via `AppView::show_toast`.
|
||
// Pick the cursor's next home on the dashboard BEFORE the row
|
||
// vanishes, so returning to the list lands the selection on the
|
||
// neighbouring agent (down 1) instead of a stale cursor that
|
||
// bounces the next ↑/↓ back to the top.
|
||
let neighbor =
|
||
dashboard_neighbor_row(app, &crate::views::dashboard::DashboardRowId::TopLevel(id));
|
||
if let Some(d) = app.dashboard.as_mut() {
|
||
d.close_popup();
|
||
}
|
||
app.active_view = ActiveView::AgentDashboard;
|
||
let effects = dispatch_sessions_confirm_close(app, id);
|
||
if !app.agents.contains_key(&id)
|
||
&& let Some(d) = app.dashboard.as_mut()
|
||
{
|
||
// Move the cursor onto the neighbouring row, or the always-present
|
||
// `[+ New Agent]` button when none is left — both go through the
|
||
// focus helpers so the "exactly one cursor active" invariant holds
|
||
// (a bare `selected = None` would leave no cursor and drop the footer
|
||
// into its defensive fallback).
|
||
match neighbor {
|
||
Some(n) => d.focus_row(n),
|
||
None => d.focus_new_agent_button(),
|
||
}
|
||
// Success feedback — the close path itself is silent. Mirrors the
|
||
// "Session closed" toast the render path shows when an attached
|
||
// agent disappears externally (`app_view.rs`, AgentDashboard arm).
|
||
// Don't clobber a refusal toast planted by the close path above.
|
||
if d.error_toast.is_none() {
|
||
d.error_toast = Some(format!("{} Session closed", crate::glyphs::check_mark()));
|
||
}
|
||
}
|
||
effects
|
||
}
|
||
|
||
/// Toggle worktree-dispatch mode for the dashboard (bound to Ctrl+W).
|
||
///
|
||
/// When armed, the next agent dispatched from the dashboard spawns in a fresh
|
||
/// git worktree and the `[+ New Agent]` button reads `[+ New Worktree]`.
|
||
/// Worktrees require a git repo, so outside one the toggle is a no-op and we
|
||
/// surface an explanatory toast instead of silently doing nothing (and the
|
||
/// dashboard is never left in worktree mode in a non-git directory).
|
||
pub(super) fn dispatch_dashboard_toggle_worktree(app: &mut AppView) -> Vec<Effect> {
|
||
let has_git = app.cwd_has_git_ancestor;
|
||
if let Some(d) = app.dashboard.as_mut() {
|
||
if has_git {
|
||
d.dispatch_worktree = !d.dispatch_worktree;
|
||
} else {
|
||
d.dispatch_worktree = false;
|
||
d.set_error_toast("Not a git repository — worktrees need one");
|
||
}
|
||
}
|
||
vec![]
|
||
}
|
||
|
||
/// Toggle auto-approve (YOLO mode) on the selected dashboard
|
||
/// row's owning agent. Subagents inherit their parent's mode, so
|
||
/// a subagent selection routes to the parent.
|
||
///
|
||
/// Reuses `set_yolo_mode` (which reads `active_view` to target
|
||
/// the agent) by temporarily switching the active view to the
|
||
/// selected agent for the duration of the call — keeps all the
|
||
/// existing drain / persist / toast logic in a single code path
|
||
/// instead of duplicating it.
|
||
pub(super) fn dispatch_dashboard_toggle_auto_approve(app: &mut AppView) -> Vec<Effect> {
|
||
use crate::views::dashboard::DashboardRowId;
|
||
|
||
let Some(d) = app.dashboard.as_ref() else {
|
||
return vec![];
|
||
};
|
||
let Some(selected) = d.selected.as_ref() else {
|
||
if let Some(d) = app.dashboard.as_mut() {
|
||
d.set_error_toast("Select a session first");
|
||
}
|
||
return vec![];
|
||
};
|
||
let agent_id = match selected {
|
||
DashboardRowId::TopLevel(id) => *id,
|
||
DashboardRowId::Subagent { parent, .. } => *parent,
|
||
DashboardRowId::Roster { .. } => return vec![],
|
||
};
|
||
if !app.agents.contains_key(&agent_id) {
|
||
if let Some(d) = app.dashboard.as_mut() {
|
||
d.set_error_toast("Session no longer exists");
|
||
}
|
||
return vec![];
|
||
}
|
||
let agent = app.agents.get(&agent_id).expect("checked above");
|
||
let new = !agent.session.yolo_mode;
|
||
|
||
// Managed policy pins always-approve off — toast on the dashboard's own
|
||
// error slot (the inner gate's agent toast is invisible from here).
|
||
if let Some(warning) = yolo_enable_blocked(app, new) {
|
||
if let Some(d) = app.dashboard.as_mut() {
|
||
d.set_error_toast(warning);
|
||
}
|
||
return vec![];
|
||
}
|
||
|
||
// Temporarily borrow active_view so `set_yolo_mode` targets
|
||
// the dashboard's selected agent rather than whichever view
|
||
// is currently active. Restored before returning.
|
||
let saved_view = app.active_view;
|
||
app.active_view = ActiveView::Agent(agent_id);
|
||
let effects = set_yolo_mode(app, new);
|
||
app.active_view = saved_view;
|
||
effects
|
||
}
|
||
|
||
fn snapshot_prompt_widget(
|
||
prompt: &mut crate::views::prompt_widget::PromptWidget,
|
||
text: String,
|
||
) -> crate::views::prompt_widget::StashedPrompt {
|
||
if prompt.text() == text
|
||
|| !prompt.images.is_empty()
|
||
|| !prompt.textarea().elements().is_empty()
|
||
{
|
||
prompt.stash().with_transformed_text(text)
|
||
} else {
|
||
crate::views::prompt_widget::StashedPrompt::from_submission(text, Vec::new(), Vec::new())
|
||
}
|
||
}
|
||
|
||
/// Open the worktree-label dialog and stash the dispatch prompt until confirm.
|
||
fn open_dashboard_worktree_dialog(
|
||
app: &mut AppView,
|
||
prompt: Option<String>,
|
||
attach: bool,
|
||
) -> Vec<Effect> {
|
||
if let Some(d) = app.dashboard.as_mut() {
|
||
d.pending_worktree_prompt =
|
||
prompt.map(|text| snapshot_prompt_widget(&mut d.dispatch, text));
|
||
d.pending_worktree_attach = attach;
|
||
d.worktree_dialog = Some(crate::app::app_view::NewWorktreeDialogState::new());
|
||
d.dispatch.set_text("");
|
||
d.error_toast = None;
|
||
}
|
||
vec![]
|
||
}
|
||
|
||
/// Create a new session AND switch into its detail view. Routed from the
|
||
/// `[+ New Agent]` button and Enter-on-empty-prompt while it's focused.
|
||
/// Mirrors `dispatch_dashboard_dispatch`'s new-session arm with `attach=true`,
|
||
/// minus the prompt enqueue.
|
||
pub(super) fn dispatch_dashboard_create_new_agent_with_detail(app: &mut AppView) -> Vec<Effect> {
|
||
// Worktree mode armed + git repo: open the label dialog (which spawns the
|
||
// agent in a fresh worktree on confirm) instead of a plain session. The
|
||
// button opens the detail view, so confirm attaches (`attach = true`).
|
||
if app.cwd_has_git_ancestor && app.dashboard.as_ref().is_some_and(|d| d.dispatch_worktree) {
|
||
return open_dashboard_worktree_dialog(app, None, /* attach */ true);
|
||
}
|
||
let pending_model = app.dashboard.as_ref().and_then(|d| d.pending_model.clone());
|
||
let pending_mode = app
|
||
.dashboard
|
||
.as_ref()
|
||
.map(|d| d.pending_mode)
|
||
.unwrap_or_default();
|
||
let model_id = pending_model.as_ref().map(|m| m.id.clone());
|
||
let (new_id, effects) = dispatch_new_session_inner_with_id(app, model_id);
|
||
let policy_block = app.yolo_policy_block;
|
||
if let Some(agent) = app.agents.get_mut(&new_id) {
|
||
apply_pending_dispatch_config(agent, pending_model.as_ref(), pending_mode, policy_block);
|
||
}
|
||
if let Some(d) = app.dashboard.as_mut() {
|
||
// Clear the dispatch input even though we don't enqueue
|
||
// anything — a stray paste while the button is focused
|
||
// (no typed Enter) shouldn't survive the view switch.
|
||
d.dispatch.set_text("");
|
||
d.error_toast = None;
|
||
d.filter = crate::views::dashboard::Filter::None;
|
||
// Snap the cursor onto the new row so the overlay's `i/n`
|
||
// indicator matches the active view, AND so the chrome's
|
||
// `[‹]` / `[›]` cycle anchors on the right starting point.
|
||
d.focus_row(crate::views::dashboard::DashboardRowId::TopLevel(new_id));
|
||
d.attached_agent = Some(new_id);
|
||
}
|
||
app.active_view = ActiveView::Agent(new_id);
|
||
surface_yolo_launch_block_notice(app, new_id);
|
||
effects
|
||
}
|
||
|
||
/// Open the dashboard's shortcuts cheatsheet modal.
|
||
///
|
||
/// Builds the entry list from the registry, scoped to the `DashboardFocused`
|
||
/// + `Always` contexts. Mirrors `ActionId::ShortcutsHelp`'s agent-view handler.
|
||
pub(super) fn dispatch_dashboard_open_shortcuts_help(app: &mut AppView) {
|
||
let Some(d) = app.dashboard.as_mut() else {
|
||
return;
|
||
};
|
||
if d.shortcuts_modal.is_some() {
|
||
// Idempotent — re-pressing Ctrl+. while the modal is
|
||
// already open is a no-op (rather than blowing away the
|
||
// user's search query / scroll position with a fresh
|
||
// build).
|
||
return;
|
||
}
|
||
use crate::actions::When;
|
||
let contexts = [When::DashboardFocused, When::Always];
|
||
let entries = crate::views::shortcuts_help::build_entries(
|
||
&contexts,
|
||
&app.registry,
|
||
/* vim_mode */ false,
|
||
);
|
||
let state = crate::views::shortcuts_help::build_initial_picker_state(&entries);
|
||
d.shortcuts_modal = Some(Box::new(crate::views::dashboard::ShortcutsModalState {
|
||
entries,
|
||
state,
|
||
window: Default::default(),
|
||
filter_active: false,
|
||
collapsed_sections: crate::views::shortcuts_help::default_collapsed(),
|
||
expanded_ids: std::collections::HashSet::new(),
|
||
mode: crate::views::shortcuts_help::ShortcutsHelpMode::Browse,
|
||
}));
|
||
}
|
||
|
||
/// Short display label for a directory in the location picker — the
|
||
/// basename (truncated), or `~` for the home directory itself.
|
||
fn location_picker_label(path: &std::path::Path) -> String {
|
||
if dirs::home_dir().is_some_and(|h| h == path) {
|
||
return "~".to_string();
|
||
}
|
||
let raw = path.file_name().and_then(|n| n.to_str()).unwrap_or("/");
|
||
crate::render::line_utils::truncate_str(raw, 30)
|
||
}
|
||
|
||
/// Resolve a raw location-picker / `/cd` path string to an absolute path,
|
||
/// expanding a leading `~` and joining relative paths against `cwd`.
|
||
/// Returns `None` for empty input or when `~` can't be expanded. The
|
||
/// caller is responsible for validating the result is a directory.
|
||
pub(super) fn resolve_location_input(
|
||
input: &str,
|
||
cwd: &std::path::Path,
|
||
) -> Option<std::path::PathBuf> {
|
||
let trimmed = input.trim();
|
||
if trimmed.is_empty() {
|
||
return None;
|
||
}
|
||
let expanded: std::path::PathBuf = if trimmed == "~" {
|
||
dirs::home_dir()?
|
||
} else if let Some(rest) = trimmed.strip_prefix("~/") {
|
||
dirs::home_dir()?.join(rest)
|
||
} else {
|
||
std::path::PathBuf::from(trimmed)
|
||
};
|
||
if expanded.is_absolute() {
|
||
Some(expanded)
|
||
} else {
|
||
Some(cwd.join(expanded))
|
||
}
|
||
}
|
||
|
||
/// Open the dashboard's location picker. Seeds the candidate list with
|
||
/// the current cwd (marked `(current)`) followed by recent project
|
||
/// directories from session history. Idempotent — a no-op if the picker
|
||
/// is already open or the dashboard isn't active.
|
||
pub(super) fn dispatch_dashboard_open_location_picker(app: &mut AppView) -> Vec<Effect> {
|
||
use crate::views::dashboard::{LocationCandidate, LocationPickerState};
|
||
|
||
if !matches!(app.active_view, ActiveView::AgentDashboard) {
|
||
// `/cd` reached from a non-dashboard surface — the location
|
||
// picker is a dashboard affordance, so guide the user there.
|
||
// Gate on the dashboard being the *foreground* view (not merely
|
||
// `app.dashboard.is_some()`, which stays true for the rest of the
|
||
// session once the dashboard has been opened even once).
|
||
app.show_toast("Open the dashboard (/dashboard) to change location");
|
||
return vec![];
|
||
}
|
||
// Idempotent — re-triggering while open keeps the current query.
|
||
if app
|
||
.dashboard
|
||
.as_ref()
|
||
.is_some_and(|d| d.location_picker.is_some())
|
||
{
|
||
return vec![];
|
||
}
|
||
|
||
let cwd = app.cwd.clone();
|
||
// Same pattern as `open_project_question` — the recent-dirs source is
|
||
// async; block the current runtime thread briefly to collect it.
|
||
let recent = tokio::task::block_in_place(|| {
|
||
tokio::runtime::Handle::current()
|
||
.block_on(crate::project_picker::sources::collect_recent_dirs(10))
|
||
});
|
||
|
||
// Worktree label index (root path → label), built once and reused to
|
||
// tag both recents and live directory suggestions.
|
||
let worktrees = crate::git_info::worktree_label_index();
|
||
let worktree_label = |path: &std::path::Path| -> Option<String> {
|
||
let key = dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
|
||
worktrees.get(&key).cloned()
|
||
};
|
||
|
||
let mut candidates: Vec<LocationCandidate> = Vec::new();
|
||
candidates.push(LocationCandidate {
|
||
label: location_picker_label(&cwd),
|
||
detail: format!(
|
||
"{} (current)",
|
||
crate::project_picker::sources::display_path(&cwd)
|
||
),
|
||
worktree: worktree_label(&cwd),
|
||
path: cwd.clone(),
|
||
});
|
||
for (path, ts) in recent.into_iter().filter(|(p, _)| p != &cwd) {
|
||
let detail = format!(
|
||
"{} ({})",
|
||
crate::project_picker::sources::display_path(&path),
|
||
crate::views::session_title::format_relative_time(
|
||
(chrono::Utc::now() - ts).to_std().unwrap_or_default()
|
||
),
|
||
);
|
||
candidates.push(LocationCandidate {
|
||
label: location_picker_label(&path),
|
||
detail,
|
||
worktree: worktree_label(&path),
|
||
path,
|
||
});
|
||
}
|
||
|
||
if let Some(d) = app.dashboard.as_mut() {
|
||
let mut lp = LocationPickerState::new(candidates, cwd, worktrees);
|
||
// Reflect the dashboard's current worktree arming so reopening the
|
||
// picker shows the toggle in its existing state.
|
||
lp.worktree_mode = d.dispatch_worktree;
|
||
d.location_picker = Some(lp);
|
||
}
|
||
crate::unified_log::info("dashboard.location_picker.opened", None, None);
|
||
vec![]
|
||
}
|
||
|
||
/// Apply a location-picker / `/cd` selection. Resolves + validates the
|
||
/// path, and on success updates `app.cwd` + the process cwd (so newly
|
||
/// dispatched dashboard sessions spawn there) and closes the modal. On
|
||
/// failure the modal stays open with an inline error and the cwd is
|
||
/// unchanged.
|
||
pub(super) fn dispatch_dashboard_change_location(app: &mut AppView, input: String) -> Vec<Effect> {
|
||
// Gate on the dashboard being the *foreground* view. `/cd <path>` typed
|
||
// from another surface (welcome / an agent session / the dashboard
|
||
// overlay) would otherwise silently change the process cwd, since
|
||
// `app.dashboard` stays `Some` for the rest of the session once opened.
|
||
if !matches!(app.active_view, ActiveView::AgentDashboard) {
|
||
app.show_toast("Open the dashboard (/dashboard) to change location");
|
||
return vec![];
|
||
}
|
||
let path = match resolve_location_input(&input, &app.cwd).filter(|p| p.is_dir()) {
|
||
Some(p) => p,
|
||
None => {
|
||
if let Some(lp) = app
|
||
.dashboard
|
||
.as_mut()
|
||
.and_then(|d| d.location_picker.as_mut())
|
||
{
|
||
lp.error = Some(format!("Not a directory: {}", input.trim()));
|
||
} else if let Some(d) = app.dashboard.as_mut() {
|
||
// `/cd <bad path>` typed into the dispatch box (no picker
|
||
// open) — surface the error as a dashboard toast.
|
||
d.set_error_toast(&format!("Not a directory: {}", input.trim()));
|
||
}
|
||
return vec![];
|
||
}
|
||
};
|
||
|
||
crate::unified_log::info(
|
||
"dashboard.location_picker.changed",
|
||
None,
|
||
Some(serde_json::json!({ "path": path.display().to_string() })),
|
||
);
|
||
|
||
let changed = app.cwd != path;
|
||
let display = crate::project_picker::sources::display_path(&path);
|
||
app.cwd = path.clone();
|
||
// Keep the git-repo flag in sync with the new cwd (it's otherwise only
|
||
// computed at startup). Worktree dispatch reads it.
|
||
app.cwd_has_git_ancestor = path.ancestors().any(|p| p.join(".git").exists());
|
||
// Warm the per-cwd git cache the header / top bar read (keyed on the
|
||
// new cwd) so the new branch + worktree label show on the next frame
|
||
// instead of waiting for the first lazy refresh (no git spawn on the
|
||
// render path).
|
||
crate::git_info::populate_from_cwd_async(path.clone());
|
||
|
||
let has_git = app.cwd_has_git_ancestor;
|
||
if let Some(d) = app.dashboard.as_mut() {
|
||
// Keep the dashboard's cwd + git-repo snapshot in sync with the new
|
||
// cwd so the header tracks it immediately (the process cwd changes
|
||
// later, via `Effect::SetWorkingDir`).
|
||
d.cwd = path.clone();
|
||
d.cwd_has_git_ancestor = has_git;
|
||
// Carry the picker's worktree toggle onto the dashboard so the next
|
||
// dispatched agent honors it after the modal closes — but only when
|
||
// the destination is a git repo. Worktrees require one, so navigating
|
||
// to a non-git directory forces worktree mode off (the dashboard is
|
||
// never in worktree mode outside a repo).
|
||
if let Some(wt) = d.location_picker.as_ref().map(|lp| lp.worktree_mode) {
|
||
d.dispatch_worktree = wt && has_git;
|
||
} else if !has_git {
|
||
d.dispatch_worktree = false;
|
||
}
|
||
d.location_picker = None;
|
||
if changed {
|
||
// Re-root the dispatch box's `@` file-context picker at the new
|
||
// cwd so completions walk the same tree that newly dispatched
|
||
// sessions run in (both keyed off `app.cwd`). Cheap: the walk
|
||
// only spawns once the user actually types `@`.
|
||
d.dispatch.file_search.retarget(&path);
|
||
d.error_toast = Some(format!("\u{2192} {display}"));
|
||
}
|
||
}
|
||
|
||
// Change the process cwd via an effect, not inline, so the reducer
|
||
// stays side-effect free and parallel tests don't leak the process
|
||
// cwd into each other.
|
||
vec![Effect::SetWorkingDir { path }]
|
||
}
|
||
|
||
/// Confirm the dashboard worktree-label dialog: create the next dashboard
|
||
/// agent in a fresh worktree at `app.cwd`, replaying any prompt stashed when
|
||
/// the dialog opened. The dialog itself was already cleared by the input
|
||
/// handler. Surfaces a dashboard toast (instead of creating) when the cwd
|
||
/// isn't a git repository.
|
||
pub(super) fn dispatch_dashboard_confirm_worktree(
|
||
app: &mut AppView,
|
||
label: Option<String>,
|
||
) -> Vec<Effect> {
|
||
// Apply the prompt, attach choice, and staged model/mode together.
|
||
// The worktree path must honor all of these exactly like the normal
|
||
// dispatch path (`dispatch_dashboard_dispatch`).
|
||
let (mut prompt, attach) = match app.dashboard.as_mut() {
|
||
Some(d) => (
|
||
d.pending_worktree_prompt.take(),
|
||
std::mem::replace(&mut d.pending_worktree_attach, false),
|
||
),
|
||
None => (None, false),
|
||
};
|
||
let pending_model = app.dashboard.as_ref().and_then(|d| d.pending_model.clone());
|
||
let pending_mode = app
|
||
.dashboard
|
||
.as_ref()
|
||
.map(|d| d.pending_mode)
|
||
.unwrap_or_default();
|
||
if !app.cwd_has_git_ancestor {
|
||
if let Some(d) = app.dashboard.as_mut() {
|
||
// Restore the typed prompt if the cwd stopped being a repo.
|
||
if let Some(p) = prompt {
|
||
d.dispatch.restore(p);
|
||
}
|
||
d.set_error_toast("Not a git repository — can't create a worktree here");
|
||
}
|
||
return vec![];
|
||
}
|
||
let (prompt_text, mut images, chip_elements) = if let Some(stashed) = prompt.take() {
|
||
let (text, images, chip_elements) = stashed.into_submission();
|
||
(Some(text), images, chip_elements)
|
||
} else {
|
||
(None, Vec::new(), Vec::new())
|
||
};
|
||
let model_id = pending_model.as_ref().map(|m| m.id.clone());
|
||
let effects =
|
||
dispatch_new_worktree_session(app, None, label, prompt_text, model_id, None, None);
|
||
if let Some(new_id) = effects.iter().find_map(|e| match e {
|
||
Effect::CreateWorktreeSession { agent_id, .. } => Some(*agent_id),
|
||
_ => None,
|
||
}) {
|
||
// Apply the staged effort / plan / always-approve mode to the new
|
||
// worktree agent (base model is seeded via the effect's `model_id`),
|
||
// then carry any pasted images onto the replayed prompt — both mirror
|
||
// `dispatch_dashboard_dispatch`.
|
||
let policy_block = app.yolo_policy_block;
|
||
if let Some(agent) = app.agents.get_mut(&new_id) {
|
||
apply_pending_dispatch_config(
|
||
agent,
|
||
pending_model.as_ref(),
|
||
pending_mode,
|
||
policy_block,
|
||
);
|
||
if let Some(entry) = agent.session.pending_prompts.back_mut() {
|
||
entry.images = std::mem::take(&mut images);
|
||
entry.chip_elements = chip_elements;
|
||
}
|
||
}
|
||
if attach {
|
||
// Send+open: attach the dashboard's detail-view overlay onto the
|
||
// agent (`dispatch_new_worktree_session` already set `active_view`).
|
||
if let Some(d) = app.dashboard.as_mut() {
|
||
d.focus_row(crate::views::dashboard::DashboardRowId::TopLevel(new_id));
|
||
d.attached_agent = Some(new_id);
|
||
}
|
||
} else {
|
||
// Plain Enter: undo the view switch `dispatch_new_worktree_session`
|
||
// made and stay on the dashboard.
|
||
app.active_view = ActiveView::AgentDashboard;
|
||
}
|
||
}
|
||
crate::prompt_images::drain_and_cleanup(&mut images);
|
||
effects
|
||
}
|
||
|
||
/// Cycle the dashboard overlay to the prev (-1) / next (+1) agent in the
|
||
/// visible row order, wrapping at the ends. Attaches overlay chrome on the
|
||
/// first cycle from a session not opened via the dashboard.
|
||
pub(super) fn dispatch_dashboard_overlay_cycle(app: &mut AppView, delta: i32) -> Vec<Effect> {
|
||
use crate::views::dashboard::DashboardRowId;
|
||
|
||
// Anchor on the agent on screen, not a possibly-stale `attached_agent`:
|
||
// keeps cycling correct after a session switch the view without
|
||
// re-attaching. (Cycling is only ever invoked from an agent view.)
|
||
let ActiveView::Agent(current) = app.active_view else {
|
||
return vec![];
|
||
};
|
||
if app.agents.len() <= 1 {
|
||
return vec![];
|
||
}
|
||
// Compute the order without permanently materializing the dashboard, so a
|
||
// no-op cycle (filter hides all / current row hidden) leaves no state. When
|
||
// the dashboard was never opened, build a throwaway state from the persisted
|
||
// layout (pins / reorder / grouping) so prev/next match what the user sees
|
||
// after opening — `load_persisted` is cached on `app.dashboard_persisted`.
|
||
let order = match app.dashboard.as_ref() {
|
||
Some(d) => crate::views::dashboard::overlay_cycle_order(d, &app.agents),
|
||
None => {
|
||
// Materializing here must honor the same gates as
|
||
// `dispatch_open_dashboard` (feature flag AND auth), so cycling
|
||
// can't surface an ungated dashboard on back-out.
|
||
if !crate::views::dashboard::dashboard_enabled()
|
||
|| !matches!(app.auth_state, crate::app::app_view::AuthState::Done)
|
||
{
|
||
return vec![];
|
||
}
|
||
let transient = dashboard_state_from_persisted(app);
|
||
crate::views::dashboard::overlay_cycle_order(&transient, &app.agents)
|
||
}
|
||
};
|
||
if order.len() <= 1 {
|
||
return vec![];
|
||
}
|
||
let Some(idx) = order.iter().position(|id| *id == current) else {
|
||
return vec![];
|
||
};
|
||
let n = order.len() as i32;
|
||
let next_idx = (((idx as i32) + delta).rem_euclid(n)) as usize;
|
||
let next_id = order[next_idx];
|
||
if next_id == current {
|
||
return vec![];
|
||
}
|
||
// Materialize + configure only on a real switch — otherwise a
|
||
// cycle-created dashboard renders bare on back-out (default cwd, empty
|
||
// `/model`, wrong auto-approve).
|
||
if app.dashboard.is_none() {
|
||
ensure_dashboard_state(app);
|
||
configure_dashboard_state(app);
|
||
}
|
||
// Drop any prior subagent takeover on the next agent so the
|
||
// overlay paints the parent view, mirroring the attach path.
|
||
if let Some(agent) = app.agents.get_mut(&next_id) {
|
||
agent.active_subagent = None;
|
||
}
|
||
// A stop-confirm armed on the CURRENT agent must not carry over to
|
||
// the next one — a mouse click on `[‹]` / `[›]` lands here without
|
||
// the key-press disarm ever running.
|
||
clear_pending_overlay_stop(app);
|
||
if let Some(d) = app.dashboard.as_mut() {
|
||
d.attached_agent = Some(next_id);
|
||
d.focus_row(DashboardRowId::TopLevel(next_id));
|
||
}
|
||
app.active_view = ActiveView::Agent(next_id);
|
||
surface_yolo_launch_block_notice(app, next_id);
|
||
vec![]
|
||
}
|
||
|
||
pub(super) fn dispatch_dashboard_dispatch(
|
||
app: &mut AppView,
|
||
text: String,
|
||
attach: bool,
|
||
) -> Vec<Effect> {
|
||
// Paste-then-immediate-send: a Cmd+V image probe is still off-thread. Stash
|
||
// this send and re-issue it once the probe completes so the image is never
|
||
// dropped from the dispatched prompt's content blocks.
|
||
if let Some(d) = app.dashboard.as_mut()
|
||
&& d.paste_probe_in_flight > 0
|
||
{
|
||
d.deferred_dispatch_send =
|
||
Some(crate::views::dashboard::state::DeferredDispatchSend { attach });
|
||
return vec![];
|
||
}
|
||
let trimmed = text.trim().to_string();
|
||
// Reject only an empty / whitespace-only prompt; any non-empty
|
||
// input (even a single character) dispatches a new session. The
|
||
// keyboard path already filters empty input upstream (see
|
||
// `DashboardState::handle_key`), so this guard mainly protects the
|
||
// slash-fallback callers.
|
||
if trimmed.is_empty() {
|
||
if let Some(d) = app.dashboard.as_mut() {
|
||
d.set_error_toast("Type a prompt to dispatch a session");
|
||
}
|
||
return vec![];
|
||
}
|
||
// Cap dispatch text at 64 KiB so a runaway paste
|
||
// doesn't queue a multi-megabyte prompt.
|
||
//
|
||
// Toast shows BOTH the character count (what the
|
||
// user thinks in) AND the byte budget (the actual limit) so the
|
||
// char/byte unit mismatch isn't surprising.
|
||
const MAX_DISPATCH_BYTES: usize = 64 * 1024;
|
||
if text.len() > MAX_DISPATCH_BYTES {
|
||
let chars = text.chars().count();
|
||
if let Some(d) = app.dashboard.as_mut() {
|
||
d.set_error_toast(&format!(
|
||
"Prompt too long ({chars} chars / {} bytes; max ~64 KiB)",
|
||
text.len()
|
||
));
|
||
}
|
||
return vec![];
|
||
}
|
||
|
||
// Worktree mode armed (via the location picker) AND the cwd is a git
|
||
// repo: stash the prompt and open the worktree-label dialog. Confirming
|
||
// it spawns the agent in a fresh worktree and replays this prompt (see
|
||
// `Action::DashboardConfirmWorktree`). The `attach` flag rides along so
|
||
// confirm honors the same detail-vs-dashboard choice as a normal dispatch
|
||
// (bare Enter stays on the dashboard; Ctrl+S opens the detail view).
|
||
// Outside a repo we fall through to a normal session dispatch (the toggle
|
||
// is hidden there anyway).
|
||
if app.cwd_has_git_ancestor && app.dashboard.as_ref().is_some_and(|d| d.dispatch_worktree) {
|
||
return open_dashboard_worktree_dialog(app, Some(text), attach);
|
||
}
|
||
|
||
// The dashboard's dispatch input ALWAYS spawns a new session — it is
|
||
// never a reply target. A row being selected is purely the overview
|
||
// navigation cursor (Enter on it OPENS the agent); it must not turn
|
||
// the input into "reply to that agent". Conflating the two trapped
|
||
// the user: navigating to a row (vim j/k) flipped the input to
|
||
// "Reply to <agent>" and there was no obvious way back to spawning a
|
||
// new session. To converse with an existing agent, open it (navigate
|
||
// + Enter, or click) and reply inside its own view.
|
||
//
|
||
// New-session path.
|
||
//
|
||
// Return the new AgentId from the inner constructor
|
||
// so we don't have to rely on `app.agents.last()`.
|
||
//
|
||
// Carry the dashboard's staged model / plan-mode (set via `/model` and
|
||
// `/plan`) onto the new session: the model id seeds `CreateSession`, and
|
||
// effort / plan are applied post-creation by `apply_pending_dispatch_config`.
|
||
let pending_model = app.dashboard.as_ref().and_then(|d| d.pending_model.clone());
|
||
let pending_mode = app
|
||
.dashboard
|
||
.as_ref()
|
||
.map(|d| d.pending_mode)
|
||
.unwrap_or_default();
|
||
let model_id = pending_model.as_ref().map(|m| m.id.clone());
|
||
let prompt_state = app
|
||
.dashboard
|
||
.as_mut()
|
||
.map(|dashboard| snapshot_prompt_widget(&mut dashboard.dispatch, text.clone()))
|
||
.unwrap_or_else(|| {
|
||
crate::views::prompt_widget::StashedPrompt::from_submission(
|
||
text,
|
||
Vec::new(),
|
||
Vec::new(),
|
||
)
|
||
});
|
||
let (prompt_text, mut pasted_images, chip_elements) = prompt_state.into_submission();
|
||
let saved_shown = app.project_picker_shown;
|
||
app.project_picker_shown = true;
|
||
let (new_id, effects) = dispatch_new_session_inner_with_id(app, model_id);
|
||
app.project_picker_shown = saved_shown;
|
||
let policy_block = app.yolo_policy_block;
|
||
if let Some(agent) = app.agents.get_mut(&new_id) {
|
||
agent.session.enqueue_prompt(prompt_text);
|
||
if let Some(entry) = agent.session.pending_prompts.back_mut() {
|
||
entry.images = std::mem::take(&mut pasted_images);
|
||
entry.chip_elements = chip_elements;
|
||
}
|
||
apply_pending_dispatch_config(agent, pending_model.as_ref(), pending_mode, policy_block);
|
||
}
|
||
crate::prompt_images::drain_and_cleanup(&mut pasted_images);
|
||
// Only clear the input AFTER successful session
|
||
// creation. If the agent failed to register the prompt above we
|
||
// would have already returned without effects.
|
||
if let Some(d) = app.dashboard.as_mut() {
|
||
d.dispatch.set_text("");
|
||
d.error_toast = None;
|
||
d.filter = crate::views::dashboard::Filter::None;
|
||
}
|
||
if attach {
|
||
// Ctrl+S (Send+Open) — walk into the new agent's
|
||
// detail view AND paint the session-overlay chrome.
|
||
// Mirrors `dispatch_dashboard_attach` and
|
||
// `dispatch_dashboard_create_new_agent_with_detail`:
|
||
// both `attached_agent` and `selected` follow the new
|
||
// row so the overlay's `i/n [‹][›] [✗]` chips have an
|
||
// anchor and Esc walks back to the dashboard.
|
||
if let Some(d) = app.dashboard.as_mut() {
|
||
d.focus_row(crate::views::dashboard::DashboardRowId::TopLevel(new_id));
|
||
d.attached_agent = Some(new_id);
|
||
}
|
||
app.active_view = ActiveView::Agent(new_id);
|
||
surface_yolo_launch_block_notice(app, new_id);
|
||
} else {
|
||
// Plain Enter (Send) stays on the dashboard, no auto-select.
|
||
// The freshly-created row is left unselected so the overview
|
||
// navigation cursor stays where the user had it — the input
|
||
// always dispatches a NEW session regardless of selection, so
|
||
// there's no reply state to worry about here.
|
||
app.active_view = ActiveView::AgentDashboard;
|
||
// `apply_pending_dispatch_config` clamps a pinned always-approve to
|
||
// Normal and toasts the new agent, but that toast is invisible while the
|
||
// view stays on the dashboard — mirror it on the dashboard's error slot.
|
||
let enabling =
|
||
pending_mode == crate::views::dashboard::DashboardDispatchMode::AlwaysApprove;
|
||
if let Some(warning) = yolo_enable_blocked(app, enabling)
|
||
&& let Some(d) = app.dashboard.as_mut()
|
||
{
|
||
d.set_error_toast(warning);
|
||
}
|
||
}
|
||
effects
|
||
}
|
||
|
||
/// Resolve a slash command typed into the dashboard's dispatch input.
|
||
///
|
||
/// The dashboard has no session context, so the execution path is more
|
||
/// limited than the agent view's:
|
||
///
|
||
/// - Builtin commands that return `CommandResult::Action(...)` (e.g.
|
||
/// `/dashboard`, `/exit`, `/theme`, `/settings`, `/help`, `/model`,
|
||
/// `/mcps`, `/plugin`, …) are dispatched identically to the agent path.
|
||
/// - `CommandResult::Message` / `Error` surface as an `error_toast`
|
||
/// on the dashboard (no scrollback to push into). `Error` strings
|
||
/// get the `✗` prefix via `set_error_toast`; `Message` strings are
|
||
/// stored verbatim (they carry their own glyph).
|
||
/// - `CommandResult::Handled` / `HandledNoOp` clear the input.
|
||
/// - `CommandResult::PassThrough` (unknown commands, ACP-advertised
|
||
/// pass-throughs, `CommandResult::QueueCommand`, and
|
||
/// `InjectSkill`) fall back to dispatching a new session with the
|
||
/// text as its first prompt — the dashboard treats them like a
|
||
/// bare free-text dispatch. This keeps the user's typing from
|
||
/// being silently dropped on the floor when they invoke a
|
||
/// plugin/skill from the dashboard surface.
|
||
///
|
||
/// Offer / execute tri-state (matches completion's [`command_offered`]):
|
||
/// - **Unknown** token → [`dispatch_dashboard_dispatch`] (new session prompt).
|
||
/// - **Registered, not offered** (session-scoped hidden on this surface,
|
||
/// or `dashboard_only` off-dashboard) → clear dispatch + error toast;
|
||
/// do **not** spawn with the slash text as the prompt.
|
||
/// - **Registered, offered** → MRU + `command.run` (e.g. `/model` /
|
||
/// `/plan` stage the next spawn).
|
||
pub(super) fn dispatch_dashboard_dispatch_slash(app: &mut AppView, text: String) -> Vec<Effect> {
|
||
use crate::slash::command::{CommandExecCtx, CommandResult};
|
||
use crate::slash::parse_invocation;
|
||
|
||
let trimmed = text.trim().to_string();
|
||
if trimmed.is_empty() || !trimmed.starts_with('/') {
|
||
return vec![];
|
||
}
|
||
|
||
let show_tips_from_app = app.show_tips;
|
||
let auto_update_from_app = app.auto_update;
|
||
let respect_manual_folds_from_app = app.appearance.scrollback.scroll.respect_manual_folds;
|
||
let auto_mode_gate_from_app = app.auto_mode_gate;
|
||
let ask_user_question_timeout_enabled_from_app = app.ask_user_question_timeout_enabled;
|
||
|
||
// Build the execution context from app-wide state. The dashboard
|
||
// is session-less, so `session_id` is `None`. Offered session-less
|
||
// opt-ins (`/model`, `/plan`) and pager-global commands still run
|
||
// and may toast if a dispatcher needs an agent.
|
||
let result = {
|
||
let Some(invocation) = parse_invocation(trimmed.as_str()) else {
|
||
return vec![];
|
||
};
|
||
|
||
// Get the slash registry from the dashboard's prompt widget.
|
||
// The dashboard owns its own registry (populated at open time
|
||
// with builtins + ACP commands from `bootstrap_acp_commands`).
|
||
let Some(dashboard) = app.dashboard.as_ref() else {
|
||
return vec![];
|
||
};
|
||
let reg = dashboard.dispatch.slash_controller.registry();
|
||
|
||
let Some(command) = reg.get(invocation.token).cloned() else {
|
||
// Unknown command. Fall back to the regular dispatch
|
||
// path so the text becomes a new session's prompt.
|
||
return dispatch_dashboard_dispatch(app, text, /* attach */ false);
|
||
};
|
||
// Registered but not offered on this surface (session-scoped
|
||
// hidden from the dropdown, or non-dashboard `dashboard_only`):
|
||
// error toast — never spawn a session whose first prompt is the
|
||
// slash text (that was worse than the old loud Action toasts).
|
||
if !dashboard
|
||
.dispatch
|
||
.slash_controller
|
||
.is_command_offered(command.as_ref(), &app.models)
|
||
{
|
||
let name = command.name();
|
||
if let Some(d) = app.dashboard.as_mut() {
|
||
d.dispatch.set_text("");
|
||
d.set_error_toast(&format!("/{name} only works in a session"));
|
||
}
|
||
return vec![];
|
||
}
|
||
if let Some(dashboard) = app.dashboard.as_mut() {
|
||
// Records MRU and queues an off-thread persist internally.
|
||
dashboard
|
||
.dispatch
|
||
.slash_controller
|
||
.record_command_use(invocation.token, invocation.token);
|
||
}
|
||
|
||
let dashboard_multiline = app.dashboard.as_ref().is_some_and(|d| d.multiline_mode);
|
||
let mut ctx = CommandExecCtx {
|
||
models: &app.models,
|
||
session_id: None,
|
||
bundle_state: &app.bundle_state,
|
||
screen_mode: app.screen_mode,
|
||
pager_state: crate::settings::PagerLocalSnapshot {
|
||
multiline_mode: dashboard_multiline,
|
||
yolo_mode: app.default_yolo,
|
||
auto_mode: app.current_ui.permission_mode.as_deref() == Some("auto")
|
||
&& !app.default_yolo,
|
||
current_model_name: app.models.current_model_name(),
|
||
available_models: app
|
||
.models
|
||
.available
|
||
.iter()
|
||
.map(|(id, info)| (info.name.clone(), id.clone()))
|
||
.collect(),
|
||
plan_mode_active: false,
|
||
show_tips: show_tips_from_app,
|
||
auto_update: auto_update_from_app,
|
||
vim_mode: crate::appearance::cache::load_vim_mode(),
|
||
scroll_speed: crate::appearance::cache::load_scroll_speed(),
|
||
respect_manual_folds: respect_manual_folds_from_app,
|
||
auto_mode_gate: auto_mode_gate_from_app,
|
||
ask_user_question_timeout_enabled: ask_user_question_timeout_enabled_from_app,
|
||
},
|
||
};
|
||
command.run(&mut ctx, invocation.args)
|
||
};
|
||
|
||
match result {
|
||
CommandResult::Handled | CommandResult::HandledNoOp => {
|
||
if let Some(d) = app.dashboard.as_mut() {
|
||
d.dispatch.set_text("");
|
||
d.error_toast = None;
|
||
}
|
||
vec![]
|
||
}
|
||
CommandResult::Error(msg) => {
|
||
if let Some(d) = app.dashboard.as_mut() {
|
||
d.dispatch.set_text("");
|
||
// Command errors are plain strings ("Unknown model: …",
|
||
// "Usage: /model <name> [effort]") with no glyph of their
|
||
// own — route through `set_error_toast` so the verbatim
|
||
// badge shows the `✗` error marker. `Message` results
|
||
// below stay verbatim: they carry their own glyph
|
||
// (e.g. `✓ Theme: …`).
|
||
d.set_error_toast(&msg);
|
||
}
|
||
vec![]
|
||
}
|
||
CommandResult::Message(msg) => {
|
||
if let Some(d) = app.dashboard.as_mut() {
|
||
d.dispatch.set_text("");
|
||
d.error_toast = Some(msg);
|
||
}
|
||
vec![]
|
||
}
|
||
CommandResult::Action(Action::ExitSession) => {
|
||
// ExitSession from a session-less surface is meaningless
|
||
// — collapse it to `/dashboard`'s exit semantics.
|
||
if let Some(d) = app.dashboard.as_mut() {
|
||
d.dispatch.set_text("");
|
||
}
|
||
dispatch(Action::ExitDashboard, app)
|
||
}
|
||
// `/model` on the session-less dashboard stages the model for the
|
||
// NEXT spawned agent instead of switching a (nonexistent) session.
|
||
// Both the effort-bearing (`SwitchModel`) and bare
|
||
// (`SetDefaultModel`) forms map to the same per-spawn staging — we
|
||
// deliberately do NOT persist a global default here.
|
||
CommandResult::Action(Action::SwitchModel { model_id, effort }) => {
|
||
stage_dashboard_model(app, model_id, effort);
|
||
vec![]
|
||
}
|
||
CommandResult::Action(Action::SetDefaultModel(model_id)) => {
|
||
stage_dashboard_model(app, model_id, None);
|
||
vec![]
|
||
}
|
||
// `/plan` toggles whether the next spawned agent starts in plan
|
||
// mode. The command always reports `On` here (the dashboard's
|
||
// `plan_mode_active` snapshot is always false), so we flip between
|
||
// Plan and Normal to give a session-less way to turn it back off.
|
||
CommandResult::Action(Action::SetPlanMode(_)) => {
|
||
use crate::views::dashboard::DashboardDispatchMode;
|
||
if let Some(d) = app.dashboard.as_mut() {
|
||
d.dispatch.set_text("");
|
||
d.error_toast = None;
|
||
d.pending_mode = if d.pending_mode == DashboardDispatchMode::Plan {
|
||
DashboardDispatchMode::Normal
|
||
} else {
|
||
DashboardDispatchMode::Plan
|
||
};
|
||
}
|
||
vec![]
|
||
}
|
||
// `/plan <description>` — stage plan mode AND spawn immediately with
|
||
// the description as the first prompt.
|
||
CommandResult::Action(Action::EnterPlanMode { description }) => {
|
||
if let Some(d) = app.dashboard.as_mut() {
|
||
d.pending_mode = crate::views::dashboard::DashboardDispatchMode::Plan;
|
||
}
|
||
match description {
|
||
Some(desc) => {
|
||
dispatch_dashboard_dispatch(app, desc, /* attach */ false)
|
||
}
|
||
None => {
|
||
if let Some(d) = app.dashboard.as_mut() {
|
||
d.dispatch.set_text("");
|
||
d.error_toast = None;
|
||
}
|
||
vec![]
|
||
}
|
||
}
|
||
}
|
||
// `/plan` with an existing plan has nothing to show on the
|
||
// session-less dashboard.
|
||
CommandResult::Action(Action::ShowPlan) => {
|
||
if let Some(d) = app.dashboard.as_mut() {
|
||
d.dispatch.set_text("");
|
||
d.set_error_toast("No plan to show on the dashboard");
|
||
}
|
||
vec![]
|
||
}
|
||
CommandResult::Action(action) => {
|
||
if let Some(d) = app.dashboard.as_mut() {
|
||
d.dispatch.set_text("");
|
||
d.error_toast = None;
|
||
}
|
||
dispatch(action, app)
|
||
}
|
||
CommandResult::QueueCommand(_)
|
||
| CommandResult::InjectSkill { .. }
|
||
| CommandResult::PassThrough(_) => {
|
||
// These results all expect an agent session to consume
|
||
// them. The dashboard has none, so route the original
|
||
// text through the normal "new session with this text as
|
||
// the first prompt" path so the user's intent isn't lost.
|
||
dispatch_dashboard_dispatch(app, text, /* attach */ false)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Stage a model (+ optional reasoning effort) for the next agent the
|
||
/// dashboard spawns. Resolves the human-readable display name from the
|
||
/// app's model catalog (falling back to the raw id) so the renderer can
|
||
/// show the indicator without a live `ModelState`. Clears the dispatch
|
||
/// input + any error toast.
|
||
fn stage_dashboard_model(
|
||
app: &mut AppView,
|
||
model_id: acp::ModelId,
|
||
effort: Option<kigi_shell::sampling::types::ReasoningEffort>,
|
||
) {
|
||
let display = app
|
||
.models
|
||
.available
|
||
.get(&model_id)
|
||
.map(|info| info.name.clone())
|
||
.unwrap_or_else(|| model_id.0.to_string());
|
||
if let Some(d) = app.dashboard.as_mut() {
|
||
d.dispatch.set_text("");
|
||
d.error_toast = None;
|
||
// Mirror the staged choice into the dashboard's catalog snapshot so a
|
||
// subsequent `/model` dropdown marks THIS model as `(current)` (and
|
||
// its effort as `(active)`) rather than the app default the snapshot
|
||
// was seeded with at open. Without this the dropdown lags one step
|
||
// behind `pending_model`.
|
||
d.models.set_current(model_id.clone(), effort);
|
||
d.pending_model = Some(crate::views::dashboard::PendingDispatchModel {
|
||
id: model_id,
|
||
effort,
|
||
display,
|
||
});
|
||
}
|
||
}
|
||
|
||
/// Apply the dashboard's staged model effort + plan mode to a freshly
|
||
/// spawned agent. The base model is already seeded via `CreateSession`'s
|
||
/// `model_id`; here we stash the reasoning effort (pushed to the shell once
|
||
/// the session exists, mirroring the agent-view flow) and the deferred plan
|
||
/// `SessionMode` (consumed in the `SessionCreated` handlers).
|
||
pub(super) fn apply_pending_dispatch_config(
|
||
agent: &mut AgentView,
|
||
pending_model: Option<&crate::views::dashboard::PendingDispatchModel>,
|
||
pending_mode: crate::views::dashboard::DashboardDispatchMode,
|
||
policy_block: Option<&'static str>,
|
||
) {
|
||
use crate::views::dashboard::DashboardDispatchMode;
|
||
|
||
if let Some(m) = pending_model {
|
||
// The base model is seeded via `CreateSession.model_id`; only stash a
|
||
// deferred switch when an explicit effort must be pushed. Setting it
|
||
// (or clearing to `None`) also overrides any CLI `-m` default so the
|
||
// dashboard's `/model` choice wins.
|
||
agent.session.deferred_model_switch = m.effort.map(|e| (m.id.clone(), Some(e)));
|
||
}
|
||
match pending_mode {
|
||
DashboardDispatchMode::Normal => {
|
||
// Explicit normal overrides any `app.default_yolo` seed.
|
||
agent.session.yolo_mode = false;
|
||
}
|
||
DashboardDispatchMode::Plan => {
|
||
agent.session.yolo_mode = false;
|
||
agent.deferred_session_mode = Some(kigi_tools::types::SessionMode::Plan);
|
||
// Optimistic so the agent view reflects plan mode immediately when
|
||
// opened via Ctrl+S, before the ACP round-trip confirms it.
|
||
agent.plan_mode_pending = Some(true);
|
||
}
|
||
// Backstop: staging is already gated, but this write sits outside
|
||
// `set_yolo_mode_inner`, so re-check the pin here.
|
||
DashboardDispatchMode::AlwaysApprove => {
|
||
if let Some(warning) = policy_block {
|
||
agent.session.yolo_mode = false;
|
||
agent.show_toast(warning);
|
||
} else {
|
||
// Client-side auto-approve for the spawned session (per-spawn;
|
||
// not persisted as a global default).
|
||
agent.session.yolo_mode = true;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Send or queue a reply typed into the peek panel's `❯ reply` input.
|
||
///
|
||
/// The reply is enqueued on the row's owning top-level agent and then
|
||
/// [`maybe_drain_queue`] decides the rest: an **idle** agent sends it
|
||
/// immediately (a turn starts), a **mid-turn** agent keeps it queued so
|
||
/// it drains after the current turn finishes. This is the same queue /
|
||
/// drain pipeline the agent view's own prompt input uses, so the two
|
||
/// surfaces behave identically.
|
||
///
|
||
/// Subagent rows can't be replied to (they're driven by their parent),
|
||
/// so they surface a toast and leave the peek open.
|
||
///
|
||
/// `attach` (Ctrl+S) additionally walks into the agent's detail
|
||
/// view, mirroring the dispatch input's send+open affordance.
|
||
/// Cycle the PEEKED agent's live mode (Normal → Plan → Always-Approve →
|
||
/// Normal), the peek-panel counterpart to `DashboardCycleMode`. Reuses
|
||
/// the shared cycle body `dispatch_cycle_mode_and_sync` by temporarily
|
||
/// targeting the peeked agent (the same `active_view` swap as
|
||
/// `dispatch_dashboard_toggle_auto_approve`), so the peek behaves exactly
|
||
/// like Shift+Tab inside that agent's chat view. The bottom-border badge
|
||
/// reflects the new mode on the next frame. Only top-level agents have a
|
||
/// mode to cycle — subagents are parent-driven.
|
||
pub(super) fn dispatch_dashboard_peek_cycle_mode(app: &mut AppView) -> Vec<Effect> {
|
||
use crate::views::dashboard::DashboardRowId;
|
||
|
||
let Some(row) = app
|
||
.dashboard
|
||
.as_ref()
|
||
.and_then(|d| d.peek.as_ref().map(|p| p.row.clone()))
|
||
else {
|
||
return vec![];
|
||
};
|
||
let agent_id = match row {
|
||
DashboardRowId::TopLevel(id) => id,
|
||
DashboardRowId::Subagent { .. } => {
|
||
if let Some(d) = app.dashboard.as_mut() {
|
||
d.set_error_toast("Can't change a subagent's mode");
|
||
}
|
||
return vec![];
|
||
}
|
||
DashboardRowId::Roster { .. } => return vec![],
|
||
};
|
||
if !app.agents.contains_key(&agent_id) {
|
||
if let Some(d) = app.dashboard.as_mut() {
|
||
d.set_peek(None);
|
||
d.set_error_toast("Session no longer exists");
|
||
}
|
||
return vec![];
|
||
}
|
||
|
||
// Temporarily target the peeked agent so `dispatch_cycle_mode_and_sync`
|
||
// (which reads `active_view`) acts on it; restored before returning so the
|
||
// dashboard stays foregrounded. Uses the telemetry-free cycle body: the
|
||
// user is viewing the dashboard, not this agent's prompt, so a plan nudge
|
||
// still within TTL must not attribute a (spurious) acceptance.
|
||
let saved_view = app.active_view;
|
||
app.active_view = ActiveView::Agent(agent_id);
|
||
let effects = dispatch_cycle_mode_and_sync(app);
|
||
app.active_view = saved_view;
|
||
effects
|
||
}
|
||
|
||
pub(super) fn dispatch_dashboard_peek_reply(
|
||
app: &mut AppView,
|
||
row: crate::views::dashboard::DashboardRowId,
|
||
text: String,
|
||
attach: bool,
|
||
) -> Vec<Effect> {
|
||
use crate::views::dashboard::DashboardRowId;
|
||
|
||
// Paste-then-immediate-send: a Cmd+V image probe is still off-thread. Stash
|
||
// this reply and re-issue it once the probe completes so the image is never
|
||
// dropped from the reply's content blocks.
|
||
if let Some(d) = app.dashboard.as_mut()
|
||
&& d.paste_probe_in_flight > 0
|
||
{
|
||
d.deferred_peek_send =
|
||
Some(crate::views::dashboard::state::DeferredPeekSend { row, attach });
|
||
return vec![];
|
||
}
|
||
|
||
// Only top-level agents accept replies. Subagents are read-only
|
||
// peeks driven by their parent turn.
|
||
let DashboardRowId::TopLevel(agent_id) = row else {
|
||
if let Some(d) = app.dashboard.as_mut() {
|
||
d.set_error_toast("Can't reply to a subagent");
|
||
}
|
||
return vec![];
|
||
};
|
||
|
||
if !app.agents.contains_key(&agent_id) {
|
||
if let Some(d) = app.dashboard.as_mut() {
|
||
d.set_peek(None);
|
||
d.set_error_toast("Session no longer exists");
|
||
}
|
||
return vec![];
|
||
}
|
||
|
||
let prompt_state = app
|
||
.dashboard
|
||
.as_mut()
|
||
.map(|dashboard| snapshot_prompt_widget(&mut dashboard.peek_reply, text.clone()))
|
||
.unwrap_or_else(|| {
|
||
crate::views::prompt_widget::StashedPrompt::from_submission(
|
||
text,
|
||
Vec::new(),
|
||
Vec::new(),
|
||
)
|
||
});
|
||
let (text, images, chip_elements) = prompt_state.into_submission();
|
||
|
||
if text.trim().is_empty() && images.is_empty() {
|
||
return vec![];
|
||
}
|
||
|
||
let Some(agent) = app.agents.get_mut(&agent_id) else {
|
||
if let Some(d) = app.dashboard.as_mut() {
|
||
d.set_peek(None);
|
||
d.set_error_toast("Session no longer exists");
|
||
}
|
||
return vec![];
|
||
};
|
||
|
||
// Enqueue + drain: idle → sends now, running → stays queued.
|
||
// Untrimmed so `chip_elements` byte ranges stay aligned with the stored text.
|
||
agent.session.enqueue_prompt(text);
|
||
if let Some(entry) = agent.session.pending_prompts.back_mut() {
|
||
entry.chip_elements = chip_elements;
|
||
if !images.is_empty() {
|
||
entry.images = images;
|
||
}
|
||
}
|
||
let effects = maybe_drain_queue(agent);
|
||
|
||
// Clear the reply draft now that it's been accepted, and drop any
|
||
// stale error toast.
|
||
if let Some(d) = app.dashboard.as_mut() {
|
||
d.clear_peek_reply();
|
||
d.error_toast = None;
|
||
}
|
||
|
||
if attach {
|
||
// Ctrl+S (Send+Open) — walk into the agent's detail view
|
||
// with the session-overlay chrome, mirroring
|
||
// `dispatch_dashboard_dispatch`'s attach branch.
|
||
if let Some(d) = app.dashboard.as_mut() {
|
||
d.focus_row(DashboardRowId::TopLevel(agent_id));
|
||
d.attached_agent = Some(agent_id);
|
||
}
|
||
app.active_view = ActiveView::Agent(agent_id);
|
||
surface_yolo_launch_block_notice(app, agent_id);
|
||
}
|
||
|
||
effects
|
||
}
|
||
|
||
pub(super) fn dispatch_dashboard_toggle_pin(app: &mut AppView) -> Vec<Effect> {
|
||
if let Some(d) = app.dashboard.as_mut() {
|
||
let _ = d.toggle_pin_selected();
|
||
}
|
||
dispatch_dashboard_persist(app)
|
||
}
|
||
|
||
pub(super) fn dispatch_dashboard_begin_rename(app: &mut AppView) {
|
||
let Some(d) = app.dashboard.as_mut() else {
|
||
return;
|
||
};
|
||
let Some(sel) = d.selected.clone() else {
|
||
return;
|
||
};
|
||
// Only top-level rows are renameable (subagents are tool-spawned
|
||
// and have no user-visible name to rename).
|
||
if sel.is_subagent() {
|
||
d.set_error_toast("Subagent rows can't be renamed");
|
||
return;
|
||
}
|
||
// The draft starts EMPTY (not prefilled with the current title):
|
||
// renames are almost always full rewrites, so prefilling only costs
|
||
// the user a hold-Backspace. Esc / empty-draft Enter cancel without
|
||
// touching the existing name.
|
||
if let Some(d) = app.dashboard.as_mut() {
|
||
d.rename = Some(crate::views::dashboard::state::RenameDraft {
|
||
row: sel,
|
||
draft: String::new(),
|
||
});
|
||
}
|
||
}
|
||
|
||
pub(super) fn dispatch_dashboard_commit_rename(app: &mut AppView) -> Vec<Effect> {
|
||
let Some(d) = app.dashboard.as_mut() else {
|
||
return vec![];
|
||
};
|
||
let Some(rn) = d.rename.take() else {
|
||
return vec![];
|
||
};
|
||
// Edge case 5: empty/whitespace draft cancels without committing.
|
||
let trimmed = rn.draft.trim();
|
||
if trimmed.is_empty() {
|
||
return vec![];
|
||
}
|
||
let title: String = crate::views::session_title::sanitize_display_text(trimmed).into_owned();
|
||
if title.is_empty() {
|
||
return vec![];
|
||
}
|
||
let crate::views::dashboard::DashboardRowId::TopLevel(agent_id) = rn.row else {
|
||
return vec![];
|
||
};
|
||
let mut effects = Vec::new();
|
||
if let Some(agent) = app.agents.get_mut(&agent_id) {
|
||
if let Some(session_id) = agent.session.session_id.clone() {
|
||
let cwd = agent.session.cwd.clone();
|
||
agent.display_name = Some(title.clone());
|
||
effects.push(Effect::RenameSession {
|
||
agent_id,
|
||
session_id,
|
||
title,
|
||
cwd,
|
||
});
|
||
} else {
|
||
agent.display_name = Some(title);
|
||
}
|
||
}
|
||
effects
|
||
}
|
||
|
||
/// Pick the dashboard row the cursor should land on after `closed` is
|
||
/// removed. Computed against the CURRENT (pre-removal) display order so
|
||
/// it mirrors what the user sees: the next visible row below `closed`
|
||
/// (so the cursor stays put while the rows below shift up — i.e. it
|
||
/// "moves down 1" relative to the list) or, when `closed` is the last
|
||
/// row, the previous visible row. Section headers are skipped so the
|
||
/// cursor always lands on an agent row. Returns `None` when `closed`
|
||
/// is the only row — the caller then clears the selection and lets
|
||
/// `reanchor_selection` fall back to the `[+ New Agent]` button.
|
||
///
|
||
/// Without this, closing the selected agent leaves a stale cursor that
|
||
/// `reanchor_selection` drops to `None`, and the next ↑/↓ restarts from
|
||
/// the top of the list — a jarring jump.
|
||
fn dashboard_neighbor_row(
|
||
app: &AppView,
|
||
closed: &crate::views::dashboard::DashboardRowId,
|
||
) -> Option<crate::views::dashboard::DashboardRowId> {
|
||
use crate::views::dashboard::Focusable;
|
||
let d = app.dashboard.as_ref()?;
|
||
let home = crate::views::dashboard::render::cached_home();
|
||
let roster: &[crate::app::roster::RosterEntry] = if app.leader_mode {
|
||
&app.leader_roster
|
||
} else {
|
||
&app.dashboard_local_sessions
|
||
};
|
||
let rows = crate::views::dashboard::build_rows_with_roster(
|
||
&app.agents,
|
||
&d.pinned,
|
||
&d.reorder,
|
||
None,
|
||
d.grouping,
|
||
&d.filter,
|
||
home,
|
||
roster,
|
||
);
|
||
let focusables = crate::views::dashboard::render::focusables(
|
||
&rows,
|
||
d.grouping,
|
||
&d.filter,
|
||
&d.collapsed_sections,
|
||
d.idle_show_all,
|
||
d.search_mode,
|
||
);
|
||
let cur = focusables
|
||
.iter()
|
||
.position(|f| matches!(f, Focusable::Row(id) if id == closed))?;
|
||
// Next row below (down 1); else the closed row was last → previous row.
|
||
let next = focusables[cur + 1..].iter().find_map(|f| match f {
|
||
Focusable::Row(id) => Some(id.clone()),
|
||
Focusable::Section(_) | Focusable::IdleOverflow => None,
|
||
});
|
||
next.or_else(|| {
|
||
focusables[..cur].iter().rev().find_map(|f| match f {
|
||
Focusable::Row(id) => Some(id.clone()),
|
||
Focusable::Section(_) | Focusable::IdleOverflow => None,
|
||
})
|
||
})
|
||
}
|
||
|
||
pub(super) fn dispatch_dashboard_stop(app: &mut AppView) -> Vec<Effect> {
|
||
use crate::views::dashboard::DashboardRowId;
|
||
use std::time::Instant;
|
||
|
||
let Some(sel) = app.dashboard.as_ref().and_then(|d| d.selected.clone()) else {
|
||
return vec![];
|
||
};
|
||
match &sel {
|
||
DashboardRowId::TopLevel(id) => {
|
||
let id = *id;
|
||
let Some(agent) = app.agents.get(&id) else {
|
||
return vec![];
|
||
};
|
||
let now = Instant::now();
|
||
// `t.elapsed()` is the idiomatic Instant API
|
||
// for "how long since this Instant". Behaviour identical
|
||
// to `now.duration_since(*t)` when `t <= now`, which is
|
||
// the only case the dispatcher constructs.
|
||
let already_confirming = app
|
||
.dashboard
|
||
.as_ref()
|
||
.and_then(|d| d.stop_confirm.as_ref())
|
||
.is_some_and(|(prev, t)| {
|
||
*prev == sel
|
||
&& t.elapsed() < crate::views::dashboard::state::STOP_CONFIRM_WINDOW
|
||
});
|
||
if already_confirming {
|
||
// Pick the cursor's next home BEFORE the row vanishes, so
|
||
// closing moves the selection down 1 instead of letting it
|
||
// go stale (which `reanchor_selection` drops to `None`,
|
||
// bouncing the next ↑/↓ back to the top of the list).
|
||
let neighbor = dashboard_neighbor_row(app, &sel);
|
||
if let Some(d) = app.dashboard.as_mut() {
|
||
d.stop_confirm = None;
|
||
}
|
||
// Second press: close the agent.
|
||
let effects = dispatch_sessions_confirm_close(app, id);
|
||
// Only move the cursor if the close actually happened
|
||
// (it's refused for the last remaining session).
|
||
if !app.agents.contains_key(&id)
|
||
&& let Some(d) = app.dashboard.as_mut()
|
||
{
|
||
match neighbor {
|
||
Some(n) => d.focus_row(n),
|
||
// No neighbour left — land on the always-present
|
||
// `[+ New Agent]` button via the focus helper so the
|
||
// "exactly one cursor active" invariant holds (a bare
|
||
// `selected = None` would leave no cursor and drop the
|
||
// footer into its defensive fallback).
|
||
None => d.focus_new_agent_button(),
|
||
}
|
||
}
|
||
return effects;
|
||
}
|
||
// First press: cancel turn if running, plant confirmation.
|
||
let mut effects = Vec::new();
|
||
if !agent.session.state.is_idle()
|
||
&& let Some(sid) = agent.session.session_id.clone()
|
||
{
|
||
effects.push(Effect::CancelTurn {
|
||
session_id: sid,
|
||
cancel_subagents: true,
|
||
trigger: None,
|
||
// Dashboard first-press cancel — no local prompt rewind.
|
||
rewind_if_pristine: false,
|
||
});
|
||
}
|
||
if let Some(d) = app.dashboard.as_mut() {
|
||
// The footer's `ShortcutsBar::with_pending` already
|
||
// paints the "press Ctrl+X again to close this
|
||
// session" prompt in the bottom bar. Surfacing the
|
||
// same line via `error_toast` would also bleed it
|
||
// into the dispatch input placeholder — two copies
|
||
// of the same hint, in two different places, with
|
||
// the dispatch one stealing visual weight from the
|
||
// user's typing area. The footer hint is the
|
||
// canonical surface.
|
||
d.stop_confirm = Some((sel, now));
|
||
}
|
||
effects
|
||
}
|
||
DashboardRowId::Subagent {
|
||
parent,
|
||
child_session_id,
|
||
} => {
|
||
let Some(agent) = app.agents.get_mut(parent) else {
|
||
return vec![];
|
||
};
|
||
let Some(info) = agent.subagent_sessions.get_mut(child_session_id) else {
|
||
return vec![];
|
||
};
|
||
let subagent_id = info.subagent_id.to_string();
|
||
info.pending_kill = true;
|
||
info.kill_requested_at = Some(Instant::now());
|
||
let session_id = agent.session.session_id.clone();
|
||
session_id
|
||
.map(|sid| Effect::KillSubagent {
|
||
session_id: sid,
|
||
subagent_id,
|
||
})
|
||
.into_iter()
|
||
.collect()
|
||
}
|
||
// Roster-only rows are hosted elsewhere — this client can't stop
|
||
// them.
|
||
DashboardRowId::Roster { .. } => vec![],
|
||
}
|
||
}
|
||
|
||
pub(super) fn dispatch_dashboard_toggle_grouping(app: &mut AppView) -> Vec<Effect> {
|
||
if let Some(d) = app.dashboard.as_mut() {
|
||
d.toggle_grouping();
|
||
}
|
||
dispatch_dashboard_persist(app)
|
||
}
|
||
|
||
pub(super) fn dispatch_dashboard_select(app: &mut AppView, next: bool) {
|
||
let Some(d) = app.dashboard.as_mut() else {
|
||
return;
|
||
};
|
||
// We don't have rows cached here; reconstruct from agents.
|
||
// Use the shared `cached_home()` instead of
|
||
// re-reading the env var on every keystroke.
|
||
let home = crate::views::dashboard::render::cached_home();
|
||
// Same roster source the renderer uses, so navigation matches the visible
|
||
// rows: leader roster in leader mode, local idle sessions otherwise.
|
||
// Disjoint field borrows (`app.dashboard` is held mutably via `d`).
|
||
let roster: &[crate::app::roster::RosterEntry] = if app.leader_mode {
|
||
&app.leader_roster
|
||
} else {
|
||
&app.dashboard_local_sessions
|
||
};
|
||
let rows = crate::views::dashboard::build_rows_with_roster(
|
||
&app.agents,
|
||
&d.pinned,
|
||
&d.reorder,
|
||
None,
|
||
d.grouping,
|
||
&d.filter,
|
||
home,
|
||
roster,
|
||
);
|
||
// Unified, display-order cursor targets: section headers AND visible
|
||
// rows (collapsed sections contribute only their header, so their
|
||
// hidden rows are skipped). Placeholders are excluded.
|
||
let focusables = crate::views::dashboard::render::focusables(
|
||
&rows,
|
||
d.grouping,
|
||
&d.filter,
|
||
&d.collapsed_sections,
|
||
d.idle_show_all,
|
||
d.search_mode,
|
||
);
|
||
let set_cursor = |d: &mut crate::views::dashboard::DashboardState,
|
||
f: &crate::views::dashboard::Focusable| {
|
||
match f {
|
||
crate::views::dashboard::Focusable::Section(key) => d.focus_section(*key),
|
||
crate::views::dashboard::Focusable::Row(id) => d.focus_row(id.clone()),
|
||
crate::views::dashboard::Focusable::IdleOverflow => d.focus_idle_overflow(),
|
||
}
|
||
};
|
||
// Button-focused navigation contract:
|
||
// - Down on the button → first focusable (header or row).
|
||
// - Up on the button → stay on the button (no wrap).
|
||
if d.new_agent_button_focused {
|
||
if next && !focusables.is_empty() {
|
||
set_cursor(d, &focusables[0]);
|
||
d.clear_manual_scroll();
|
||
}
|
||
return;
|
||
}
|
||
if focusables.is_empty() {
|
||
// Nothing to land on AND button isn't focused → fall back to the
|
||
// button so the cursor lives somewhere.
|
||
d.focus_new_agent_button();
|
||
return;
|
||
}
|
||
// Current index from the active cursor (section header or row).
|
||
let cur = focusables
|
||
.iter()
|
||
.position(|f| match f {
|
||
crate::views::dashboard::Focusable::Section(key) => d.selected_section == Some(*key),
|
||
crate::views::dashboard::Focusable::Row(id) => d.selected.as_ref() == Some(id),
|
||
crate::views::dashboard::Focusable::IdleOverflow => d.selected_idle_overflow,
|
||
})
|
||
.unwrap_or(0);
|
||
// Up on the first focusable → focus the `[+ New Agent]` button.
|
||
// The button acts as a sentinel above index 0, exactly like the
|
||
// agent's tabs in the agents modal.
|
||
if !next && cur == 0 {
|
||
d.focus_new_agent_button();
|
||
d.clear_manual_scroll();
|
||
return;
|
||
}
|
||
let new = if next {
|
||
(cur + 1).min(focusables.len() - 1)
|
||
} else {
|
||
cur.saturating_sub(1)
|
||
};
|
||
set_cursor(d, &focusables[new]);
|
||
// Arrow-key nav is selection-driven: re-engage the clamp's
|
||
// snap-to-selection so the viewport tracks the cursor. Without
|
||
// this, ↑/↓ after a wheel scroll would leave the cursor visibly
|
||
// selected on a row outside the viewport.
|
||
d.clear_manual_scroll();
|
||
}
|
||
|
||
pub(super) fn dispatch_dashboard_reorder(app: &mut AppView, up: bool) -> Vec<Effect> {
|
||
let Some(d) = app.dashboard.as_mut() else {
|
||
return vec![];
|
||
};
|
||
let Some(sel) = d.selected.clone() else {
|
||
return vec![];
|
||
};
|
||
// Maintain a stable explicit ordering list. The renderer applies
|
||
// this list as a "float to position" rule inside the row's group.
|
||
let pos = d.reorder.iter().position(|r| *r == sel);
|
||
if up {
|
||
match pos {
|
||
Some(0) => {
|
||
d.reorder.remove(0);
|
||
}
|
||
Some(i) => {
|
||
d.reorder.swap(i, i - 1);
|
||
}
|
||
None => {
|
||
d.reorder.insert(0, sel);
|
||
}
|
||
}
|
||
} else {
|
||
match pos {
|
||
Some(i) if i + 1 < d.reorder.len() => {
|
||
d.reorder.swap(i, i + 1);
|
||
}
|
||
Some(_) => {
|
||
// Already at the bottom — append to end.
|
||
}
|
||
None => {
|
||
d.reorder.push(sel);
|
||
}
|
||
}
|
||
}
|
||
dispatch_dashboard_persist(app)
|
||
}
|
||
|
||
fn dispatch_dashboard_persist(app: &mut AppView) -> Vec<Effect> {
|
||
let Some(d) = app.dashboard.as_ref() else {
|
||
return vec![];
|
||
};
|
||
// Don't hardcode `enabled = true`. Thread through
|
||
// the on-disk value so a user who deliberately set `enabled =
|
||
// false` doesn't get it silently overwritten on the next pin or
|
||
// grouping toggle.
|
||
let enabled = app
|
||
.dashboard_persisted
|
||
.as_ref()
|
||
.map(|p| p.enabled)
|
||
.unwrap_or(true);
|
||
let resolver = crate::views::dashboard::SessionIdResolver::from_agents(&app.agents);
|
||
let persisted = d.to_persisted(enabled, &resolver);
|
||
app.dashboard_persisted = Some(persisted.clone());
|
||
vec![Effect::PersistDashboard(persisted)]
|
||
}
|
||
|
||
/// Answer a permission request from the
|
||
/// dashboard peek panel without going through `PermissionSelect`,
|
||
/// which only works when `active_view == Agent(_)`. Routes directly
|
||
/// to the row's owning agent and verifies the request_id has not
|
||
/// rotated since the peek snapshot was taken.
|
||
pub(super) fn dispatch_dashboard_permission_select(
|
||
app: &mut AppView,
|
||
row: crate::views::dashboard::DashboardRowId,
|
||
request_id: usize,
|
||
option_id: acp::PermissionOptionId,
|
||
) -> Vec<Effect> {
|
||
// Determine the owning AgentId.
|
||
let target_id = match &row {
|
||
crate::views::dashboard::DashboardRowId::TopLevel(id) => *id,
|
||
crate::views::dashboard::DashboardRowId::Subagent { parent, .. } => *parent,
|
||
crate::views::dashboard::DashboardRowId::Roster { .. } => return vec![],
|
||
};
|
||
let Some(agent) = app.agents.get_mut(&target_id) else {
|
||
if let Some(d) = app.dashboard.as_mut() {
|
||
d.set_peek(None);
|
||
d.set_error_toast("Row no longer exists");
|
||
}
|
||
return vec![];
|
||
};
|
||
// Stale-snapshot guard.
|
||
let front_matches = agent
|
||
.permission_queue
|
||
.front()
|
||
.is_some_and(|p| p.id == request_id);
|
||
if !front_matches {
|
||
if let Some(d) = app.dashboard.as_mut() {
|
||
d.set_peek(None);
|
||
d.set_error_toast("Permission has changed — re-open peek");
|
||
}
|
||
return vec![];
|
||
}
|
||
let Some(perm) = agent.permission_queue.pop_front() else {
|
||
return vec![];
|
||
};
|
||
|
||
let meta = if let Some(scope) = perm
|
||
.mcp_scope
|
||
.as_ref()
|
||
.filter(|_| option_id.0.as_ref() == "allow-always-mcp")
|
||
{
|
||
let selection = match scope.selected {
|
||
crate::views::permission_view::McpScope::Tool => {
|
||
kigi_workspace::permission::McpScopeSelection::Tool {
|
||
tool_name: scope.tool_name.clone(),
|
||
}
|
||
}
|
||
crate::views::permission_view::McpScope::Server => match &scope.server_prefix {
|
||
Some(prefix) => kigi_workspace::permission::McpScopeSelection::Server {
|
||
server: prefix.clone(),
|
||
},
|
||
None => kigi_workspace::permission::McpScopeSelection::Tool {
|
||
tool_name: scope.tool_name.clone(),
|
||
},
|
||
},
|
||
};
|
||
serde_json::to_value(selection)
|
||
.ok()
|
||
.and_then(|v| v.as_object().cloned())
|
||
} else if let Some(ref h) = perm.bash_highlights
|
||
&& perm.bash_selection_count > 0
|
||
{
|
||
let parts: Vec<String> = h.highlighted_words[..perm.bash_selection_count].to_vec();
|
||
serde_json::to_value(kigi_workspace::permission::BashCommandSelectedTerms {
|
||
command_parts: parts,
|
||
})
|
||
.ok()
|
||
.and_then(|v| v.as_object().cloned())
|
||
} else {
|
||
None
|
||
};
|
||
|
||
perm.request
|
||
.response_tx
|
||
.send(Ok(acp::RequestPermissionResponse::new(
|
||
acp::RequestPermissionOutcome::Selected(acp::SelectedPermissionOutcome::new(option_id)),
|
||
)
|
||
.meta(meta)))
|
||
.ok();
|
||
|
||
resolve_permission_queue_transition(agent);
|
||
|
||
// Refresh the peek (it likely no longer has a question).
|
||
if let Some(d) = app.dashboard.as_mut() {
|
||
d.set_peek(None);
|
||
}
|
||
vec![]
|
||
}
|
||
|
||
/// Reject the peeked agent's pending permission with a typed feedback
|
||
/// message — the peek panel's "No, type to add feedback" path.
|
||
///
|
||
/// Mirrors [`dispatch_permission_followup`](super::permissions::dispatch_permission_followup) (resolve the front request
|
||
/// with the `RejectOnce` option + `followup_message` meta) but targets
|
||
/// the dashboard row's agent instead of the active view, with the same
|
||
/// stale-request guard as [`dispatch_dashboard_permission_select`].
|
||
pub(super) fn dispatch_dashboard_permission_followup(
|
||
app: &mut AppView,
|
||
row: crate::views::dashboard::DashboardRowId,
|
||
request_id: usize,
|
||
text: String,
|
||
) -> Vec<Effect> {
|
||
let target_id = match &row {
|
||
crate::views::dashboard::DashboardRowId::TopLevel(id) => *id,
|
||
crate::views::dashboard::DashboardRowId::Subagent { parent, .. } => *parent,
|
||
crate::views::dashboard::DashboardRowId::Roster { .. } => return vec![],
|
||
};
|
||
let Some(agent) = app.agents.get_mut(&target_id) else {
|
||
if let Some(d) = app.dashboard.as_mut() {
|
||
d.set_peek(None);
|
||
d.set_error_toast("Row no longer exists");
|
||
}
|
||
return vec![];
|
||
};
|
||
// Stale-snapshot guard — the front request must still match.
|
||
let front_matches = agent
|
||
.permission_queue
|
||
.front()
|
||
.is_some_and(|p| p.id == request_id);
|
||
if !front_matches {
|
||
if let Some(d) = app.dashboard.as_mut() {
|
||
d.set_peek(None);
|
||
d.set_error_toast("Permission has changed — re-open peek");
|
||
}
|
||
return vec![];
|
||
}
|
||
let Some(perm) = agent.permission_queue.pop_front() else {
|
||
return vec![];
|
||
};
|
||
// Resolve with the RejectOnce option + feedback; cancel if the
|
||
// request somehow has no reject option.
|
||
let option_id = perm
|
||
.options
|
||
.iter()
|
||
.find(|o| o.kind == acp::PermissionOptionKind::RejectOnce)
|
||
.map(|o| o.option_id.clone());
|
||
let outcome = match option_id {
|
||
Some(option_id) => {
|
||
acp::RequestPermissionOutcome::Selected(acp::SelectedPermissionOutcome::new(option_id))
|
||
}
|
||
None => acp::RequestPermissionOutcome::Cancelled,
|
||
};
|
||
let meta = if !text.trim().is_empty() {
|
||
serde_json::json!({ "followup_message": text })
|
||
.as_object()
|
||
.cloned()
|
||
} else {
|
||
None
|
||
};
|
||
perm.request
|
||
.response_tx
|
||
.send(Ok(acp::RequestPermissionResponse::new(outcome).meta(meta)))
|
||
.ok();
|
||
resolve_permission_queue_transition(agent);
|
||
if let Some(d) = app.dashboard.as_mut() {
|
||
d.set_peek(None);
|
||
}
|
||
vec![]
|
||
}
|
||
|
||
/// Answer the peeked agent's pending `AskUserQuestion` (the Ask tool)
|
||
/// from the dashboard peek panel — `option_idx` selects an option,
|
||
/// `None` + non-empty `freeform` submits the "Other" free-text answer.
|
||
/// Delegates to [`AgentView::dashboard_answer_question`], which sends the
|
||
/// ext-response; the peek closes once an answer is actually submitted.
|
||
pub(super) fn dispatch_dashboard_question_answer(
|
||
app: &mut AppView,
|
||
row: crate::views::dashboard::DashboardRowId,
|
||
option_idx: Option<usize>,
|
||
freeform: String,
|
||
) -> Vec<Effect> {
|
||
let target_id = match &row {
|
||
crate::views::dashboard::DashboardRowId::TopLevel(id) => *id,
|
||
crate::views::dashboard::DashboardRowId::Subagent { parent, .. } => *parent,
|
||
crate::views::dashboard::DashboardRowId::Roster { .. } => return vec![],
|
||
};
|
||
let Some(agent) = app.agents.get_mut(&target_id) else {
|
||
if let Some(d) = app.dashboard.as_mut() {
|
||
d.set_peek(None);
|
||
d.set_error_toast("Row no longer exists");
|
||
}
|
||
return vec![];
|
||
};
|
||
match agent.dashboard_answer_question(option_idx, freeform) {
|
||
// Whole form answered → close the peek.
|
||
crate::app::agent_view::PeekAnswerOutcome::Submitted => {
|
||
if let Some(d) = app.dashboard.as_mut() {
|
||
d.set_peek(None);
|
||
}
|
||
}
|
||
// Advanced to the next question → keep the peek open but reset the
|
||
// per-question draft (option cursor + free-text) so the next
|
||
// question starts fresh; the next render refreshes its content.
|
||
crate::app::agent_view::PeekAnswerOutcome::Advanced => {
|
||
if let Some(d) = app.dashboard.as_mut() {
|
||
if let Some(p) = d.peek.as_mut() {
|
||
p.selected_option = None;
|
||
}
|
||
d.clear_peek_reply();
|
||
}
|
||
}
|
||
// Nothing happened (e.g. empty "Other") → leave the peek as-is.
|
||
crate::app::agent_view::PeekAnswerOutcome::NoOp => {}
|
||
}
|
||
vec![]
|
||
}
|