Files
Kigi-CLI/crates/codegen/kigi-tui/src/app/dispatch/dashboard.rs
T
ZacharyZhang-NY ea0ce9d15f F3: Kimi inference pipeline + full grok cloud-surface excision
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"
2026-07-17 16:05:51 -04:00

2244 lines
94 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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![]
}