Files
Kigi-CLI/crates/codegen/kigi-tui/src/views/dashboard/row.rs
T
ZacharyZhang-NY 6f31415ed6 §9 acceptance: grep-zero sweep — every internal x.ai/grok identifier renamed
The PRD's first acceptance gate now holds: grep -RinE '\bx\.ai\b|grok'
crates/ --include='*.rs' → 0 matches (exempt: NOTICE and third-party
license archives, README provenance, and the required 'Based on Grok
Build Open Source' attribution, now sourced from version_attribution.txt).

Wire-visible renames (both sides in this repo, changed in lockstep):
- Auth method id 'grok.com' → 'kimi-code' (AuthMethodKind::KimiCode).
- Every x.ai/* and _x.ai/* ACP ext method and meta key → kigi/* /
  _kigi/* (~200 names; grokShell → kigiShell). Session-file replay keeps
  a read-side alias for the legacy '_x.ai/session/update' method so
  existing updates.jsonl histories load; writes emit only the new name
  (both directions test-pinned).
- Agent types grok-build* → kigi* with a documented legacy-prefix alias
  at resolution time so persisted sessions keep resolving.
- ToolNamespace/BuiltinAgentName GrokBuild* → Kigi* (wire snake_case
  kigi/kigi_concise/kigi_hashline; schema regenerated); grok_build
  implementation dirs renamed to kigi*.
- x-grok-* headers → x-kigi-*, __GROK_* sentinels → __KIGI_*, themes
  grokday/groknight → kigiday/kiginight (old persisted values fall back
  to the default theme), web_fetch allowlist xAI hosts → kimi.com +
  moonshot platforms, changelog CDN → this repo, grok-build changelog
  archives deleted.
- BYOK default endpoint removed: [endpoints] api_base_url is now truly
  optional with NO default — consumers fail fast with the flag name when
  unset (no silent x.ai egress). Mock harnesses inject it explicitly.
- System-prompt identity fixed: 'released by xAI' → 'an unofficial
  community CLI for Kimi' (template + regenerated encrypted form).

Also repaired pre-existing grok-era test debt found by the sweep: the
stale trace_classify default-model pin, the grok-pager UA label test,
pty-harness stale-binary reuse and non-hermetic moonshot routing (a PTY
test could previously reach the real api.moonshot.cn), and the outdated
oauth fixture scope key.

Gates: §9 grep 0; fmt clean; workspace check/clippy 0/0 (-D warnings);
FULL cargo test --workspace: 234 suites, 21,961 passed, 0 failed;
deny advisories ok.
2026-07-18 02:48:46 -04:00

2028 lines
76 KiB
Rust

//! Dashboard rows: classification, build, filter, sort.
use super::state::{DashboardRowId, Filter, RowState};
use crate::acp::tracker::TurnActivity;
use crate::app::agent::AgentId;
use crate::app::agent_view::AgentView;
use crate::app::roster::{RosterActivity, RosterEntry};
use crate::app::subagent::{SubagentInfo, format_activity_label, format_subagent_label};
use indexmap::IndexMap;
use std::path::PathBuf;
use std::time::{Instant, SystemTime};
/// Title prefix for a session that has no name / generated title / prompt
/// yet. The renderer paints this part in the primary colour and the trailing
/// ` #<id>` suffix in dim gray (see `render::render_row`).
pub(crate) const NEW_SESSION_LABEL: &str = "New session";
/// A single row in the dashboard. Built per-frame from `app.agents`.
#[derive(Debug, Clone)]
pub struct DashboardRow {
pub id: DashboardRowId,
/// Display label (e.g. `"implementer · fix login bug"`).
pub label: String,
/// Right-of-label subtitle painted after a ` · ` separator in
/// dim text (e.g. `"xai my-branch-2 worktree"`). `None` when the
/// row has no repo / branch context worth surfacing.
pub subtitle: Option<String>,
/// Coarse state used for grouping.
pub state: RowState,
/// Activity summary (e.g. `"Running: cargo test"`). `None` when
/// idle or finished.
pub activity: Option<String>,
/// Secondary dim line painted directly below the title row —
/// holds the last tool call, the last assistant message, or
/// (for `NeedsInput`) a "Pending: …" preview of the front-most
/// permission request. `None` collapses the row to a single
/// line (e.g. very recently created with no activity yet).
pub secondary_line: Option<String>,
/// Working directory display string (already compacted to `~/...`).
pub cwd_display: String,
/// Raw cwd for downstream filter / grouping consumers.
pub cwd: PathBuf,
/// Wall-clock moment of the row's last change — drives the age column and
/// is the recency tiebreaker for sort (more recent floats higher in its
/// group).
///
/// Wall-clock [`SystemTime`], not a monotonic [`Instant`]: roster
/// timestamps can predate this process — even the machine's boot — which
/// an `Instant` cannot represent (its floor is system boot, so an older
/// moment underflows and collapses back to "just now"). Local rows project
/// their live `Instant` anchors onto the wall clock via
/// [`crate::util::system_time_from_instant`].
pub last_change_at: SystemTime,
/// True when this row is pinned (always floats above non-pinned).
pub pinned: bool,
/// True when this row is currently the active view target (so the
/// renderer can highlight it). Reserved — wired through but
/// always `false` in v1; populated when we eventually support
/// "the agent you came from" highlighting from the dashboard.
#[allow(dead_code)]
pub is_active: bool,
/// Optional row badges (e.g. `worktree`, `needs-input`).
pub badges: Vec<RowBadge>,
/// Context window usage percent, when known. Drives the mini gauge.
pub context_pct: Option<u8>,
/// Indent level for nested subagent rendering. 0 = top-level,
/// 1 = subagent under its parent.
pub indent: u8,
/// Display title for the parent agent — used to scope group
/// headers in `Grouping::Directory` mode where subagents must
/// sort with their parent.
pub parent_label: Option<String>,
/// True when this row is a "… N more" collapse placeholder. The
/// renderer paints it dimmed and the cursor cannot land on it
/// (selectable=false).
pub is_more_placeholder: bool,
/// Number of rolled-up rows when `is_more_placeholder == true`.
pub more_count: usize,
}
/// Compact badge rendered next to the label.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RowBadge {
Worktree,
NeedsInput,
BgTask,
Pinned,
Failed,
}
impl RowBadge {
pub fn label(self) -> &'static str {
match self {
Self::Worktree => "worktree",
Self::NeedsInput => "needs-input",
Self::BgTask => "bg",
Self::Pinned => "pinned",
Self::Failed => "failed",
}
}
}
/// Maximum number of subagents to show per parent before collapsing
/// completed/failed ones into a "… N more" row (edge case 6).
pub const MAX_VISIBLE_SUBAGENTS: usize = 8;
/// Process-wide fallback anchor for the age column when an agent
/// somehow lacks both `turn_started_at` AND `last_active_at` (rare —
/// `AgentView::new` always stamps the latter, but a hypothetical
/// future caller could omit it). Initialised lazily on first call so
/// the fallback is genuinely frozen rather than re-anchoring every
/// frame.
fn fallback_epoch() -> Instant {
static FALLBACK: std::sync::OnceLock<Instant> = std::sync::OnceLock::new();
*FALLBACK.get_or_init(Instant::now)
}
/// Build the dashboard row list from the live `app.agents` map.
///
/// Iterates `agents` so a stale `AgentId` can never panic (edge case 2).
/// Subagents are appended directly after their parent — never reordered
/// across parents — and a `… N more` collapse placeholder appears at
/// the parent's scope once more than [`MAX_VISIBLE_SUBAGENTS`] rows
/// exist (edge case 6).
pub fn build_rows(
agents: &IndexMap<AgentId, AgentView>,
pinned: &std::collections::BTreeSet<DashboardRowId>,
reorder: &[DashboardRowId],
active: Option<AgentId>,
grouping: super::state::Grouping,
filter: &Filter,
home: Option<&str>,
) -> Vec<DashboardRow> {
let mut rows = build_local_rows(agents, pinned, active, home, true);
apply_filter(&mut rows, filter, home);
sort_rows(&mut rows, grouping, reorder);
rows
}
/// Build the row list shown by the live dashboard (rendering in
/// [`super::render::render_dashboard`] and keyboard navigation in
/// `dispatch_dashboard_select` both go through here).
///
/// Like [`build_rows`] but (a) appends "roster-only" rows for leader
/// sessions this client is NOT locally attached to (FleetView dashboard)
/// and (b) does NOT surface subagents as their own rows — only top-level
/// agents and roster sessions are listed (see [`build_local_rows`]).
///
/// Local `AgentView` rows are built first (richest data). Then, for each
/// [`RosterEntry`] whose `session_id` is not already represented by a
/// local agent, a synthetic [`DashboardRow`] is appended. In non-leader
/// mode `roster` is empty.
#[allow(clippy::too_many_arguments)]
pub fn build_rows_with_roster(
agents: &IndexMap<AgentId, AgentView>,
pinned: &std::collections::BTreeSet<DashboardRowId>,
reorder: &[DashboardRowId],
active: Option<AgentId>,
grouping: super::state::Grouping,
filter: &Filter,
home: Option<&str>,
roster: &[RosterEntry],
) -> Vec<DashboardRow> {
let mut rows = build_local_rows(agents, pinned, active, home, false);
append_roster_rows(&mut rows, roster, agents, pinned, home);
apply_filter(&mut rows, filter, home);
sort_rows(&mut rows, grouping, reorder);
rows
}
/// Build the local-agent rows WITHOUT applying filter or sort. Shared by
/// [`build_rows`] and [`build_rows_with_roster`].
///
/// `include_subagents` controls whether each parent's subagent sessions are
/// appended as nested rows. The live dashboard passes `false` (subagents are
/// not surfaced as their own rows — see [`build_rows_with_roster`]); callers
/// that want the full tree (and the row-building unit tests) pass `true` via
/// [`build_rows`].
fn build_local_rows(
agents: &IndexMap<AgentId, AgentView>,
pinned: &std::collections::BTreeSet<DashboardRowId>,
active: Option<AgentId>,
home: Option<&str>,
include_subagents: bool,
) -> Vec<DashboardRow> {
let mut rows = Vec::new();
for (id, agent) in agents.iter() {
let top_id = DashboardRowId::TopLevel(*id);
if is_empty_top_level(agent)
&& !pinned.contains(&top_id)
&& !matches!(
classify_top_level(agent),
RowState::Working | RowState::NeedsInput
)
{
continue;
}
let row = top_level_row(
*id,
agent,
pinned.contains(&top_id),
active == Some(*id),
home,
);
rows.push(row);
if !include_subagents {
continue;
}
let mut subagents: Vec<&SubagentInfo> = agent.subagent_sessions.values().collect();
subagents.sort_by(|a, b| {
let a_running = !a.finished;
let b_running = !b.finished;
match (a_running, b_running) {
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
_ => b.started_at.cmp(&a.started_at),
}
});
let total = subagents.len();
let keep = MAX_VISIBLE_SUBAGENTS.min(total);
for info in subagents.iter().take(keep) {
rows.push(subagent_row(
*id,
agent,
info,
pinned.contains(&DashboardRowId::Subagent {
parent: *id,
child_session_id: info.child_session_id.to_string(),
}),
home,
));
}
if total > keep {
let agent_label = top_level_label(agent);
rows.push(DashboardRow {
id: DashboardRowId::Subagent {
parent: *id,
child_session_id: format!("__more_{}", id.0),
},
label: format!("\u{2026} {} more", total - keep),
subtitle: None,
state: RowState::Idle,
activity: None,
secondary_line: None,
cwd_display: String::new(),
cwd: agent.session.cwd.clone(),
last_change_at: SystemTime::now(),
pinned: false,
is_active: false,
badges: Vec::new(),
context_pct: None,
indent: 1,
parent_label: Some(agent_label),
is_more_placeholder: true,
more_count: total - keep,
});
}
}
rows
}
/// Map a leader [`RosterActivity`] to the dashboard's coarse [`RowState`].
fn roster_activity_to_state(activity: RosterActivity) -> RowState {
match activity {
RosterActivity::Working => RowState::Working,
RosterActivity::NeedsInput => RowState::NeedsInput,
RosterActivity::Idle | RosterActivity::Dormant => RowState::Inactive,
RosterActivity::Completed => RowState::Completed,
RosterActivity::Dead => RowState::Failed,
}
}
/// Append "roster-only" rows for leader sessions not represented by a
/// local `AgentView`. Skips any roster entry whose `session_id` already
/// matches a locally-attached agent (those carry richer data via
/// [`build_local_rows`]).
fn append_roster_rows(
rows: &mut Vec<DashboardRow>,
roster: &[RosterEntry],
agents: &IndexMap<AgentId, AgentView>,
pinned: &std::collections::BTreeSet<DashboardRowId>,
home: Option<&str>,
) {
if roster.is_empty() {
return;
}
let local_ids: std::collections::HashSet<&str> = agents
.values()
.filter_map(|a| a.session.session_id.as_ref().map(|s| s.0.as_ref()))
.collect();
for entry in roster {
if local_ids.contains(entry.session_id.as_str()) {
continue;
}
let id = DashboardRowId::Roster {
session_id: entry.session_id.clone(),
};
let has_title = entry
.title
.as_deref()
.map(str::trim)
.is_some_and(|t| !t.is_empty());
let active = matches!(
entry.activity,
RosterActivity::Working | RosterActivity::NeedsInput
);
if !has_title && !active && !pinned.contains(&id) {
continue;
}
let cwd = PathBuf::from(&entry.cwd);
let label = entry
.title
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(sanitize)
.or_else(|| {
cwd.file_name()
.and_then(|n| n.to_str())
.filter(|s| !s.is_empty())
.map(sanitize)
})
.unwrap_or_else(|| sanitize(&entry.session_id));
let state = roster_activity_to_state(entry.activity);
let activity = match state {
RowState::NeedsInput => Some("Awaiting input".to_string()),
RowState::Working => Some("Working".to_string()),
_ => None,
};
let mut badges = Vec::new();
if entry.is_worktree {
badges.push(RowBadge::Worktree);
}
if entry.activity == RosterActivity::NeedsInput {
badges.push(RowBadge::NeedsInput);
}
let cwd_display = super::state::compact_cwd(&cwd, home);
let is_pinned = pinned.contains(&id);
rows.push(DashboardRow {
id,
label,
subtitle: None,
state,
activity,
secondary_line: entry
.model_id
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(sanitize),
cwd_display,
cwd,
last_change_at: crate::util::system_time_from_unix_ms(entry.last_change_unix_ms),
pinned: is_pinned,
is_active: false,
badges,
context_pct: None,
indent: 0,
parent_label: None,
is_more_placeholder: false,
more_count: 0,
});
}
}
/// Classify a top-level agent.
///
/// Returns one of [`RowState::NeedsInput`], [`RowState::Working`],
/// [`RowState::Idle`].
///
/// `NeedsInput` triggers on pending permission OR pending
/// `QuestionView` (`ask_user_question` tool). `Working` covers any
/// non-idle session state, including command runs (compaction,
/// worktree create, etc.) — which is why edge case 14 (compaction)
/// works: a row in `AgentState::CommandRunning { Compact, .. }` is
/// "Working" and the activity label reads `Compacting`. A turn-idle
/// agent still classifies as `Working` while it has live background work
/// (`has_background_work`): a running background task / `monitor` or an
/// active scheduled `/loop`. Each is ongoing, user-dispatched work — and
/// monitors / loops can wake the agent for a fresh turn — so the agent
/// isn't meaningfully idle while any are running.
pub fn classify_top_level(agent: &AgentView) -> RowState {
if !agent.permission_queue.is_empty() || agent.question_view.is_some() {
return RowState::NeedsInput;
}
if !agent.session.state.is_idle()
|| agent.session.turn_activity().is_some()
|| !agent.session.pending_prompts.is_empty()
{
return RowState::Working;
}
if agent.session.loading_replay {
return RowState::Working;
}
if has_background_work(agent) {
return RowState::Working;
}
RowState::Idle
}
/// Whether `agent` has live background work that keeps it out of the
/// `Idle` group even when its turn is idle: a running background task
/// (`run_terminal_command` with `background=true`), a running `monitor`
/// (a background task with `is_monitor`), or an active scheduled `/loop`.
/// Mirrors the agent view's idle "watching" cue
/// (`crate::views::turn_status::Watchers`) but also counts plain
/// background tasks — any in-flight background work the user dispatched
/// should read as "Working" on the dashboard.
pub fn has_background_work(agent: &AgentView) -> bool {
agent
.session
.bg_tasks
.values()
.any(|t| t.status == crate::app::agent::BgTaskStatus::Running)
|| !agent.session.scheduled_tasks.is_empty()
}
/// Compact `"watching · …"` label summarising a turn-idle agent's live
/// background work, listing only the non-zero kinds with singular/plural
/// nouns — e.g. `"watching · 1 monitor · 2 loops"` or
/// `"watching · 1 task"`. `None` when there's no background work (the
/// caller then falls back to a bare `"Working"`). Mirrors the agent
/// view's idle "watching" cue (`turn_status::watching_label`) so the
/// dashboard and the agent view speak the same language; the counting
/// matches [`has_background_work`].
fn background_work_label(agent: &AgentView) -> Option<String> {
use std::fmt::Write as _;
let mut monitors = 0usize;
let mut tasks = 0usize;
for t in agent.session.bg_tasks.values() {
if t.status != crate::app::agent::BgTaskStatus::Running {
continue;
}
if t.is_monitor {
monitors += 1;
} else {
tasks += 1;
}
}
let loops = agent.session.scheduled_tasks.len();
if monitors + tasks + loops == 0 {
return None;
}
let mut label = String::with_capacity(24);
label.push_str("watching");
if monitors > 0 {
let noun = if monitors == 1 { "monitor" } else { "monitors" };
let _ = write!(label, " \u{00b7} {monitors} {noun}");
}
if loops > 0 {
let noun = if loops == 1 { "loop" } else { "loops" };
let _ = write!(label, " \u{00b7} {loops} {noun}");
}
if tasks > 0 {
let noun = if tasks == 1 { "task" } else { "tasks" };
let _ = write!(label, " \u{00b7} {tasks} {noun}");
}
Some(label)
}
/// Classify a subagent.
///
/// Subagents never enter `NeedsInput` in v1 — they have no user-prompt
/// channel. Asserted in tests.
pub fn classify_subagent(info: &SubagentInfo) -> RowState {
if info.finished {
match info.status.as_deref() {
Some("failed") | Some("cancelled") | Some("error") => RowState::Failed,
_ => RowState::Completed,
}
} else {
RowState::Working
}
}
/// Issues 30 + 31 fix — sanitise every string derived from
/// backend / model-controlled content before returning. The
/// dashboard's renderer paints raw via `set_string`, which preserves
/// embedded escape sequences in the ratatui buffer.
fn sanitize(s: &str) -> String {
crate::views::session_title::sanitize_display_text(s).into_owned()
}
/// Whether a top-level agent has no real conversation yet: no display name, no
/// generated title, no queued prompt, and no user-typed message in scrollback.
///
/// These are the exact conditions under which [`top_level_label`] falls back to
/// `New session #<id>`. A session created on pager launch carries only the
/// system prompt plus injected `<system-reminder>` context (which are not real
/// user turns and never render as `UserPrompt` blocks), so it reads as empty
/// here until the user actually sends something. Used by [`build_local_rows`]
/// to keep such sessions off the dashboard.
fn is_empty_top_level(agent: &AgentView) -> bool {
let has_text = |s: Option<&str>| s.map(str::trim).is_some_and(|t| !t.is_empty());
if has_text(agent.display_name.as_deref()) {
return false;
}
if has_text(agent.generated_session_title.as_deref()) {
return false;
}
if !agent.session.pending_prompts.is_empty() {
return false;
}
if !agent.subagent_sessions.is_empty() || !agent.session.bg_tasks.is_empty() {
return false;
}
if super::peek::extract_first_user_message(agent).is_some() {
return false;
}
true
}
fn top_level_label(agent: &AgentView) -> String {
if let Some(name) = agent.display_name.as_deref() {
let trimmed = name.trim();
if !trimmed.is_empty() {
return sanitize(trimmed);
}
}
if let Some(title) = agent.generated_session_title.as_deref() {
let trimmed = title.trim();
if !trimmed.is_empty() {
return sanitize(trimmed);
}
}
if let Some(first) = agent.session.pending_prompts.front() {
let preview: String = first.text.trim().chars().take(30).collect();
if !preview.is_empty() {
return sanitize(&preview);
}
}
if let Some(first_msg) = super::peek::extract_first_user_message(agent) {
let preview: String = first_msg.chars().take(30).collect();
if !preview.is_empty() {
return preview;
}
}
if let Some(sid) = agent.session.session_id.as_ref() {
let short: String = sid.0.chars().take(8).collect();
return format!("{NEW_SESSION_LABEL} #{short}");
}
NEW_SESSION_LABEL.to_string()
}
fn top_level_row(
id: AgentId,
agent: &AgentView,
pinned: bool,
is_active: bool,
home: Option<&str>,
) -> DashboardRow {
let state = classify_top_level(agent);
let label = top_level_label(agent);
let subtitle = top_level_subtitle(agent);
let activity = top_level_activity(agent, state);
let secondary_line = top_level_secondary_line(agent, state, activity.as_deref());
let anchor: Instant = match state {
RowState::Working => agent
.turn_started_at
.or(agent.last_active_at)
.unwrap_or_else(fallback_epoch),
RowState::NeedsInput
| RowState::Idle
| RowState::Inactive
| RowState::Completed
| RowState::Failed
| RowState::Blocked => agent.last_active_at.unwrap_or_else(fallback_epoch),
};
let last_change_at = crate::util::system_time_from_instant(anchor);
let mut badges = Vec::new();
if agent.is_worktree {
badges.push(RowBadge::Worktree);
}
if state == RowState::NeedsInput {
badges.push(RowBadge::NeedsInput);
}
if agent
.session
.bg_tasks
.values()
.any(|t| t.status == crate::app::agent::BgTaskStatus::Running)
{
badges.push(RowBadge::BgTask);
}
if pinned {
badges.push(RowBadge::Pinned);
}
let cwd_display = super::state::compact_cwd(&agent.session.cwd, home);
DashboardRow {
id: DashboardRowId::TopLevel(id),
label,
subtitle,
state,
activity,
secondary_line,
cwd_display,
cwd: agent.session.cwd.clone(),
last_change_at,
pinned,
is_active,
badges,
context_pct: agent.context_state.as_ref().map(|c| c.usage_pct),
indent: 0,
parent_label: None,
is_more_placeholder: false,
more_count: 0,
}
}
fn subagent_row(
parent: AgentId,
parent_view: &AgentView,
info: &SubagentInfo,
pinned: bool,
home: Option<&str>,
) -> DashboardRow {
let state = classify_subagent(info);
let (label_raw, desc_raw) = format_subagent_label(info);
let label = {
let label = sanitize(&label_raw);
let desc = sanitize(&desc_raw);
if desc.trim().is_empty() {
label
} else {
format!("{label} · {desc}")
}
};
let activity = subagent_activity(info, state);
let cwd = info
.child_cwd
.as_deref()
.map(PathBuf::from)
.unwrap_or_else(|| parent_view.session.cwd.clone());
let cwd_display = super::state::compact_cwd(&cwd, home);
let last_change_at = crate::util::system_time_from_instant(info.last_progress_at);
let mut badges = Vec::new();
if info.worktree_path.is_some() {
badges.push(RowBadge::Worktree);
}
if pinned {
badges.push(RowBadge::Pinned);
}
if state == RowState::Failed {
badges.push(RowBadge::Failed);
}
let parent_label = Some(top_level_label(parent_view));
let subtitle = subagent_subtitle(info, &cwd);
let secondary_line = subagent_secondary_line(info, state, activity.as_deref());
DashboardRow {
id: DashboardRowId::Subagent {
parent,
child_session_id: info.child_session_id.to_string(),
},
label,
subtitle,
state,
activity,
secondary_line,
cwd_display,
cwd,
last_change_at,
pinned,
is_active: false,
badges,
context_pct: info.context_usage_pct,
indent: 1,
parent_label,
is_more_placeholder: false,
more_count: 0,
}
}
/// Basename of a directory (the leaf folder name), or `None` for the
/// filesystem root / an empty path.
fn cwd_basename(cwd: &std::path::Path) -> Option<String> {
cwd.file_name()
.and_then(|n| n.to_str())
.map(|s| s.to_string())
}
/// Build the dim right-of-label subtitle for a top-level row.
///
/// Format: `{branch} {location} [worktree]` where `location` is the
/// worktree's human label when the agent's cwd is a managed worktree
/// (falling back to the cwd's folder name), and the cwd's folder name
/// otherwise. Branch first, then the worktree/dir name, then the
/// `worktree` marker. Examples:
///
/// - Worktree: `alice/feature location-picker worktree`
/// - Plain repo / subdir: `main foo`
/// - No branch (detached / non-git): just the folder name, or `None`
/// when even that is unavailable.
///
/// `current_branch` / `is_worktree` / `worktree_label` are refreshed when
/// the dashboard opens (see `dispatch_open_dashboard`) so the branch is
/// the latest, not a stale notification value.
fn top_level_subtitle(agent: &AgentView) -> Option<String> {
let lazy = crate::git_info::cwd_git_info_lazy(&agent.session.cwd);
let trimmed = |s: &str| {
let t = s.trim();
(!t.is_empty()).then(|| t.to_string())
};
let branch = agent
.current_branch
.as_deref()
.and_then(trimmed)
.or_else(|| {
lazy.as_ref()
.and_then(|i| i.branch.as_deref())
.and_then(trimmed)
});
let is_worktree = agent.is_worktree || lazy.as_ref().is_some_and(|i| i.is_worktree);
let location = if is_worktree {
agent
.worktree_label
.as_deref()
.and_then(trimmed)
.or_else(|| {
lazy.as_ref()
.and_then(|i| i.worktree_label.as_deref())
.and_then(trimmed)
})
.or_else(|| cwd_basename(&agent.session.cwd))
} else {
cwd_basename(&agent.session.cwd)
};
let mut parts: Vec<String> = Vec::new();
if let Some(b) = branch {
parts.push(sanitize(&b));
}
if let Some(loc) = location {
parts.push(sanitize(&loc));
}
if parts.is_empty() {
return None;
}
if is_worktree {
parts.push("worktree".to_string());
}
Some(parts.join(" "))
}
/// Compose the dim "last tool / message" line painted directly below
/// the title row. Driven by row state so the most relevant detail
/// surfaces:
///
/// - **NeedsInput** — front-most permission's title (`"Pending: {title}"`)
/// or the question view's prompt. Falls back to `activity` if both
/// are absent (shouldn't happen in practice; defensive).
/// - **Working** — the activity string (`"read foo.rs"`, etc.).
/// - **Idle / Completed / Failed / Blocked** — the last
/// `AgentMessage` block in scrollback, trimmed to a single line.
/// When no message is available yet (e.g. a session whose
/// scrollback hasn't been replayed because the user hasn't opened
/// it) it falls back to the model name so the line isn't blank;
/// `None` only when the model is also unknown.
///
/// All strings are sanitised before returning — the renderer paints
/// the result directly via `set_string` with no escape filtering.
fn top_level_secondary_line(
agent: &AgentView,
state: RowState,
activity: Option<&str>,
) -> Option<String> {
match state {
RowState::NeedsInput => {
if let Some(perm) = agent.permission_queue.front() {
let title = perm.title.trim();
if !title.is_empty() {
return Some(format!("Pending: {}", sanitize(title)));
}
}
if agent.question_view.is_some() {
return Some("Pending: question".to_string());
}
activity.map(sanitize)
}
RowState::Working => activity.map(sanitize),
RowState::Idle
| RowState::Inactive
| RowState::Completed
| RowState::Failed
| RowState::Blocked => last_agent_message_preview(agent),
}
}
/// Walk the scrollback from the end, returning the first
/// `AgentMessage` block's text trimmed to a single line. Returns
/// `None` when no agent message has been produced yet.
fn last_agent_message_preview(agent: &AgentView) -> Option<String> {
use crate::scrollback::block::RenderBlock;
let len = agent.scrollback.len();
for idx in (0..len).rev() {
let entry = agent.scrollback.get(idx)?;
if let RenderBlock::AgentMessage(msg) = &entry.block {
let text = msg.text();
let line = first_nonempty_line(&text)?;
return Some(sanitize(line.trim()));
}
}
None
}
/// Return the first non-empty trimmed line of `s`, or `None` when
/// every line is blank.
fn first_nonempty_line(s: &str) -> Option<&str> {
for line in s.lines() {
let trimmed = line.trim();
if !trimmed.is_empty() {
return Some(line);
}
}
None
}
/// Subagent equivalent of `top_level_subtitle`. Subagents don't carry
/// their own branch / repo metadata, so we show the cwd's folder name
/// (which is the worktree folder name when running in a worktree) plus a
/// `worktree` suffix when one is set.
fn subagent_subtitle(info: &SubagentInfo, cwd: &std::path::Path) -> Option<String> {
let name = cwd_basename(cwd)?;
if info.worktree_path.is_some() {
Some(format!("{name} worktree"))
} else {
Some(name)
}
}
/// Secondary line for subagent rows: the running tool (working) or
/// the duration summary (finished).
fn subagent_secondary_line(
_info: &SubagentInfo,
_state: RowState,
activity: Option<&str>,
) -> Option<String> {
activity.map(sanitize)
}
fn top_level_activity(agent: &AgentView, state: RowState) -> Option<String> {
match state {
RowState::NeedsInput => Some("Awaiting your input".to_string()),
RowState::Working => {
if let Some(cmd) = agent.session.state.command_in_flight() {
Some(format!("{}…", cmd.display_name()))
} else if let Some(activity) = agent.resolve_turn_activity() {
Some(sanitize(&format_activity_label(&activity)))
} else if agent.session.loading_replay {
Some("Loading…".to_string())
} else if let Some(bg) = background_work_label(agent) {
Some(bg)
} else {
Some("Working".to_string())
}
}
_ => None,
}
}
fn subagent_activity(info: &SubagentInfo, state: RowState) -> Option<String> {
if state == RowState::Working {
if let Some(label) = info
.activity_label
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
{
return Some(sanitize(label));
}
let last_tool = info.tools_used.last().map(|s| s.as_ref()).unwrap_or("");
if last_tool.is_empty() {
Some("Working".to_string())
} else {
Some(sanitize(&format_activity_label(
&TurnActivity::ToolRunning {
title: last_tool.to_string(),
description: None,
},
)))
}
} else if info.finished {
let turns = info.turns.unwrap_or(0);
let tools = info.tool_calls.unwrap_or(0);
let toks = info.tokens_used.unwrap_or(0);
Some(format!("{tools} tools · {toks} tok · {turns} turns"))
} else {
None
}
}
/// Apply a filter to the list in place.
///
/// Filter cases — see edge case 11.
///
/// Needle is lowercased once outside `retain` instead
/// of per-row, so a 100-row list doesn't allocate 100 fresh
/// lowercase Strings per keystroke.
pub fn apply_filter(rows: &mut Vec<DashboardRow>, filter: &Filter, _home: Option<&str>) {
if matches!(filter, Filter::None) {
return;
}
let needle_lower: Option<String> = match filter {
Filter::Agent(s) | Filter::Substring(s) => Some(s.to_lowercase()),
_ => None,
};
rows.retain(|r| {
if r.is_more_placeholder {
return true;
}
match filter {
Filter::None => true,
Filter::Agent(_) => {
let n = needle_lower.as_deref().unwrap_or("");
r.label.to_lowercase().contains(n)
|| r.parent_label
.as_deref()
.is_some_and(|p| p.to_lowercase().contains(n))
}
Filter::State(rs) => r.state == *rs,
Filter::Substring(_) => {
let n = needle_lower.as_deref().unwrap_or("");
if n.is_empty() {
return true;
}
r.label.to_lowercase().contains(n) || r.cwd_display.to_lowercase().contains(n)
}
}
});
}
/// Sort rows in place.
///
/// Order:
/// 1. Pinned first (regardless of group).
/// 2. Group primitive: descending state-priority in `State` grouping;
/// `cwd` in `Directory` grouping.
/// 3. `reorder` overrides (Shift+↑/↓) float a row to its declared index
/// INSIDE its group only — never across groups. In `State` grouping
/// this means within the same state (so the group can't be split
/// into `Idle → Working → Idle`); in `Directory` grouping it means
/// within the same cwd. See [`sort_cluster_key`].
/// 4. Then descending by `last_change_at`.
/// 5. Final tiebreak by `DashboardRowId` so the order is
/// deterministic across rebuilds even when every other field
/// ties. Asserted by `sort_rows_tiebreaks_by_id_when_keys_equal`.
/// 6. Subagents stay glued to their parent (we never sort the row
/// vector across parents — that's already enforced by the build
/// step which emits subagents immediately after their parent).
pub fn sort_rows(
rows: &mut [DashboardRow],
grouping: super::state::Grouping,
reorder: &[DashboardRowId],
) {
use super::state::Grouping;
match grouping {
Grouping::State => sort_within_groups(rows, reorder),
Grouping::Directory => sort_within_directory_groups(rows, reorder),
}
}
fn sort_within_groups(rows: &mut [DashboardRow], reorder: &[DashboardRowId]) {
let clusters = build_clusters(rows);
let mut indexed: Vec<(usize, usize, ClusterKey)> = clusters
.iter()
.map(|(start, end)| {
let parent = &rows[*start];
(
*start,
*end,
ClusterKey {
pinned: parent.pinned,
state: parent.state.group_priority(),
last_change_at: parent.last_change_at,
reorder_idx: reorder.iter().position(|r| *r == parent.id),
id: parent.id.clone(),
},
)
})
.collect();
indexed.sort_by(|a, b| sort_cluster_key(&a.2, &b.2, true));
let snapshot: Vec<DashboardRow> = rows.to_vec();
let mut write = 0usize;
for (start, end, _) in &indexed {
for src in snapshot.iter().take(*end).skip(*start) {
rows[write] = src.clone();
write += 1;
}
}
}
fn sort_within_directory_groups(rows: &mut [DashboardRow], reorder: &[DashboardRowId]) {
let clusters = build_clusters(rows);
let mut indexed: Vec<(usize, usize, (String, ClusterKey))> = clusters
.iter()
.map(|(start, end)| {
let parent = &rows[*start];
let key = ClusterKey {
pinned: parent.pinned,
state: parent.state.group_priority(),
last_change_at: parent.last_change_at,
reorder_idx: reorder.iter().position(|r| *r == parent.id),
id: parent.id.clone(),
};
(*start, *end, (parent.cwd_display.clone(), key))
})
.collect();
indexed.sort_by(|a, b| match a.2.0.cmp(&b.2.0) {
std::cmp::Ordering::Equal => sort_cluster_key(&a.2.1, &b.2.1, false),
other => other,
});
let snapshot: Vec<DashboardRow> = rows.to_vec();
let mut write = 0usize;
for (start, end, _) in &indexed {
for src in snapshot.iter().take(*end).skip(*start) {
rows[write] = src.clone();
write += 1;
}
}
}
#[derive(Debug, Clone)]
struct ClusterKey {
pinned: bool,
state: u8,
last_change_at: SystemTime,
reorder_idx: Option<usize>,
/// Final tiebreak: when every other field is equal,
/// fall back to the parent row's id so the order is deterministic
/// across rebuilds (no reliance on `sort_by`'s stable-on-equal
/// pass-through).
id: DashboardRowId,
}
/// Compare two cluster sort keys.
///
/// `state_before_reorder` decides where the explicit-reorder override
/// (Shift+↑/↓) sits relative to the state-priority key — and that choice
/// is what keeps the rendered groups contiguous:
///
/// - **State grouping (`true`)** — state is the grouping primitive AND
/// the primary sort key, so a reorder must only float a row *within*
/// its own state group. Ranking `reorder_idx` above `state` (the old
/// behaviour) let a reordered Idle row jump above the Working group,
/// which made the renderer emit a second "Idle" header below the
/// Working one (Idle → Working → Idle). Keeping `state` first confines
/// the reorder to one group, matching the documented contract
/// ("float to position inside the row's group") in
/// [`super::super::app::dispatch`]'s `dispatch_dashboard_reorder`.
/// - **Directory grouping (`false`)** — the cwd is the grouping
/// primitive (sorted ahead of this key in
/// [`sort_within_directory_groups`]), so within a cwd the reorder is
/// free to float across states; there are no state sub-headers to
/// split.
fn sort_cluster_key(
a: &ClusterKey,
b: &ClusterKey,
state_before_reorder: bool,
) -> std::cmp::Ordering {
use std::cmp::Ordering;
if a.pinned != b.pinned {
return b.pinned.cmp(&a.pinned);
}
let by_reorder = |a: &ClusterKey, b: &ClusterKey| match (a.reorder_idx, b.reorder_idx) {
(Some(x), Some(y)) => x.cmp(&y),
(Some(_), None) => Ordering::Less,
(None, Some(_)) => Ordering::Greater,
(None, None) => Ordering::Equal,
};
let by_state = |a: &ClusterKey, b: &ClusterKey| b.state.cmp(&a.state);
if state_before_reorder {
if a.state != b.state {
return by_state(a, b);
}
match by_reorder(a, b) {
Ordering::Equal => {}
ord => return ord,
}
} else {
match by_reorder(a, b) {
Ordering::Equal => {}
ord => return ord,
}
if a.state != b.state {
return by_state(a, b);
}
}
b.last_change_at
.cmp(&a.last_change_at)
.then_with(|| a.id.cmp(&b.id))
}
/// Identify `(start, end)` ranges of clustered rows — each cluster is
/// one top-level row followed by zero or more subagents.
fn build_clusters(rows: &[DashboardRow]) -> Vec<(usize, usize)> {
let mut clusters = Vec::new();
let mut i = 0;
while i < rows.len() {
let start = i;
i += 1;
while i < rows.len() && rows[i].indent > 0 {
i += 1;
}
clusters.push((start, i));
}
clusters
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
use std::time::{Duration, UNIX_EPOCH};
fn make_subagent(child_id: &str, finished: bool, status: Option<&str>) -> SubagentInfo {
let now = Instant::now();
SubagentInfo {
subagent_id: Arc::from(format!("sa-{child_id}")),
child_session_id: Arc::from(child_id),
description: Arc::from("test task"),
subagent_type: Arc::from("explore"),
persona: None,
role: None,
model: None,
context_source: None,
resumed_from: None,
capability_mode: None,
context_normalized: false,
child_updates_replayed: false,
parent_prompt_id: None,
started_at: now,
last_progress_at: now,
finished,
status: status.map(Arc::from),
error: None,
duration_ms: None,
tool_calls: None,
turns: None,
turn_count: None,
tool_call_count: None,
tokens_used: None,
context_window_tokens: None,
context_usage_pct: None,
tools_used: Vec::new(),
error_count: None,
activity_label: None,
is_background: false,
pending_kill: false,
kill_requested_at: None,
scrollback_entry_id: None,
prompt: None,
child_cwd: None,
worktree_path: None,
}
}
#[test]
fn classify_subagent_running() {
let info = make_subagent("a", false, None);
assert_eq!(classify_subagent(&info), RowState::Working);
}
#[test]
fn classify_subagent_completed() {
let info = make_subagent("a", true, Some("completed"));
assert_eq!(classify_subagent(&info), RowState::Completed);
}
#[test]
fn classify_subagent_failed() {
let info = make_subagent("a", true, Some("failed"));
assert_eq!(classify_subagent(&info), RowState::Failed);
}
#[test]
fn classify_subagent_cancelled() {
let info = make_subagent("a", true, Some("cancelled"));
assert_eq!(classify_subagent(&info), RowState::Failed);
}
#[test]
fn subagent_activity_prefers_live_label_over_tool_reconstruction() {
let mut info = make_subagent("a", false, None);
info.tools_used = vec![Arc::from("bash")];
info.activity_label = Some("Running: cargo build".into());
assert_eq!(
subagent_activity(&info, RowState::Working).as_deref(),
Some("Running: cargo build")
);
info.activity_label = None;
assert_eq!(
subagent_activity(&info, RowState::Working).as_deref(),
Some("Running: bash")
);
}
/// Edge case 7: subagent rows never reach `NeedsInput` in v1.
#[test]
fn subagent_classifier_never_emits_needs_input() {
for finished in [false, true] {
for status in [None, Some("completed"), Some("failed"), Some("cancelled")] {
let info = make_subagent("a", finished, status);
let state = classify_subagent(&info);
assert_ne!(
state,
RowState::NeedsInput,
"finished={finished} status={status:?}"
);
}
}
}
#[test]
fn cluster_keeps_subagents_with_parent() {
let rows = vec![
make_row("a", 0, RowState::Working),
make_row("a:1", 1, RowState::Working),
make_row("a:2", 1, RowState::Idle),
make_row("b", 0, RowState::Idle),
make_row("b:1", 1, RowState::Working),
];
let clusters = build_clusters(&rows);
assert_eq!(clusters, vec![(0, 3), (3, 5)]);
}
fn make_row(label: &str, indent: u8, state: RowState) -> DashboardRow {
DashboardRow {
id: if indent == 0 {
DashboardRowId::TopLevel(AgentId(label.len()))
} else {
DashboardRowId::Subagent {
parent: AgentId(0),
child_session_id: label.to_string(),
}
},
label: label.to_string(),
subtitle: None,
state,
activity: None,
secondary_line: None,
cwd_display: "~/x".to_string(),
cwd: PathBuf::from("/tmp"),
last_change_at: SystemTime::now(),
pinned: false,
is_active: false,
badges: Vec::new(),
context_pct: None,
indent,
parent_label: None,
is_more_placeholder: false,
more_count: 0,
}
}
fn make_row_with_id(id: DashboardRowId, indent: u8, state: RowState) -> DashboardRow {
DashboardRow {
id,
label: "row".to_string(),
subtitle: None,
state,
activity: None,
secondary_line: None,
cwd_display: "~/x".to_string(),
cwd: PathBuf::from("/tmp"),
last_change_at: SystemTime::now(),
pinned: false,
is_active: false,
badges: Vec::new(),
context_pct: None,
indent,
parent_label: None,
is_more_placeholder: false,
more_count: 0,
}
}
/// Sort: pinned floats above non-pinned regardless of state.
#[test]
fn sort_pinned_first() {
let mut rows = vec![
DashboardRow {
pinned: false,
state: RowState::Working,
..make_row_with_id(DashboardRowId::TopLevel(AgentId(2)), 0, RowState::Working)
},
DashboardRow {
pinned: true,
state: RowState::Idle,
..make_row_with_id(DashboardRowId::TopLevel(AgentId(1)), 0, RowState::Idle)
},
];
sort_rows(&mut rows, super::super::state::Grouping::State, &[]);
assert!(rows[0].pinned);
assert!(!rows[1].pinned);
}
#[test]
fn sort_state_priority_within_group() {
let mut rows = vec![
make_row_with_id(DashboardRowId::TopLevel(AgentId(1)), 0, RowState::Idle),
make_row_with_id(
DashboardRowId::TopLevel(AgentId(2)),
0,
RowState::NeedsInput,
),
make_row_with_id(DashboardRowId::TopLevel(AgentId(3)), 0, RowState::Working),
];
sort_rows(&mut rows, super::super::state::Grouping::State, &[]);
assert_eq!(rows[0].state, RowState::NeedsInput);
assert_eq!(rows[1].state, RowState::Working);
assert_eq!(rows[2].state, RowState::Idle);
}
/// Renamed from `sort_deterministic_with_equal_keys`.
/// The original name implied a tiebreak guarantee that
/// `sort_cluster_key` does NOT provide; documents the actual
/// behavioural contract: idempotent on identical inputs.
#[test]
fn sort_is_idempotent_for_identical_inputs() {
let now = SystemTime::now();
let mut rows1 = vec![
DashboardRow {
last_change_at: now,
..make_row_with_id(DashboardRowId::TopLevel(AgentId(1)), 0, RowState::Idle)
},
DashboardRow {
last_change_at: now,
..make_row_with_id(DashboardRowId::TopLevel(AgentId(2)), 0, RowState::Idle)
},
];
let mut rows2 = rows1.clone();
sort_rows(&mut rows1, super::super::state::Grouping::State, &[]);
sort_rows(&mut rows2, super::super::state::Grouping::State, &[]);
assert_eq!(
rows1.iter().map(|r| r.id.clone()).collect::<Vec<_>>(),
rows2.iter().map(|r| r.id.clone()).collect::<Vec<_>>()
);
}
/// When every other field ties, the sort tiebreaks
/// by id, producing a fully deterministic order independent of
/// the input row order. AgentId(1) sorts before AgentId(2)
/// regardless of which appears first in the input vector.
#[test]
fn sort_rows_tiebreaks_by_id_when_keys_equal() {
let now = SystemTime::now();
let id1 = DashboardRowId::TopLevel(AgentId(1));
let id2 = DashboardRowId::TopLevel(AgentId(2));
let mut forward = vec![
DashboardRow {
last_change_at: now,
..make_row_with_id(id1.clone(), 0, RowState::Idle)
},
DashboardRow {
last_change_at: now,
..make_row_with_id(id2.clone(), 0, RowState::Idle)
},
];
let mut reverse = vec![
DashboardRow {
last_change_at: now,
..make_row_with_id(id2.clone(), 0, RowState::Idle)
},
DashboardRow {
last_change_at: now,
..make_row_with_id(id1.clone(), 0, RowState::Idle)
},
];
sort_rows(&mut forward, super::super::state::Grouping::State, &[]);
sort_rows(&mut reverse, super::super::state::Grouping::State, &[]);
assert_eq!(forward[0].id, id1, "forward: id1 must sort first");
assert_eq!(forward[1].id, id2, "forward: id2 must sort second");
assert_eq!(reverse[0].id, id1, "reverse: id1 must sort first");
assert_eq!(reverse[1].id, id2, "reverse: id2 must sort second");
}
#[test]
fn filter_substring_label_match() {
let mut rows = vec![
DashboardRow {
label: "fix login bug".to_string(),
..make_row("a", 0, RowState::Working)
},
DashboardRow {
label: "investigate cache".to_string(),
..make_row("b", 0, RowState::Working)
},
];
apply_filter(&mut rows, &Filter::Substring("login".into()), None);
assert_eq!(rows.len(), 1);
}
#[test]
fn filter_agent_matches_label_and_parent() {
let mut rows = vec![
DashboardRow {
label: "implementer".to_string(),
parent_label: None,
..make_row("a", 0, RowState::Working)
},
DashboardRow {
label: "explore".to_string(),
parent_label: Some("implementer".to_string()),
..make_row("a:1", 1, RowState::Working)
},
DashboardRow {
label: "other".to_string(),
parent_label: None,
..make_row("b", 0, RowState::Working)
},
];
apply_filter(&mut rows, &Filter::Agent("implementer".into()), None);
assert_eq!(rows.len(), 2);
}
#[test]
fn filter_state_matches_exact() {
let mut rows = vec![
DashboardRow {
state: RowState::NeedsInput,
..make_row("a", 0, RowState::NeedsInput)
},
DashboardRow {
state: RowState::Idle,
..make_row("b", 0, RowState::Idle)
},
];
apply_filter(&mut rows, &Filter::State(RowState::Idle), None);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].state, RowState::Idle);
}
/// Sort: explicit reorderings (Shift+↑/↓) float a row to its
/// declared position WITHIN its state group. Two Working rows: the
/// older one (id2) is reordered above the more-recent one (id1).
#[test]
fn sort_reorder_floats_within_state_group() {
let id1 = DashboardRowId::TopLevel(AgentId(1));
let id2 = DashboardRowId::TopLevel(AgentId(2));
let newer = SystemTime::now();
let older = newer - Duration::from_secs(60);
let mut rows = vec![
DashboardRow {
last_change_at: newer,
..make_row_with_id(id1.clone(), 0, RowState::Working)
},
DashboardRow {
last_change_at: older,
..make_row_with_id(id2.clone(), 0, RowState::Working)
},
];
sort_rows(
&mut rows,
super::super::state::Grouping::State,
std::slice::from_ref(&id2),
);
assert_eq!(rows[0].id, id2, "reorder must float id2 to the top");
assert_eq!(rows[1].id, id1);
}
/// Regression: reordering must NOT split a state group. With Working
/// and Idle rows interleaved in the input, reordering an Idle row
/// keeps every Working row above every Idle row — the renderer would
/// otherwise emit `Idle → Working → Idle` headers (the reported bug).
/// The reorder only floats the Idle row WITHIN the Idle group.
#[test]
fn reorder_does_not_split_state_groups() {
let w1 = DashboardRowId::TopLevel(AgentId(1));
let i1 = DashboardRowId::TopLevel(AgentId(2));
let w2 = DashboardRowId::TopLevel(AgentId(3));
let i2 = DashboardRowId::TopLevel(AgentId(4));
let mut rows = vec![
make_row_with_id(w1, 0, RowState::Working),
make_row_with_id(i1.clone(), 0, RowState::Idle),
make_row_with_id(w2, 0, RowState::Working),
make_row_with_id(i2.clone(), 0, RowState::Idle),
];
sort_rows(
&mut rows,
super::super::state::Grouping::State,
&[i1.clone(), i2.clone()],
);
let states: Vec<RowState> = rows.iter().map(|r| r.state).collect();
assert_eq!(
states,
vec![
RowState::Working,
RowState::Working,
RowState::Idle,
RowState::Idle,
],
"reorder must not interleave states, got {states:?}",
);
let idle_order: Vec<DashboardRowId> = rows
.iter()
.filter(|r| r.state == RowState::Idle)
.map(|r| r.id.clone())
.collect();
assert_eq!(idle_order, vec![i1, i2], "reorder must order within Idle");
}
/// Sort: when both rows are pinned, ordering falls back to the
/// secondary key (state).
#[test]
fn sort_two_pinned_rows_order_by_state() {
let mut rows = vec![
DashboardRow {
pinned: true,
state: RowState::Idle,
..make_row_with_id(DashboardRowId::TopLevel(AgentId(1)), 0, RowState::Idle)
},
DashboardRow {
pinned: true,
state: RowState::Working,
..make_row_with_id(DashboardRowId::TopLevel(AgentId(2)), 0, RowState::Working)
},
];
sort_rows(&mut rows, super::super::state::Grouping::State, &[]);
assert!(rows[0].pinned);
assert_eq!(rows[0].state, RowState::Working);
}
/// Filter: agent prefix with empty needle keeps everything (treated
/// as `Filter::None` upstream by `Filter::from_value`).
#[test]
fn filter_agent_with_empty_string_does_not_hide_rows() {
let mut rows = vec![
make_row("a", 0, RowState::Working),
make_row("b", 0, RowState::Idle),
];
apply_filter(&mut rows, &Filter::Agent(String::new()), None);
assert_eq!(rows.len(), 2);
}
/// Edge case 6: more-placeholder rows survive filtering.
#[test]
fn filter_keeps_more_placeholders() {
let mut rows = vec![
DashboardRow {
label: "real row".to_string(),
..make_row("a", 1, RowState::Idle)
},
DashboardRow {
label: "… 3 more".to_string(),
is_more_placeholder: true,
..make_row("b", 1, RowState::Idle)
},
];
apply_filter(
&mut rows,
&Filter::Substring("nothing matches".into()),
None,
);
assert!(rows.iter().any(|r| r.is_more_placeholder));
}
fn roster_entry(session_id: &str, last_change_unix_ms: i64) -> RosterEntry {
use crate::app::roster::RosterOrigin;
RosterEntry {
session_id: session_id.to_string(),
title: Some(format!("title {session_id}")),
cwd: "/repo".to_string(),
is_worktree: false,
model_id: None,
yolo: false,
activity: RosterActivity::Dormant,
resident: false,
last_change_unix_ms,
origin: RosterOrigin::default(),
}
}
fn now_unix_ms() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis() as i64
}
/// The age rendered for a roster row (`render` uses
/// `last_change_at.elapsed()`).
fn roster_row_age(entry: RosterEntry) -> Duration {
let mut rows = Vec::new();
append_roster_rows(
&mut rows,
&[entry],
&IndexMap::new(),
&std::collections::BTreeSet::new(),
None,
);
assert_eq!(rows.len(), 1);
rows[0].last_change_at.elapsed().unwrap_or_default()
}
/// A roster entry touched a while ago must NOT render as "just now":
/// its `last_change_at` is derived from `last_change_unix_ms`, so the
/// age reflects the real wall-clock age (regression guard for the bug
/// where every roster row stamped `Instant::now()`).
#[test]
fn roster_row_age_reflects_last_change_unix_ms() {
let two_hours_ago = now_unix_ms() - 2 * 3_600_000;
let elapsed = roster_row_age(roster_entry("sess-old", two_hours_ago));
assert!(
elapsed.as_secs() >= 3600,
"roster row age should reflect the real timestamp, got {elapsed:?}"
);
assert_eq!(crate::util::format_time_ago(elapsed), "2h");
}
/// Roster rows sort by their real `last_change_unix_ms`: a session
/// touched long ago floats below a recently-touched one.
#[test]
fn roster_rows_sort_by_real_timestamp() {
let now_ms = now_unix_ms();
let mut rows = Vec::new();
let agents = IndexMap::new();
let pinned = std::collections::BTreeSet::new();
append_roster_rows(
&mut rows,
&[
roster_entry("sess-old", now_ms - 86_400_000),
roster_entry("sess-new", now_ms - 5_000),
],
&agents,
&pinned,
None,
);
sort_rows(&mut rows, super::super::state::Grouping::State, &[]);
assert_eq!(
rows[0].id,
DashboardRowId::Roster {
session_id: "sess-new".to_string()
}
);
assert_eq!(
rows[1].id,
DashboardRowId::Roster {
session_id: "sess-old".to_string()
}
);
}
/// Build a roster entry with an explicit title + activity for the
/// empty-session filtering tests.
fn roster_entry_with(
session_id: &str,
title: Option<&str>,
activity: RosterActivity,
) -> RosterEntry {
RosterEntry {
title: title.map(String::from),
activity,
..roster_entry(session_id, now_unix_ms())
}
}
fn collect_roster(
entries: &[RosterEntry],
pinned: &std::collections::BTreeSet<DashboardRowId>,
) -> Vec<DashboardRow> {
let mut rows = Vec::new();
append_roster_rows(&mut rows, entries, &IndexMap::new(), pinned, None);
rows
}
/// An untitled, inactive roster session is the "New session" noise the
/// dashboard hides — every pager launch leaves one behind.
#[test]
fn append_roster_rows_hides_untitled_idle_entry() {
let empty = std::collections::BTreeSet::new();
for activity in [
RosterActivity::Idle,
RosterActivity::Dormant,
RosterActivity::Completed,
RosterActivity::Dead,
] {
let rows = collect_roster(&[roster_entry_with("e", None, activity)], &empty);
assert!(
rows.is_empty(),
"untitled {activity:?} entry must be hidden"
);
}
}
/// An untitled session that is actively working / awaiting input stays
/// visible: its title hasn't been generated yet but it is doing real work.
#[test]
fn append_roster_rows_keeps_untitled_active_entry() {
let empty = std::collections::BTreeSet::new();
for activity in [RosterActivity::Working, RosterActivity::NeedsInput] {
let rows = collect_roster(&[roster_entry_with("a", None, activity)], &empty);
assert_eq!(rows.len(), 1, "untitled {activity:?} entry must be kept");
}
}
/// Titled (real) sessions always render, even when idle/dormant.
#[test]
fn append_roster_rows_keeps_titled_entry() {
let empty = std::collections::BTreeSet::new();
let rows = collect_roster(
&[roster_entry_with(
"t",
Some("Fix the bug"),
RosterActivity::Dormant,
)],
&empty,
);
assert_eq!(rows.len(), 1, "titled entries are kept");
}
/// A blank/whitespace title is treated as no title.
#[test]
fn append_roster_rows_hides_blank_titled_idle_entry() {
let empty = std::collections::BTreeSet::new();
let rows = collect_roster(
&[roster_entry_with("b", Some(" "), RosterActivity::Idle)],
&empty,
);
assert!(rows.is_empty(), "blank-title idle entry must be hidden");
}
/// A user-pinned row is always shown, even untitled + idle.
#[test]
fn append_roster_rows_keeps_pinned_untitled_entry() {
let mut pinned = std::collections::BTreeSet::new();
pinned.insert(DashboardRowId::Roster {
session_id: "p".to_string(),
});
let rows = collect_roster(
&[roster_entry_with("p", None, RosterActivity::Idle)],
&pinned,
);
assert_eq!(rows.len(), 1, "pinned untitled entries are kept");
}
/// A roster session with a known model surfaces it on the otherwise
/// blank second line (the wire carries no last-message preview, so
/// the model is the only per-session detail left to show).
#[test]
fn append_roster_rows_uses_model_id_as_secondary_line() {
let empty = std::collections::BTreeSet::new();
let entry = RosterEntry {
model_id: Some("kigi-4.5".to_string()),
..roster_entry_with("m", Some("Fix the bug"), RosterActivity::Dormant)
};
let rows = collect_roster(&[entry], &empty);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].secondary_line.as_deref(), Some("kigi-4.5"));
}
/// Without a model id there's genuinely nothing to show, so the
/// second line stays empty rather than rendering a placeholder.
#[test]
fn append_roster_rows_secondary_line_none_without_model() {
let empty = std::collections::BTreeSet::new();
let rows = collect_roster(
&[roster_entry_with(
"m",
Some("Fix the bug"),
RosterActivity::Dormant,
)],
&empty,
);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].secondary_line, None);
}
/// Build a minimal idle local agent with an empty scrollback (no
/// replayed messages) and an optional current model — mirrors a
/// session listed on the dashboard that the user hasn't opened yet.
fn make_idle_agent_with_model(model: Option<&str>) -> AgentView {
use crate::acp::model_state::ModelState;
use crate::app::agent::{AgentSession, AgentState};
use crate::scrollback::state::ScrollbackState;
use agent_client_protocol as acp;
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
let mut models = ModelState::default();
if let Some(m) = model {
models.set_current(acp::ModelId::new(std::sync::Arc::from(m)), None);
}
let session = AgentSession {
id: AgentId(0),
acp_tx: tx,
session_id: Some(acp::SessionId::new("test-session")),
models,
state: AgentState::Idle,
tracker: crate::acp::tracker::AcpUpdateTracker::new(),
cwd: PathBuf::from("/tmp"),
is_worktree: false,
forked_from: None,
pending_prompts: std::collections::VecDeque::new(),
next_queue_id: 0,
yolo_mode: false,
auto_mode: false,
prompt_history: Vec::new(),
prompt_history_loading: false,
loading_replay: false,
restore_degree: None,
rate_limited: false,
model_incompatible: false,
available_commands: Vec::new(),
available_commands_generation: 0,
available_tools: None,
model_switch_pending: false,
user_model_preference: None,
deferred_model_switch: None,
bg_tasks: std::collections::BTreeMap::new(),
bg_tool_call_to_task: std::collections::HashMap::new(),
scheduled_tasks: std::collections::HashMap::new(),
in_flight_prompt: None,
current_prompt_id: None,
created_via_new: false,
};
AgentView::new(session, ScrollbackState::new())
}
/// An idle local agent with no last message has a BLANK second line —
/// the model is no longer used as a fallback there (it now shows in
/// the peek panel's bottom-border badge for the selected row, keeping
/// the list uncluttered).
#[test]
fn idle_local_agent_without_message_has_blank_secondary() {
let agent = make_idle_agent_with_model(Some("kigi-4.5"));
assert_eq!(classify_top_level(&agent), RowState::Idle);
let row = top_level_row(AgentId(0), &agent, false, false, None);
assert_eq!(
row.secondary_line, None,
"the model must not appear on the list row anymore",
);
}
/// When neither a message nor a model is known the second line is
/// left empty (no placeholder).
#[test]
fn idle_local_agent_without_message_or_model_has_no_secondary() {
let agent = make_idle_agent_with_model(None);
let row = top_level_row(AgentId(0), &agent, false, false, None);
assert_eq!(row.secondary_line, None);
}
/// A worktree agent's subtitle shows the branch + the worktree's
/// human label + a `worktree` marker.
#[test]
fn subtitle_worktree_shows_label_branch_and_marker() {
let mut agent = make_idle_agent_with_model(None);
agent.session.cwd = PathBuf::from("/home/me/.kigi/worktrees/x/location-picker");
agent.is_worktree = true;
agent.worktree_label = Some("location-picker".to_string());
agent.current_branch = Some("kevin/feature".to_string());
assert_eq!(
top_level_subtitle(&agent).as_deref(),
Some("kevin/feature location-picker worktree"),
);
}
/// A worktree without a DB label falls back to the cwd folder name.
#[test]
fn subtitle_worktree_without_label_uses_folder_name() {
let mut agent = make_idle_agent_with_model(None);
agent.session.cwd = PathBuf::from("/home/me/wt/my-wt-dir");
agent.is_worktree = true;
agent.worktree_label = None;
agent.current_branch = Some("main".to_string());
assert_eq!(
top_level_subtitle(&agent).as_deref(),
Some("main my-wt-dir worktree"),
);
}
/// A non-worktree agent shows the branch + the cwd folder name (the
/// actual working subdir, not the repo root), no worktree marker.
#[test]
fn subtitle_non_worktree_shows_branch_and_folder() {
let mut agent = make_idle_agent_with_model(None);
agent.session.cwd = PathBuf::from("/home/me/xai/crates/foo");
agent.is_worktree = false;
agent.current_branch = Some("main".to_string());
assert_eq!(top_level_subtitle(&agent).as_deref(), Some("main foo"));
}
/// With no branch known, the subtitle is just the folder name.
#[test]
fn subtitle_no_branch_shows_only_folder() {
let mut agent = make_idle_agent_with_model(None);
agent.session.cwd = PathBuf::from("/home/me/projects/bar");
agent.is_worktree = false;
agent.current_branch = None;
assert_eq!(top_level_subtitle(&agent).as_deref(), Some("bar"));
}
/// The `[bg]` badge reflects RUNNING background tasks only —
/// `bg_tasks` retains finished (`Done` / `Failed`) tasks for the
/// tasks-pane history, and those must not pin a stale badge on the
/// row forever.
#[test]
fn bg_badge_only_for_running_tasks() {
use crate::app::agent::{BgTaskState, BgTaskStatus};
let make_task = |status: BgTaskStatus| BgTaskState {
task_id: "t1".into(),
tool_call_id: String::new(),
command: "sleep 99".into(),
description: None,
cwd: String::new(),
output_file: String::new(),
status,
start_time: std::time::SystemTime::now(),
end_time: None,
exit_code: None,
signal: None,
stdout: String::new(),
stdout_line_count: 0,
truncated: false,
pending_kill: false,
kill_requested_at: None,
scrollback_entry_id: None,
is_monitor: false,
restored_from_replay: false,
};
for (status, expect_badge) in [
(BgTaskStatus::Running, true),
(BgTaskStatus::Done, false),
(BgTaskStatus::Failed, false),
] {
let mut agent = make_idle_agent_with_model(None);
agent
.session
.bg_tasks
.insert("t1".into(), make_task(status));
let row = top_level_row(AgentId(0), &agent, false, false, None);
assert_eq!(
row.badges.contains(&RowBadge::BgTask),
expect_badge,
"status={status:?} badge expectation",
);
}
}
/// A running background task (`run_terminal_command background=true`).
fn running_bg_task(task_id: &str, is_monitor: bool) -> crate::app::agent::BgTaskState {
crate::app::agent::BgTaskState {
task_id: task_id.into(),
tool_call_id: String::new(),
command: "sleep 99".into(),
description: None,
cwd: String::new(),
output_file: String::new(),
status: crate::app::agent::BgTaskStatus::Running,
start_time: std::time::SystemTime::now(),
end_time: None,
exit_code: None,
signal: None,
stdout: String::new(),
stdout_line_count: 0,
truncated: false,
pending_kill: false,
kill_requested_at: None,
scrollback_entry_id: None,
is_monitor,
restored_from_replay: false,
}
}
/// An active scheduled `/loop` task.
fn scheduled_loop(task_id: &str) -> crate::app::agent::ScheduledTaskInfo {
crate::app::agent::ScheduledTaskInfo {
task_id: task_id.into(),
prompt: "check things".into(),
human_schedule: "every 5m".into(),
created_at: std::time::Instant::now(),
next_fire_at: None,
tag: "loop".into(),
}
}
/// A turn-idle agent with a RUNNING background task is `Working`, not
/// `Idle`. Finished tasks (Done / Failed) don't keep it Working — they
/// linger in `bg_tasks` only for the tasks-pane history.
#[test]
fn running_bg_task_classifies_as_working() {
use crate::app::agent::BgTaskStatus;
for (status, expected) in [
(BgTaskStatus::Running, RowState::Working),
(BgTaskStatus::Done, RowState::Idle),
(BgTaskStatus::Failed, RowState::Idle),
] {
let mut agent = make_idle_agent_with_model(None);
let mut task = running_bg_task("t1", false);
task.status = status;
agent.session.bg_tasks.insert("t1".into(), task);
assert_eq!(
classify_top_level(&agent),
expected,
"status={status:?} must classify as {expected:?}",
);
}
}
/// A running `monitor` (a bg task with `is_monitor`) keeps the agent
/// `Working`, and the activity line names it.
#[test]
fn running_monitor_classifies_as_working_with_label() {
let mut agent = make_idle_agent_with_model(None);
agent
.session
.bg_tasks
.insert("m1".into(), running_bg_task("m1", true));
assert_eq!(classify_top_level(&agent), RowState::Working);
let row = top_level_row(AgentId(0), &agent, false, false, None);
assert_eq!(row.activity.as_deref(), Some("watching · 1 monitor"));
}
/// An active scheduled `/loop` keeps the agent `Working` even with a
/// fully idle turn, labelled as a loop.
#[test]
fn scheduled_loop_classifies_as_working_with_label() {
let mut agent = make_idle_agent_with_model(None);
agent
.session
.scheduled_tasks
.insert("l1".into(), scheduled_loop("l1"));
assert_eq!(classify_top_level(&agent), RowState::Working);
let row = top_level_row(AgentId(0), &agent, false, false, None);
assert_eq!(row.activity.as_deref(), Some("watching · 1 loop"));
}
/// The background-work label lists every non-zero kind (monitors,
/// then loops, then plain tasks) with correct singular/plural nouns.
#[test]
fn background_work_label_lists_all_kinds() {
let mut agent = make_idle_agent_with_model(None);
agent
.session
.bg_tasks
.insert("m1".into(), running_bg_task("m1", true));
agent
.session
.bg_tasks
.insert("b1".into(), running_bg_task("b1", false));
agent
.session
.bg_tasks
.insert("b2".into(), running_bg_task("b2", false));
agent
.session
.scheduled_tasks
.insert("l1".into(), scheduled_loop("l1"));
assert_eq!(classify_top_level(&agent), RowState::Working);
let row = top_level_row(AgentId(0), &agent, false, false, None);
assert_eq!(
row.activity.as_deref(),
Some("watching · 1 monitor · 1 loop · 2 tasks"),
);
}
/// The background-work label is the LAST activity fallback: a more
/// specific Working signal (here, replay loading) still wins over
/// "watching · …", so a real turn is never masked by it.
#[test]
fn specific_working_activity_wins_over_background_label() {
let mut agent = make_idle_agent_with_model(None);
agent.session.loading_replay = true;
agent
.session
.bg_tasks
.insert("m1".into(), running_bg_task("m1", true));
assert_eq!(classify_top_level(&agent), RowState::Working);
let row = top_level_row(AgentId(0), &agent, false, false, None);
assert_eq!(
row.activity.as_deref(),
Some("Loading…"),
"loading-replay activity must win over the background label",
);
}
/// Roster-only idle / dormant sessions classify as `Inactive` — the
/// dedicated section for sessions not loaded in this pager — while
/// the active roster states keep their existing mapping. (Local
/// idle agents stay `Idle`; `classify_top_level` never returns
/// `Inactive` — pinned by `idle_local_agent_without_message_has_blank_secondary`.)
#[test]
fn roster_idle_and_dormant_classify_as_inactive() {
let empty = std::collections::BTreeSet::new();
for (activity, expected) in [
(RosterActivity::Idle, RowState::Inactive),
(RosterActivity::Dormant, RowState::Inactive),
(RosterActivity::Working, RowState::Working),
(RosterActivity::NeedsInput, RowState::NeedsInput),
(RosterActivity::Completed, RowState::Completed),
(RosterActivity::Dead, RowState::Failed),
] {
let rows = collect_roster(&[roster_entry_with("m", Some("Task"), activity)], &empty);
assert_eq!(rows.len(), 1, "activity={activity:?}");
assert_eq!(rows[0].state, expected, "activity={activity:?}");
}
}
/// In state grouping, `Inactive` sorts below `Idle` (not loaded
/// here → less immediately actionable) but above `Done` / `Failed`
/// (still live, resumable sessions).
#[test]
fn sort_rows_places_inactive_between_idle_and_done() {
let mut rows = vec![
make_row("done", 0, RowState::Completed),
make_row("inactive", 0, RowState::Inactive),
make_row("idle", 0, RowState::Idle),
make_row("failed", 0, RowState::Failed),
];
sort_rows(&mut rows, super::super::state::Grouping::State, &[]);
let order: Vec<RowState> = rows.iter().map(|r| r.state).collect();
assert_eq!(
order,
vec![
RowState::Idle,
RowState::Inactive,
RowState::Completed,
RowState::Failed,
],
);
}
}