Synced from monorepo Changes: - Refresh tool search when the managed MCP catalog is re-fetched - Prevent duplicate leader process spawn and startup hang from stale leaders - Document marketplaces, plugins, and organization controls - Stamp session ID on image generation direct-to-API requests - Fix auto mode blocked documentation - Auto mode considers recent user intent - Expose deploy archive, taken-down, limit, and in-progress reasons on the chat API - Fail-closed auth refresh contract for shell clients - Emit a chat-supplied per-session turn index in turn hooks - Show bash mode chrome in minimal mode - Add metrics for true-noop and stationarity stops - Include voice interim text on prompt submit - Silently end turn on true-noop thrash - Quiet copy toast when clipboard delivery is confirmed - Fix session fork truncating at the wrong prompt in rewound sessions - Make the idle "still running" watcher cue clickable to open the tasks pane - Default web search model to grok-4.5 - Let plugin subagents inherit parent MCP servers - Gate no-op end-turn reminder on system reminders - Add gateway bridge lifecycle telemetry - Allow editing finalized text while voice is open - Relocate token carrier to turn-commit events and plumb per-turn origin context - Raise workflow scratch quotas and make failed runs resumable - Workflows overlay: auto-progress phases, live agent status, and drop budget meter Source-Revision: 9b8d35b46d959c042ea9aa31cbbebbd1f0c5c527
8947 lines
344 KiB
Rust
8947 lines
344 KiB
Rust
//! Dashboard rendering.
|
||
|
||
use indexmap::IndexMap;
|
||
use ratatui::buffer::Buffer;
|
||
use ratatui::layout::Rect;
|
||
use ratatui::style::{Color, Modifier, Style};
|
||
use ratatui::text::Span;
|
||
use unicode_width::UnicodeWidthStr;
|
||
|
||
use super::layout::{MIN_DASHBOARD_WIDTH, compute_layout};
|
||
use super::row::{DashboardRow, RowBadge, build_rows_with_roster};
|
||
use super::state::{
|
||
DashboardRowId, DashboardState, Filter, Focusable, Grouping, LocationPickerState, RenameDraft,
|
||
RowState, SectionKey,
|
||
};
|
||
use crate::app::agent::AgentId;
|
||
use crate::app::agent_view::AgentView;
|
||
use crate::render::line_utils::{truncate_line, truncate_str};
|
||
use crate::theme::Theme;
|
||
use crate::util::format_time_ago;
|
||
|
||
/// Show each spinner frame for this many animation ticks. The frames
|
||
/// themselves come from [`crate::glyphs::dot_spinner_frames`] so they
|
||
/// degrade to an ASCII pulse on legacy Windows consoles.
|
||
const SPINNER_DIVISOR: u64 = 4;
|
||
/// How many ticks each phase of the `NeedsInput` bullet blink lasts. At the
|
||
/// ~30 Hz dashboard tick this toggles roughly every 0.33 s (≈1.5 Hz blink).
|
||
const NEEDS_INPUT_BLINK_DIVISOR: u64 = 10;
|
||
|
||
// Row markers use the filled (◆) / hollow (◇) diamonds from `crate::glyphs`
|
||
// (with CP437 fallbacks on legacy consoles). The dashboard uses
|
||
// diamonds instead of circles to differentiate this view's vocabulary from
|
||
// sibling activity views (which use circles). Filled marks the non-working states that
|
||
// need a strong visual presence (needs-input, completed, failed, blocked);
|
||
// hollow marks idle rows.
|
||
|
||
fn ensure_peek_viewport_lifecycle(
|
||
state: &mut DashboardState,
|
||
agents: &mut IndexMap<AgentId, AgentView>,
|
||
) {
|
||
if state.attached_agent.is_some() {
|
||
return;
|
||
}
|
||
// Peek row → begin/keep lease; else restore agent viewport.
|
||
let Some(row) = state.peek.as_ref().map(|p| p.row.clone()) else {
|
||
state.restore_peek_viewport(agents);
|
||
return;
|
||
};
|
||
if state
|
||
.peek_viewport
|
||
.as_ref()
|
||
.is_some_and(|lease| lease.row == row)
|
||
{
|
||
return;
|
||
}
|
||
if super::state::scrollback_available_for_row(&row, agents) {
|
||
state.begin_peek_viewport(row, agents);
|
||
} else {
|
||
state.restore_peek_viewport(agents);
|
||
}
|
||
}
|
||
|
||
// The thin left vertical bar marking the active/selected row
|
||
// (`crate::glyphs::selection_bar()`, with a `│` CP437 fallback on legacy
|
||
// consoles) is painted on *every* content line of a selected row so it
|
||
// spans the row's full visual height.
|
||
|
||
/// Per-row visual height (cells) when each row renders as a
|
||
/// title-row + secondary-row + 1-cell breathing gap.
|
||
const ROW_HEIGHT: u16 = 3;
|
||
/// Per-group-header visual height (label row + 1-cell breathing gap).
|
||
const GROUP_HEADER_HEIGHT: u16 = 2;
|
||
|
||
/// Promo upgrade CTA for the dashboard header, resolved through the shared
|
||
/// slot gate by the producer (`app_view`).
|
||
#[derive(Clone, Copy)]
|
||
pub struct HeaderUpgradeCta<'a> {
|
||
/// The `[label]` button text.
|
||
pub label: &'a str,
|
||
/// Non-dismissible promo → the `Ctrl+O` override applies.
|
||
pub pinned: bool,
|
||
/// The promo's trimmed `cta.caption` accessor value; pinned-gated at paint.
|
||
pub caption: Option<&'a str>,
|
||
}
|
||
|
||
/// Render the dashboard. Mirrors `agent_view::draw` — returns the
|
||
/// cursor position to place after the frame is committed.
|
||
///
|
||
/// Edge case 8 contract: when a permission request fires on an agent
|
||
/// while the dashboard is open, this renderer DOES NOT auto-popup the
|
||
/// `PermissionView` modal. Instead, the row's state flips to
|
||
/// `NeedsInput` (blinking yellow bullet + yellow `Pending:` subtitle). The user
|
||
/// must press Space to peek (which routes the permission question +
|
||
/// options into the peek panel) and then a number key to answer. This
|
||
/// is intentional: the dashboard is the top-level view, and we don't
|
||
/// want random subagent permission requests to obscure it.
|
||
#[allow(clippy::too_many_arguments)]
|
||
pub fn render_dashboard(
|
||
buf: &mut Buffer,
|
||
area: Rect,
|
||
state: &mut DashboardState,
|
||
agents: &mut IndexMap<AgentId, AgentView>,
|
||
registry: &crate::actions::ActionRegistry,
|
||
// App-level double-press confirmation hint (e.g. "press again to
|
||
// quit" for Ctrl+Q / Ctrl+C / Ctrl+D). Threaded to the footer so the
|
||
// session-less dashboard shows the same feedback the agent view does.
|
||
pending_hint: Option<crate::views::shortcuts_bar::PendingHint>,
|
||
// Leader-mode session roster (FleetView). Empty in non-leader mode,
|
||
// which naturally gates the appended roster-only rows.
|
||
roster: &[crate::app::roster::RosterEntry],
|
||
// Whether the local on-disk session roster is still being fetched
|
||
// (non-leader mode). When true and there's nothing to show yet, the
|
||
// empty body reads "Loading sessions…" instead of the "no agents
|
||
// yet" hint so a fresh open doesn't flash an empty-looking screen.
|
||
dashboard_sessions_loading: bool,
|
||
// Promo upgrade CTA to paint in the header after the location label
|
||
// (`None` = no CTA); field meanings live on [`HeaderUpgradeCta`].
|
||
upgrade_cta: Option<HeaderUpgradeCta<'_>>,
|
||
) -> Option<(u16, u16)> {
|
||
// Cache whether a pinned (non-dismissible) promo CTA is live so the key
|
||
// handler can steal Ctrl+O for it; the dispatch re-resolves the gate.
|
||
state.pinned_upgrade_cta_live = upgrade_cta.is_some_and(|cta| cta.pinned);
|
||
// Re-anchor selection BEFORE we build the rows so that the
|
||
// visible set drives selection clamping.
|
||
let theme = Theme::current();
|
||
// `spinner_tick` is bumped in `AppView::tick()`,
|
||
// not here, so the spinner advances even when no redraw was
|
||
// triggered by other state changes.
|
||
state.last_area = area;
|
||
|
||
// Paint the full area with the theme's base background BEFORE
|
||
// any sub-renderer runs. Mirrors `welcome::render` and
|
||
// `PromptWidget::draw` — without this, cells that no sub-renderer
|
||
// touches in a given frame (e.g. blank rows between the last list
|
||
// row and the dispatch input, or trailing whitespace past short
|
||
// row content) retain stale paint from the previous frame and the
|
||
// dashboard looks like it doesn't cover the full panel.
|
||
buf.set_style(area, ratatui::style::Style::default().bg(theme.bg_base));
|
||
|
||
let home = cached_home();
|
||
// The dashboard is not anchored to a specific agent; we treat every
|
||
// row equally for highlighting. `is_active` survives for legacy
|
||
// comparators that want to know what view the user came from (None
|
||
// in fresh dashboard renders).
|
||
let active: Option<AgentId> = None;
|
||
let rows = build_rows_with_roster(
|
||
agents,
|
||
&state.pinned,
|
||
&state.reorder,
|
||
active,
|
||
state.grouping,
|
||
&state.filter,
|
||
home,
|
||
roster,
|
||
);
|
||
state.reanchor_selection(&rows);
|
||
|
||
// DO NOT GC pinned/reorder at render time. The old
|
||
// code built the alive-set from the *post-filter* row list, so a
|
||
// user-typed filter that hid a pinned row would (a) remove the
|
||
// pin from the in-memory set, (b) be persisted on the next pin /
|
||
// grouping toggle, (c) silently destroy the user's state.
|
||
//
|
||
// GC now runs only at open time (see `dispatch_open_dashboard`),
|
||
// where it has access to the raw `app.agents` snapshot.
|
||
|
||
// When an agent is attached, the popup overlay
|
||
// covers the bottom portion of the screen with the agent's full
|
||
// view (scrollback + status bar + prompt + shortcuts). Rendering
|
||
// the dashboard's own dispatch input + footer below the popup
|
||
// produced TWO stacked input bars, which the user explicitly
|
||
// flagged as broken UX. In popup mode we paint ONLY a compact
|
||
// dashboard banner at the top (rows in a bordered panel), and
|
||
// the popup fills the rest. The dashboard's header, dispatch
|
||
// input, footer, and bottom margin are all skipped — the
|
||
// attached agent carries its own equivalents.
|
||
if state.attached_agent.is_some() {
|
||
state.peek_close_rect = None;
|
||
state.slash_dropdown_items_area = None;
|
||
state.slash_dropdown_hit = Default::default();
|
||
state.file_search_dropdown_items_area = None;
|
||
state.dispatch_rect = None;
|
||
let popup = popup_rect(area);
|
||
let banner_h = popup.y.saturating_sub(area.y);
|
||
let banner_area = Rect {
|
||
x: area.x,
|
||
y: area.y,
|
||
width: area.width,
|
||
height: banner_h,
|
||
};
|
||
render_dashboard_banner(buf, banner_area, &theme, &rows, state);
|
||
// No cursor for the dashboard itself — the popup's agent
|
||
// owns the cursor.
|
||
return None;
|
||
}
|
||
|
||
// Peek: list-first allocation (see layout::allocate_peek /
|
||
// docs/internal/33-dashboard-peek-responsive-layout.md). Provisional
|
||
// layout gives dispatch width for reply wrapping before we decide
|
||
// whether peek fits.
|
||
let mut layout = compute_layout(area, false);
|
||
let fixed = super::layout::chrome_overhead(area);
|
||
let reply_text_w = layout.dispatch.width.saturating_sub(6);
|
||
|
||
match state.selected.clone() {
|
||
Some(sel) => match super::peek::compute_peek_fields(&sel, agents) {
|
||
Some(fields) => {
|
||
let question = fields.question.is_some();
|
||
let peek_min = if question {
|
||
super::layout::PEEK_MIN_BOX_QUESTION
|
||
} else {
|
||
super::layout::PEEK_MIN_BOX_LIVE_TAIL
|
||
};
|
||
let content_rows = if question {
|
||
1 + fields.options.len().min(9) as u16
|
||
} else {
|
||
let reply_rows = super::peek::reply_row_count(
|
||
&state.peek_reply,
|
||
reply_text_w,
|
||
super::peek::MAX_REPLY_ROWS,
|
||
);
|
||
let max_content = super::layout::max_peek_content_rows(area);
|
||
// Middle content width ≈ dispatch box minus borders + insets.
|
||
let middle_w = layout.dispatch.width.saturating_sub(4);
|
||
let (body_measured, pin_user) =
|
||
super::state::scrollback_mut_for_row(&sel, agents)
|
||
.map(|sb| {
|
||
(
|
||
super::peek_tail::densified_body_line_count(sb, middle_w),
|
||
super::peek_tail::scrollback_has_last_user(sb),
|
||
)
|
||
})
|
||
.unwrap_or((0, false));
|
||
super::layout::peek_live_tail_desired_content(
|
||
max_content,
|
||
reply_rows,
|
||
body_measured,
|
||
pin_user,
|
||
)
|
||
.content_rows
|
||
};
|
||
let alloc =
|
||
super::layout::allocate_peek(area.height, fixed, content_rows, peek_min);
|
||
if alloc.show_peek {
|
||
state.set_peek_reply_target_cwd(peeked_agent_cwd(&sel, agents));
|
||
let badge = super::peek::peek_model_and_mode(&sel, agents);
|
||
match state.peek.as_mut() {
|
||
Some(p) => {
|
||
if p.apply_fields(sel, fields) {
|
||
state.clear_peek_reply();
|
||
}
|
||
}
|
||
None => state.set_peek(Some(super::peek::PeekPanelState::new(sel, fields))),
|
||
}
|
||
if let Some(p) = state.peek.as_mut() {
|
||
p.model_name = badge.model;
|
||
p.auto_approve = badge.yolo;
|
||
p.auto = badge.auto;
|
||
p.plan_mode = badge.plan;
|
||
}
|
||
layout = super::layout::compute_layout_with_peek_box(area, alloc.peek_box_h);
|
||
} else {
|
||
state.set_peek_reply_target_cwd(None);
|
||
state.set_peek(None);
|
||
}
|
||
}
|
||
None => {
|
||
state.set_peek_reply_target_cwd(None);
|
||
state.set_peek(None);
|
||
}
|
||
},
|
||
None => {
|
||
state.set_peek_reply_target_cwd(None);
|
||
state.set_peek(None);
|
||
}
|
||
}
|
||
|
||
ensure_peek_viewport_lifecycle(state, agents);
|
||
|
||
if state.peek.is_none() && area.height > 8 && !state.dispatch.text().is_empty() {
|
||
let rows = dispatch_text_rows(state, layout.dispatch.width, area.height);
|
||
if rows > 1 {
|
||
layout = super::layout::compute_layout_with_dispatch(area, false, rows);
|
||
}
|
||
}
|
||
|
||
// Header.
|
||
render_header(buf, layout.header, &theme, &rows, state, upgrade_cta);
|
||
|
||
// Body: key off visible rows (local agents + roster), not the local map alone.
|
||
if rows.is_empty() {
|
||
if state.filter.is_active() {
|
||
render_no_match(buf, layout.list, &theme, &state.filter);
|
||
} else {
|
||
render_empty_state(buf, layout.list, &theme, dashboard_sessions_loading);
|
||
}
|
||
} else if area.width < MIN_DASHBOARD_WIDTH {
|
||
render_narrow_rows(buf, layout.list, &theme, &rows, state);
|
||
} else {
|
||
render_rows(buf, layout.list, &theme, &rows, state);
|
||
}
|
||
|
||
// Compute the contextual placeholder hint + footer mode once
|
||
// here so both sub-renderers stay pure functions of the
|
||
// selected-row state.
|
||
let selected_state = state
|
||
.selected
|
||
.as_ref()
|
||
.and_then(|sel| rows.iter().find(|r| r.id == *sel).map(|r| r.state));
|
||
let peek_active = state.peek.is_some();
|
||
|
||
// Peek REPLACES the dispatch input when active
|
||
// (single rounded box at the same screen position, instead
|
||
// of a separate panel floating above it). When peek is
|
||
// closed, the dispatch input renders normally.
|
||
let dispatch_cursor = if peek_active {
|
||
// Peek has no in-box close button; `render_peek_panel` returns
|
||
// the `❯ reply` caret position so the terminal cursor parks in
|
||
// the live reply input, plus the reply row's rect (recorded for
|
||
// click-to-focus / drag-selection mouse routing).
|
||
state.peek_close_rect = None;
|
||
// Split borrows: the panel is read from `state.peek` while the
|
||
// reply widget (`state.peek_reply`) is drawn mutably — disjoint
|
||
// fields, destructured so the borrow checker can see it.
|
||
// Capture voice state before the disjoint destructure so the peek
|
||
// reply can show the record badge + interim transcript (the peek box
|
||
// replaces the dispatch box, which would otherwise own the overlay).
|
||
let voice_listening = state.voice_listening;
|
||
let voice_interim = state.voice_interim.clone();
|
||
let multiline = state.multiline_mode;
|
||
let peeked_row = state.peek.as_ref().map(|p| p.row.clone());
|
||
let question_pending = state.peek.as_ref().is_some_and(|p| p.question.is_some());
|
||
let (empty_hint, has_scrollback) = match peeked_row.as_ref() {
|
||
Some(DashboardRowId::Subagent {
|
||
parent,
|
||
child_session_id,
|
||
}) => {
|
||
let parent_ok = agents
|
||
.get(parent)
|
||
.is_some_and(|p| p.subagent_sessions.contains_key(child_session_id));
|
||
let loaded = agents
|
||
.get(parent)
|
||
.is_some_and(|p| p.subagent_views.contains_key(child_session_id));
|
||
if parent_ok && !loaded {
|
||
(Some("Subagent not loaded"), false)
|
||
} else {
|
||
(None, loaded)
|
||
}
|
||
}
|
||
Some(row) => (
|
||
None,
|
||
super::state::scrollback_available_for_row(row, agents),
|
||
),
|
||
None => (None, false),
|
||
};
|
||
let render = if let Some(panel) = state.peek.as_ref() {
|
||
let live_tail = if !question_pending && has_scrollback {
|
||
peeked_row
|
||
.as_ref()
|
||
.and_then(|row| super::state::scrollback_mut_for_row(row, agents))
|
||
.map(|scrollback| super::peek::PeekLiveTailArgs { scrollback })
|
||
} else {
|
||
None
|
||
};
|
||
super::peek::render_peek_panel(
|
||
buf,
|
||
layout.dispatch,
|
||
panel,
|
||
&mut state.peek_reply,
|
||
&theme,
|
||
voice_listening,
|
||
voice_interim.as_deref(),
|
||
multiline,
|
||
Some(layout.list).filter(|r| r.area() > 0),
|
||
live_tail,
|
||
empty_hint,
|
||
)
|
||
} else {
|
||
Default::default()
|
||
};
|
||
state.peek_reply_rect = render.reply_rect;
|
||
let cursor = render.caret;
|
||
// The reply is a full PromptWidget, so its `@` file-context
|
||
// picker paints ABOVE the peek box (same chrome as the dispatch
|
||
// box's). Slash completion stays inert for the reply.
|
||
state.slash_dropdown_items_area = None;
|
||
state.slash_dropdown_hit = Default::default();
|
||
if state.peek_reply.file_search_visible() {
|
||
state.file_search_dropdown_items_area = render_file_search_dropdown_for(
|
||
buf,
|
||
area,
|
||
layout.dispatch,
|
||
&theme,
|
||
&mut state.peek_reply.file_search,
|
||
);
|
||
} else {
|
||
state.file_search_dropdown_items_area = None;
|
||
}
|
||
// Peek replaces the input box — no input to click-to-focus.
|
||
state.dispatch_rect = None;
|
||
cursor
|
||
} else {
|
||
state.peek_close_rect = None;
|
||
state.peek_reply_rect = None;
|
||
// Record the box rect so a click anywhere on it focuses the
|
||
// input (see `handle_mouse`).
|
||
state.dispatch_rect = Some(layout.dispatch);
|
||
let cursor = render_dispatch(
|
||
buf,
|
||
layout.dispatch,
|
||
&theme,
|
||
state,
|
||
Some(layout.list).filter(|r| r.area() > 0),
|
||
);
|
||
// Completion dropdowns paint ABOVE the dispatch box. The `@`
|
||
// file-search picker and the `/` slash dropdown never render
|
||
// together — file search wins while the user is mid-`@token`,
|
||
// otherwise the slash dropdown shows.
|
||
if state.dispatch.file_search_visible() {
|
||
render_file_search_dropdown(buf, area, layout.dispatch, &theme, state);
|
||
state.slash_dropdown_items_area = None;
|
||
state.slash_dropdown_hit = Default::default();
|
||
} else {
|
||
render_slash_dropdown(buf, area, layout.dispatch, &theme, state);
|
||
state.file_search_dropdown_items_area = None;
|
||
}
|
||
cursor
|
||
};
|
||
|
||
// Footer.
|
||
render_footer(
|
||
buf,
|
||
layout.footer,
|
||
&theme,
|
||
state,
|
||
registry,
|
||
selected_state,
|
||
peek_active,
|
||
pending_hint,
|
||
);
|
||
|
||
// Cheatsheet modal paints LAST so it overlays everything — the
|
||
// row list, the dispatch widget, the footer hints. Modal lives
|
||
// on `DashboardState` (mirrors `agent_view`'s `active_modal`);
|
||
// when None, nothing to paint and the regular cursor logic
|
||
// below proceeds. When Some, we suppress the dispatch cursor
|
||
// because input is routed to the modal until it closes.
|
||
if let Some(modal) = state.shortcuts_modal.as_mut() {
|
||
crate::views::shortcuts_help::render_modal(
|
||
buf,
|
||
area,
|
||
&modal.entries,
|
||
&mut modal.state,
|
||
&mut modal.window,
|
||
modal.filter_active,
|
||
&modal.collapsed_sections,
|
||
&modal.expanded_ids,
|
||
&modal.mode,
|
||
&theme,
|
||
/* compact */ false,
|
||
);
|
||
return None;
|
||
}
|
||
|
||
// The location picker overlays everything too (mutually exclusive
|
||
// with the shortcuts modal in practice). When open, input is routed
|
||
// to it, so the dispatch cursor is suppressed.
|
||
if let Some(modal) = state.location_picker.as_mut() {
|
||
render_location_picker(buf, area, &theme, modal);
|
||
return None;
|
||
}
|
||
|
||
// The worktree-label dialog overlays the dashboard while the user names
|
||
// the worktree for a dashboard-dispatched agent. Input is routed to it,
|
||
// so the dispatch cursor is suppressed.
|
||
if let Some(dialog) = state.worktree_dialog.as_ref() {
|
||
crate::views::new_worktree_dialog::render_new_worktree_dialog(area, buf, dialog);
|
||
return None;
|
||
}
|
||
|
||
// An active rename replaces the dispatch caret with its row-local editor caret.
|
||
if let Some(pos) = rename_cursor_pos(state, &rows) {
|
||
return Some(pos);
|
||
}
|
||
dispatch_cursor
|
||
}
|
||
|
||
const RENAME_PREFIX: &str = "rename: ";
|
||
|
||
fn rename_editor_view(draft: &RenameDraft, width: u16) -> (&str, u16) {
|
||
let prefix_width = UnicodeWidthStr::width(RENAME_PREFIX) as u16;
|
||
let editor_width = width.saturating_sub(prefix_width);
|
||
let viewport = draft.viewport(editor_width as usize);
|
||
let visible = &draft.text()[viewport.visible_byte_range];
|
||
let cursor_offset = prefix_width
|
||
.saturating_add(viewport.cursor_display_column as u16)
|
||
.min(width.saturating_sub(1));
|
||
(visible, cursor_offset)
|
||
}
|
||
|
||
fn render_rename_editor(
|
||
buf: &mut Buffer,
|
||
x: u16,
|
||
y: u16,
|
||
width: u16,
|
||
style: Style,
|
||
draft: &RenameDraft,
|
||
) {
|
||
if width == 0 {
|
||
return;
|
||
}
|
||
let prefix_width = UnicodeWidthStr::width(RENAME_PREFIX) as u16;
|
||
buf.set_span(
|
||
x,
|
||
y,
|
||
&Span::styled(RENAME_PREFIX, style),
|
||
prefix_width.min(width),
|
||
);
|
||
let (visible, _) = rename_editor_view(draft, width);
|
||
if !visible.is_empty() && prefix_width < width {
|
||
buf.set_span(
|
||
x + prefix_width,
|
||
y,
|
||
&Span::styled(visible, style),
|
||
width - prefix_width,
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Return the in-flight rename caret when its row is visible.
|
||
fn rename_cursor_pos(state: &DashboardState, rows: &[DashboardRow]) -> Option<(u16, u16)> {
|
||
let rn = state.rename.as_ref()?;
|
||
let (_, rect) = state.row_rects.iter().find(|(id, _)| *id == rn.row)?;
|
||
let row = rows.iter().find(|r| r.id == rn.row);
|
||
let (marker_width, indent_width, icon_width) = row
|
||
.map(|r| {
|
||
(
|
||
UnicodeWidthStr::width(crate::glyphs::selection_bar()) as u16,
|
||
(r.indent as u16) * 2,
|
||
UnicodeWidthStr::width(state_icon(r.state, state.spinner_tick)) as u16,
|
||
)
|
||
})
|
||
.unwrap_or((1, 0, 1));
|
||
let chrome_width = marker_width + 1 + indent_width + icon_width + 1;
|
||
let content_x = rect.x.saturating_add(chrome_width);
|
||
let content_width = rect.x.saturating_add(rect.width).saturating_sub(content_x);
|
||
let (_, cursor_offset) = rename_editor_view(rn, content_width);
|
||
let cursor_x = content_x
|
||
.saturating_add(cursor_offset)
|
||
.min(rect.x.saturating_add(rect.width.saturating_sub(1)));
|
||
// Mirror `render_row`'s vertical centering so the caret lands on
|
||
// the title line (narrow-mode single-line rects yield offset 0).
|
||
let title_y = rect.y + row.map_or(0, |r| row_content_offset(rect.height, r));
|
||
Some((cursor_x, title_y))
|
||
}
|
||
|
||
/// Render the compact dashboard "banner" used when an agent is
|
||
/// attached as a popup. Replaces the previous
|
||
/// stacked-input-bars layout (where the dashboard's dispatch +
|
||
/// footer rendered visibly BELOW the popup). The banner is a
|
||
/// bordered panel containing the row list summary; the popup
|
||
/// renders directly below it carrying the focused agent's full
|
||
/// view (scrollback + prompt + shortcuts), so the user sees a
|
||
/// single coherent surface with the agent's prompt as the only
|
||
/// input bar.
|
||
///
|
||
/// Visual:
|
||
///
|
||
/// ```text
|
||
/// ╭──── Dashboard · 3 agents · 1 working ─────────────────────╮
|
||
/// │ Working (1) │
|
||
/// │ ⸬ session A Responding 4.4s │
|
||
/// │ Idle (2) │
|
||
/// │ ○ session B 1m12s │
|
||
/// │ ○ session C 2m30s │
|
||
/// ╰────────────────────────────────────────────────────────────╯
|
||
/// ```
|
||
///
|
||
/// The border + title are drawn via ratatui's `Block` widget so the
|
||
/// visual chrome matches other bordered panels (subagent fullscreen,
|
||
/// popup overlays). Rows are clipped to fit inside the bordered
|
||
/// area; the user can see additional rows by closing the popup
|
||
/// (Esc) to reach the full dashboard.
|
||
fn render_dashboard_banner(
|
||
buf: &mut Buffer,
|
||
area: Rect,
|
||
theme: &Theme,
|
||
rows: &[DashboardRow],
|
||
state: &mut DashboardState,
|
||
) {
|
||
use ratatui::text::Line;
|
||
use ratatui::widgets::{Block, Borders, Widget};
|
||
|
||
state.row_rects.clear();
|
||
state.section_rects.clear();
|
||
if area.area() == 0 || area.height < 3 {
|
||
return;
|
||
}
|
||
|
||
// Build the title chip: `Dashboard · N agents · M working`.
|
||
let mut total = 0usize;
|
||
let mut working = 0usize;
|
||
let mut needs_input = 0usize;
|
||
for r in rows.iter().filter(|r| r.indent == 0) {
|
||
total += 1;
|
||
if r.state == RowState::Working {
|
||
working += 1;
|
||
}
|
||
if r.state == RowState::NeedsInput {
|
||
needs_input += 1;
|
||
}
|
||
}
|
||
let agent_word = if total == 1 { "agent" } else { "agents" };
|
||
let mut title_parts: Vec<String> = vec!["Dashboard".to_string()];
|
||
title_parts.push(format!("{total} {agent_word}"));
|
||
if working > 0 {
|
||
title_parts.push(format!("{working} working"));
|
||
}
|
||
if needs_input > 0 {
|
||
title_parts.push(format!("{needs_input} awaiting"));
|
||
}
|
||
let title = format!(" {} ", title_parts.join(" · "));
|
||
|
||
// Draw the bordered frame with the title centred on the top edge.
|
||
let block = Block::default()
|
||
.borders(Borders::ALL)
|
||
.border_style(
|
||
Style::default()
|
||
.fg(theme.selection_border)
|
||
.bg(theme.bg_base),
|
||
)
|
||
.title(
|
||
Line::from(title).style(
|
||
Style::default()
|
||
.fg(theme.text_primary)
|
||
.bg(theme.bg_base)
|
||
.add_modifier(Modifier::BOLD),
|
||
),
|
||
);
|
||
let inner = block.inner(area);
|
||
block.render(area, buf);
|
||
|
||
// Render rows inside the bordered area. Clip to the inner height
|
||
// so we never overrun the border.
|
||
if inner.area() == 0 {
|
||
return;
|
||
}
|
||
if rows.is_empty() {
|
||
let hint = " No sessions yet — Esc to dispatch one. ";
|
||
let trunc = truncate_str(hint, inner.width as usize);
|
||
buf.set_string(
|
||
inner.x,
|
||
inner.y,
|
||
trunc,
|
||
Style::default().fg(theme.gray_dim).bg(theme.bg_base),
|
||
);
|
||
return;
|
||
}
|
||
|
||
if inner.width < MIN_DASHBOARD_WIDTH {
|
||
render_narrow_rows(buf, inner, theme, rows, state);
|
||
} else {
|
||
render_rows(buf, inner, theme, rows, state);
|
||
}
|
||
}
|
||
|
||
/// Render the dashboard header row:
|
||
///
|
||
/// ```text
|
||
/// main worktree ~/wt/wt1 (worktree of ~/proj) ◆ 2 awaiting · ⋮ 3 working · ◇ 1 idle │ [+ New Agent]
|
||
/// ```
|
||
///
|
||
/// Left: the current location — git branch + worktree badge + cwd, the
|
||
/// same line the welcome top bar paints (see
|
||
/// `views::welcome::location_line`), rendered the same way as the session
|
||
/// status bar — so the dashboard shows where a dispatched session will
|
||
/// run. Truncated with `…` against the chips.
|
||
/// Right: state-count chips with state-coloured diamond/spinner glyphs
|
||
/// (matching the row icon vocabulary so the same glyph that marks a
|
||
/// working row also marks the "working" chip). Counts are top-level
|
||
/// rows only — subagents inherit their parent's group and would
|
||
/// inflate the tallies if counted directly. Chips with zero count are
|
||
/// suppressed.
|
||
///
|
||
/// Reintroduces a `[+ New Agent]` button on the right edge of
|
||
/// the header. The button is the default cursor target whenever
|
||
/// no row is selected — Up-arrow from the first row, Esc deselect,
|
||
/// and dashboard-open-without-prior-agent all land here. While
|
||
/// focused, Enter on an empty prompt creates a session and opens
|
||
/// detail view; click does the same. The focused colour bumps to
|
||
/// the green `accent_success` (mirroring the affirmative "create"
|
||
/// affordance) so the cursor's location is obvious without a
|
||
/// separate marker.
|
||
fn render_header(
|
||
buf: &mut Buffer,
|
||
area: Rect,
|
||
theme: &Theme,
|
||
rows: &[DashboardRow],
|
||
state: &mut DashboardState,
|
||
upgrade_cta: Option<HeaderUpgradeCta<'_>>,
|
||
) {
|
||
use ratatui::text::{Line, Span};
|
||
|
||
use crate::views::agent_status::AgentStatusBar;
|
||
|
||
// Clear the click rects at the start of every frame; the paint
|
||
// blocks below repopulate them when the header has room. `set(None)`
|
||
// preserves the `hovered` flag (driven by mouse-move events) so it
|
||
// survives the per-frame rect reset.
|
||
state.new_agent_button_hit.set(None);
|
||
state.location_hit.set(None);
|
||
state.upgrade_cta_hit.set(None);
|
||
|
||
if area.area() == 0 {
|
||
return;
|
||
}
|
||
buf.set_style(area, Style::default().bg(theme.bg_base));
|
||
|
||
// Paint the `[+ New Agent]` button on the far right FIRST,
|
||
// before the status chips. We then shrink the chip rendering
|
||
// area so the chips don't overlap the button. Right-margin
|
||
// is 1 cell so the button doesn't kiss the panel edge.
|
||
// Worktree mode armed (and the cwd is a git repo, so it can actually
|
||
// take effect) → the button creates the next session in a fresh git
|
||
// worktree, so label it `[+ New Worktree]` to match. Otherwise it's the
|
||
// plain new-session button. Width is derived from the chosen label so the
|
||
// positioning + hit rect below stay correct for either string.
|
||
let button_label: &str = if state.dispatch_worktree && state.cwd_has_git_ancestor {
|
||
"[+ New Worktree]"
|
||
} else {
|
||
"[+ New Agent]"
|
||
};
|
||
let button_w = UnicodeWidthStr::width(button_label) as u16;
|
||
let right_margin: u16 = 1;
|
||
let mut chip_area = area;
|
||
if area.width >= button_w + right_margin {
|
||
let button_x = area.x + area.width - right_margin - button_w;
|
||
// Focused → light green `accent_success` (the affirmative
|
||
// "create a new session" affordance), so the focus is obvious.
|
||
// Hovered (mouse over, not focused) → brighter `text_primary`
|
||
// foreground so the button stands out under the cursor. Only
|
||
// the text colour changes on hover (no background fill).
|
||
// Otherwise dim gray.
|
||
let hovered = state.new_agent_button_hit.hovered;
|
||
let fg = if state.new_agent_button_focused {
|
||
theme.accent_success
|
||
} else if hovered {
|
||
theme.text_primary
|
||
} else {
|
||
theme.gray
|
||
};
|
||
let style = Style::default()
|
||
.fg(fg)
|
||
.bg(theme.bg_base)
|
||
.add_modifier(Modifier::BOLD);
|
||
buf.set_string(button_x, area.y, button_label, style);
|
||
state.new_agent_button_hit.set(Some(Rect {
|
||
x: button_x,
|
||
y: area.y,
|
||
width: button_w,
|
||
height: 1,
|
||
}));
|
||
// Leave one breathing cell between the rightmost chip and
|
||
// the button's `[`.
|
||
let chip_budget = area.width.saturating_sub(button_w + right_margin + 1);
|
||
chip_area = Rect {
|
||
x: area.x,
|
||
y: area.y,
|
||
width: chip_budget,
|
||
height: area.height,
|
||
};
|
||
}
|
||
|
||
// Count top-level rows per state. Subagents inherit their
|
||
// parent's group so we explicitly skip `indent > 0` rows.
|
||
let mut awaiting = 0usize;
|
||
let mut working = 0usize;
|
||
let mut idle = 0usize;
|
||
let mut done = 0usize;
|
||
let mut failed = 0usize;
|
||
let mut blocked = 0usize;
|
||
for r in rows.iter().filter(|r| r.indent == 0) {
|
||
match r.state {
|
||
RowState::NeedsInput => awaiting += 1,
|
||
RowState::Working => working += 1,
|
||
RowState::Idle => idle += 1,
|
||
// Inactive (roster-only) sessions get no header chip — the
|
||
// chips surface actionable local state; the section header
|
||
// already carries the inactive count.
|
||
RowState::Inactive => {}
|
||
RowState::Completed => done += 1,
|
||
RowState::Failed => failed += 1,
|
||
RowState::Blocked => blocked += 1,
|
||
}
|
||
}
|
||
|
||
// Build right-aligned chips. Order mirrors `RowState::group_priority`
|
||
// so the most-actionable state (awaiting your input) appears
|
||
// leftmost in the chip group, where the eye lands first.
|
||
let mut status = AgentStatusBar::new(theme);
|
||
let chip = |glyph: &str, color: Color, count: usize, label: &'static str| {
|
||
Line::from(vec![
|
||
Span::styled(
|
||
glyph.to_string(),
|
||
Style::default().fg(color).bg(theme.bg_base),
|
||
),
|
||
Span::styled(
|
||
format!(" {count} {label}"),
|
||
Style::default().fg(theme.gray).bg(theme.bg_base),
|
||
),
|
||
])
|
||
};
|
||
if awaiting > 0 {
|
||
// Yellow bullet to match the per-row awaiting bullet (no blink in the
|
||
// compact header chip).
|
||
status.push(
|
||
"awaiting",
|
||
chip(
|
||
crate::glyphs::diamond_filled(),
|
||
theme.warning,
|
||
awaiting,
|
||
"awaiting",
|
||
),
|
||
);
|
||
}
|
||
if working > 0 {
|
||
// Use the spinner glyph at the current tick so the chip's
|
||
// working glyph matches the spinner painted on individual
|
||
// rows (visual consistency).
|
||
let frames = crate::glyphs::dot_spinner_frames();
|
||
let spin = frames[(state.spinner_tick / SPINNER_DIVISOR) as usize % frames.len()];
|
||
status.push(
|
||
"working",
|
||
chip(spin, theme.accent_running, working, "working"),
|
||
);
|
||
}
|
||
if blocked > 0 {
|
||
status.push(
|
||
"blocked",
|
||
chip(
|
||
crate::glyphs::diamond_filled(),
|
||
theme.warning,
|
||
blocked,
|
||
"blocked",
|
||
),
|
||
);
|
||
}
|
||
if idle > 0 {
|
||
status.push(
|
||
"idle",
|
||
chip(
|
||
crate::glyphs::diamond_hollow(),
|
||
theme.gray_dim,
|
||
idle,
|
||
"idle",
|
||
),
|
||
);
|
||
}
|
||
if done > 0 {
|
||
status.push(
|
||
"done",
|
||
chip(
|
||
crate::glyphs::diamond_filled(),
|
||
theme.accent_success,
|
||
done,
|
||
"done",
|
||
),
|
||
);
|
||
}
|
||
if failed > 0 {
|
||
status.push(
|
||
"failed",
|
||
chip(
|
||
crate::glyphs::diamond_filled(),
|
||
theme.accent_error,
|
||
failed,
|
||
"failed",
|
||
),
|
||
);
|
||
}
|
||
// Chips render right-aligned within `chip_area` so they sit
|
||
// immediately to the left of the `[+ New Agent]` button. Capture
|
||
// the per-chip rects so the left label's width budget stops short
|
||
// of the leftmost chip instead of painting over it.
|
||
let chip_rects = status.render(buf, chip_area);
|
||
|
||
// Paint the current location — git branch + cwd (with worktree
|
||
// label) — on the left, mirroring the session surfaces (welcome
|
||
// top bar / agent status bar) so the dashboard shows WHERE a
|
||
// dispatched session will run. Replaces the old bare "Agents"
|
||
// label.
|
||
//
|
||
// Width budget: from `area.x` up to the leftmost chip's leading
|
||
// ` │ ` separator (3 cells, painted by `AgentStatusBar::render`
|
||
// before its first item), or the full chip area when no chips
|
||
// rendered. `truncate_line` appends `…` when the path is cut.
|
||
let full_label_budget = chip_rects
|
||
.values()
|
||
.map(|r| r.x)
|
||
.min()
|
||
.map(|min_x| min_x.saturating_sub(3).saturating_sub(area.x))
|
||
.unwrap_or(chip_area.width) as usize;
|
||
// Reserve the upgrade CTA (lead space + `[label]` + pinned-only `cta.caption`)
|
||
// so the location label truncates first (reservation-first); the shared
|
||
// painter then clamps to the space left, so it can't overpaint chips.
|
||
// Caption gates on pinned only: the Ctrl+O CTA chord is handled before the peek-permission key handler, so it opens the CTA (not YOLO) even while a peek prompt is pending.
|
||
let upgrade_caption = upgrade_cta.and_then(|cta| cta.pinned.then_some(cta.caption).flatten());
|
||
let upgrade_reserve = upgrade_cta.map_or(0usize, |cta| {
|
||
1 + crate::views::announcements::upgrade_cta_reserve(cta.label, upgrade_caption) as usize
|
||
});
|
||
let label_budget = full_label_budget.saturating_sub(upgrade_reserve);
|
||
let mut location = crate::views::welcome::location_line_at(theme, &state.cwd);
|
||
// 1-cell left inset, matching the old ` Agents` label.
|
||
location
|
||
.spans
|
||
.insert(0, Span::styled(" ", Style::default().bg(theme.bg_base)));
|
||
let mut location = truncate_line(location, label_budget);
|
||
// Underline on hover so the label reads as a click target (opens the
|
||
// location picker). Underline only visible text: whitespace-only spans
|
||
// (the leading inset, the git↔path separator) stay bare, and the git
|
||
// span (`{icon} {branch}`) keeps its branch icon + the space after it
|
||
// un-underlined — only the branch name is underlined. Hover is
|
||
// mouse-driven on the prior frame.
|
||
if state.location_hit.hovered {
|
||
let icon = crate::git_info::branch_icon();
|
||
location.spans = underline_location_on_hover(std::mem::take(&mut location.spans), icon);
|
||
}
|
||
let location_w = location.width() as u16;
|
||
buf.set_line(area.x, area.y, &location, location_w);
|
||
|
||
// Record the painted label as a click target so the mouse handler
|
||
// can open the location picker. Width is clamped to the label budget
|
||
// so the hit area never extends under the chips / `[+ New Agent]`.
|
||
let hit_w = location_w.min(label_budget as u16);
|
||
if hit_w > 0 {
|
||
state.location_hit.set(Some(Rect {
|
||
x: area.x,
|
||
y: area.y,
|
||
width: hit_w,
|
||
height: 1,
|
||
}));
|
||
}
|
||
|
||
// Upgrade CTA painted right after the location label (free-tier upsell),
|
||
// clamped to the space left before the chips — a lead space then the shared
|
||
// clamping button painter. Pointer click → Dashboard, Ctrl+O → Keyboard.
|
||
if let Some(HeaderUpgradeCta { label, .. }) = upgrade_cta {
|
||
let avail = full_label_budget.saturating_sub(location_w as usize);
|
||
if avail > 1 {
|
||
let cta_x = area.x + location_w;
|
||
buf.set_span(
|
||
cta_x,
|
||
area.y,
|
||
&Span::styled(" ", Style::default().bg(theme.bg_base)),
|
||
1,
|
||
);
|
||
let painted = crate::views::announcements::render_cta_button(
|
||
buf,
|
||
theme,
|
||
cta_x + 1,
|
||
area.y,
|
||
(avail - 1) as u16,
|
||
label,
|
||
upgrade_caption,
|
||
state.upgrade_cta_hit.hovered,
|
||
);
|
||
state.upgrade_cta_hit.set(painted);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Apply the header location label's hover underline: underline only the
|
||
/// visible text. Whitespace-only spans (the leading inset, the git↔path
|
||
/// separator) stay bare, and the git span (`{icon} {branch}`) keeps the
|
||
/// branch `icon` plus the space after it un-underlined — only the branch
|
||
/// name is underlined. Total width is preserved (the git span is split,
|
||
/// not resized).
|
||
fn underline_location_on_hover(spans: Vec<Span<'static>>, icon: &str) -> Vec<Span<'static>> {
|
||
let underline = |content: &str, style: Style| -> Style {
|
||
if content.chars().any(|c| !c.is_whitespace()) {
|
||
style.add_modifier(Modifier::UNDERLINED)
|
||
} else {
|
||
style
|
||
}
|
||
};
|
||
let mut out: Vec<Span<'static>> = Vec::with_capacity(spans.len() + 1);
|
||
for span in spans {
|
||
let style = span.style;
|
||
let content = span.content.into_owned();
|
||
// The git span is `{icon} {branch}`: emit the icon + its trailing
|
||
// space bare, and underline only the branch name.
|
||
if content.starts_with(icon)
|
||
&& let Some(space) = content.find(' ')
|
||
{
|
||
let (icon_part, branch) = content.split_at(space + 1);
|
||
out.push(Span::styled(icon_part.to_owned(), style));
|
||
if !branch.is_empty() {
|
||
let s = underline(branch, style);
|
||
out.push(Span::styled(branch.to_owned(), s));
|
||
}
|
||
continue;
|
||
}
|
||
let s = underline(&content, style);
|
||
out.push(Span::styled(content, s));
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Render the location picker modal over the dashboard. Content-row hit
|
||
/// areas from the render are stashed on the modal for the mouse handler.
|
||
fn render_location_picker(
|
||
buf: &mut Buffer,
|
||
area: Rect,
|
||
theme: &Theme,
|
||
modal: &mut LocationPickerState,
|
||
) {
|
||
use crate::views::modal_window::{
|
||
ModalSizing, ModalWindowConfig, Shortcut, push_vim_nav_search_hint, render_modal_window,
|
||
};
|
||
use crate::views::picker::{
|
||
PickerEntry, PickerRow, render_divider, render_picker_content,
|
||
render_picker_search_bar_with_label,
|
||
};
|
||
|
||
let mut shortcuts = vec![
|
||
Shortcut {
|
||
label: "\u{2191}\u{2193} nav",
|
||
clickable: false,
|
||
id: 0,
|
||
},
|
||
Shortcut {
|
||
label: "Tab complete",
|
||
clickable: false,
|
||
id: 1,
|
||
},
|
||
Shortcut {
|
||
label: "Enter select",
|
||
clickable: false,
|
||
id: 2,
|
||
},
|
||
Shortcut {
|
||
label: "Esc close",
|
||
clickable: false,
|
||
id: 3,
|
||
},
|
||
];
|
||
// Surface `i search` in the footer when vim nav mode is active (input-default
|
||
// picker, but Esc drops to nav under vim).
|
||
push_vim_nav_search_hint(&mut shortcuts, modal.picker.search_active);
|
||
let config = ModalWindowConfig {
|
||
title: "Change directory",
|
||
tabs: None,
|
||
shortcuts: &shortcuts,
|
||
sizing: ModalSizing::medium(),
|
||
fold_info: None,
|
||
};
|
||
let Some(content) = render_modal_window(buf, area, &mut modal.window, &config, theme) else {
|
||
modal.content_hits = None;
|
||
return;
|
||
};
|
||
|
||
let mut content_area = content.content;
|
||
|
||
// The effective candidate list — computed once and reused for both the
|
||
// worktree-eligibility check and the rows below.
|
||
let visible = modal.visible_candidates();
|
||
// Worktrees require a git repo. The directory a selection would land in
|
||
// (the highlighted row, else the base cwd) decides whether the worktree
|
||
// toggle is meaningful; in a non-repo it's hidden and dispatch proceeds
|
||
// normally.
|
||
let target_dir = visible
|
||
.get(modal.picker.selected)
|
||
.map(|c| c.path.clone())
|
||
.unwrap_or_else(|| modal.base_cwd.clone());
|
||
let show_worktree = modal.target_is_repo(&target_dir);
|
||
|
||
// Path input line — a visible, editable field (cursor always shown)
|
||
// so the user can see / type an absolute, `~`, or relative path.
|
||
// Doubles as the live filter for the candidate list below.
|
||
modal.worktree_hit.set(None);
|
||
if content_area.height >= 2 {
|
||
// Reserve room at the right end of the path row for the worktree
|
||
// toggle button, but only when the modal is wide enough to keep a
|
||
// usable path field; otherwise the field spans the full width and
|
||
// the button is hidden.
|
||
let wt_text = if modal.worktree_mode {
|
||
"[worktree:on]"
|
||
} else {
|
||
"[worktree:off]"
|
||
};
|
||
let wt_w = wt_text.len() as u16; // ASCII → byte len == display width
|
||
const WT_GAP: u16 = 1;
|
||
const MIN_PATH_W: u16 = 16;
|
||
let (path_w, wt_rect) = if show_worktree && content_area.width >= wt_w + WT_GAP + MIN_PATH_W
|
||
{
|
||
(
|
||
content_area.width - wt_w - WT_GAP,
|
||
Some(Rect {
|
||
x: content_area.x + content_area.width - wt_w,
|
||
y: content_area.y,
|
||
width: wt_w,
|
||
height: 1,
|
||
}),
|
||
)
|
||
} else {
|
||
(content_area.width, None)
|
||
};
|
||
render_picker_search_bar_with_label(
|
||
buf,
|
||
content_area.x,
|
||
content_area.y,
|
||
path_w,
|
||
theme,
|
||
" path: ",
|
||
&modal.picker,
|
||
/* active */ false,
|
||
/* show_hint */ false,
|
||
Some(theme.bg_base),
|
||
);
|
||
modal.worktree_hit.set(wt_rect);
|
||
if let Some(r) = wt_rect {
|
||
// Dim label by default; brighten the text on hover, like other
|
||
// clickable buttons. When armed, the "on" word is green in either
|
||
// state so the active state reads at a glance.
|
||
let label_fg = if modal.worktree_hit.hovered {
|
||
theme.text_primary
|
||
} else {
|
||
theme.gray
|
||
};
|
||
buf.set_string(
|
||
r.x,
|
||
r.y,
|
||
wt_text,
|
||
Style::default().fg(label_fg).bg(theme.bg_base),
|
||
);
|
||
if modal.worktree_mode
|
||
&& let Some(on_at) = wt_text.find("on")
|
||
{
|
||
buf.set_string(
|
||
r.x + on_at as u16,
|
||
r.y,
|
||
"on",
|
||
Style::default().fg(theme.accent_success).bg(theme.bg_base),
|
||
);
|
||
}
|
||
}
|
||
// Second header row: the inline error (red) when present, else a
|
||
// divider separating the input from the list.
|
||
if let Some(err) = modal.error.as_deref() {
|
||
let err_line = truncate_str(err, content_area.width as usize);
|
||
buf.set_string(
|
||
content_area.x,
|
||
content_area.y + 1,
|
||
&err_line,
|
||
Style::default().fg(theme.accent_error).bg(theme.bg_base),
|
||
);
|
||
} else {
|
||
render_divider(
|
||
buf,
|
||
content_area.x,
|
||
content_area.y + 1,
|
||
content_area.width,
|
||
theme,
|
||
Some(theme.bg_base),
|
||
);
|
||
}
|
||
content_area.y += 2;
|
||
content_area.height = content_area.height.saturating_sub(2);
|
||
}
|
||
|
||
// Build entries from the effective list (`visible`, computed above) —
|
||
// recents (fuzzy-filtered) or live directory suggestions
|
||
// (prefix-matched), depending on the query.
|
||
// Worktree badge per row: just `worktree` when the directory name
|
||
// already is the worktree name, else `worktree: <name>`. Built into a
|
||
// parallel Vec so the `&str` badges outlive the entries.
|
||
let badges: Vec<String> = visible
|
||
.iter()
|
||
.map(|c| match &c.worktree {
|
||
Some(name) if name == &c.label => "worktree".to_string(),
|
||
Some(name) => format!("worktree: {name}"),
|
||
None => String::new(),
|
||
})
|
||
.collect();
|
||
// Truncation priority: the directory name (label) is shown in full
|
||
// whenever it fits; the path (right label) is truncated first. The
|
||
// shared `render_picker_row` does the opposite (right label keeps its
|
||
// full width, label truncates), so we pre-truncate the path here to
|
||
// leave room for the full label + badge. Constants mirror
|
||
// `render_picker_row`'s layout (fold prefix 2, gap 2, trailing 1); the
|
||
// `-1` conservatively reserves a scrollbar column.
|
||
let details: Vec<String> = {
|
||
const PREFIX: u16 = 2;
|
||
const GAP: u16 = 2;
|
||
const TRAILING: u16 = 1;
|
||
let row_w = content_area.width.saturating_sub(1);
|
||
visible
|
||
.iter()
|
||
.zip(&badges)
|
||
.map(|(c, badge)| {
|
||
let badge_w = if badge.is_empty() {
|
||
0
|
||
} else {
|
||
badge.width() as u16 + 1
|
||
};
|
||
let reserved = PREFIX + c.label.width() as u16 + badge_w + GAP + TRAILING;
|
||
let budget = row_w.saturating_sub(reserved) as usize;
|
||
truncate_str(&c.detail, budget)
|
||
})
|
||
.collect()
|
||
};
|
||
let entries: Vec<PickerEntry<'_>> = visible
|
||
.iter()
|
||
.zip(&badges)
|
||
.zip(&details)
|
||
.enumerate()
|
||
.map(|(vis, ((c, badge), detail))| {
|
||
PickerEntry::Row(PickerRow {
|
||
label: c.label.as_str(),
|
||
right_label: detail.as_str(),
|
||
selected: vis == modal.picker.selected,
|
||
expanded: false,
|
||
fields: &[],
|
||
description_lines: &[],
|
||
summary_lines: &[],
|
||
dimmed: false,
|
||
indent: 0,
|
||
badge: badge.as_str(),
|
||
badge_color: (!badge.is_empty()).then_some(theme.accent_user),
|
||
collapsible: false,
|
||
underline_last_desc: false,
|
||
})
|
||
})
|
||
.collect();
|
||
|
||
let hits = render_picker_content(
|
||
buf,
|
||
content_area,
|
||
theme,
|
||
&mut modal.picker,
|
||
&entries,
|
||
/* non_selectable */ &[],
|
||
/* non_selectable_clickable */ &[],
|
||
Some(theme.bg_base),
|
||
/* loading */ false,
|
||
);
|
||
modal.content_hits = Some(hits);
|
||
}
|
||
|
||
/// One line in the dashboard's vertical stack — either a state-group
|
||
/// header or a content row.
|
||
///
|
||
/// Reintroduces explicit state group headers (`──
|
||
/// Needs input (2) ──`). The per-row dot + state colour alone didn't
|
||
/// communicate group boundaries clearly enough; users couldn't tell at
|
||
/// a glance how many sessions were awaiting input vs working vs idle
|
||
/// vs done. Headers are emitted on every top-level state transition
|
||
/// (subagent rows inherit their parent's group and never trigger a
|
||
/// header) when `grouping == Grouping::State` and the filter isn't
|
||
/// already pinned to a single state.
|
||
///
|
||
/// Headers do NOT register `row_rects` — they're not selectable,
|
||
/// hoverable, or clickable.
|
||
enum DashboardLine<'a> {
|
||
/// Cross-cutting "Pinned" section header (with count), emitted above the
|
||
/// pinned block when grouping is ON.
|
||
PinnedHeader {
|
||
count: usize,
|
||
},
|
||
/// Textless horizontal rule. Used when grouping is OFF to separate the
|
||
/// pinned block from the rest without a labelled header.
|
||
Divider,
|
||
Header {
|
||
state: RowState,
|
||
count: usize,
|
||
},
|
||
Row(&'a DashboardRow),
|
||
/// The Idle group's "N more" overflow toggle row,
|
||
/// emitted at the bottom of a capped Idle group. `hidden` is the
|
||
/// number of folded agents; `expanded` reflects
|
||
/// [`super::state::DashboardState::idle_show_all`] so the label can
|
||
/// flip to "show fewer".
|
||
IdleOverflow {
|
||
hidden: usize,
|
||
expanded: bool,
|
||
},
|
||
}
|
||
|
||
/// Walk `rows` and intersperse `DashboardLine::Header` entries at
|
||
/// every top-level state transition. Returns a flat sequence that the
|
||
/// renderer iterates over directly; viewport clamping operates on
|
||
/// THIS sequence (not on `rows`) so the header rows count toward the
|
||
/// visible window's height budget.
|
||
///
|
||
/// Headers are suppressed when:
|
||
/// - `grouping == Grouping::Directory` — the cwd is the grouping
|
||
/// primitive in that mode and state headers would land
|
||
/// out-of-band relative to the cwd sections.
|
||
/// - `filter == Filter::State(_)` — the filtered view already only
|
||
/// contains a single state, so the header is redundant chrome.
|
||
fn build_dashboard_lines<'a>(
|
||
rows: &'a [DashboardRow],
|
||
grouping: Grouping,
|
||
filter: &Filter,
|
||
collapsed: &std::collections::HashSet<SectionKey>,
|
||
idle_show_all: bool,
|
||
search_active: bool,
|
||
) -> Vec<DashboardLine<'a>> {
|
||
let groups_on = matches!(grouping, Grouping::State);
|
||
let emit_state_headers = groups_on && !matches!(filter, Filter::State(_));
|
||
|
||
// Pinned top-level agents are sorted to the front (see `sort_rows`), so
|
||
// they form a contiguous prefix of clusters. Split that prefix off as a
|
||
// dedicated "Pinned" section above the state / directory groups, so a
|
||
// pinned (say) idle agent reads as pinned rather than landing under an
|
||
// "Idle" header.
|
||
let mut pinned_end = 0usize;
|
||
let mut pinned_count = 0usize;
|
||
{
|
||
let mut i = 0usize;
|
||
while i < rows.len() && rows[i].indent == 0 && rows[i].pinned {
|
||
pinned_count += 1;
|
||
i += 1;
|
||
// Glue the pinned parent's subagents into the section.
|
||
while i < rows.len() && rows[i].indent != 0 {
|
||
i += 1;
|
||
}
|
||
pinned_end = i;
|
||
}
|
||
}
|
||
|
||
let mut out: Vec<DashboardLine<'a>> = Vec::with_capacity(rows.len() + 6);
|
||
if pinned_count > 0 {
|
||
// Grouping ON → a labelled "Pinned N" header above the block.
|
||
// Grouping OFF (Ctrl+G) → no header; a textless divider separates the
|
||
// pinned block from the rest (only when there's a rest to separate).
|
||
if groups_on {
|
||
out.push(DashboardLine::PinnedHeader {
|
||
count: pinned_count,
|
||
});
|
||
}
|
||
// A collapsed "Pinned" section keeps its header but hides the
|
||
// pinned rows. Collapse only applies when grouping is ON (the
|
||
// header is the toggle affordance; the grouping-OFF divider has
|
||
// none).
|
||
let pinned_collapsed = groups_on && collapsed.contains(&SectionKey::Pinned);
|
||
if !pinned_collapsed {
|
||
out.extend(rows[..pinned_end].iter().map(DashboardLine::Row));
|
||
}
|
||
if !groups_on && pinned_end < rows.len() {
|
||
out.push(DashboardLine::Divider);
|
||
}
|
||
}
|
||
|
||
let rest = &rows[pinned_end..];
|
||
if !emit_state_headers {
|
||
out.extend(rest.iter().map(DashboardLine::Row));
|
||
return out;
|
||
}
|
||
let mut last_top_state: Option<RowState> = None;
|
||
// Whether the section currently being emitted is collapsed; when so
|
||
// its rows (and their subagents) are skipped but the header stays.
|
||
let mut current_collapsed = false;
|
||
// Idle-overflow cap bookkeeping for the group currently being emitted.
|
||
// `idle_limit` is `Some(visible_top_level_limit)` only while inside a
|
||
// capped Idle group; `idle_top_seen` counts emitted top-level Idle
|
||
// rows; `idle_capping` latches once the limit is passed so the
|
||
// over-cap rows AND their subagents are skipped. `pending_overflow`
|
||
// holds the `(hidden, expanded)` line to emit at the group's end
|
||
// (after its rows, before the next header). Capping is suppressed
|
||
// whenever the user is filtering OR in search mode — when you're
|
||
// looking for something, every match shows. `search_active` is needed
|
||
// in addition to the filter check because entering search mode clears
|
||
// the filter to `None` (the live query rebuilds it per keystroke), so
|
||
// an empty search query would otherwise leave old idle agents folded.
|
||
let idle_cap_active = matches!(filter, Filter::None) && !search_active;
|
||
let now = std::time::SystemTime::now();
|
||
let mut idle_limit: Option<usize> = None;
|
||
let mut idle_top_seen = 0usize;
|
||
let mut idle_capping = false;
|
||
let mut pending_overflow: Option<(usize, bool)> = None;
|
||
for (i, row) in rest.iter().enumerate() {
|
||
if row.indent == 0 && Some(row.state) != last_top_state {
|
||
// Emit the overflow row of the group we're leaving before the
|
||
// new header, so it lands at the bottom of the Idle group.
|
||
if let Some((hidden, expanded)) = pending_overflow.take() {
|
||
out.push(DashboardLine::IdleOverflow { hidden, expanded });
|
||
}
|
||
// Count consecutive top-level rows of this state from `i`
|
||
// (looking forward until the next top-level state change
|
||
// or end of list). Subagents are skipped over rather than
|
||
// breaking the count, since they share their parent's
|
||
// group. The count reflects the true group size even when
|
||
// collapsed or capped. `recent` tracks how many are inside
|
||
// the freshness window (Idle only).
|
||
let mut count = 0usize;
|
||
let mut recent = 0usize;
|
||
for r in &rest[i..] {
|
||
if r.indent != 0 {
|
||
continue;
|
||
}
|
||
if r.state == row.state {
|
||
count += 1;
|
||
if idle_row_is_recent(r, now) {
|
||
recent += 1;
|
||
}
|
||
} else {
|
||
break;
|
||
}
|
||
}
|
||
out.push(DashboardLine::Header {
|
||
state: row.state,
|
||
count,
|
||
});
|
||
last_top_state = Some(row.state);
|
||
current_collapsed = collapsed.contains(&SectionKey::State(row.state));
|
||
// Reset / arm the Idle cap for the new group.
|
||
idle_limit = None;
|
||
idle_top_seen = 0;
|
||
idle_capping = false;
|
||
if row.state == RowState::Idle && idle_cap_active && !current_collapsed {
|
||
// Keep the freshest agents: at least MAX_VISIBLE_IDLE,
|
||
// extended to cover everything still inside the freshness
|
||
// window. Only fold when it hides >= MIN_IDLE_FOLD (a
|
||
// single folded row saves no space).
|
||
let base_limit = MAX_VISIBLE_IDLE.max(recent).min(count);
|
||
let base_hidden = count - base_limit;
|
||
if base_hidden >= MIN_IDLE_FOLD {
|
||
idle_limit = Some(if idle_show_all { count } else { base_limit });
|
||
pending_overflow = Some((base_hidden, idle_show_all));
|
||
}
|
||
}
|
||
}
|
||
if current_collapsed {
|
||
continue;
|
||
}
|
||
// Idle cap: once past the limit, skip over-cap top-level rows and
|
||
// their subagents (idle_capping latches until the next group).
|
||
if let Some(limit) = idle_limit {
|
||
if row.indent == 0 {
|
||
idle_top_seen += 1;
|
||
idle_capping = idle_top_seen > limit;
|
||
}
|
||
if idle_capping {
|
||
continue;
|
||
}
|
||
}
|
||
out.push(DashboardLine::Row(row));
|
||
}
|
||
if let Some((hidden, expanded)) = pending_overflow.take() {
|
||
out.push(DashboardLine::IdleOverflow { hidden, expanded });
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Maximum number of top-level Idle agents shown before the rest fold
|
||
/// into the "N more" overflow row. The Idle group is
|
||
/// sorted most-recent-first, so the folded tail is always the oldest.
|
||
pub const MAX_VISIBLE_IDLE: usize = 8;
|
||
|
||
/// Idle agents last active within this window are never folded, even
|
||
/// beyond [`MAX_VISIBLE_IDLE`] — a burst of fresh sessions stays visible
|
||
/// (the count cap only hides genuinely *old* idle agents).
|
||
const IDLE_FRESHNESS: std::time::Duration = std::time::Duration::from_secs(60 * 60);
|
||
|
||
/// Don't fold fewer than this many rows: a "1 older" overflow row costs
|
||
/// the same vertical space as the single row it would hide.
|
||
const MIN_IDLE_FOLD: usize = 2;
|
||
|
||
/// Whether an Idle `row` was last active within [`IDLE_FRESHNESS`] of
|
||
/// `now`. Future timestamps (clock skew across pager processes / roster)
|
||
/// count as recent.
|
||
fn idle_row_is_recent(row: &DashboardRow, now: std::time::SystemTime) -> bool {
|
||
now.duration_since(row.last_change_at)
|
||
.map(|age| age < IDLE_FRESHNESS)
|
||
.unwrap_or(true)
|
||
}
|
||
|
||
/// Ordered, display-order list of keyboard cursor targets — section
|
||
/// headers and visible rows — derived from the same
|
||
/// [`build_dashboard_lines`] the renderer paints, so navigation and
|
||
/// rendering never disagree about what's on screen. Collapsed sections
|
||
/// contribute only their header (their rows are skipped by the line
|
||
/// builder), and `… N more` placeholders are excluded (not selectable).
|
||
pub(crate) fn focusables(
|
||
rows: &[DashboardRow],
|
||
grouping: Grouping,
|
||
filter: &Filter,
|
||
collapsed: &std::collections::HashSet<SectionKey>,
|
||
idle_show_all: bool,
|
||
search_active: bool,
|
||
) -> Vec<Focusable> {
|
||
build_dashboard_lines(
|
||
rows,
|
||
grouping,
|
||
filter,
|
||
collapsed,
|
||
idle_show_all,
|
||
search_active,
|
||
)
|
||
.into_iter()
|
||
.filter_map(|line| match line {
|
||
DashboardLine::PinnedHeader { .. } => Some(Focusable::Section(SectionKey::Pinned)),
|
||
DashboardLine::Header { state, .. } => Some(Focusable::Section(SectionKey::State(state))),
|
||
DashboardLine::Row(row) if !row.is_more_placeholder => Some(Focusable::Row(row.id.clone())),
|
||
DashboardLine::IdleOverflow { .. } => Some(Focusable::IdleOverflow),
|
||
_ => None,
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// The section header that owns `row_id` under the current grouping /
|
||
/// filter, or `None` when no headers are emitted (directory grouping,
|
||
/// `s:state` filter) or the row isn't present in `rows` at all. Walks
|
||
/// the same [`build_dashboard_lines`] the renderer paints but with an
|
||
/// EMPTY collapsed set, so a row hidden inside a collapsed section
|
||
/// still resolves to its owning header — that's the point: it lets
|
||
/// `reanchor_selection` move a cursor stranded on a collapse-hidden
|
||
/// row onto the header that hid it. Subagent rows resolve to their
|
||
/// parent's section (they're emitted under the parent's header).
|
||
pub(crate) fn section_of_row(
|
||
rows: &[DashboardRow],
|
||
grouping: Grouping,
|
||
filter: &Filter,
|
||
row_id: &super::DashboardRowId,
|
||
) -> Option<SectionKey> {
|
||
let none_collapsed = std::collections::HashSet::new();
|
||
let mut current: Option<SectionKey> = None;
|
||
// `idle_show_all = true` disables the Idle cap here, mirroring the
|
||
// empty collapsed set: a row hidden by the cap must still resolve to
|
||
// its Idle header so `reanchor_selection` can move a stranded cursor.
|
||
for line in build_dashboard_lines(rows, grouping, filter, &none_collapsed, true, false) {
|
||
match line {
|
||
DashboardLine::PinnedHeader { .. } => current = Some(SectionKey::Pinned),
|
||
DashboardLine::Header { state, .. } => current = Some(SectionKey::State(state)),
|
||
DashboardLine::Row(row) if row.id == *row_id => return current,
|
||
_ => {}
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
fn render_rows(
|
||
buf: &mut Buffer,
|
||
area: Rect,
|
||
theme: &Theme,
|
||
rows: &[DashboardRow],
|
||
state: &mut DashboardState,
|
||
) {
|
||
state.row_rects.clear();
|
||
state.section_rects.clear();
|
||
state.idle_overflow_rect = None;
|
||
if area.area() == 0 {
|
||
return;
|
||
}
|
||
|
||
// Rows are 3 visual cells tall (title + secondary
|
||
// + padding) and headers are 2 cells tall (label + gap).
|
||
// Viewport scrolling works on cumulative cell offsets so partial
|
||
// rows can't peek out at the top / bottom of the list. The
|
||
// clamp helper still operates in "1 unit = 1 cell" — we just
|
||
// pass cell offsets instead of line indices.
|
||
let lines = build_dashboard_lines(
|
||
rows,
|
||
state.grouping,
|
||
&state.filter,
|
||
&state.collapsed_sections,
|
||
state.idle_show_all,
|
||
state.search_mode,
|
||
);
|
||
let heights: Vec<u16> = lines
|
||
.iter()
|
||
.map(|l| match l {
|
||
DashboardLine::Row(_) => ROW_HEIGHT,
|
||
DashboardLine::Header { .. }
|
||
| DashboardLine::PinnedHeader { .. }
|
||
| DashboardLine::IdleOverflow { .. }
|
||
| DashboardLine::Divider => GROUP_HEADER_HEIGHT,
|
||
})
|
||
.collect();
|
||
let total_cells: usize = heights.iter().map(|h| *h as usize).sum();
|
||
|
||
// Compute the selected row's `(top_cell, height)`. The viewport
|
||
// clamp ensures the WHOLE row stays in view rather than just its
|
||
// top cell.
|
||
let mut selected_cell: Option<(usize, u16)> = None;
|
||
{
|
||
let mut cum = 0usize;
|
||
for (line, &h) in lines.iter().zip(heights.iter()) {
|
||
// The cursor is whichever of the three targets is active: a
|
||
// row, or a section header (the button lives outside the list).
|
||
let is_cursor = match line {
|
||
DashboardLine::Row(r) => state.selected.as_ref().is_some_and(|s| *s == r.id),
|
||
DashboardLine::PinnedHeader { .. } => {
|
||
state.selected_section == Some(SectionKey::Pinned)
|
||
}
|
||
DashboardLine::Header { state: rs, .. } => {
|
||
state.selected_section == Some(SectionKey::State(*rs))
|
||
}
|
||
DashboardLine::IdleOverflow { .. } => state.selected_idle_overflow,
|
||
DashboardLine::Divider => false,
|
||
};
|
||
if is_cursor {
|
||
selected_cell = Some((cum, h));
|
||
break;
|
||
}
|
||
cum += h as usize;
|
||
}
|
||
}
|
||
let viewport_h = area.height as usize;
|
||
let snap_target = selected_cell.map(|(top, h)| top + (h as usize).saturating_sub(1));
|
||
let offset = state.clamp_viewport(snap_target, viewport_h, total_cells);
|
||
// Bias the offset up if the selection's TOP cell ended up above
|
||
// the visible window (clamp only guarantees the bottom edge of
|
||
// the selection is in range when snap_target was used).
|
||
//
|
||
// Skipped while `manual_scroll_active` so the mouse-wheel can
|
||
// travel past the selected row's top cell — the bias is the
|
||
// same snap-to-selection that the clamp itself skips in that
|
||
// mode. Keyboard nav clears the flag, which restores both the
|
||
// clamp's snap AND this bias.
|
||
let offset = if !state.manual_scroll_active
|
||
&& let Some((sel_top, _)) = selected_cell
|
||
&& sel_top < offset
|
||
{
|
||
state.viewport_offset = sel_top;
|
||
sel_top
|
||
} else {
|
||
offset
|
||
};
|
||
|
||
// Snap the offset DOWN to the nearest line boundary. `render_row`
|
||
// paints title + secondary starting at `rect.y`, so a partial
|
||
// clip at the top would still show the title (cell 0) where the
|
||
// gap (cell 2) belongs — visually the row "sticks" to the top
|
||
// instead of scrolling away. Trackpad scroll deltas (1 line at a
|
||
// time) made this trivially reproducible. Snapping ensures the
|
||
// topmost visible item always starts at its first cell. Offsets
|
||
// that already coincide with a boundary are left untouched.
|
||
let offset = snap_offset_to_line_boundary(offset, &heights);
|
||
state.viewport_offset = offset;
|
||
|
||
let needs_scrollbar = total_cells > viewport_h && area.width >= 4;
|
||
// Overlay the scrollbar on the right edge rather than reserving a column,
|
||
// so showing / hiding it never shifts the row layout. Rows always paint at
|
||
// full width; the thumb sits on the trailing margin column.
|
||
let body_width = area.width;
|
||
let max_y = area.y + area.height;
|
||
|
||
// Content background per visible line (`None` = spacer), consumed
|
||
// by the half-block halo pass after the items are painted.
|
||
let mut line_bg: Vec<Option<Color>> = vec![None; viewport_h];
|
||
|
||
let mut cell_y: usize = 0;
|
||
for (line, &h) in lines.iter().zip(heights.iter()) {
|
||
let next_cell_y = cell_y + h as usize;
|
||
// Skip items entirely above the viewport.
|
||
if next_cell_y <= offset {
|
||
cell_y = next_cell_y;
|
||
continue;
|
||
}
|
||
// Stop once we've painted past the visible window.
|
||
if cell_y >= offset + viewport_h {
|
||
break;
|
||
}
|
||
// Item start y (relative to the area top), accounting for
|
||
// the part that may be clipped above the viewport.
|
||
let visible_top = cell_y.max(offset);
|
||
let y = area.y + (visible_top - offset) as u16;
|
||
if y >= max_y {
|
||
break;
|
||
}
|
||
let item_height = (next_cell_y - visible_top) as u16;
|
||
let render_h = item_height.min(max_y - y);
|
||
let line_rect = Rect {
|
||
x: area.x,
|
||
y,
|
||
width: body_width,
|
||
height: render_h,
|
||
};
|
||
// `line_bg` records each visible line's CONTENT background
|
||
// (`None` = spacer line) for the half-block halo pass below.
|
||
let mark = |line_bg: &mut Vec<Option<Color>>, dy: u16, bg: Color| {
|
||
let idx = (y - area.y + dy) as usize;
|
||
if let Some(slot) = line_bg.get_mut(idx) {
|
||
*slot = Some(bg);
|
||
}
|
||
};
|
||
match line {
|
||
DashboardLine::PinnedHeader { count } => {
|
||
let key = SectionKey::Pinned;
|
||
let collapsed = state.is_section_collapsed(key);
|
||
let selected = state.selected_section == Some(key);
|
||
let hovered = state.hovered_section == Some(key);
|
||
render_group_header(
|
||
buf, line_rect, theme, "Pinned", *count, collapsed, selected, hovered,
|
||
);
|
||
mark(&mut line_bg, 0, theme.bg_base);
|
||
// Full-height hit rect (label + trailing gap) — no
|
||
// hover/click dead zone between items.
|
||
state
|
||
.section_rects
|
||
.push((key, Rect::new(area.x, y, body_width, render_h)));
|
||
}
|
||
DashboardLine::Divider => {
|
||
render_divider(buf, line_rect, theme);
|
||
mark(&mut line_bg, 0, theme.bg_base);
|
||
}
|
||
DashboardLine::Header { state: rs, count } => {
|
||
// Headers only paint into the first cell; the
|
||
// trailing gap stays at bg_base.
|
||
let key = SectionKey::State(*rs);
|
||
let collapsed = state.is_section_collapsed(key);
|
||
let selected = state.selected_section == Some(key);
|
||
let hovered = state.hovered_section == Some(key);
|
||
render_group_header(
|
||
buf,
|
||
line_rect,
|
||
theme,
|
||
rs.group_label(),
|
||
*count,
|
||
collapsed,
|
||
selected,
|
||
hovered,
|
||
);
|
||
mark(&mut line_bg, 0, theme.bg_base);
|
||
// Full-height hit rect (label + trailing gap) — no
|
||
// hover/click dead zone between items.
|
||
state
|
||
.section_rects
|
||
.push((key, Rect::new(area.x, y, body_width, render_h)));
|
||
}
|
||
DashboardLine::Row(row) => {
|
||
render_row(buf, line_rect, theme, row, state);
|
||
let bg = row_bg(theme, state, row);
|
||
let content_top = row_content_offset(render_h, row);
|
||
let content_h = row_content_height(row).min(render_h);
|
||
for dy in content_top..(content_top + content_h).min(render_h) {
|
||
mark(&mut line_bg, dy, bg);
|
||
}
|
||
if !row.is_more_placeholder {
|
||
// Full-height hit rect (content + spacer lines) —
|
||
// no hover/click dead zone between items; the
|
||
// highlight covers the content plus half-cell
|
||
// halos on the neighbouring spacer lines.
|
||
let hit = Rect {
|
||
x: area.x,
|
||
y,
|
||
width: body_width,
|
||
height: render_h,
|
||
};
|
||
state.row_rects.push((row.id.clone(), hit));
|
||
}
|
||
}
|
||
DashboardLine::IdleOverflow { hidden, expanded } => {
|
||
render_idle_overflow(
|
||
buf,
|
||
line_rect,
|
||
theme,
|
||
*hidden,
|
||
*expanded,
|
||
state.selected_idle_overflow,
|
||
state.hovered_idle_overflow,
|
||
);
|
||
mark(&mut line_bg, 0, theme.bg_base);
|
||
// Full-height hit rect (label + trailing gap) — no
|
||
// hover/click dead zone below the overflow row.
|
||
state.idle_overflow_rect = Some(Rect::new(area.x, y, body_width, render_h));
|
||
}
|
||
}
|
||
cell_y = next_cell_y;
|
||
}
|
||
|
||
render_spacer_halos(buf, area, body_width, &line_bg, theme.bg_base);
|
||
|
||
if needs_scrollbar {
|
||
render_scrollbar(buf, area, offset, viewport_h, total_cells, theme);
|
||
}
|
||
}
|
||
|
||
/// Paint the spacer lines between items as half-cell "halos" so a
|
||
/// highlighted row reads as vertically centered: the spacer below a
|
||
/// highlighted block shows the highlight in its TOP half, and the
|
||
/// spacer above shows it in its BOTTOM half. Implemented with the
|
||
/// upper-half-block glyph (`▀`, CP437 `0xDF` — safe on legacy
|
||
/// consoles): fg paints the top half with the colour of the content
|
||
/// line above, bg paints the bottom half with the colour of the
|
||
/// content line below. Spacers between two `bg_base` neighbours are
|
||
/// left untouched.
|
||
fn render_spacer_halos(
|
||
buf: &mut Buffer,
|
||
area: Rect,
|
||
body_width: u16,
|
||
line_bg: &[Option<Color>],
|
||
base: Color,
|
||
) {
|
||
for (i, slot) in line_bg.iter().enumerate() {
|
||
if slot.is_some() {
|
||
continue;
|
||
}
|
||
let above = if i > 0 {
|
||
line_bg[i - 1].unwrap_or(base)
|
||
} else {
|
||
base
|
||
};
|
||
let below = line_bg.get(i + 1).copied().flatten().unwrap_or(base);
|
||
if above == base && below == base {
|
||
continue;
|
||
}
|
||
let y = area.y + i as u16;
|
||
if above == below {
|
||
let fill = " ".repeat(body_width as usize);
|
||
buf.set_string(area.x, y, &fill, Style::default().bg(above));
|
||
} else {
|
||
let fill = "\u{2580}".repeat(body_width as usize);
|
||
buf.set_string(area.x, y, &fill, Style::default().fg(above).bg(below));
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Wide-mode group header reads:
|
||
///
|
||
/// ```text
|
||
/// Working 3 ──────────────────────────────────────────────────
|
||
/// ```
|
||
///
|
||
/// Bold flush-left label (within the padded list area), dim count two
|
||
/// cells later, faint horizontal rule filling the remainder of the row.
|
||
/// The dim rule visually anchors the boundary the way gh-dash / lazygit
|
||
/// / k9s do. Section titles are left-aligned within the list; row text
|
||
/// is indented after the marker + icon columns.
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn render_group_header(
|
||
buf: &mut Buffer,
|
||
rect: Rect,
|
||
theme: &Theme,
|
||
label: &str,
|
||
count: usize,
|
||
collapsed: bool,
|
||
selected: bool,
|
||
hovered: bool,
|
||
) {
|
||
let bg = Style::default().bg(theme.bg_base);
|
||
let fill = " ".repeat(rect.width as usize);
|
||
buf.set_string(rect.x, rect.y, fill, bg);
|
||
if rect.width == 0 {
|
||
return;
|
||
}
|
||
// Selected (keyboard cursor) → accent_user; hovered (mouse) →
|
||
// brighter text_primary; otherwise the dim gray default.
|
||
let label_fg = if selected {
|
||
theme.accent_user
|
||
} else if hovered {
|
||
theme.text_primary
|
||
} else {
|
||
theme.gray
|
||
};
|
||
let count_str = format!(" {count}");
|
||
let label_style = Style::default()
|
||
.fg(label_fg)
|
||
.bg(theme.bg_base)
|
||
.add_modifier(Modifier::BOLD);
|
||
let count_style = Style::default().fg(theme.gray_dim).bg(theme.bg_base);
|
||
let rule_style = Style::default()
|
||
.fg(theme.selection_border)
|
||
.bg(theme.bg_base);
|
||
|
||
// Disclosure indicator: ▾ expanded, ▸ collapsed.
|
||
let glyph_str = format!(
|
||
"{} ",
|
||
if collapsed {
|
||
crate::glyphs::disclosure_closed()
|
||
} else {
|
||
crate::glyphs::disclosure_open()
|
||
}
|
||
);
|
||
let glyph_w = UnicodeWidthStr::width(glyph_str.as_str()) as u16;
|
||
let label_w = UnicodeWidthStr::width(label) as u16;
|
||
let count_w = UnicodeWidthStr::width(count_str.as_str()) as u16;
|
||
|
||
// Layout: disclosure glyph, then title text, then count + rule.
|
||
let mut cx = rect.x;
|
||
if glyph_w >= rect.width {
|
||
return;
|
||
}
|
||
buf.set_string(cx, rect.y, &glyph_str, label_style);
|
||
cx += glyph_w;
|
||
|
||
let label_avail = (rect.x + rect.width).saturating_sub(cx);
|
||
if label_w >= label_avail {
|
||
let trunc = truncate_str(label, label_avail as usize);
|
||
buf.set_string(cx, rect.y, trunc, label_style);
|
||
return;
|
||
}
|
||
buf.set_string(cx, rect.y, label, label_style);
|
||
cx += label_w;
|
||
|
||
if cx + count_w >= rect.x + rect.width {
|
||
return;
|
||
}
|
||
buf.set_string(cx, rect.y, &count_str, count_style);
|
||
cx += count_w;
|
||
|
||
// Single-cell pad between count and rule so the digits don't
|
||
// bleed into the line.
|
||
let pad = 1u16;
|
||
if cx + pad >= rect.x + rect.width {
|
||
return;
|
||
}
|
||
cx += pad;
|
||
|
||
let rule_w = (rect.x + rect.width).saturating_sub(cx);
|
||
if rule_w == 0 {
|
||
return;
|
||
}
|
||
let rule: String = "\u{2500}".repeat(rule_w as usize);
|
||
buf.set_string(cx, rect.y, &rule, rule_style);
|
||
}
|
||
|
||
/// Textless divider — a full-width horizontal rule in the section-header
|
||
/// rule style. Used when grouping is OFF to separate the pinned block from
|
||
/// the rest without a labelled header. Paints only the first cell; any
|
||
/// trailing cell of its rect stays at `bg_base` (the breathing gap).
|
||
fn render_divider(buf: &mut Buffer, rect: Rect, theme: &Theme) {
|
||
let bg = Style::default().bg(theme.bg_base);
|
||
let fill = " ".repeat(rect.width as usize);
|
||
buf.set_string(rect.x, rect.y, fill, bg);
|
||
if rect.width == 0 {
|
||
return;
|
||
}
|
||
let rule_style = Style::default()
|
||
.fg(theme.selection_border)
|
||
.bg(theme.bg_base);
|
||
let rule: String = "\u{2500}".repeat(rect.width as usize);
|
||
buf.set_string(rect.x, rect.y, &rule, rule_style);
|
||
}
|
||
|
||
/// The Idle group's "N more" overflow toggle row — a
|
||
/// single dim line painted at the bottom of a capped Idle group: a
|
||
/// `+` / `-` expand indicator in the icon column (`+` collapsed, `-`
|
||
/// expanded) and the label in the agent-name column, aligned with the
|
||
/// rows above. The
|
||
/// cursor (keyboard) paints it in `accent_user`; mouse hover brightens
|
||
/// it to `text_primary`. `expanded` flips the label to "show fewer" so
|
||
/// the same row re-folds the list. Shared by the wide and narrow paths
|
||
/// (the affordance is single-line in both).
|
||
fn render_idle_overflow(
|
||
buf: &mut Buffer,
|
||
rect: Rect,
|
||
theme: &Theme,
|
||
hidden: usize,
|
||
expanded: bool,
|
||
selected: bool,
|
||
hovered: bool,
|
||
) {
|
||
let bg = Style::default().bg(theme.bg_base);
|
||
let fill = " ".repeat(rect.width as usize);
|
||
buf.set_string(rect.x, rect.y, fill, bg);
|
||
if rect.width == 0 {
|
||
return;
|
||
}
|
||
let fg = if selected {
|
||
theme.accent_user
|
||
} else if hovered {
|
||
theme.text_primary
|
||
} else {
|
||
theme.gray_dim
|
||
};
|
||
let style = Style::default().fg(fg).bg(theme.bg_base);
|
||
let label = if expanded {
|
||
"show fewer".to_string()
|
||
} else {
|
||
format!("{hidden} more")
|
||
};
|
||
// A `+` / `-` expand indicator in the icon column + the label in the
|
||
// agent-name column, so the row aligns with the Idle rows above:
|
||
// marker (1) + gap (1) + icon + gap (1); the Idle group is top-level,
|
||
// so indent is 0.
|
||
let indicator = if expanded { "-" } else { "+" };
|
||
let icon_w = unicode_width::UnicodeWidthStr::width(state_icon(RowState::Idle, 0)) as u16;
|
||
let indicator_x = rect.x.saturating_add(2);
|
||
let name_x = indicator_x.saturating_add(icon_w + 1);
|
||
if indicator_x < rect.x + rect.width {
|
||
buf.set_string(indicator_x, rect.y, indicator, style);
|
||
}
|
||
if name_x >= rect.x + rect.width {
|
||
return;
|
||
}
|
||
let avail = (rect.x + rect.width).saturating_sub(name_x);
|
||
let trunc = truncate_str(&label, avail as usize);
|
||
buf.set_string(name_x, rect.y, trunc, style);
|
||
}
|
||
|
||
/// Narrow-mode group header. Compact `Done 12` form — no trailing
|
||
/// rule because the narrow layout doesn't have the width budget.
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn render_group_header_narrow(
|
||
buf: &mut Buffer,
|
||
rect: Rect,
|
||
theme: &Theme,
|
||
label: &str,
|
||
count: usize,
|
||
collapsed: bool,
|
||
selected: bool,
|
||
hovered: bool,
|
||
) {
|
||
let bg_style = Style::default().bg(theme.bg_base);
|
||
let label_fg = if selected {
|
||
theme.accent_user
|
||
} else if hovered {
|
||
theme.text_primary
|
||
} else {
|
||
theme.gray
|
||
};
|
||
let label_style = Style::default()
|
||
.fg(label_fg)
|
||
.bg(theme.bg_base)
|
||
.add_modifier(Modifier::BOLD);
|
||
let fill = " ".repeat(rect.width as usize);
|
||
buf.set_string(rect.x, rect.y, fill, bg_style);
|
||
let glyph = if collapsed {
|
||
crate::glyphs::disclosure_closed()
|
||
} else {
|
||
crate::glyphs::disclosure_open()
|
||
};
|
||
let line = format!("{glyph} {label} {count}");
|
||
let trunc = truncate_str(&line, rect.width as usize);
|
||
buf.set_string(rect.x, rect.y, trunc, label_style);
|
||
}
|
||
|
||
/// Render the list scrollbar in the right gutter, stuck to the window's
|
||
/// rightmost column, using the exact scrollback style: a `█` thumb in
|
||
/// `scrollbar_fg` over a `scrollbar_bg` track (no `│` line). Reuses the
|
||
/// shared `render_scrollbar_styled` so the thumb sizing/colour matches the
|
||
/// scrollback pixel-for-pixel.
|
||
///
|
||
/// The list content is inset by `LIST_OUTER_HPAD`; the scrollbar lives in
|
||
/// that right margin so it never reserves width from the rows (no layout
|
||
/// shift). The position is clamped to the buffer edge so isolated renders
|
||
/// (where `area` spans the whole buffer, e.g. tests) still paint in-bounds.
|
||
fn render_scrollbar(
|
||
buf: &mut Buffer,
|
||
area: Rect,
|
||
offset: usize,
|
||
visible: usize,
|
||
total: usize,
|
||
theme: &Theme,
|
||
) {
|
||
use crate::render::scrollbar::render_scrollbar_styled;
|
||
|
||
let x = (area.x + area.width - 1 + super::layout::LIST_OUTER_HPAD)
|
||
.min(buf.area.right().saturating_sub(1));
|
||
let scrollbar_area = Rect {
|
||
x,
|
||
y: area.y,
|
||
width: 1,
|
||
height: area.height,
|
||
};
|
||
let track_style = Style::default().bg(theme.scrollbar_bg);
|
||
let thumb_style = Style::default()
|
||
.fg(theme.scrollbar_fg)
|
||
.bg(theme.scrollbar_bg);
|
||
let cap = |v: usize| v.min(u16::MAX as usize) as u16;
|
||
render_scrollbar_styled(
|
||
buf,
|
||
Some(scrollbar_area),
|
||
cap(total),
|
||
cap(visible),
|
||
cap(offset),
|
||
track_style,
|
||
thumb_style,
|
||
);
|
||
}
|
||
|
||
/// Snap a cell-granular viewport offset DOWN to the nearest item-start
|
||
/// boundary in `heights`. Each entry in `heights` is the cell-height of
|
||
/// one `DashboardLine` (rows = 3, headers = 2). The returned offset
|
||
/// is the cumulative start of the item that currently overlaps `offset`.
|
||
/// Offsets that already land on a boundary are returned unchanged.
|
||
///
|
||
/// This exists because `render_row` paints its content from `rect.y`
|
||
/// (title at the top, secondary just below) — it has no notion of
|
||
/// "partial top clip". Without snapping, a trackpad scrolling by 1
|
||
/// cell would paint the row's title at the viewport top where the
|
||
/// row's gap-cell should be: visually the row "sticks" instead of
|
||
/// scrolling away. Snapping forces whole-row alignment so the
|
||
/// topmost row always starts at its first cell.
|
||
fn snap_offset_to_line_boundary(offset: usize, heights: &[u16]) -> usize {
|
||
let mut cum = 0usize;
|
||
let mut snapped = 0usize;
|
||
for &h in heights {
|
||
if cum > offset {
|
||
break;
|
||
}
|
||
snapped = cum;
|
||
cum = cum.saturating_add(h as usize);
|
||
}
|
||
snapped
|
||
}
|
||
|
||
/// Number of content lines a row renders: title + optional secondary.
|
||
fn row_content_height(row: &DashboardRow) -> u16 {
|
||
if row.secondary_line.as_deref().is_some_and(|s| !s.is_empty()) {
|
||
2
|
||
} else {
|
||
1
|
||
}
|
||
}
|
||
|
||
/// Vertical offset of a row's content block within its rect. The
|
||
/// 1- or 2-line content is centered at cell granularity: a title-only
|
||
/// row in a 3-cell rect gets one padding line above and below, while
|
||
/// a title+secondary row stays top-aligned ((3 - 2) / 2 = 0).
|
||
fn row_content_offset(height: u16, row: &DashboardRow) -> u16 {
|
||
height.saturating_sub(row_content_height(row)) / 2
|
||
}
|
||
|
||
/// The row's background: keyboard selection wins over mouse hover.
|
||
fn row_bg(theme: &Theme, state: &DashboardState, row: &DashboardRow) -> Color {
|
||
if state.selected.as_ref().is_some_and(|s| *s == row.id) {
|
||
theme.bg_highlight
|
||
} else if state.hovered_row.as_ref().is_some_and(|h| *h == row.id) {
|
||
theme.bg_hover
|
||
} else {
|
||
theme.bg_base
|
||
}
|
||
}
|
||
|
||
/// Render a row as a 2-line block plus a trailing padding line
|
||
/// (`rect.height` is expected to be `>= 2`; the caller —
|
||
/// `render_rows` — sizes the rect to either 2 or 3 lines depending
|
||
/// on whether the padding is in budget).
|
||
///
|
||
/// Visual:
|
||
///
|
||
/// ```text
|
||
/// ◆ Add responsiveness to /context · xai my-branch-2 worktree 4 mins
|
||
/// Pending: plan approval plan.md
|
||
/// ```
|
||
///
|
||
/// Line 1 (title): selection marker + icon + label + subtitle + age.
|
||
/// Line 2 (secondary): aligned-under-label, dim text — the last
|
||
/// tool call, the last assistant message, or a `Pending: …` preview
|
||
/// of the front-most permission request.
|
||
///
|
||
/// The content block is vertically centered within the rect (see
|
||
/// [`row_content_offset`]): a title-only row in a 3-cell rect renders
|
||
/// as padding + title + padding.
|
||
///
|
||
/// Selection / hover backgrounds fill the CONTENT lines; the spacer
|
||
/// lines around them are painted afterwards by `render_rows`'s
|
||
/// half-block pass (see `render_spacer_halos`), which extends the
|
||
/// highlight half a cell above and below so it reads as centered on
|
||
/// the text.
|
||
fn render_row(
|
||
buf: &mut Buffer,
|
||
rect: Rect,
|
||
theme: &Theme,
|
||
row: &DashboardRow,
|
||
state: &DashboardState,
|
||
) {
|
||
if rect.area() == 0 {
|
||
return;
|
||
}
|
||
let selected = state.selected.as_ref().is_some_and(|s| *s == row.id);
|
||
let renaming = state.rename.as_ref().is_some_and(|r| r.row == row.id);
|
||
let bg = row_bg(theme, state, row);
|
||
|
||
// Paint the content lines with the row background. Spacer lines
|
||
// keep `bg_base` here; the halo pass splits them between the
|
||
// neighbouring items.
|
||
let content_top = row_content_offset(rect.height, row);
|
||
let content_h = row_content_height(row).min(rect.height);
|
||
let fill = " ".repeat(rect.width as usize);
|
||
for dy in content_top..(content_top + content_h).min(rect.height) {
|
||
buf.set_string(rect.x, rect.y + dy, &fill, Style::default().bg(bg));
|
||
}
|
||
|
||
// Layout columns: marker (1) | gap (1) | indent (2*n) | icon (1-2)
|
||
// | gap (1) | label/secondary start.
|
||
let marker = if selected {
|
||
crate::glyphs::selection_bar()
|
||
} else {
|
||
" "
|
||
};
|
||
let marker_w = UnicodeWidthStr::width(marker) as u16;
|
||
let indent_w = (row.indent as u16) * 2;
|
||
let icon = state_icon(row.state, state.spinner_tick);
|
||
// `NeedsInput` bullets blink yellow ↔ dim yellow; every other state uses
|
||
// its steady state colour.
|
||
let icon_color = if row.state == RowState::NeedsInput {
|
||
needs_input_bullet_color(state.spinner_tick, theme)
|
||
} else {
|
||
state_color(row.state, theme)
|
||
};
|
||
let icon_w = UnicodeWidthStr::width(icon) as u16;
|
||
// Title-row paint cursor (no leading 1-col gap before the marker
|
||
// — the marker IS the leftmost cell, mirroring the wide-mode
|
||
// header which starts flush-left at col 0). The content block is
|
||
// vertically centered within the rect at cell granularity:
|
||
// title-only rows sit padded above and below, while 2-line rows
|
||
// stay top-aligned (2 lines cannot center in a 3-cell row).
|
||
let title_y = rect.y + row_content_offset(rect.height, row);
|
||
let content_start_x = rect.x + marker_w + 1 + indent_w + icon_w + 1;
|
||
|
||
// Rename overlay: keep the row's chrome (marker + state icon) in
|
||
// place and swap ONLY the title text for `rename: {draft}`, painted
|
||
// at the title's own column so the row stays visually aligned with
|
||
// its neighbours while editing.
|
||
if renaming && let Some(rn) = state.rename.as_ref() {
|
||
buf.set_string(
|
||
rect.x,
|
||
title_y,
|
||
marker,
|
||
Style::default()
|
||
.bg(bg)
|
||
.fg(theme.accent_user)
|
||
.add_modifier(Modifier::BOLD),
|
||
);
|
||
// Keep the left bar continuous on every content line even
|
||
// while the rename overlay is active on the title line.
|
||
if selected {
|
||
let bar_style = Style::default()
|
||
.bg(bg)
|
||
.fg(theme.accent_user)
|
||
.add_modifier(Modifier::BOLD);
|
||
for dy in content_top..(content_top + content_h).min(rect.height) {
|
||
buf.set_string(
|
||
rect.x,
|
||
rect.y + dy,
|
||
crate::glyphs::selection_bar(),
|
||
bar_style,
|
||
);
|
||
}
|
||
}
|
||
// State icon stays put (same column + colour as the normal row).
|
||
buf.set_string(
|
||
rect.x + marker_w + 1 + indent_w,
|
||
title_y,
|
||
icon,
|
||
Style::default().fg(icon_color).bg(bg),
|
||
);
|
||
let available = (rect.x + rect.width).saturating_sub(content_start_x);
|
||
render_rename_editor(
|
||
buf,
|
||
content_start_x,
|
||
title_y,
|
||
available,
|
||
Style::default()
|
||
.fg(theme.accent_user)
|
||
.bg(bg)
|
||
.add_modifier(Modifier::BOLD),
|
||
rn,
|
||
);
|
||
return;
|
||
}
|
||
|
||
// Marker + icon.
|
||
buf.set_string(
|
||
rect.x,
|
||
title_y,
|
||
marker,
|
||
Style::default()
|
||
.bg(bg)
|
||
.fg(theme.accent_user)
|
||
.add_modifier(Modifier::BOLD),
|
||
);
|
||
|
||
// For the active selection, extend the thin left bar down every
|
||
// content line of the row so it forms one continuous vertical rule
|
||
// along the highlighted text. Hover and normal states keep their
|
||
// marker only on the title line.
|
||
if selected {
|
||
let bar_style = Style::default()
|
||
.bg(bg)
|
||
.fg(theme.accent_user)
|
||
.add_modifier(Modifier::BOLD);
|
||
for dy in content_top..(content_top + content_h).min(rect.height) {
|
||
buf.set_string(
|
||
rect.x,
|
||
rect.y + dy,
|
||
crate::glyphs::selection_bar(),
|
||
bar_style,
|
||
);
|
||
}
|
||
}
|
||
|
||
let icon_x = rect.x + marker_w + 1 + indent_w;
|
||
buf.set_string(
|
||
icon_x,
|
||
title_y,
|
||
icon,
|
||
Style::default().bg(bg).fg(icon_color),
|
||
);
|
||
|
||
// Age column — reserve up to 8 cells on the right edge (to fit
|
||
// "just now"). Uses coarse buckets: just now / m / h / d / mo / y.
|
||
let age = format_time_ago(row.last_change_at.elapsed().unwrap_or_default());
|
||
let age_str = format!("{age:>6}");
|
||
let age_w = UnicodeWidthStr::width(age_str.as_str()) as u16;
|
||
let age_x = rect.x + rect.width.saturating_sub(age_w + 1);
|
||
if age_x > content_start_x {
|
||
buf.set_string(
|
||
age_x,
|
||
title_y,
|
||
&age_str,
|
||
Style::default().bg(bg).fg(theme.gray),
|
||
);
|
||
}
|
||
|
||
// Title text: `{label}` (bright) + ` · {subtitle}` (dim) +
|
||
// optional `[badge]` chips for failed / pinned.
|
||
// Trimmed to fit between the icon and the age column.
|
||
let title_avail = age_x.saturating_sub(content_start_x).saturating_sub(2);
|
||
let mut cx = content_start_x;
|
||
if title_avail > 0 {
|
||
let label_style = Style::default().bg(bg).fg(if row.is_more_placeholder {
|
||
theme.gray_dim
|
||
} else {
|
||
theme.text_primary
|
||
});
|
||
// Fallback "New session #<id>" gets two-tone styling: the
|
||
// `New session` head in the primary colour, the ` #id` suffix dim.
|
||
// Detected on the shared prefix + a `#` to avoid dimming real titles
|
||
// that merely start with "New session".
|
||
let dim_suffix = (!row.is_more_placeholder)
|
||
.then(|| row.label.strip_prefix(super::row::NEW_SESSION_LABEL))
|
||
.flatten()
|
||
.filter(|rest| rest.starts_with(" #"));
|
||
if let Some(suffix) = dim_suffix {
|
||
let head_trunc = truncate_str(super::row::NEW_SESSION_LABEL, title_avail as usize);
|
||
let head_w = UnicodeWidthStr::width(&head_trunc[..]) as u16;
|
||
buf.set_string(cx, title_y, &head_trunc, label_style);
|
||
cx += head_w;
|
||
let remaining = title_avail.saturating_sub(head_w) as usize;
|
||
if remaining > 0 {
|
||
let suffix_trunc = truncate_str(suffix, remaining);
|
||
let suffix_w = UnicodeWidthStr::width(&suffix_trunc[..]) as u16;
|
||
buf.set_string(
|
||
cx,
|
||
title_y,
|
||
&suffix_trunc,
|
||
Style::default().bg(bg).fg(theme.gray_dim),
|
||
);
|
||
cx += suffix_w;
|
||
}
|
||
} else {
|
||
let label_trunc = truncate_str(&row.label, title_avail as usize);
|
||
let label_w = UnicodeWidthStr::width(&label_trunc[..]) as u16;
|
||
buf.set_string(cx, title_y, label_trunc, label_style);
|
||
cx += label_w;
|
||
}
|
||
|
||
// Subtitle: ` · xai my-branch-2 worktree`.
|
||
if let Some(sub) = row.subtitle.as_deref()
|
||
&& cx + 4 < age_x
|
||
{
|
||
let remaining = age_x.saturating_sub(cx).saturating_sub(2) as usize;
|
||
let sub_str = format!(" \u{00B7} {sub}");
|
||
let sub_trunc = truncate_str(&sub_str, remaining);
|
||
let sub_w = UnicodeWidthStr::width(&sub_trunc[..]) as u16;
|
||
buf.set_string(
|
||
cx,
|
||
title_y,
|
||
sub_trunc,
|
||
Style::default().bg(bg).fg(theme.gray_dim),
|
||
);
|
||
cx += sub_w;
|
||
}
|
||
|
||
// Compact badges — only the visually meaningful ones. Several are
|
||
// dropped because another cue already conveys them: `worktree` is
|
||
// folded into the subtitle; `needs-input` shows via the blinking
|
||
// yellow bullet + yellow `Pending:` subtitle; `pinned` shows via the
|
||
// dedicated Pinned section at the top of the list.
|
||
for badge in &row.badges {
|
||
if matches!(
|
||
badge,
|
||
RowBadge::Worktree | RowBadge::NeedsInput | RowBadge::Pinned
|
||
) {
|
||
continue;
|
||
}
|
||
let label = match badge {
|
||
RowBadge::NeedsInput | RowBadge::Worktree | RowBadge::Pinned => continue,
|
||
RowBadge::Failed => "failed",
|
||
RowBadge::BgTask => "bg",
|
||
};
|
||
let chip = format!(" [{label}]");
|
||
let cw = UnicodeWidthStr::width(chip.as_str()) as u16;
|
||
if cx + cw + 1 < age_x {
|
||
buf.set_string(
|
||
cx,
|
||
title_y,
|
||
&chip,
|
||
Style::default().bg(bg).fg(badge_color(*badge, theme)),
|
||
);
|
||
cx += cw;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Secondary row. The selected row brightens its secondary
|
||
// line so the user can read what the agent is doing right
|
||
// now without leaving the dashboard. Unselected rows keep
|
||
// the dimmer `gray_dim` so they read as the row's metadata
|
||
// tail rather than competing with the title.
|
||
if rect.height >= 2
|
||
&& let Some(secondary) = row.secondary_line.as_deref()
|
||
&& !secondary.is_empty()
|
||
{
|
||
let sec_y = title_y + 1;
|
||
let avail = rect
|
||
.width
|
||
.saturating_sub(content_start_x - rect.x)
|
||
.saturating_sub(1);
|
||
if avail > 0 {
|
||
let trunc = truncate_str(secondary, avail as usize);
|
||
let secondary_fg = if selected {
|
||
theme.text_secondary
|
||
} else {
|
||
theme.gray_dim
|
||
};
|
||
// The awaiting-input subtitle is `Pending: …`; paint the
|
||
// `Pending:` prefix in yellow so the actionable state stands out,
|
||
// and the rest in the normal secondary colour.
|
||
const PENDING_PREFIX: &str = "Pending:";
|
||
if let Some(rest) = trunc.strip_prefix(PENDING_PREFIX) {
|
||
buf.set_string(
|
||
content_start_x,
|
||
sec_y,
|
||
PENDING_PREFIX,
|
||
Style::default().bg(bg).fg(theme.warning),
|
||
);
|
||
let prefix_w = UnicodeWidthStr::width(PENDING_PREFIX) as u16;
|
||
buf.set_string(
|
||
content_start_x + prefix_w,
|
||
sec_y,
|
||
rest,
|
||
Style::default().bg(bg).fg(secondary_fg),
|
||
);
|
||
} else {
|
||
buf.set_string(
|
||
content_start_x,
|
||
sec_y,
|
||
trunc,
|
||
Style::default().bg(bg).fg(secondary_fg),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
fn render_narrow_rows(
|
||
buf: &mut Buffer,
|
||
area: Rect,
|
||
theme: &Theme,
|
||
rows: &[DashboardRow],
|
||
state: &mut DashboardState,
|
||
) {
|
||
state.row_rects.clear();
|
||
state.section_rects.clear();
|
||
state.idle_overflow_rect = None;
|
||
if area.area() == 0 {
|
||
return;
|
||
}
|
||
|
||
// Narrow mode stays single-line per row (the wide path's 2-line
|
||
// form would push too many rows off-screen on a 40-col terminal).
|
||
// We still emit group headers and the selection marker so the
|
||
// visual vocabulary stays consistent.
|
||
let lines = build_dashboard_lines(
|
||
rows,
|
||
state.grouping,
|
||
&state.filter,
|
||
&state.collapsed_sections,
|
||
state.idle_show_all,
|
||
state.search_mode,
|
||
);
|
||
let viewport_h = area.height as usize;
|
||
// The clamp follows whichever cursor is active — a row OR a section
|
||
// header — so navigating onto a section title scrolls it into view,
|
||
// matching the wide layout. Previously only a selected row was
|
||
// tracked, so a selected header could stay off-screen.
|
||
let selected_line_idx = lines.iter().position(|l| match l {
|
||
DashboardLine::Row(r) => state.selected.as_ref().is_some_and(|s| r.id == *s),
|
||
DashboardLine::PinnedHeader { .. } => state.selected_section == Some(SectionKey::Pinned),
|
||
DashboardLine::Header { state: rs, .. } => {
|
||
state.selected_section == Some(SectionKey::State(*rs))
|
||
}
|
||
DashboardLine::IdleOverflow { .. } => state.selected_idle_overflow,
|
||
DashboardLine::Divider => false,
|
||
});
|
||
let offset = state.clamp_viewport(selected_line_idx, viewport_h, lines.len());
|
||
|
||
let needs_scrollbar = lines.len() > viewport_h && area.width >= 4;
|
||
// Overlay the scrollbar (see `render_rows`) — no reserved column, no shift.
|
||
let body_width = area.width;
|
||
|
||
let mut y = area.y;
|
||
for line in lines.iter().skip(offset).take(viewport_h) {
|
||
if y >= area.y + area.height {
|
||
break;
|
||
}
|
||
let line_rect = Rect {
|
||
x: area.x,
|
||
y,
|
||
width: body_width,
|
||
height: 1,
|
||
};
|
||
let row = match line {
|
||
DashboardLine::PinnedHeader { count } => {
|
||
let key = SectionKey::Pinned;
|
||
let collapsed = state.is_section_collapsed(key);
|
||
let selected = state.selected_section == Some(key);
|
||
let hovered = state.hovered_section == Some(key);
|
||
render_group_header_narrow(
|
||
buf, line_rect, theme, "Pinned", *count, collapsed, selected, hovered,
|
||
);
|
||
state
|
||
.section_rects
|
||
.push((key, Rect::new(area.x, y, body_width, 1)));
|
||
y += 1;
|
||
continue;
|
||
}
|
||
DashboardLine::Header { state: rs, count } => {
|
||
let key = SectionKey::State(*rs);
|
||
let collapsed = state.is_section_collapsed(key);
|
||
let selected = state.selected_section == Some(key);
|
||
let hovered = state.hovered_section == Some(key);
|
||
render_group_header_narrow(
|
||
buf,
|
||
line_rect,
|
||
theme,
|
||
rs.group_label(),
|
||
*count,
|
||
collapsed,
|
||
selected,
|
||
hovered,
|
||
);
|
||
state
|
||
.section_rects
|
||
.push((key, Rect::new(area.x, y, body_width, 1)));
|
||
y += 1;
|
||
continue;
|
||
}
|
||
DashboardLine::Divider => {
|
||
render_divider(buf, line_rect, theme);
|
||
y += 1;
|
||
continue;
|
||
}
|
||
DashboardLine::IdleOverflow { hidden, expanded } => {
|
||
render_idle_overflow(
|
||
buf,
|
||
line_rect,
|
||
theme,
|
||
*hidden,
|
||
*expanded,
|
||
state.selected_idle_overflow,
|
||
state.hovered_idle_overflow,
|
||
);
|
||
state.idle_overflow_rect = Some(Rect::new(area.x, y, body_width, 1));
|
||
y += 1;
|
||
continue;
|
||
}
|
||
DashboardLine::Row(row) => row,
|
||
};
|
||
|
||
let selected = state.selected.as_ref().is_some_and(|s| *s == row.id);
|
||
let hovered = state.hovered_row.as_ref().is_some_and(|h| *h == row.id);
|
||
let renaming = state.rename.as_ref().is_some_and(|r| r.row == row.id);
|
||
let bg = if selected {
|
||
theme.bg_highlight
|
||
} else if hovered {
|
||
theme.bg_hover
|
||
} else {
|
||
theme.bg_base
|
||
};
|
||
|
||
if renaming && let Some(rn) = state.rename.as_ref() {
|
||
// Mirror the wide layout: keep the marker + state icon
|
||
// chrome and swap only the label for `rename: {draft}`, so
|
||
// the editing row stays column-aligned with its neighbours.
|
||
let marker = if selected {
|
||
crate::glyphs::selection_bar()
|
||
} else {
|
||
" "
|
||
};
|
||
let icon = state_icon(row.state, state.spinner_tick);
|
||
let indent = " ".repeat(row.indent as usize);
|
||
let chrome = format!("{marker} {indent}{icon} ");
|
||
let chrome_w = UnicodeWidthStr::width(chrome.as_str()) as u16;
|
||
buf.set_string(
|
||
area.x,
|
||
y,
|
||
&chrome,
|
||
Style::default().fg(theme.text_primary).bg(bg),
|
||
);
|
||
render_rename_editor(
|
||
buf,
|
||
area.x + chrome_w,
|
||
y,
|
||
body_width.saturating_sub(chrome_w),
|
||
Style::default()
|
||
.fg(theme.accent_user)
|
||
.bg(bg)
|
||
.add_modifier(Modifier::BOLD),
|
||
rn,
|
||
);
|
||
} else {
|
||
let marker = if selected {
|
||
crate::glyphs::selection_bar()
|
||
} else {
|
||
" "
|
||
};
|
||
let marker_w = UnicodeWidthStr::width(marker) as u16;
|
||
let icon = state_icon(row.state, state.spinner_tick);
|
||
let icon_w = UnicodeWidthStr::width(icon) as u16;
|
||
let indent = " ".repeat(row.indent as usize);
|
||
let indent_w = UnicodeWidthStr::width(indent.as_str()) as u16;
|
||
let gap_after_marker = 1u16;
|
||
let chrome = marker_w + gap_after_marker + indent_w + icon_w + 1;
|
||
let label = truncate_str(&row.label, body_width.saturating_sub(chrome) as usize);
|
||
let line = format!("{marker} {indent}{icon} {label}");
|
||
buf.set_string(
|
||
area.x,
|
||
y,
|
||
line,
|
||
Style::default().fg(theme.text_primary).bg(bg),
|
||
);
|
||
}
|
||
if !row.is_more_placeholder {
|
||
state.row_rects.push((row.id.clone(), line_rect));
|
||
}
|
||
y += 1;
|
||
}
|
||
|
||
if needs_scrollbar {
|
||
render_scrollbar(buf, area, offset, viewport_h, lines.len(), theme);
|
||
}
|
||
}
|
||
|
||
/// Rendered when agents exist but the filter has
|
||
/// hidden every row. Distinct from the empty-state hint so the
|
||
/// user knows their filter is what's hiding the rows.
|
||
fn render_no_match(buf: &mut Buffer, area: Rect, theme: &Theme, filter: &Filter) {
|
||
if area.area() == 0 {
|
||
return;
|
||
}
|
||
let hint = match filter {
|
||
Filter::None => "No matching rows.".to_string(),
|
||
Filter::Agent(n) => format!("No agents match `a:{n}` — press Esc to clear the filter."),
|
||
Filter::State(s) => format!(
|
||
"No agents in state `{}` — press Esc to clear the filter.",
|
||
s.group_label()
|
||
),
|
||
Filter::Substring(n) => format!("No rows match `{n}` — press Esc to clear the filter."),
|
||
};
|
||
let truncated = truncate_str(&hint, area.width.saturating_sub(2) as usize);
|
||
// Explicit offset to avoid `area.y + 1.min(...)`
|
||
// precedence ambiguity. Drop one row of padding when the area
|
||
// can accommodate it; otherwise stay at the top.
|
||
let y_offset: u16 = if area.height >= 2 { 1 } else { 0 };
|
||
buf.set_string(
|
||
area.x + 1,
|
||
area.y + y_offset,
|
||
truncated,
|
||
Style::default().fg(theme.gray),
|
||
);
|
||
}
|
||
|
||
fn render_empty_state(buf: &mut Buffer, area: Rect, theme: &Theme, loading: bool) {
|
||
if area.area() == 0 {
|
||
return;
|
||
}
|
||
// A single dim line — the dispatch input below is the call to
|
||
// action, so no multi-line onboarding is needed (edge case 25
|
||
// still holds: never render a fully blank screen). While the local
|
||
// session roster is still being fetched we show a loading hint so a
|
||
// fresh open doesn't flash the "no agents" copy before rows land.
|
||
let line = if loading {
|
||
"Loading sessions…"
|
||
} else {
|
||
"No agents yet, type a prompt to start one."
|
||
};
|
||
let truncated = truncate_str(line, area.width.saturating_sub(2) as usize);
|
||
// See `render_no_match` for the precedence rationale.
|
||
let y_offset: u16 = if area.height >= 2 { 1 } else { 0 };
|
||
buf.set_string(
|
||
area.x + 1,
|
||
area.y + y_offset,
|
||
truncated,
|
||
Style::default().fg(theme.gray),
|
||
);
|
||
}
|
||
|
||
/// Paint a rounded-box
|
||
/// chrome around the dispatch input so it reads as a real input
|
||
/// field. On a 3-row rect the layout is:
|
||
///
|
||
/// ```text
|
||
/// ╭──────────────────────────────────────────────────────────╮
|
||
/// │ ❯ Dispatch a new agent │
|
||
/// ╰──────────────────────────────────────────────────────────╯
|
||
/// ```
|
||
///
|
||
/// On a 1-row rect (very short terminals) we fall back to the
|
||
/// bare `❯ {text}` line so the input stays usable.
|
||
///
|
||
/// `reply_label` flips the placeholder between `Dispatch a new
|
||
/// agent` (`None`) and `Reply to {label}` (`Some`) so the
|
||
/// chrome reflects what Enter will do: dispatch a new session vs.
|
||
/// enqueue / send a prompt to the currently-selected agent.
|
||
/// Paint a short right-aligned feedback badge onto the dispatch box's
|
||
/// **top border** (e.g. `✗ Session no longer exists`, `✓ Theme: Grok
|
||
/// Day`), in a neutral accent colour. The message is painted VERBATIM:
|
||
/// it already carries its own status glyph — errors are built via
|
||
/// [`DashboardState::set_error_toast`] (`✗`), while successes / info
|
||
/// arrive from the `show_toast` builders (`✓` / `⚠`). The badge
|
||
/// therefore neither prepends a glyph nor forces a colour; doing so
|
||
/// previously produced a doubled `✗ ✓ …` and painted non-errors (like
|
||
/// "Session closed") red. The glyph, not the colour, conveys severity —
|
||
/// mirroring the per-agent toast in [`crate::app::agent_view`]. The
|
||
/// badge ends one column before the right corner (`╮`) and is truncated
|
||
/// to fit. No-op when there's no toast or the box is too narrow.
|
||
fn paint_dispatch_feedback_badge(
|
||
buf: &mut Buffer,
|
||
area: Rect,
|
||
theme: &Theme,
|
||
error_toast: Option<&str>,
|
||
) {
|
||
let Some(err) = error_toast else {
|
||
return;
|
||
};
|
||
// Leave room for the two rounded corners plus a little breathing
|
||
// space, so the bar still reads as a border rather than a full
|
||
// banner.
|
||
let max_w = area.width.saturating_sub(4);
|
||
if max_w < 6 {
|
||
return;
|
||
}
|
||
// Leading/trailing spaces pad the chip away from the surrounding
|
||
// border glyphs. No glyph is prepended — the message already owns
|
||
// one (see the doc comment).
|
||
let label = format!(" {err} ");
|
||
let trunc = truncate_str(&label, max_w as usize);
|
||
let label_w = UnicodeWidthStr::width(trunc.as_str()) as u16;
|
||
// Right-align so the badge ends one column before the `╮` corner.
|
||
let x = area.x + area.width.saturating_sub(1 + label_w);
|
||
buf.set_string(
|
||
x,
|
||
area.y,
|
||
&trunc,
|
||
Style::default()
|
||
.fg(theme.accent_user)
|
||
.bg(theme.bg_base)
|
||
.add_modifier(Modifier::BOLD),
|
||
);
|
||
}
|
||
|
||
/// Paint the model + mode indicator onto the dispatch box's **bottom
|
||
/// border**, reusing the shared prompt info-line renderer so its style,
|
||
/// spacing, and position match the chat prompt exactly (`╰──model · flag──╯`).
|
||
/// The model name renders in dim secondary text; the mode shows as a `plan`
|
||
/// (plan accent) or `always-approve` (default) flag. No-op when the box is
|
||
/// too short or there's nothing to show.
|
||
fn paint_dispatch_config_badge(
|
||
buf: &mut Buffer,
|
||
area: Rect,
|
||
theme: &Theme,
|
||
state: &DashboardState,
|
||
input_focused: bool,
|
||
) {
|
||
use crate::views::dashboard::DashboardDispatchMode;
|
||
use crate::views::prompt_widget::{PromptFlag, PromptInfo};
|
||
|
||
if area.height < 3 || area.width < 6 {
|
||
return;
|
||
}
|
||
let model_label = state
|
||
.pending_model
|
||
.as_ref()
|
||
.map(|m| match m.effort {
|
||
Some(effort) => format!("{} ({effort})", m.display),
|
||
None => m.display.clone(),
|
||
})
|
||
.or_else(|| state.models.current_model_name())
|
||
.unwrap_or_default();
|
||
|
||
// Mode flag, styled exactly like the chat prompt's mode flags.
|
||
let mut flags: Vec<PromptFlag> = Vec::new();
|
||
match state.pending_mode {
|
||
DashboardDispatchMode::Plan => flags.push(PromptFlag {
|
||
text: "plan",
|
||
color: Some(theme.accent_plan),
|
||
bold: false,
|
||
}),
|
||
DashboardDispatchMode::AlwaysApprove => flags.push(PromptFlag {
|
||
text: "always-approve",
|
||
color: None,
|
||
bold: false,
|
||
}),
|
||
DashboardDispatchMode::Normal => {}
|
||
}
|
||
|
||
if model_label.is_empty() && flags.is_empty() && !state.multiline_mode {
|
||
return;
|
||
}
|
||
|
||
let info = PromptInfo {
|
||
model_name: &model_label,
|
||
flags: &flags,
|
||
multiline: state.multiline_mode,
|
||
usage_warning: None,
|
||
usage_warning_critical: false,
|
||
};
|
||
// Bottom border row, inside the corners — the same content rect the chat
|
||
// prompt uses for its info line.
|
||
let info_rect = Rect {
|
||
x: area.x + 1,
|
||
y: area.y + area.height - 1,
|
||
width: area.width.saturating_sub(2),
|
||
height: 1,
|
||
};
|
||
state
|
||
.dispatch
|
||
.render_info_line(buf, info_rect, &info, theme.bg_base, theme, input_focused);
|
||
}
|
||
|
||
/// Paint the left-aligned `● rec` badge on a box's top border while the mic is
|
||
/// hot. Shared by the dispatch box and the peek panel that replaces it, so a
|
||
/// capture started in either surface shows the same indicator.
|
||
pub(super) fn paint_record_badge(buf: &mut Buffer, area: Rect, theme: &Theme, listening: bool) {
|
||
if listening && area.width >= 12 {
|
||
buf.set_string(
|
||
area.x + 2,
|
||
area.y,
|
||
" \u{25CF} rec ",
|
||
Style::default()
|
||
.fg(theme.accent_error)
|
||
.bg(theme.bg_base)
|
||
.add_modifier(Modifier::BOLD),
|
||
);
|
||
}
|
||
}
|
||
|
||
fn render_dispatch(
|
||
buf: &mut Buffer,
|
||
area: Rect,
|
||
theme: &Theme,
|
||
state: &mut DashboardState,
|
||
overlay_area: Option<Rect>,
|
||
) -> Option<(u16, u16)> {
|
||
use ratatui::widgets::{Block, BorderType, Borders, Widget};
|
||
|
||
use crate::views::prompt_widget::PromptStyle;
|
||
|
||
if area.area() == 0 {
|
||
return None;
|
||
}
|
||
let bg = Style::default().bg(theme.bg_base);
|
||
let fill = " ".repeat(area.width as usize);
|
||
for dy in 0..area.height {
|
||
buf.set_string(area.x, area.y + dy, &fill, bg);
|
||
}
|
||
|
||
// Two-focus model: when the overview list is focused (Tab), the
|
||
// input is inactive — dim its border and suppress the caret so the
|
||
// focus cue is unambiguous. `input_focused` drives both.
|
||
let input_focused = !state.list_focused;
|
||
let border_fg = if input_focused {
|
||
theme.selection_border
|
||
} else {
|
||
theme.prompt_border
|
||
};
|
||
|
||
// Draw the rounded box and carve out the content rows. The content
|
||
// height tracks the box height so multiline input (Alt+Enter) is
|
||
// fully visible — the box itself grows via
|
||
// `compute_layout_with_dispatch`.
|
||
let content = if area.height >= 3 {
|
||
let block = Block::default()
|
||
.borders(Borders::ALL)
|
||
.border_type(BorderType::Rounded)
|
||
.border_style(Style::default().fg(border_fg).bg(theme.bg_base));
|
||
let inner = block.inner(area);
|
||
block.render(area, buf);
|
||
// Surface dispatch-validation feedback (e.g. "Too short") as a
|
||
// right-aligned badge on the box's top border so it stays
|
||
// visible even while the rejected text is still in the input.
|
||
paint_dispatch_feedback_badge(buf, area, theme, state.error_toast.as_deref());
|
||
// Bottom-right model + mode indicator, painted through the shared
|
||
// prompt info-line renderer so its style, spacing, and position match
|
||
// the chat prompt's info line exactly. Always shows the model the next
|
||
// spawned agent will use (the `/model`-staged choice, else the current
|
||
// default), plus the staged mode as a flag.
|
||
paint_dispatch_config_badge(buf, area, theme, state, input_focused);
|
||
paint_record_badge(buf, area, theme, state.voice_listening);
|
||
Rect {
|
||
x: inner.x + 1,
|
||
y: inner.y,
|
||
width: inner.width.saturating_sub(2),
|
||
height: inner.height.max(1),
|
||
}
|
||
} else {
|
||
// Fallback — single line, no chrome.
|
||
Rect {
|
||
x: area.x,
|
||
y: area.y,
|
||
width: area.width,
|
||
height: 1,
|
||
}
|
||
};
|
||
if content.width == 0 {
|
||
return None;
|
||
}
|
||
|
||
// Search mode: the prompt is a live filter query, rendered on a
|
||
// single line with a bold yellow `Search:` prefix so it's
|
||
// unmistakable that typing filters rows (Enter confirms) rather than
|
||
// dispatching. No chips / multiline here.
|
||
if state.search_mode {
|
||
let prefix = "Search: ";
|
||
let prefix_w = UnicodeWidthStr::width(prefix) as u16;
|
||
let painted_prefix_w = prefix_w.min(content.width);
|
||
buf.set_span(
|
||
content.x,
|
||
content.y,
|
||
&Span::styled(
|
||
prefix,
|
||
Style::default()
|
||
.fg(theme.warning)
|
||
.bg(theme.bg_base)
|
||
.add_modifier(Modifier::BOLD),
|
||
),
|
||
painted_prefix_w,
|
||
);
|
||
let editor_x = content.x + painted_prefix_w;
|
||
let avail = content.width - painted_prefix_w;
|
||
let cursor_column = if state.dispatch.text().is_empty() {
|
||
if avail > 0 {
|
||
let placeholder = truncate_str("Type to filter sessions\u{2026}", avail as usize);
|
||
buf.set_string(
|
||
editor_x,
|
||
content.y,
|
||
placeholder,
|
||
Style::default().fg(theme.gray_dim).bg(theme.bg_base),
|
||
);
|
||
}
|
||
0
|
||
} else {
|
||
let viewport = xai_ratatui_textarea::EditBuffer::from_parts(
|
||
state.dispatch.text(),
|
||
state.dispatch.cursor(),
|
||
)
|
||
.single_line_viewport(avail as usize);
|
||
let visible = &state.dispatch.text()[viewport.visible_byte_range];
|
||
if avail > 0 {
|
||
buf.set_span(
|
||
editor_x,
|
||
content.y,
|
||
&Span::styled(
|
||
visible,
|
||
Style::default().fg(theme.text_primary).bg(theme.bg_base),
|
||
),
|
||
(UnicodeWidthStr::width(visible) as u16).min(avail),
|
||
);
|
||
}
|
||
viewport.cursor_display_column as u16
|
||
};
|
||
let cursor_offset = painted_prefix_w
|
||
.saturating_add(cursor_column)
|
||
.min(content.width - 1);
|
||
let cx = content.x + cursor_offset;
|
||
return input_focused.then_some((cx, content.y));
|
||
}
|
||
|
||
if content.width < 4 {
|
||
return None;
|
||
}
|
||
|
||
let prefix = "\u{276F} ";
|
||
let prefix_w = UnicodeWidthStr::width(prefix) as u16;
|
||
|
||
// When voice is active, draw through PromptWidget even on an empty buffer
|
||
// so the manual empty-state branch is skipped.
|
||
let voice_overlay = (state.voice_listening || state.voice_interim.is_some()).then_some(
|
||
crate::views::prompt_widget::VoicePromptOverlay {
|
||
interim: state.voice_interim.as_deref(),
|
||
color: theme.accent_running,
|
||
},
|
||
);
|
||
|
||
// Empty input (non-search): paint the `❯` prefix + the contextual
|
||
// placeholder (reply / error toast / new-session) and park the caret
|
||
// at the text start. `PromptWidget::draw` only paints a placeholder
|
||
// when *unfocused*, but we want a visible caret, so the empty state
|
||
// is rendered directly here.
|
||
if state.dispatch.text().is_empty() && voice_overlay.is_none() {
|
||
buf.set_string(
|
||
content.x,
|
||
content.y,
|
||
prefix,
|
||
Style::default().fg(theme.accent_user).bg(theme.bg_base),
|
||
);
|
||
// The dispatch input always spawns a NEW session — never a
|
||
// reply — so the placeholder is constant regardless of which
|
||
// row the overview cursor is on. It only paints while the
|
||
// input is UNFOCUSED (matching `PromptWidget::draw`); when
|
||
// focused the visible caret is the affordance and the text
|
||
// area stays clear. Validation feedback (e.g. "Too short")
|
||
// is surfaced as a badge on the box's top border
|
||
// (`paint_dispatch_feedback_badge`), not here, so it stays
|
||
// visible even when the rejected text is still in the box.
|
||
if !input_focused {
|
||
let msg = "Dispatch a new agent";
|
||
let style = Style::default().fg(theme.gray_dim).bg(theme.bg_base);
|
||
let trunc = truncate_str(msg, content.width.saturating_sub(prefix_w) as usize);
|
||
buf.set_string(content.x + prefix_w, content.y, trunc, style);
|
||
}
|
||
return input_focused.then_some((content.x + prefix_w, content.y));
|
||
}
|
||
|
||
// Non-empty input (non-search): shared PromptWidget for cursor,
|
||
// chips, multiline. `chrome: false` keeps the dashboard box;
|
||
// `image_preview: false` keeps image chips without an overlay.
|
||
let style = PromptStyle {
|
||
focused: input_focused,
|
||
show_prefix: true,
|
||
vpad_top: 0,
|
||
chrome: false,
|
||
bg_override: Some(theme.bg_base),
|
||
image_preview: false,
|
||
..PromptStyle::default()
|
||
};
|
||
state
|
||
.dispatch
|
||
.draw(buf, content, overlay_area, &style, None, voice_overlay)
|
||
.cursor_pos
|
||
}
|
||
|
||
/// Desired number of *text* rows for the dispatch box given its content
|
||
/// and the box's outer width. Grows the box for multiline input
|
||
/// (Alt+Enter) while capping growth so the row list keeps usable space.
|
||
/// Returns ≥1.
|
||
fn dispatch_text_rows(state: &DashboardState, dispatch_width: u16, area_height: u16) -> u16 {
|
||
use crate::views::prompt_widget::PromptStyle;
|
||
|
||
// Match `render_dispatch`'s content width: box width minus the two
|
||
// border columns and the 1-col inset on each side (`-4` total).
|
||
let content_w = dispatch_width.saturating_sub(4);
|
||
if content_w < 4 {
|
||
return 1;
|
||
}
|
||
// Cap so the box never eats more than ~a third of the panel; the
|
||
// textarea scrolls beyond that.
|
||
let max_text_rows = (area_height / 3).clamp(1, 8);
|
||
let style = PromptStyle {
|
||
focused: true,
|
||
show_prefix: true,
|
||
vpad_top: 0,
|
||
chrome: false,
|
||
..PromptStyle::default()
|
||
};
|
||
state
|
||
.dispatch
|
||
.desired_height(content_w, &style, false, max_text_rows)
|
||
}
|
||
|
||
/// Render the `/command` completion dropdown above the dispatch box.
|
||
/// Mirrors `agent_view`'s slash dropdown chrome. No-op (and clears the
|
||
/// stored hit rect) when the dropdown is closed.
|
||
fn render_slash_dropdown(
|
||
buf: &mut Buffer,
|
||
area: Rect,
|
||
dispatch_rect: Rect,
|
||
theme: &Theme,
|
||
state: &mut DashboardState,
|
||
) {
|
||
use ratatui::widgets::{Clear, Widget};
|
||
|
||
use crate::views::slash_dropdown::{desired_item_rows, render_dropdown as render_slash};
|
||
|
||
let snap = state.dispatch.slash_snapshot();
|
||
if !snap.open || snap.matches.is_empty() {
|
||
state.slash_dropdown_items_area = None;
|
||
state.slash_dropdown_hit = Default::default();
|
||
return;
|
||
}
|
||
|
||
let item_count = snap.matches.len();
|
||
// Height in wrapped lines, not items (see `desired_item_rows`);
|
||
// items render inset 1 col on each side.
|
||
let item_rows = desired_item_rows(&snap.matches, dispatch_rect.width.saturating_sub(2));
|
||
let panel_h = item_rows.saturating_add(2);
|
||
let max_top = dispatch_rect.y.saturating_sub(1);
|
||
if max_top <= area.y || panel_h == 0 {
|
||
state.slash_dropdown_items_area = None;
|
||
state.slash_dropdown_hit = Default::default();
|
||
return;
|
||
}
|
||
let top_y = max_top.saturating_sub(panel_h - 1);
|
||
if top_y < area.y {
|
||
state.slash_dropdown_items_area = None;
|
||
state.slash_dropdown_hit = Default::default();
|
||
return;
|
||
}
|
||
let panel_x = dispatch_rect.x;
|
||
let panel_width = dispatch_rect.width;
|
||
if panel_width < 4 {
|
||
state.slash_dropdown_items_area = None;
|
||
state.slash_dropdown_hit = Default::default();
|
||
return;
|
||
}
|
||
let panel_area = Rect {
|
||
x: panel_x,
|
||
y: top_y,
|
||
width: panel_width,
|
||
height: panel_h,
|
||
};
|
||
|
||
Clear.render(panel_area, buf);
|
||
buf.set_style(
|
||
panel_area,
|
||
Style::default().fg(theme.text_primary).bg(theme.bg_light),
|
||
);
|
||
|
||
let border_style = Style::default().fg(theme.bg_highlight).bg(theme.bg_base);
|
||
let bar: String = "\u{2500}".repeat(panel_width as usize);
|
||
buf.set_string(panel_x, top_y, &bar, border_style);
|
||
buf.set_string(panel_x, top_y + panel_h - 1, &bar, border_style);
|
||
|
||
let hint = format!("{item_count}");
|
||
let hint_w = hint.len() as u16;
|
||
if hint_w + 2 <= panel_width {
|
||
let hint_x = panel_x + panel_width - hint_w - 1;
|
||
buf.set_string(
|
||
hint_x,
|
||
top_y,
|
||
&hint,
|
||
Style::default().fg(theme.gray).bg(theme.bg_base),
|
||
);
|
||
}
|
||
|
||
let items_x = panel_x + 1;
|
||
let items_width = panel_width.saturating_sub(2);
|
||
let items_area = Rect {
|
||
x: items_x,
|
||
y: top_y + 1,
|
||
width: items_width,
|
||
height: item_rows,
|
||
};
|
||
state.slash_dropdown_hit = render_slash(
|
||
buf,
|
||
items_area,
|
||
&snap,
|
||
state.dispatch.slash_hovered(),
|
||
theme,
|
||
);
|
||
state.slash_dropdown_items_area = Some(items_area);
|
||
}
|
||
|
||
/// Working directory of the agent owning the peeked `row`, used to root
|
||
/// the reply's `@` file picker. Top-level rows use their own cwd;
|
||
/// subagent rows reply to — and resolve `@paths` against — their parent;
|
||
/// roster rows have no local agent (`None`). `None` too when the agent
|
||
/// has since vanished.
|
||
fn peeked_agent_cwd(
|
||
row: &super::DashboardRowId,
|
||
agents: &IndexMap<AgentId, AgentView>,
|
||
) -> Option<std::path::PathBuf> {
|
||
let id = match row {
|
||
super::DashboardRowId::TopLevel(id) => *id,
|
||
super::DashboardRowId::Subagent { parent, .. } => *parent,
|
||
super::DashboardRowId::Roster { .. } => return None,
|
||
};
|
||
agents.get(&id).map(|a| a.session.cwd.clone())
|
||
}
|
||
|
||
/// Render the session-less `@` file-context picker dropdown above the
|
||
/// dispatch box. Twin of [`render_slash_dropdown`] with a `k/n` count
|
||
/// hint. No-op (and clears the hit rect) when the picker is hidden.
|
||
fn render_file_search_dropdown(
|
||
buf: &mut Buffer,
|
||
area: Rect,
|
||
dispatch_rect: Rect,
|
||
theme: &Theme,
|
||
state: &mut DashboardState,
|
||
) {
|
||
state.file_search_dropdown_items_area = render_file_search_dropdown_for(
|
||
buf,
|
||
area,
|
||
dispatch_rect,
|
||
theme,
|
||
&mut state.dispatch.file_search,
|
||
);
|
||
}
|
||
|
||
/// Paint a session-less `@` file-context dropdown ABOVE `anchor_rect`
|
||
/// (the dispatch box, or the peek box when the reply is the active
|
||
/// input) for the given [`FileSearchState`]. Returns the items-area
|
||
/// rect for mouse routing, or `None` when nothing was drawn (hidden,
|
||
/// empty, or no room above the anchor).
|
||
fn render_file_search_dropdown_for(
|
||
buf: &mut Buffer,
|
||
area: Rect,
|
||
anchor_rect: Rect,
|
||
theme: &Theme,
|
||
file_search: &mut crate::views::file_search::FileSearchState,
|
||
) -> Option<Rect> {
|
||
use ratatui::widgets::{Clear, Widget};
|
||
|
||
use crate::views::file_search::dropdown::{MAX_DROPDOWN_ROWS, render_dropdown as render_files};
|
||
|
||
if !file_search.is_visible() {
|
||
return None;
|
||
}
|
||
let item_count = file_search.result_count();
|
||
let item_rows = (item_count as u16).min(MAX_DROPDOWN_ROWS);
|
||
if item_rows == 0 {
|
||
return None;
|
||
}
|
||
let panel_h = item_rows.saturating_add(2);
|
||
let max_top = anchor_rect.y.saturating_sub(1);
|
||
if max_top <= area.y {
|
||
return None;
|
||
}
|
||
let top_y = max_top.saturating_sub(panel_h - 1);
|
||
if top_y < area.y {
|
||
return None;
|
||
}
|
||
let panel_x = anchor_rect.x;
|
||
let panel_width = anchor_rect.width;
|
||
if panel_width < 4 {
|
||
return None;
|
||
}
|
||
|
||
// Keep the selected row inside the visible window before painting.
|
||
file_search.ensure_visible(item_rows as usize);
|
||
|
||
let panel_area = Rect {
|
||
x: panel_x,
|
||
y: top_y,
|
||
width: panel_width,
|
||
height: panel_h,
|
||
};
|
||
Clear.render(panel_area, buf);
|
||
buf.set_style(
|
||
panel_area,
|
||
Style::default().fg(theme.text_primary).bg(theme.bg_light),
|
||
);
|
||
|
||
let border_style = Style::default().fg(theme.bg_highlight).bg(theme.bg_base);
|
||
let bar: String = "\u{2500}".repeat(panel_width as usize);
|
||
buf.set_string(panel_x, top_y, &bar, border_style);
|
||
buf.set_string(panel_x, top_y + panel_h - 1, &bar, border_style);
|
||
|
||
let (k, n) = (file_search.result_count(), file_search.total_items());
|
||
let hint = if k >= 1000 {
|
||
format!("1k+/{n}")
|
||
} else {
|
||
format!("{k}/{n}")
|
||
};
|
||
let hint_w = hint.len() as u16;
|
||
if hint_w + 2 <= panel_width {
|
||
let hint_x = panel_x + panel_width - hint_w - 1;
|
||
buf.set_string(
|
||
hint_x,
|
||
top_y,
|
||
&hint,
|
||
Style::default().fg(theme.gray).bg(theme.bg_base),
|
||
);
|
||
}
|
||
|
||
let items_x = panel_x + 1;
|
||
let items_width = panel_width.saturating_sub(2);
|
||
let items_area = Rect {
|
||
x: items_x,
|
||
y: top_y + 1,
|
||
width: items_width,
|
||
height: item_rows,
|
||
};
|
||
render_files(buf, items_area, file_search, theme);
|
||
Some(items_area)
|
||
}
|
||
|
||
/// Render the dashboard's footer / shortcuts hint row.
|
||
///
|
||
/// Switched to the shared `ShortcutsBar` widget so
|
||
/// the dashboard's shortcut bar uses the same visual vocabulary as
|
||
/// the agent view's bottom bar: `Key:label` with bold keys + dim
|
||
/// ` │ ` separators on `bg_base`, instead of the previous custom
|
||
/// `key label · key label` gray-only string. When a
|
||
/// stop-confirm is armed, `ShortcutsBar::with_pending` paints the
|
||
/// `press again to {label}` hint in place of the regular list,
|
||
/// matching the agent view's identical mechanism.
|
||
///
|
||
/// Keys are resolved from the action registry every
|
||
/// frame so user rebindings show up immediately.
|
||
/// Footer hints flip based on the selected row's
|
||
/// state:
|
||
///
|
||
/// - `NeedsInput` selection → `Enter:see details · Ctrl+x:stop · ?:shortcuts`
|
||
/// (no inline approve/reject yet — punted per the user's note
|
||
/// "maybe its just easier to hit enter and go details view";
|
||
/// the dashboard is intentionally a navigator, not a permission UI).
|
||
/// - Anything else → `Enter:open · Ctrl+x:stop|close · ?:shortcuts`.
|
||
///
|
||
/// The Ctrl+x chip label follows the selected agent's state: `stop`
|
||
/// for an agent with a live turn (Working, or NeedsInput — paused but
|
||
/// still running, so the first Ctrl+x cancels), `close` for an idle /
|
||
/// quiet one.
|
||
///
|
||
/// The ↑/↓ nav chip is intentionally omitted from every state — the
|
||
/// list is obviously arrow-navigable, and dropping it frees space so
|
||
/// the Ctrl+x chip stays visible while an agent is selected.
|
||
///
|
||
/// Stop-confirm still routes through `with_pending` so the canonical
|
||
/// `press again to close this session` message takes over.
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn render_footer(
|
||
buf: &mut Buffer,
|
||
area: Rect,
|
||
theme: &Theme,
|
||
state: &DashboardState,
|
||
registry: &crate::actions::ActionRegistry,
|
||
selected_state: Option<RowState>,
|
||
peek_active: bool,
|
||
pending_hint: Option<crate::views::shortcuts_bar::PendingHint>,
|
||
) {
|
||
use ratatui::widgets::Widget;
|
||
|
||
use crate::input::key::{KeyShortcut, key};
|
||
use crate::views::shortcuts_bar::{HintItem, PendingHint, ShortcutsBar};
|
||
|
||
if area.area() == 0 {
|
||
return;
|
||
}
|
||
|
||
// 2-col left padding to match the agent view's footer position
|
||
// (`block_pad_left = 2`).
|
||
const FOOTER_PAD_LEFT: u16 = 2;
|
||
let _ = theme;
|
||
let inner = Rect {
|
||
x: area.x.saturating_add(FOOTER_PAD_LEFT),
|
||
y: area.y,
|
||
width: area.width.saturating_sub(FOOTER_PAD_LEFT),
|
||
height: area.height,
|
||
};
|
||
|
||
// App-level double-press confirmation (quit via Ctrl+Q / Ctrl+C /
|
||
// Ctrl+D) takes precedence over the dashboard-local stop-confirm —
|
||
// both render through `with_pending`. Without this the keys set a
|
||
// pending quit but the dashboard showed no "press again" feedback.
|
||
if let Some(pending) = pending_hint {
|
||
ShortcutsBar::new(&[])
|
||
.with_pending(Some(pending))
|
||
.render(inner, buf);
|
||
return;
|
||
}
|
||
|
||
// Only paint the "press again" hint while the confirm window is
|
||
// actually live — the dispatcher re-arms (rather than closes) on a
|
||
// press after [`super::state::STOP_CONFIRM_WINDOW`], so an expired
|
||
// confirm must not keep claiming the footer (e.g. after a mouse
|
||
// click moved the selection without a keypress to disarm it).
|
||
let stop_confirm_live = state
|
||
.stop_confirm
|
||
.as_ref()
|
||
.is_some_and(|(_, t)| t.elapsed() < super::state::STOP_CONFIRM_WINDOW);
|
||
if stop_confirm_live {
|
||
let stop_key = registry
|
||
.find(crate::actions::ActionId::DashboardStop)
|
||
.map(|d| d.default_key)
|
||
.unwrap_or_else(|| key!('x', CONTROL));
|
||
let pending = PendingHint {
|
||
shortcut: stop_key,
|
||
label: "close this session",
|
||
};
|
||
ShortcutsBar::new(&[])
|
||
.with_pending(Some(pending))
|
||
.render(inner, buf);
|
||
return;
|
||
}
|
||
|
||
// An in-flight rename owns the keyboard (`handle_key` routes to
|
||
// `handle_rename_key` before anything else), so the footer shows
|
||
// exactly its two actions instead of the dispatch + nav matrix.
|
||
if state.rename.is_some() {
|
||
let hints = vec![
|
||
HintItem::new(key!(Enter), "save"),
|
||
HintItem::new(key!(Esc), "cancel"),
|
||
];
|
||
ShortcutsBar::new(&hints)
|
||
.compact(4, None)
|
||
.render(inner, buf);
|
||
return;
|
||
}
|
||
|
||
// Search mode owns the footer — show how to confirm / cancel the
|
||
// live filter rather than the dispatch + nav matrix.
|
||
if state.search_mode {
|
||
let hints = vec![
|
||
HintItem::paired(key!(Up), key!(Down), "nav"),
|
||
HintItem::new(key!(Enter), "apply"),
|
||
HintItem::new(key!(Esc), "cancel"),
|
||
];
|
||
ShortcutsBar::new(&hints)
|
||
.compact(4, None)
|
||
.render(inner, buf);
|
||
return;
|
||
}
|
||
|
||
// A selected Inactive row is roster-only (owned by another pager
|
||
// process, never loaded here) — there's nothing running to stop,
|
||
// so every branch below suppresses its `stop` chip.
|
||
let stoppable = selected_state != Some(RowState::Inactive);
|
||
|
||
// Ctrl+x cancels the live turn for a busy agent, else closes the
|
||
// session — mirroring `dispatch_dashboard_stop` (cancel-if-running,
|
||
// else close). A `NeedsInput` row keeps a paused-but-running turn (the
|
||
// permission/Q&A prompt suspends it, never idles it), so its first
|
||
// Ctrl+x cancels too — label it `stop`, not `close`.
|
||
let stop_label = if matches!(
|
||
selected_state,
|
||
Some(RowState::Working | RowState::NeedsInput)
|
||
) {
|
||
"stop"
|
||
} else {
|
||
"close"
|
||
};
|
||
|
||
// Overview list focused (via Tab) — navigation hints: arrows / j-k
|
||
// move between agents, Enter opens the focused one, Tab returns to
|
||
// the input. Surfaced so the two-focus model is discoverable.
|
||
//
|
||
// Skipped while peek is open: peek owns Enter (and may disagree with
|
||
// the dispatch list_focused flag — especially vim unfocused reply),
|
||
// so fall through to the peek_active branch below.
|
||
if state.list_focused && !peek_active {
|
||
let key_for = |id: crate::actions::ActionId, fallback: KeyShortcut| -> KeyShortcut {
|
||
registry.find(id).map(|d| d.default_key).unwrap_or(fallback)
|
||
};
|
||
let stop = key_for(crate::actions::ActionId::DashboardStop, key!('x', CONTROL));
|
||
let help = key_for(
|
||
crate::actions::ActionId::DashboardShortcutsHelp,
|
||
key!('.', CONTROL),
|
||
);
|
||
// The ↑/↓ (and vim j/k) nav chip is intentionally omitted — the
|
||
// overview is obviously arrow-navigable, and dropping it frees
|
||
// the bottom bar for the action hints (open / stop).
|
||
// Idle overflow toggle under the cursor — Enter reveals / re-folds
|
||
// the older idle agents, so `open` / `stop` would lie. The nav chip
|
||
// is omitted here too, mirroring the section-header row below.
|
||
if state.selected_idle_overflow {
|
||
let toggle = if state.idle_show_all {
|
||
"show fewer"
|
||
} else {
|
||
"show all"
|
||
};
|
||
let hints = vec![
|
||
HintItem::new(key!(Enter), toggle),
|
||
HintItem::new(key!(Tab), "input"),
|
||
];
|
||
ShortcutsBar::new(&hints)
|
||
.compact(4, Some(HintItem::new(help, "shortcuts")))
|
||
.render(inner, buf);
|
||
return;
|
||
}
|
||
// Section header under the cursor — Enter toggles the section,
|
||
// not a row, so `open` / `stop` would lie. Tab hands focus back
|
||
// to the dispatch input (Esc does too, one tier at a time).
|
||
if let Some(section) = state.selected_section {
|
||
let toggle = if state.is_section_collapsed(section) {
|
||
"expand"
|
||
} else {
|
||
"collapse"
|
||
};
|
||
let hints = vec![
|
||
HintItem::new(key!(Enter), toggle),
|
||
HintItem::new(key!(Tab), "input"),
|
||
];
|
||
ShortcutsBar::new(&hints)
|
||
.compact(4, Some(HintItem::new(help, "shortcuts")))
|
||
.render(inner, buf);
|
||
return;
|
||
}
|
||
let mut hints = vec![
|
||
HintItem::new(key!(Enter), "open"),
|
||
HintItem::new(key!(Tab), "input"),
|
||
];
|
||
if stoppable {
|
||
// Pinned so the stop chip always survives compact
|
||
// truncation while an agent row is selected.
|
||
hints.push(HintItem::new(stop, stop_label).pinned());
|
||
}
|
||
ShortcutsBar::new(&hints)
|
||
.compact(4, Some(HintItem::new(help, "shortcuts")))
|
||
.render(inner, buf);
|
||
return;
|
||
}
|
||
|
||
let resolve = |id: crate::actions::ActionId, fallback: KeyShortcut| -> KeyShortcut {
|
||
registry.find(id).map(|d| d.default_key).unwrap_or(fallback)
|
||
};
|
||
let enter = key!(Enter);
|
||
// "Send + open" is `Ctrl+S` (was `Shift+Enter`, which now inserts a
|
||
// newline). Hardcoded in the dispatch / peek key handlers, not a
|
||
// registry action, so the chip is built directly.
|
||
let send_open = key!('s', CONTROL);
|
||
// Multiline: bare Enter inserts a newline; Shift+Enter (or Alt+Enter
|
||
// when the terminal can't distinguish Shift+Enter) sends — same as
|
||
// the agent prompt keybar.
|
||
let send_key = if state.multiline_mode {
|
||
if crate::terminal::terminal_context().shift_enter_unavailable() {
|
||
key!(Enter, ALT)
|
||
} else {
|
||
key!(Enter, SHIFT)
|
||
}
|
||
} else {
|
||
enter
|
||
};
|
||
let stop = resolve(crate::actions::ActionId::DashboardStop, key!('x', CONTROL));
|
||
let help = resolve(
|
||
crate::actions::ActionId::DashboardShortcutsHelp,
|
||
key!('.', CONTROL),
|
||
);
|
||
|
||
let help_hint = HintItem::new(help, "shortcuts");
|
||
|
||
// Submit chord is `send_key` (Enter, or Shift/Alt+Enter in multiline).
|
||
// Ctrl+S is send+open. Empty draft: create/open on the submit chord;
|
||
// non-empty: send.
|
||
let button_focused = state.new_agent_button_focused;
|
||
let row_selected = state.selected.is_some();
|
||
let prompt_empty = state.dispatch.text().trim().is_empty();
|
||
|
||
let hints: Vec<HintItem> = if peek_active {
|
||
// Peek mode — Enter labels mirror `DashboardState::handle_peek_key`:
|
||
// vim + unfocused reply → Enter focuses reply ("input")
|
||
// focused + non-empty → Enter sends
|
||
// otherwise → Enter opens / attaches
|
||
// Ctrl+S remains send+open whenever there is a non-empty draft.
|
||
// Esc: no draft → unselect ("New Agent"); with draft → "back".
|
||
let esc = key!(Esc);
|
||
// Pending permission / ask-tool: `1-9` selects even while unfocused
|
||
// (handler focuses the panel). Focused + selected option → answer.
|
||
// Mirrors `DashboardState::handle_peek_key`.
|
||
let has_pending_question = state
|
||
.peek
|
||
.as_ref()
|
||
.is_some_and(|p| p.question.is_some() && !p.options.is_empty());
|
||
let option_selected = state
|
||
.peek
|
||
.as_ref()
|
||
.is_some_and(|p| p.selected_option.is_some());
|
||
let reply_empty = state.peek_reply.text().trim().is_empty();
|
||
let esc_label = if reply_empty { "New Agent" } else { "back" };
|
||
// Pin Esc when it clears a draft (`back`) so compact doesn't drop it
|
||
// behind stop/help — matches how important it is in handle_peek_key.
|
||
let esc_hint = {
|
||
let h = HintItem::new(esc, esc_label);
|
||
if reply_empty { h } else { h.pinned() }
|
||
};
|
||
let vim_mode = crate::appearance::cache::load_vim_mode();
|
||
// Two-focus model: Tab toggles reply ↔ row nav. Vim opens the
|
||
// reply unfocused so j/k keep selecting.
|
||
let peek_focused = state.peek.as_ref().map(|p| p.focused).unwrap_or(true);
|
||
let question_focused = peek_focused && has_pending_question;
|
||
let tab_hint = HintItem::new(key!(Tab), if peek_focused { "list" } else { "input" });
|
||
// `1-9 select` hint for the question picker (no single bound key).
|
||
let select_hint = HintItem {
|
||
keys: vec![],
|
||
label: "select".into(),
|
||
custom_display: Some("1-9"),
|
||
description: None,
|
||
pinned: false,
|
||
};
|
||
if question_focused && option_selected {
|
||
// An option is selected — the panel is an answer surface:
|
||
// Enter answers; `Tab` unfocuses to the row list (the same
|
||
// two-focus toggle the other peek states surface). (↑/↓ still
|
||
// move within the options; the nav chip is dropped to save
|
||
// bottom-bar space.)
|
||
vec![HintItem::new(enter, "answer"), tab_hint, esc_hint]
|
||
} else if has_pending_question && peek_focused {
|
||
// Question pending, focused, nothing selected — navigation + select.
|
||
let mut h = vec![
|
||
HintItem::new(enter, "open"),
|
||
select_hint,
|
||
tab_hint,
|
||
esc_hint,
|
||
];
|
||
if stoppable {
|
||
h.push(HintItem::new(stop, stop_label).pinned());
|
||
}
|
||
h
|
||
} else if vim_mode && !peek_focused {
|
||
// Vim unfocused: Enter focuses the reply (not open/send).
|
||
// Right still attaches — surface it so open stays discoverable.
|
||
// Pending question: keep 1-9 select (digits still work unfocused).
|
||
let mut h = vec![
|
||
HintItem::new(enter, "input"),
|
||
// Pin open: attach is the replacement for Enter in this mode.
|
||
HintItem::new(key!(Right), "open").pinned(),
|
||
tab_hint,
|
||
esc_hint,
|
||
];
|
||
if has_pending_question {
|
||
h.insert(2, select_hint);
|
||
}
|
||
if !reply_empty {
|
||
h.insert(1, HintItem::new(send_open, "send+open"));
|
||
}
|
||
if stoppable {
|
||
h.push(HintItem::new(stop, stop_label).pinned());
|
||
}
|
||
h
|
||
} else if has_pending_question {
|
||
// Non-vim unfocused (or other) with a pending question — open + select.
|
||
let mut h = vec![
|
||
HintItem::new(enter, "open"),
|
||
select_hint,
|
||
tab_hint,
|
||
esc_hint,
|
||
];
|
||
if stoppable {
|
||
h.push(HintItem::new(stop, stop_label).pinned());
|
||
}
|
||
h
|
||
} else if peek_focused && !reply_empty {
|
||
vec![
|
||
HintItem::new(send_key, "send"),
|
||
HintItem::new(send_open, "send+open"),
|
||
tab_hint,
|
||
HintItem::new(esc, "back").pinned(),
|
||
]
|
||
} else {
|
||
// Focused empty: open is on the submit chord (send_key). Unfocused:
|
||
// bare Enter still attaches.
|
||
let open_key = if peek_focused { send_key } else { enter };
|
||
let mut h = vec![HintItem::new(open_key, "open"), tab_hint, esc_hint];
|
||
if stoppable {
|
||
h.push(HintItem::new(stop, stop_label).pinned());
|
||
}
|
||
h
|
||
}
|
||
} else if let Some(section) = state.selected_section {
|
||
// A section header is selected. No stop chip in either state —
|
||
// there's no session under a section header.
|
||
if prompt_empty {
|
||
// ↑↓ navigate, Enter toggles collapse/expand, Esc returns
|
||
// to the `[+ New Agent]` button.
|
||
let toggle = if state.is_section_collapsed(section) {
|
||
"expand"
|
||
} else {
|
||
"collapse"
|
||
};
|
||
vec![
|
||
HintItem::new(enter, toggle),
|
||
HintItem::new(key!(Esc), "New Agent"),
|
||
]
|
||
} else {
|
||
// Typed text dispatches a NEW agent (a section header is
|
||
// never a reply target), so surface the same chips as the
|
||
// `[+ New Agent]` button with a draft: send_key sends (stays
|
||
// on the dashboard), Ctrl+S sends + opens detail,
|
||
// Shift+Tab cycles the dispatch mode.
|
||
vec![
|
||
HintItem::new(send_key, "send"),
|
||
HintItem::new(send_open, "send+open"),
|
||
HintItem::new(key!(BackTab), "mode"),
|
||
]
|
||
}
|
||
} else if state.selected_idle_overflow {
|
||
// The Idle overflow toggle is selected. Like a section header,
|
||
// there's no session under it — no stop chip.
|
||
if prompt_empty {
|
||
let toggle = if state.idle_show_all {
|
||
"show fewer"
|
||
} else {
|
||
"show all"
|
||
};
|
||
vec![
|
||
HintItem::new(enter, toggle),
|
||
HintItem::new(key!(Esc), "New Agent"),
|
||
]
|
||
} else {
|
||
vec![
|
||
HintItem::new(send_key, "send"),
|
||
HintItem::new(send_open, "send+open"),
|
||
HintItem::new(key!(BackTab), "mode"),
|
||
]
|
||
}
|
||
} else if button_focused {
|
||
let mut h: Vec<HintItem> = vec![];
|
||
if prompt_empty {
|
||
h.push(HintItem::new(send_key, "create"));
|
||
h.push(HintItem::new(key!(Tab), "list"));
|
||
} else {
|
||
h.push(HintItem::new(send_key, "send"));
|
||
h.push(HintItem::new(send_open, "send+open"));
|
||
}
|
||
h.push(HintItem::new(key!(BackTab), "mode"));
|
||
h
|
||
} else if row_selected {
|
||
let mut h: Vec<HintItem> = vec![];
|
||
if prompt_empty {
|
||
h.push(HintItem::new(send_key, "open"));
|
||
h.push(HintItem::new(key!(Tab), "list"));
|
||
} else {
|
||
h.push(HintItem::new(send_key, "send"));
|
||
h.push(HintItem::new(send_open, "send+open"));
|
||
}
|
||
if stoppable {
|
||
// Pinned so Ctrl+x always shows while an agent row is
|
||
// selected, even if earlier chips would otherwise fill the
|
||
// compact bar.
|
||
h.push(HintItem::new(stop, stop_label).pinned());
|
||
}
|
||
h
|
||
} else {
|
||
// Defensive — neither the button nor a row is focused.
|
||
// Should never happen given the invariant on
|
||
// `DashboardState`, but a fall-through keeps the bar
|
||
// populated rather than silently empty.
|
||
vec![
|
||
HintItem::new(send_key, "create"),
|
||
HintItem::new(stop, stop_label),
|
||
]
|
||
};
|
||
|
||
ShortcutsBar::new(&hints)
|
||
.compact(4, Some(help_hint))
|
||
.render(inner, buf);
|
||
}
|
||
|
||
fn state_icon(state: RowState, tick: u64) -> &'static str {
|
||
match state {
|
||
RowState::Working => {
|
||
let frames = crate::glyphs::dot_spinner_frames();
|
||
let i = (tick / SPINNER_DIVISOR) as usize % frames.len();
|
||
frames[i]
|
||
}
|
||
// Hollow diamond for idle rows; filled diamond for every
|
||
// state that needs visual presence (needs-input, completed,
|
||
// failed, blocked). The foreground colour disambiguates
|
||
// (accent_user for needs-input, accent_success for done,
|
||
// accent_error for failed, warning for blocked).
|
||
RowState::Idle | RowState::Inactive => crate::glyphs::diamond_hollow(),
|
||
RowState::NeedsInput | RowState::Completed | RowState::Failed | RowState::Blocked => {
|
||
crate::glyphs::diamond_filled()
|
||
}
|
||
}
|
||
}
|
||
|
||
fn state_color(state: RowState, theme: &Theme) -> Color {
|
||
match state {
|
||
RowState::Working => theme.accent_running,
|
||
RowState::NeedsInput => theme.warning,
|
||
RowState::Idle | RowState::Inactive => theme.gray_dim,
|
||
RowState::Completed => theme.accent_success,
|
||
RowState::Failed => theme.accent_error,
|
||
RowState::Blocked => theme.warning,
|
||
}
|
||
}
|
||
|
||
/// Bullet colour for a `NeedsInput` row: blinks between full yellow
|
||
/// (`warning`) and a dimmed yellow so an agent awaiting input draws the eye.
|
||
/// On non-truecolor terminals the dim blend falls back to the full colour
|
||
/// (the blink is invisible but the bullet stays yellow).
|
||
fn needs_input_bullet_color(tick: u64, theme: &Theme) -> Color {
|
||
let bright = (tick / NEEDS_INPUT_BLINK_DIVISOR).is_multiple_of(2);
|
||
if bright {
|
||
theme.warning
|
||
} else {
|
||
crate::render::color::blend_color(theme.bg_base, theme.warning, 0.5)
|
||
.unwrap_or(theme.warning)
|
||
}
|
||
}
|
||
|
||
fn badge_color(badge: RowBadge, theme: &Theme) -> Color {
|
||
match badge {
|
||
RowBadge::Worktree => theme.accent_user,
|
||
RowBadge::NeedsInput => theme.warning,
|
||
RowBadge::BgTask => theme.command,
|
||
RowBadge::Pinned => theme.accent_running,
|
||
RowBadge::Failed => theme.accent_error,
|
||
}
|
||
}
|
||
|
||
/// process-wide cached `$HOME`. Shared by render
|
||
/// and dispatch (`dispatch_dashboard_select`) so we don't re-read the
|
||
/// env var on every keystroke. Test-visible reset helper below.
|
||
pub(crate) fn cached_home() -> Option<&'static str> {
|
||
HOME.get_or_init(|| std::env::var("HOME").ok().filter(|s| !s.is_empty()))
|
||
.as_deref()
|
||
}
|
||
|
||
static HOME: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Popup overlay (banner-style)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Compute the rect for the attached-agent popup overlay.
|
||
///
|
||
/// The popup now takes the FULL bottom portion of
|
||
/// the screen (no horizontal inset, no bottom inset) with only a
|
||
/// dynamic top inset reserved for the dashboard banner.
|
||
///
|
||
/// The previous ~1/6-inset-on-all-sides design left the dashboard's
|
||
/// dispatch input + footer visible BELOW the popup, producing two
|
||
/// stacked input bars. The banner above the popup carries the live
|
||
/// row list in a bordered panel.
|
||
///
|
||
/// (Historical legacy comment kept for context — the old layout
|
||
/// description is no longer accurate; see the body of this function
|
||
/// for the current banner-style layout.)
|
||
/// terminal yields a ~164×48 popup with a ~36×12 dashboard frame
|
||
/// visible around it.
|
||
///
|
||
/// On terminals too small to honour the minimum inset, falls through
|
||
/// to a 0-inset takeover (no escape: any non-zero inset would clip
|
||
/// the agent's prompt below readability).
|
||
pub fn popup_rect(view: Rect) -> Rect {
|
||
// Popup takes the FULL bottom area (no
|
||
// horizontal inset, no bottom inset) with only a small TOP
|
||
// inset reserved for the dashboard banner that shows the in-flight
|
||
// rows. The previous ~1/6-inset-on-all-sides design left the
|
||
// dashboard's own dispatch input + footer visible BELOW the
|
||
// popup, producing two visible input bars stacked vertically.
|
||
//
|
||
// The banner height is dynamic: ~30% of the screen up to a
|
||
// BANNER_MAX_HEIGHT cap, with a BANNER_MIN_HEIGHT floor on tall
|
||
// terminals so a 1-row banner doesn't crowd the rows out. Very
|
||
// short terminals (height < BANNER_MIN_HEIGHT + 10) drop the
|
||
// banner to 0 so the popup gets every available row.
|
||
const BANNER_MIN_HEIGHT: u16 = 6;
|
||
const BANNER_MAX_HEIGHT: u16 = 14;
|
||
|
||
let banner_h: u16 = if view.height >= BANNER_MIN_HEIGHT + 10 {
|
||
(view.height / 3).clamp(BANNER_MIN_HEIGHT, BANNER_MAX_HEIGHT)
|
||
} else {
|
||
0
|
||
};
|
||
Rect {
|
||
x: view.x,
|
||
y: view.y + banner_h,
|
||
width: view.width,
|
||
height: view.height.saturating_sub(banner_h),
|
||
}
|
||
}
|
||
|
||
/// Render the popup overlay frame and the attached agent's view via
|
||
/// the canonical [`picker::render_bordered_frame`] — the same chrome
|
||
/// primitive used by `agent_view::draw_subagent_fullscreen`. Returns
|
||
/// `(cursor_pos, post_flush)` from the agent's draw so the caller
|
||
/// can place the cursor on the popup's prompt and merge any
|
||
/// kitty/sixel image escape sequences into the frame's outgoing
|
||
/// notification stream.
|
||
///
|
||
/// `title_label` is passed by the caller (rather than computed here
|
||
/// from a borrowed `AgentView`) so the closure can take a mutable
|
||
/// borrow of the agents map without conflicting with the title
|
||
/// lookup.
|
||
///
|
||
/// Side effects on `state`:
|
||
/// - `popup_close_rect` is registered so a click on the `[✗]`
|
||
/// affordance can dispatch a popup close in `handle_mouse`.
|
||
/// - `popup_outer_rect` is registered so clicks outside the popup
|
||
/// can be routed correctly.
|
||
///
|
||
/// When the popup area is too small for the bordered frame
|
||
/// (`area.height < 5` or `area.width < 10`), paints a minimal
|
||
/// fallback ("terminal too small — Esc to close") instead of leaving
|
||
/// the user staring at an empty box and
|
||
/// returns `(None, None, false)`.
|
||
pub fn render_popup_overlay(
|
||
buf: &mut Buffer,
|
||
area: Rect,
|
||
theme: &Theme,
|
||
title_label: &str,
|
||
state: &mut DashboardState,
|
||
draw_agent: impl FnOnce(
|
||
Rect,
|
||
&mut Buffer,
|
||
) -> (
|
||
Option<(u16, u16)>,
|
||
Option<crate::terminal::overlay::PostFlush>,
|
||
),
|
||
) -> (
|
||
Option<(u16, u16)>,
|
||
Option<crate::terminal::overlay::PostFlush>,
|
||
bool,
|
||
) {
|
||
use ratatui::widgets::{Block, Borders, Clear, Widget};
|
||
|
||
if area.area() == 0 {
|
||
state.popup_close_rect = None;
|
||
state.popup_outer_rect = None;
|
||
return (None, None, false);
|
||
}
|
||
state.popup_outer_rect = Some(area);
|
||
|
||
let border_color = theme.selection_border;
|
||
|
||
// The canonical bordered-frame primitive — used by
|
||
// `AgentView::draw_subagent_fullscreen` in app/agent_view/render.rs (the
|
||
// implementer cited it as the design reference). Paints
|
||
// header (top border + title row), divider (T-junctions), and
|
||
// content frame with full borders. The divider sits ABOVE the
|
||
// returned `content` rect, so `draw_agent` cannot overwrite it.
|
||
let Some(frame) =
|
||
crate::views::picker::render_bordered_frame(buf, area, border_color, theme.bg_base)
|
||
else {
|
||
// Too small for the canonical frame (height < 5 || width < 10).
|
||
// Paint a Clear + outline + single-line hint so the popup
|
||
// still communicates its presence and how to dismiss it.
|
||
//
|
||
// `popup_outer_rect` is set above
|
||
// so the modal-popup mouse intercept in `AppView::handle_input`
|
||
// swallows ALL clicks inside this rect. The agent is never
|
||
// drawn on this branch (we return `(None, None)` below without
|
||
// invoking `draw_agent`), so its hit-area maps stay empty by
|
||
// design — there's nothing for the user to click on inside
|
||
// the fallback box other than the (also-non-functional)
|
||
// border. The user's only exit is Esc / Ctrl+\\ / a wider
|
||
// terminal.
|
||
Clear.render(area, buf);
|
||
buf.set_style(area, Style::default().bg(theme.bg_base));
|
||
let outline = Block::default()
|
||
.borders(Borders::ALL)
|
||
.border_style(Style::default().fg(border_color).bg(theme.bg_base));
|
||
outline.render(area, buf);
|
||
if area.height >= 3 && area.width >= 6 {
|
||
let hint = truncate_str(
|
||
"(terminal too small — Esc to close)",
|
||
area.width.saturating_sub(2) as usize,
|
||
);
|
||
buf.set_string(
|
||
area.x + 1,
|
||
area.y + area.height / 2,
|
||
hint,
|
||
Style::default().fg(theme.gray_dim).bg(theme.bg_base),
|
||
);
|
||
}
|
||
state.popup_close_rect = None;
|
||
return (None, None, false);
|
||
};
|
||
|
||
let title_row = frame.title_row;
|
||
let inner = frame.content;
|
||
|
||
let title_text = format!(" \u{2771} {title_label} ");
|
||
|
||
let close_label = crate::glyphs::ballot_x_button();
|
||
let close_w = UnicodeWidthStr::width(close_label) as u16;
|
||
// Reserve close-affordance width + a 1-cell gap on the right;
|
||
// `truncate_str` handles overflow with an ellipsis.
|
||
let title_max = title_row.width.saturating_sub(close_w + 2).max(1) as usize;
|
||
let truncated = truncate_str(&title_text, title_max);
|
||
buf.set_string(
|
||
title_row.x,
|
||
title_row.y,
|
||
truncated,
|
||
Style::default()
|
||
.fg(theme.text_primary)
|
||
.bg(theme.bg_base)
|
||
.add_modifier(Modifier::BOLD),
|
||
);
|
||
|
||
// Close affordance — register its hit rect so `handle_mouse`
|
||
// can dispatch a popup close on click.
|
||
if close_w + 1 < title_row.width {
|
||
let close_x = title_row.x + title_row.width - close_w;
|
||
buf.set_string(
|
||
close_x,
|
||
title_row.y,
|
||
close_label,
|
||
Style::default().fg(theme.gray).bg(theme.bg_base),
|
||
);
|
||
state.popup_close_rect = Some(Rect {
|
||
x: close_x,
|
||
y: title_row.y,
|
||
width: close_w,
|
||
height: 1,
|
||
});
|
||
} else {
|
||
state.popup_close_rect = None;
|
||
}
|
||
|
||
let (cursor, post_flush) = draw_agent(inner, buf);
|
||
(cursor, post_flush, true)
|
||
}
|
||
|
||
/// Paint a bordered frame around an attached agent
|
||
/// view, matching the subagent-fullscreen pattern. The frame
|
||
/// reserves a title row at the top with `{title}` on the left
|
||
/// and `{i}/{n} [‹][›] [Dashboard]` on the right, then a horizontal
|
||
/// divider, then the agent's content area.
|
||
///
|
||
/// Layout:
|
||
///
|
||
/// ```text
|
||
/// ╭─────────────────────────────────────────────────────────╮
|
||
/// │ Add responsiveness to /context 1/3 [‹][›] [Dashboard] │
|
||
/// ├─────────────────────────────────────────────────────────┤
|
||
/// │ │
|
||
/// │ <agent.draw paints into here> │
|
||
/// │ │
|
||
/// ╰──────────────────────────────────────────────────────────╯
|
||
/// ```
|
||
///
|
||
/// The agent renders inside the frame's `content` rect — the
|
||
/// agent's own shortcuts bar (with the `Ctrl+\\:dashboard` +
|
||
/// `Ctrl+[/]:agents` hints added by `agent.draw` when overlay is
|
||
/// active) sits at the bottom of the content, inside the frame.
|
||
///
|
||
/// Returns `None` when the area is too small for the bordered
|
||
/// frame; the caller falls back to a chromeless render so the
|
||
/// user can still use the agent.
|
||
///
|
||
/// `position` carries `(current_1_based, total)` for the visible
|
||
/// row list. When `Some` and `total > 1`, the renderer paints
|
||
/// the position indicator + cycle chips; when `None` (or only
|
||
/// one row), the cycle chips are omitted. `hover` flags drive
|
||
/// per-button highlight so the mouse user gets visual feedback.
|
||
#[allow(clippy::too_many_arguments)]
|
||
pub fn render_dashboard_session_overlay(
|
||
buf: &mut Buffer,
|
||
area: Rect,
|
||
theme: &Theme,
|
||
title_label: &str,
|
||
position: Option<(usize, usize)>,
|
||
hover_prev: bool,
|
||
hover_next: bool,
|
||
hover_close: bool,
|
||
) -> Option<DashboardOverlayChrome> {
|
||
if area.area() == 0 || area.height < 5 || area.width < 20 {
|
||
return None;
|
||
}
|
||
let border_color = theme.selection_border;
|
||
let frame =
|
||
crate::views::picker::render_bordered_frame(buf, area, border_color, theme.bg_base)?;
|
||
// `(1, 1)` insets: keep a 1-col gap between the title / chips and
|
||
// the frame's left / right borders.
|
||
let (close_rect, prev_rect, next_rect) = paint_session_title_bar(
|
||
buf,
|
||
frame.title_row,
|
||
theme,
|
||
title_label,
|
||
position,
|
||
hover_prev,
|
||
hover_next,
|
||
hover_close,
|
||
1,
|
||
1,
|
||
);
|
||
Some(DashboardOverlayChrome {
|
||
content: frame.content,
|
||
close_rect,
|
||
prev_rect,
|
||
next_rect,
|
||
})
|
||
}
|
||
|
||
/// Like [`render_dashboard_session_overlay`] but paints ONLY a top
|
||
/// header bar — the agent's title on the left and the `{i}/{n} [‹][›]
|
||
/// [Dashboard]` affordances on the right — with **no surrounding border**. The
|
||
/// returned `content` is `area` minus the header band, so the agent
|
||
/// body below renders full-width and its prompt position + overall
|
||
/// padding match the dashboard list view (instead of being inset by a
|
||
/// modal frame).
|
||
///
|
||
/// `pad_left` / `pad_right` / `pad_top` mirror the agent body's outer
|
||
/// padding (`eff_hpad_left` / `eff_hpad_right` / `eff_outer_vpad`) so
|
||
/// the title text lines up with the body's left content edge, the
|
||
/// chips line up with its right edge, and the bar gets the same top
|
||
/// breathing room as everything below it. The header band is
|
||
/// `pad_top` blank rows + one title row; the agent body that fills
|
||
/// `content` then applies its OWN top padding, separating it from the
|
||
/// header.
|
||
#[allow(clippy::too_many_arguments)]
|
||
pub fn render_dashboard_session_header(
|
||
buf: &mut Buffer,
|
||
area: Rect,
|
||
theme: &Theme,
|
||
title_label: &str,
|
||
position: Option<(usize, usize)>,
|
||
hover_prev: bool,
|
||
hover_next: bool,
|
||
hover_close: bool,
|
||
pad_left: u16,
|
||
pad_right: u16,
|
||
pad_top: u16,
|
||
) -> Option<DashboardOverlayChrome> {
|
||
// Need room for the header band (`pad_top` + 1 title row) plus at
|
||
// least one body row, and enough width for the title + chips after
|
||
// the side padding is removed.
|
||
let band_height = pad_top.saturating_add(1);
|
||
if area.area() == 0
|
||
|| area.height <= band_height
|
||
|| area.width <= pad_left.saturating_add(pad_right).saturating_add(12)
|
||
{
|
||
return None;
|
||
}
|
||
// Fill the whole header band's background (top-padding rows + the
|
||
// title row) so it reads as one contiguous bar flush with the
|
||
// agent body's `bg_base` fill below.
|
||
let fill = " ".repeat(area.width as usize);
|
||
for dy in 0..band_height {
|
||
buf.set_string(
|
||
area.x,
|
||
area.y + dy,
|
||
&fill,
|
||
Style::default().bg(theme.bg_base),
|
||
);
|
||
}
|
||
// Title row sits below the top padding, inset by the side padding
|
||
// so it aligns with the body. `(0, 0)` insets — the boundaries are
|
||
// already encoded in `title_row`.
|
||
let title_row = Rect {
|
||
x: area.x + pad_left,
|
||
y: area.y + pad_top,
|
||
width: area.width.saturating_sub(pad_left + pad_right),
|
||
height: 1,
|
||
};
|
||
let (close_rect, prev_rect, next_rect) = paint_session_title_bar(
|
||
buf,
|
||
title_row,
|
||
theme,
|
||
title_label,
|
||
position,
|
||
hover_prev,
|
||
hover_next,
|
||
hover_close,
|
||
0,
|
||
0,
|
||
);
|
||
let content = Rect {
|
||
x: area.x,
|
||
y: area.y + band_height,
|
||
width: area.width,
|
||
height: area.height.saturating_sub(band_height),
|
||
};
|
||
Some(DashboardOverlayChrome {
|
||
content,
|
||
close_rect,
|
||
prev_rect,
|
||
next_rect,
|
||
})
|
||
}
|
||
|
||
/// Paint the session title bar (title on the left; `{i}/{n} [‹][›] [Dashboard]`
|
||
/// affordances on the right) onto a single-row `title_row`, on
|
||
/// `bg_base` with no button fills. Returns the three affordance hit
|
||
/// rects. Shared by the bordered overlay and the chromeless header bar.
|
||
///
|
||
/// `left_inset` / `right_inset` reserve that many columns inside
|
||
/// `title_row` before the title text (left) and after the chips
|
||
/// (right) — the bordered overlay passes `1` on each side for a small
|
||
/// gap from its border; the chromeless header passes `0` because its
|
||
/// `title_row` is already positioned at the desired padding boundaries.
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn paint_session_title_bar(
|
||
buf: &mut Buffer,
|
||
title_row: Rect,
|
||
theme: &Theme,
|
||
title_label: &str,
|
||
position: Option<(usize, usize)>,
|
||
hover_prev: bool,
|
||
hover_next: bool,
|
||
hover_close: bool,
|
||
left_inset: u16,
|
||
right_inset: u16,
|
||
) -> (Option<Rect>, Option<Rect>, Option<Rect>) {
|
||
// `‹` / `›` / `✗` are all painted as plain bracketed text
|
||
// (no button background fills). Hover only changes the fg
|
||
// color (`text_primary` vs `gray`) for subtle clickability
|
||
// feedback. This matches the style of every other close
|
||
// affordance in the pager's overlays (peek, popups, etc.).
|
||
//
|
||
// U+2039 / U+203A (single guillemets) read as paired nav
|
||
// arrows in monospace fonts at small sizes — softer than
|
||
// the ASCII `<` / `>` and visually closer to the chevrons
|
||
// used in macOS-native pickers.
|
||
// Labelled with its destination ("Dashboard") rather than a
|
||
// generic `[✗]` so it's obvious that clicking it returns to the
|
||
// dashboard. Plain ASCII, so no legacy-console fallback needed.
|
||
let close_label = "[Dashboard]";
|
||
let prev_label = format!("[{}]", crate::glyphs::chevron_left());
|
||
let next_label = format!("[{}]", crate::glyphs::chevron());
|
||
let close_w = UnicodeWidthStr::width(close_label) as u16;
|
||
let prev_w = prev_label.width() as u16;
|
||
let next_w = next_label.width() as u16;
|
||
|
||
// Left boundary: nothing paints left of here (title text starts
|
||
// exactly at `left_bound`).
|
||
let left_bound = title_row.x.saturating_add(left_inset);
|
||
|
||
// Right edge: paint right-to-left so the rightmost element
|
||
// anchors the edge regardless of which buttons are enabled.
|
||
let mut rx = title_row
|
||
.x
|
||
.saturating_add(title_row.width)
|
||
.saturating_sub(right_inset);
|
||
|
||
// [Dashboard] close — plain bracketed text on `bg_base` (NOT a
|
||
// filled button), styled like every other close affordance in the
|
||
// pager (peek panel, popup overlay, etc.). The label spells out
|
||
// its destination so it's clear that clicking it returns to the
|
||
// dashboard. Hover still bumps the fg colour for subtle feedback
|
||
// that it's clickable.
|
||
let close_rect = if rx >= left_bound + close_w {
|
||
rx = rx.saturating_sub(close_w);
|
||
let fg = if hover_close {
|
||
theme.text_primary
|
||
} else {
|
||
theme.gray
|
||
};
|
||
let style = Style::default()
|
||
.fg(fg)
|
||
.bg(theme.bg_base)
|
||
.add_modifier(Modifier::BOLD);
|
||
buf.set_string(rx, title_row.y, close_label, style);
|
||
Some(Rect {
|
||
x: rx,
|
||
y: title_row.y,
|
||
width: close_w,
|
||
height: 1,
|
||
})
|
||
} else {
|
||
None
|
||
};
|
||
|
||
let cycle_enabled = position.is_some_and(|(_, n)| n > 1);
|
||
let (prev_rect, next_rect) = if cycle_enabled {
|
||
let mut prev_r = None;
|
||
let mut next_r = None;
|
||
|
||
// Paint `[›]` as plain text (no bg), like [Dashboard].
|
||
if rx >= left_bound + 1 + next_w {
|
||
rx = rx.saturating_sub(1 + next_w);
|
||
let fg = if hover_next {
|
||
theme.text_primary
|
||
} else {
|
||
theme.gray
|
||
};
|
||
let style = Style::default()
|
||
.fg(fg)
|
||
.bg(theme.bg_base)
|
||
.add_modifier(Modifier::BOLD);
|
||
buf.set_string(rx, title_row.y, &next_label, style);
|
||
next_r = Some(Rect {
|
||
x: rx,
|
||
y: title_row.y,
|
||
width: next_w,
|
||
height: 1,
|
||
});
|
||
}
|
||
|
||
// Paint `[‹]` flush against `[›]` (no separating space)
|
||
// — the pair reads as one tight nav widget rather than
|
||
// two unrelated chips. The space before `[‹]` (separating
|
||
// it from the position indicator) is owned by the
|
||
// indicator's paint call below.
|
||
if rx >= left_bound + prev_w {
|
||
rx = rx.saturating_sub(prev_w);
|
||
let fg = if hover_prev {
|
||
theme.text_primary
|
||
} else {
|
||
theme.gray
|
||
};
|
||
let style = Style::default()
|
||
.fg(fg)
|
||
.bg(theme.bg_base)
|
||
.add_modifier(Modifier::BOLD);
|
||
buf.set_string(rx, title_row.y, &prev_label, style);
|
||
prev_r = Some(Rect {
|
||
x: rx,
|
||
y: title_row.y,
|
||
width: prev_w,
|
||
height: 1,
|
||
});
|
||
}
|
||
|
||
// Position indicator `{i}/{n}` — painted to the LEFT of
|
||
// the chips, separated by a single space. Dim foreground
|
||
// so it reads as metadata, not as another clickable
|
||
// chip.
|
||
if let Some((cur, total)) = position {
|
||
let pos_text = format!("{cur}/{total}");
|
||
let pos_w = UnicodeWidthStr::width(pos_text.as_str()) as u16;
|
||
if rx >= left_bound + 1 + pos_w {
|
||
rx = rx.saturating_sub(1 + pos_w);
|
||
let style = Style::default().fg(theme.gray_dim).bg(theme.bg_base);
|
||
buf.set_string(rx, title_row.y, &pos_text, style);
|
||
}
|
||
}
|
||
(prev_r, next_r)
|
||
} else {
|
||
(None, None)
|
||
};
|
||
|
||
// Title on the left — plain bold text, no `❱` prefix, starting
|
||
// exactly at `left_bound`. Leave a 1-col gap before whatever sits
|
||
// to its right (chips / position indicator) via the `- 1`.
|
||
if rx > left_bound {
|
||
let title_avail = rx.saturating_sub(left_bound).saturating_sub(1) as usize;
|
||
if title_avail > 0 {
|
||
let trunc = truncate_str(title_label, title_avail);
|
||
buf.set_string(
|
||
left_bound,
|
||
title_row.y,
|
||
trunc,
|
||
Style::default()
|
||
.fg(theme.text_primary)
|
||
.bg(theme.bg_base)
|
||
.add_modifier(Modifier::BOLD),
|
||
);
|
||
}
|
||
}
|
||
|
||
(close_rect, prev_rect, next_rect)
|
||
}
|
||
|
||
/// Output of [`render_dashboard_session_overlay`]: the agent's
|
||
/// drawing rect (the bordered frame's inner content area) plus
|
||
/// the three hit rects for the title-bar affordances.
|
||
#[derive(Debug, Clone, Copy)]
|
||
pub struct DashboardOverlayChrome {
|
||
pub content: Rect,
|
||
pub close_rect: Option<Rect>,
|
||
pub prev_rect: Option<Rect>,
|
||
pub next_rect: Option<Rect>,
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::views::dashboard::DashboardRowId;
|
||
use crate::views::dashboard::state::DashboardState;
|
||
|
||
/// Spinner glyph stays stable for `SPINNER_DIVISOR`
|
||
/// successive ticks before advancing.
|
||
#[test]
|
||
fn state_icon_spinner_advances_every_n_ticks() {
|
||
let g0 = state_icon(RowState::Working, 0);
|
||
// Same glyph for divisor-1 more ticks.
|
||
for t in 1..SPINNER_DIVISOR {
|
||
assert_eq!(state_icon(RowState::Working, t), g0);
|
||
}
|
||
let g1 = state_icon(RowState::Working, SPINNER_DIVISOR);
|
||
assert_ne!(g1, g0, "spinner must advance after SPINNER_DIVISOR ticks");
|
||
}
|
||
|
||
/// Every `RowState` variant resolves to a glyph.
|
||
#[test]
|
||
fn state_icon_one_per_variant() {
|
||
assert!(!state_icon(RowState::Working, 0).is_empty());
|
||
assert!(!state_icon(RowState::NeedsInput, 0).is_empty());
|
||
assert!(!state_icon(RowState::Idle, 0).is_empty());
|
||
assert!(!state_icon(RowState::Completed, 0).is_empty());
|
||
assert!(!state_icon(RowState::Failed, 0).is_empty());
|
||
assert!(!state_icon(RowState::Blocked, 0).is_empty());
|
||
}
|
||
|
||
/// Helper: read buffer row-by-row so multi-cell substring checks
|
||
/// see the visible text in left-to-right order.
|
||
fn buf_to_text(buf: &Buffer) -> String {
|
||
let mut content = String::new();
|
||
for y in 0..buf.area.height {
|
||
for x in 0..buf.area.width {
|
||
content.push_str(buf[(x, y)].symbol());
|
||
}
|
||
content.push('\n');
|
||
}
|
||
content
|
||
}
|
||
|
||
/// edge cases 1+25: empty state with no agents renders
|
||
/// the single hint line (never a fully blank screen).
|
||
#[test]
|
||
fn render_empty_state_paints_hint_line() {
|
||
let mut buf = Buffer::empty(Rect::new(0, 0, 80, 10));
|
||
let theme = Theme::current();
|
||
render_empty_state(&mut buf, Rect::new(0, 0, 80, 10), &theme, false);
|
||
let content = buf_to_text(&buf);
|
||
assert!(
|
||
content.contains("No agents yet, type a prompt to start one."),
|
||
"expected empty-state hint, got: {content:?}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn render_dashboard_shows_roster_when_local_agents_empty() {
|
||
use crate::app::roster::{RosterActivity, RosterEntry, RosterOrigin};
|
||
|
||
let area = Rect::new(0, 0, 100, 24);
|
||
let mut buf = Buffer::empty(area);
|
||
let mut agents: IndexMap<AgentId, AgentView> = IndexMap::new();
|
||
let mut state = DashboardState::new();
|
||
let registry = crate::actions::ActionRegistry::defaults();
|
||
let roster = [RosterEntry {
|
||
session_id: "sess-fleet-1".into(),
|
||
title: Some("Fix fleet dashboard".into()),
|
||
cwd: "/repo/work".into(),
|
||
is_worktree: false,
|
||
model_id: None,
|
||
yolo: false,
|
||
activity: RosterActivity::Working,
|
||
resident: true,
|
||
last_change_unix_ms: 1_725_000_000_000,
|
||
origin: RosterOrigin::default(),
|
||
}];
|
||
|
||
let _ = render_dashboard(
|
||
&mut buf,
|
||
area,
|
||
&mut state,
|
||
&mut agents,
|
||
®istry,
|
||
None,
|
||
&roster,
|
||
false,
|
||
None,
|
||
);
|
||
|
||
let content = buf_to_text(&buf);
|
||
assert!(
|
||
content.contains("Fix fleet dashboard"),
|
||
"roster-only working session must paint when local agents are empty, got: {content:?}"
|
||
);
|
||
assert!(
|
||
!content.contains("No agents yet"),
|
||
"must not show empty-state while roster rows exist, got: {content:?}"
|
||
);
|
||
}
|
||
|
||
/// While the local session roster is still loading the empty body
|
||
/// shows a loading hint instead of the "no agents" copy.
|
||
#[test]
|
||
fn render_empty_state_paints_loading_hint() {
|
||
let mut buf = Buffer::empty(Rect::new(0, 0, 80, 10));
|
||
let theme = Theme::current();
|
||
render_empty_state(&mut buf, Rect::new(0, 0, 80, 10), &theme, true);
|
||
let content = buf_to_text(&buf);
|
||
assert!(
|
||
content.contains("Loading sessions"),
|
||
"expected loading hint while sessions load, got: {content:?}"
|
||
);
|
||
assert!(
|
||
!content.contains("No agents yet"),
|
||
"loading state must not show the empty-state copy, got: {content:?}"
|
||
);
|
||
}
|
||
|
||
/// The hint still paints on a 1-row area (the `y_offset` collapses
|
||
/// to 0 instead of overflowing the rect).
|
||
#[test]
|
||
fn render_empty_state_paints_on_single_row_area() {
|
||
let mut buf = Buffer::empty(Rect::new(0, 0, 80, 1));
|
||
let theme = Theme::current();
|
||
render_empty_state(&mut buf, Rect::new(0, 0, 80, 1), &theme, false);
|
||
let content = buf_to_text(&buf);
|
||
assert!(
|
||
content.contains("No agents yet"),
|
||
"expected empty-state hint on 1-row area, got: {content:?}"
|
||
);
|
||
}
|
||
|
||
/// Local-only preview: render a representative dashboard frame
|
||
/// to stdout. Run with:
|
||
///
|
||
/// ```text
|
||
/// cargo test -p xai-grok-pager --lib \
|
||
/// views::dashboard::render::tests::dashboard_visual_preview -- --ignored --nocapture
|
||
/// ```
|
||
#[test]
|
||
#[ignore]
|
||
fn dashboard_visual_preview() {
|
||
use std::path::PathBuf;
|
||
use std::time::SystemTime;
|
||
let mut buf = Buffer::empty(Rect::new(0, 0, 100, 32));
|
||
let mut state = DashboardState::new();
|
||
state.spinner_tick = 8;
|
||
let theme = Theme::current();
|
||
let now = SystemTime::now();
|
||
let rows = vec![
|
||
DashboardRow {
|
||
id: DashboardRowId::TopLevel(crate::app::agent::AgentId(1)),
|
||
label: "Add responsiveness to /context".to_string(),
|
||
subtitle: Some("xai my-branch-2 worktree".to_string()),
|
||
state: RowState::NeedsInput,
|
||
activity: Some("Awaiting your input".to_string()),
|
||
secondary_line: Some("Pending: plan approval plan.md".to_string()),
|
||
cwd_display: String::new(),
|
||
cwd: PathBuf::from("/tmp"),
|
||
last_change_at: now - std::time::Duration::from_secs(240),
|
||
pinned: false,
|
||
is_active: false,
|
||
badges: Vec::new(),
|
||
context_pct: None,
|
||
indent: 0,
|
||
parent_label: None,
|
||
is_more_placeholder: false,
|
||
more_count: 0,
|
||
},
|
||
DashboardRow {
|
||
id: DashboardRowId::TopLevel(crate::app::agent::AgentId(2)),
|
||
label: "Add buttons for /models".to_string(),
|
||
subtitle: Some("xai my-branch-3 worktree".to_string()),
|
||
state: RowState::Completed,
|
||
activity: None,
|
||
secondary_line: Some("all tests completed, should I push?".to_string()),
|
||
cwd_display: String::new(),
|
||
cwd: PathBuf::from("/tmp"),
|
||
last_change_at: now - std::time::Duration::from_secs(3600),
|
||
pinned: false,
|
||
is_active: false,
|
||
badges: Vec::new(),
|
||
context_pct: None,
|
||
indent: 0,
|
||
parent_label: None,
|
||
is_more_placeholder: false,
|
||
more_count: 0,
|
||
},
|
||
DashboardRow {
|
||
id: DashboardRowId::TopLevel(crate::app::agent::AgentId(3)),
|
||
label: "Investigate bug".to_string(),
|
||
subtitle: Some("xai main".to_string()),
|
||
state: RowState::Working,
|
||
activity: Some("read somefile.md".to_string()),
|
||
secondary_line: Some("read somefile.md".to_string()),
|
||
cwd_display: String::new(),
|
||
cwd: PathBuf::from("/tmp"),
|
||
last_change_at: now - std::time::Duration::from_secs(5),
|
||
pinned: false,
|
||
is_active: false,
|
||
badges: Vec::new(),
|
||
context_pct: None,
|
||
indent: 0,
|
||
parent_label: None,
|
||
is_more_placeholder: false,
|
||
more_count: 0,
|
||
},
|
||
DashboardRow {
|
||
id: DashboardRowId::TopLevel(crate::app::agent::AgentId(4)),
|
||
label: "Add responsiveness to /context".to_string(),
|
||
subtitle: Some("xai my-branch-2 worktree".to_string()),
|
||
state: RowState::Working,
|
||
activity: Some("edit somefile.md".to_string()),
|
||
secondary_line: Some("edit somefile.md".to_string()),
|
||
cwd_display: String::new(),
|
||
cwd: PathBuf::from("/tmp"),
|
||
last_change_at: now - std::time::Duration::from_secs(240),
|
||
pinned: false,
|
||
is_active: false,
|
||
badges: Vec::new(),
|
||
context_pct: None,
|
||
indent: 0,
|
||
parent_label: None,
|
||
is_more_placeholder: false,
|
||
more_count: 0,
|
||
},
|
||
DashboardRow {
|
||
id: DashboardRowId::TopLevel(crate::app::agent::AgentId(5)),
|
||
label: "Add buttons for /models".to_string(),
|
||
subtitle: Some("xai mybranch worktree".to_string()),
|
||
state: RowState::Working,
|
||
activity: Some("thinking about life".to_string()),
|
||
secondary_line: Some("thinking about life".to_string()),
|
||
cwd_display: String::new(),
|
||
cwd: PathBuf::from("/tmp"),
|
||
last_change_at: now - std::time::Duration::from_secs(3600),
|
||
pinned: false,
|
||
is_active: false,
|
||
badges: Vec::new(),
|
||
context_pct: None,
|
||
indent: 0,
|
||
parent_label: None,
|
||
is_more_placeholder: false,
|
||
more_count: 0,
|
||
},
|
||
];
|
||
// Select the first row (NeedsInput) so the footer flips
|
||
// to the "see details" mode and the dispatch placeholder
|
||
// reads "Reply to agent".
|
||
state.selected = Some(rows[0].id.clone());
|
||
|
||
let area = Rect::new(0, 0, 100, 32);
|
||
let layout = super::super::layout::compute_layout(area, false);
|
||
// Manually paint each region so we don't need a live AgentView.
|
||
buf.set_style(area, Style::default().bg(theme.bg_base));
|
||
render_header(&mut buf, layout.header, &theme, &rows, &mut state, None);
|
||
render_rows(&mut buf, layout.list, &theme, &rows, &mut state);
|
||
let _ = render_dispatch(&mut buf, layout.dispatch, &theme, &mut state, None);
|
||
let registry = crate::actions::ActionRegistry::defaults();
|
||
render_footer(
|
||
&mut buf,
|
||
layout.footer,
|
||
&theme,
|
||
&state,
|
||
®istry,
|
||
Some(RowState::NeedsInput),
|
||
false,
|
||
None,
|
||
);
|
||
|
||
println!(
|
||
"\n┌─── Dashboard preview ({}x{}) ───\n│",
|
||
area.width, area.height
|
||
);
|
||
for y in 0..buf.area.height {
|
||
print!("│");
|
||
for x in 0..buf.area.width {
|
||
print!("{}", buf[(x, y)].symbol());
|
||
}
|
||
println!();
|
||
}
|
||
println!("└─── end preview ───\n");
|
||
}
|
||
|
||
/// Local-only preview of the overlay chrome alone. Run with:
|
||
///
|
||
/// ```text
|
||
/// cargo test -p xai-grok-pager --lib \
|
||
/// views::dashboard::render::tests::dashboard_overlay_visual_preview \
|
||
/// -- --ignored --nocapture
|
||
/// ```
|
||
#[test]
|
||
#[ignore]
|
||
fn dashboard_overlay_visual_preview() {
|
||
let mut buf = Buffer::empty(Rect::new(0, 0, 100, 12));
|
||
let theme = Theme::current();
|
||
let _ = render_dashboard_session_overlay(
|
||
&mut buf,
|
||
Rect::new(0, 0, 100, 12),
|
||
&theme,
|
||
"Add responsiveness to /context",
|
||
Some((1, 3)),
|
||
false,
|
||
false,
|
||
false,
|
||
);
|
||
println!("\n┌── overlay preview ──");
|
||
for y in 0..buf.area.height {
|
||
print!("│");
|
||
for x in 0..buf.area.width {
|
||
print!("{}", buf[(x, y)].symbol());
|
||
}
|
||
println!();
|
||
}
|
||
println!("└── end ──\n");
|
||
}
|
||
|
||
/// Session overlay paints a full bordered frame with
|
||
/// `{title}` on the left of the title row and four
|
||
/// affordances on the right: position indicator `{i}/{n}`,
|
||
/// previous-row chip `[‹]`, next-row chip `[›]`, and close
|
||
/// chip `[Dashboard]`. All four are plain bracketed text on
|
||
/// `bg_base` (no button-fill background); hover only changes
|
||
/// the foreground color.
|
||
#[test]
|
||
fn render_dashboard_session_overlay_paints_bordered_frame_chrome() {
|
||
let mut buf = Buffer::empty(Rect::new(0, 0, 80, 10));
|
||
let theme = Theme::current();
|
||
let chrome = render_dashboard_session_overlay(
|
||
&mut buf,
|
||
Rect::new(0, 0, 80, 10),
|
||
&theme,
|
||
"Add responsiveness to /context",
|
||
Some((1, 2)),
|
||
false,
|
||
false,
|
||
false,
|
||
)
|
||
.expect("overlay must paint on a reasonably sized area");
|
||
let content = buf_to_text(&buf);
|
||
assert!(
|
||
content.contains("Add responsiveness to /context"),
|
||
"title must render, got: {content:?}",
|
||
);
|
||
for chip in ["[‹]", "[›]", "[Dashboard]"] {
|
||
assert!(
|
||
content.contains(chip),
|
||
"overlay must paint `{chip}`, got: {content:?}",
|
||
);
|
||
}
|
||
assert!(
|
||
content.contains("1/2"),
|
||
"overlay must paint the `1/2` position indicator, got: {content:?}",
|
||
);
|
||
// Frame chrome.
|
||
for corner in ['\u{250c}', '\u{2510}', '\u{2514}', '\u{2518}'] {
|
||
assert!(
|
||
content.contains(corner),
|
||
"overlay must paint frame corner `{corner}`, got: {content:?}",
|
||
);
|
||
}
|
||
for tee in ['\u{251c}', '\u{2524}'] {
|
||
assert!(
|
||
content.contains(tee),
|
||
"overlay must paint title-divider T-junction `{tee}`, got: {content:?}",
|
||
);
|
||
}
|
||
assert!(chrome.prev_rect.is_some(), "prev_rect must be populated");
|
||
assert!(chrome.next_rect.is_some(), "next_rect must be populated");
|
||
assert!(chrome.close_rect.is_some(), "close_rect must be populated");
|
||
// The `[‹]` / `[›]` chips are painted as plain text on
|
||
// `bg_base` (matching `[Dashboard]` and every other close
|
||
// affordance in the pager).
|
||
let prev = chrome.prev_rect.unwrap();
|
||
let prev_cell = &buf[(prev.x, prev.y)];
|
||
assert_eq!(
|
||
prev_cell.bg, theme.bg_base,
|
||
"`[‹]` must paint on `bg_base` (plain text like [Dashboard], no button bg), got bg={:?}",
|
||
prev_cell.bg,
|
||
);
|
||
// The `[‹]` and `[›]` chips paint flush against each
|
||
// other (no separating space) so the pair reads as one
|
||
// nav widget. Trailing edge of `[‹]` (prev.x + prev.width)
|
||
// must equal the leading edge of `[›]` (next.x).
|
||
let next = chrome.next_rect.unwrap();
|
||
assert_eq!(
|
||
prev.x + prev.width,
|
||
next.x,
|
||
"`[‹]` and `[›]` must be adjacent (no space between), got prev_end={}, next_start={}",
|
||
prev.x + prev.width,
|
||
next.x,
|
||
);
|
||
// The rendered row should literally contain the adjacent
|
||
// pair `[‹][›]` (no internal whitespace).
|
||
assert!(
|
||
content.contains("[‹][›]"),
|
||
"row must contain `[‹][›]` as a single adjacent group, got: {content:?}",
|
||
);
|
||
let close = chrome.close_rect.unwrap();
|
||
let close_cell = &buf[(close.x, close.y)];
|
||
assert_eq!(
|
||
close_cell.bg, theme.bg_base,
|
||
"close `[Dashboard]` must paint on `bg_base` (NOT a button), got bg={:?}",
|
||
close_cell.bg,
|
||
);
|
||
}
|
||
|
||
/// With `position = None` (overlay not active or single-agent
|
||
/// dashboard), the overlay still paints the close button but
|
||
/// omits the position indicator and both cycle chips.
|
||
#[test]
|
||
fn render_dashboard_session_overlay_omits_cycle_chips_when_position_is_none() {
|
||
let mut buf = Buffer::empty(Rect::new(0, 0, 80, 10));
|
||
let theme = Theme::current();
|
||
let chrome = render_dashboard_session_overlay(
|
||
&mut buf,
|
||
Rect::new(0, 0, 80, 10),
|
||
&theme,
|
||
"Investigate bug",
|
||
None,
|
||
false,
|
||
false,
|
||
false,
|
||
)
|
||
.expect("overlay must paint");
|
||
let content = buf_to_text(&buf);
|
||
assert!(content.contains("[Dashboard]"));
|
||
assert!(!content.contains("[‹]"));
|
||
assert!(!content.contains("[›]"));
|
||
assert!(chrome.prev_rect.is_none());
|
||
assert!(chrome.next_rect.is_none());
|
||
assert!(chrome.close_rect.is_some());
|
||
}
|
||
|
||
/// `position = Some((1, 1))` (the user is the only attachable
|
||
/// row) also omits the cycle chips — there's nowhere to walk
|
||
/// to, so the chips would be dead clicks.
|
||
#[test]
|
||
fn render_dashboard_session_overlay_omits_cycle_chips_when_total_is_one() {
|
||
let mut buf = Buffer::empty(Rect::new(0, 0, 80, 10));
|
||
let theme = Theme::current();
|
||
let chrome = render_dashboard_session_overlay(
|
||
&mut buf,
|
||
Rect::new(0, 0, 80, 10),
|
||
&theme,
|
||
"Solo agent",
|
||
Some((1, 1)),
|
||
false,
|
||
false,
|
||
false,
|
||
)
|
||
.expect("overlay must paint");
|
||
let content = buf_to_text(&buf);
|
||
assert!(!content.contains("[‹]"));
|
||
assert!(!content.contains("[›]"));
|
||
// The position indicator is also suppressed — a `1/1`
|
||
// chip would be visual noise.
|
||
assert!(
|
||
!content.contains("1/1"),
|
||
"single-row overlays must omit the position indicator, got: {content:?}",
|
||
);
|
||
assert!(chrome.prev_rect.is_none());
|
||
assert!(chrome.next_rect.is_none());
|
||
}
|
||
|
||
/// Hover feedback on the plain-text affordances (`[‹]`, `[›]`)
|
||
/// only changes the foreground color (to `text_primary` on
|
||
/// hover, `gray` otherwise). Background is always `bg_base`
|
||
/// (no button fill).
|
||
#[test]
|
||
fn render_dashboard_session_overlay_highlights_hovered_affordance() {
|
||
let mut buf = Buffer::empty(Rect::new(0, 0, 80, 10));
|
||
let theme = Theme::current();
|
||
let chrome = render_dashboard_session_overlay(
|
||
&mut buf,
|
||
Rect::new(0, 0, 80, 10),
|
||
&theme,
|
||
"x",
|
||
Some((1, 2)),
|
||
false,
|
||
true, // hover_next
|
||
false,
|
||
)
|
||
.expect("overlay must paint");
|
||
let next = chrome.next_rect.unwrap();
|
||
let next_cell = &buf[(next.x, next.y)];
|
||
assert_eq!(
|
||
next_cell.bg, theme.bg_base,
|
||
"hovered `[›]` must paint on `bg_base` (plain text like [Dashboard]), got bg={:?}",
|
||
next_cell.bg,
|
||
);
|
||
assert_eq!(
|
||
next_cell.fg, theme.text_primary,
|
||
"hovered `[›]` must use text_primary fg, got: {:?}",
|
||
next_cell.fg,
|
||
);
|
||
|
||
let prev = chrome.prev_rect.unwrap();
|
||
let prev_cell = &buf[(prev.x, prev.y)];
|
||
assert_eq!(
|
||
prev_cell.bg, theme.bg_base,
|
||
"non-hovered `[‹]` must paint on `bg_base`, got bg={:?}",
|
||
prev_cell.bg,
|
||
);
|
||
assert_eq!(
|
||
prev_cell.fg, theme.gray,
|
||
"non-hovered `[‹]` must use gray fg, got: {:?}",
|
||
prev_cell.fg,
|
||
);
|
||
}
|
||
|
||
/// The `[+ New Agent]` header button paints green (`accent_success`)
|
||
/// when focused so the cursor is obvious, and dim gray otherwise.
|
||
#[test]
|
||
fn header_new_agent_button_focused_is_green() {
|
||
let theme = Theme::current();
|
||
let rows: Vec<DashboardRow> = Vec::new();
|
||
let area = Rect::new(0, 0, 120, 1);
|
||
|
||
// Focused (default for a fresh dashboard with no row selected).
|
||
let mut focused = DashboardState::new();
|
||
focused.focus_new_agent_button();
|
||
let mut buf = Buffer::empty(area);
|
||
render_header(&mut buf, area, &theme, &rows, &mut focused, None);
|
||
let rect = focused
|
||
.new_agent_button_hit
|
||
.rect
|
||
.expect("button must render");
|
||
assert_eq!(
|
||
buf[(rect.x, rect.y)].fg,
|
||
theme.accent_success,
|
||
"focused [+ New Agent] must paint green (accent_success), got {:?}",
|
||
buf[(rect.x, rect.y)].fg,
|
||
);
|
||
|
||
// Unfocused (a row holds the cursor instead).
|
||
let mut unfocused = DashboardState::new();
|
||
unfocused.focus_row(super::super::state::DashboardRowId::TopLevel(
|
||
crate::app::agent::AgentId(0),
|
||
));
|
||
let mut buf2 = Buffer::empty(area);
|
||
render_header(&mut buf2, area, &theme, &rows, &mut unfocused, None);
|
||
let rect2 = unfocused
|
||
.new_agent_button_hit
|
||
.rect
|
||
.expect("button must render");
|
||
assert_eq!(
|
||
buf2[(rect2.x, rect2.y)].fg,
|
||
theme.gray,
|
||
"unfocused [+ New Agent] must paint dim gray, got {:?}",
|
||
buf2[(rect2.x, rect2.y)].fg,
|
||
);
|
||
}
|
||
|
||
/// The `[+ New Agent]` header button brightens its text on hover
|
||
/// (`gray` → `text_primary`, when not focused) so the mouse user
|
||
/// gets clear feedback that it's clickable. Only the foreground
|
||
/// changes — the background stays `bg_base` (no fill). Driven by
|
||
/// the `hovered` flag the mouse-move handler flips via
|
||
/// `HitArea::update_hover`.
|
||
#[test]
|
||
fn header_new_agent_button_hover_brightens_text() {
|
||
let theme = Theme::current();
|
||
let rows: Vec<DashboardRow> = Vec::new();
|
||
let area = Rect::new(0, 0, 120, 1);
|
||
|
||
// Unfocused so the hover styling is isolated from the focus
|
||
// (green) styling.
|
||
let mut state = DashboardState::new();
|
||
state.focus_row(super::super::state::DashboardRowId::TopLevel(
|
||
crate::app::agent::AgentId(0),
|
||
));
|
||
|
||
// First render populates the button's hit rect.
|
||
let mut buf = Buffer::empty(area);
|
||
render_header(&mut buf, area, &theme, &rows, &mut state, None);
|
||
let rect = state.new_agent_button_hit.rect.expect("button must render");
|
||
|
||
// Moving the mouse over the button flips hover on.
|
||
assert!(
|
||
state.new_agent_button_hit.update_hover(rect.x, rect.y),
|
||
"moving the mouse over the button must flip hover on",
|
||
);
|
||
|
||
// Re-render with hover active → text_primary fg, background
|
||
// unchanged (still bg_base — no fill on hover).
|
||
let mut buf2 = Buffer::empty(area);
|
||
render_header(&mut buf2, area, &theme, &rows, &mut state, None);
|
||
let cell = &buf2[(rect.x, rect.y)];
|
||
assert_eq!(
|
||
cell.fg, theme.text_primary,
|
||
"hovered [+ New Agent] must use text_primary fg, got {:?}",
|
||
cell.fg,
|
||
);
|
||
assert_eq!(
|
||
cell.bg, theme.bg_base,
|
||
"hovered [+ New Agent] must keep bg_base (no hover fill), got {:?}",
|
||
cell.bg,
|
||
);
|
||
|
||
// Moving the mouse off the button clears hover → back to the
|
||
// dim resting state (gray fg, bg_base).
|
||
assert!(
|
||
state.new_agent_button_hit.update_hover(0, 0),
|
||
"moving the mouse off the button must flip hover off",
|
||
);
|
||
let mut buf3 = Buffer::empty(area);
|
||
render_header(&mut buf3, area, &theme, &rows, &mut state, None);
|
||
let cell3 = &buf3[(rect.x, rect.y)];
|
||
assert_eq!(
|
||
cell3.bg, theme.bg_base,
|
||
"non-hovered [+ New Agent] must paint on bg_base, got {:?}",
|
||
cell3.bg,
|
||
);
|
||
assert_eq!(
|
||
cell3.fg, theme.gray,
|
||
"non-hovered [+ New Agent] must use gray fg, got {:?}",
|
||
cell3.fg,
|
||
);
|
||
}
|
||
|
||
/// Regression: the header renders from the dashboard's STAGED `cwd`
|
||
/// (synced from `app.cwd` on a `/cd`), not the live process cwd. A
|
||
/// location change updates `state.cwd` immediately while the process cwd
|
||
/// only moves later via `Effect::SetWorkingDir` (which can fail), so the
|
||
/// header must follow `state.cwd` to show where dispatches will run.
|
||
#[test]
|
||
fn header_location_renders_from_staged_cwd() {
|
||
let theme = Theme::current();
|
||
let rows: Vec<DashboardRow> = Vec::new();
|
||
// Wide area so the path isn't width-truncated.
|
||
let area = Rect::new(0, 0, 200, 1);
|
||
|
||
let mut state = DashboardState::new();
|
||
// A distinct absolute path outside $HOME (rendered verbatim) that
|
||
// differs from the process cwd. No git cache entry → no branch span.
|
||
state.cwd = std::path::PathBuf::from("/grok-staged-cwd-marker");
|
||
|
||
let mut buf = Buffer::empty(area);
|
||
render_header(&mut buf, area, &theme, &rows, &mut state, None);
|
||
|
||
let top_row: String = (0..area.width)
|
||
.map(|x| buf[(x, 0)].symbol().to_string())
|
||
.collect();
|
||
assert!(
|
||
top_row.contains("/grok-staged-cwd-marker"),
|
||
"header must render the staged cwd, not the process cwd; got: {top_row:?}",
|
||
);
|
||
}
|
||
|
||
/// The header button reads `[+ New Worktree]` when worktree mode is armed
|
||
/// in a git repo, and `[+ New Agent]` otherwise (off, or armed but not a
|
||
/// git repo — worktree mode can't take effect there).
|
||
#[test]
|
||
fn header_button_label_reflects_worktree_mode() {
|
||
let theme = Theme::current();
|
||
let rows: Vec<DashboardRow> = Vec::new();
|
||
let area = Rect::new(0, 0, 120, 1);
|
||
|
||
// Off → plain new-agent button.
|
||
let mut off = DashboardState::new();
|
||
off.cwd_has_git_ancestor = true;
|
||
let mut buf = Buffer::empty(area);
|
||
render_header(&mut buf, area, &theme, &rows, &mut off, None);
|
||
let text = buf_to_text(&buf);
|
||
assert!(
|
||
text.contains("[+ New Agent]") && !text.contains("Worktree"),
|
||
"worktree mode off → [+ New Agent], got: {text:?}",
|
||
);
|
||
|
||
// Armed in a git repo → worktree button.
|
||
let mut armed = DashboardState::new();
|
||
armed.cwd_has_git_ancestor = true;
|
||
armed.dispatch_worktree = true;
|
||
let mut buf2 = Buffer::empty(area);
|
||
render_header(&mut buf2, area, &theme, &rows, &mut armed, None);
|
||
let text2 = buf_to_text(&buf2);
|
||
assert!(
|
||
text2.contains("[+ New Worktree]"),
|
||
"worktree mode armed in a repo → [+ New Worktree], got: {text2:?}",
|
||
);
|
||
|
||
// Armed but NOT a git repo → still the plain button (mode is inert).
|
||
let mut armed_no_git = DashboardState::new();
|
||
armed_no_git.cwd_has_git_ancestor = false;
|
||
armed_no_git.dispatch_worktree = true;
|
||
let mut buf3 = Buffer::empty(area);
|
||
render_header(&mut buf3, area, &theme, &rows, &mut armed_no_git, None);
|
||
let text3 = buf_to_text(&buf3);
|
||
assert!(
|
||
text3.contains("[+ New Agent]") && !text3.contains("Worktree"),
|
||
"armed outside a repo → [+ New Agent], got: {text3:?}",
|
||
);
|
||
}
|
||
|
||
/// Tiny areas return `None` so the caller falls back to a
|
||
/// chromeless render.
|
||
#[test]
|
||
fn render_dashboard_session_overlay_returns_none_on_tiny_area() {
|
||
let mut buf = Buffer::empty(Rect::new(0, 0, 12, 3));
|
||
let theme = Theme::current();
|
||
let chrome = render_dashboard_session_overlay(
|
||
&mut buf,
|
||
Rect::new(0, 0, 12, 3),
|
||
&theme,
|
||
"x",
|
||
Some((1, 2)),
|
||
false,
|
||
false,
|
||
false,
|
||
);
|
||
assert!(chrome.is_none());
|
||
}
|
||
|
||
/// The chromeless header variant paints the title + chips on the
|
||
/// title row, applies the requested top / side padding so it
|
||
/// aligns with the body below, populates the affordance hit rects,
|
||
/// hands back a full-width `content` rect beneath the header band,
|
||
/// and crucially paints NO border frame.
|
||
#[test]
|
||
fn render_dashboard_session_header_paints_padded_top_bar_without_border() {
|
||
const PAD_LEFT: u16 = 2;
|
||
const PAD_RIGHT: u16 = 2;
|
||
const PAD_TOP: u16 = 1;
|
||
let mut buf = Buffer::empty(Rect::new(0, 0, 80, 10));
|
||
let theme = Theme::current();
|
||
let chrome = render_dashboard_session_header(
|
||
&mut buf,
|
||
Rect::new(0, 0, 80, 10),
|
||
&theme,
|
||
"Add responsiveness to /context",
|
||
Some((1, 2)),
|
||
false,
|
||
false,
|
||
false,
|
||
PAD_LEFT,
|
||
PAD_RIGHT,
|
||
PAD_TOP,
|
||
)
|
||
.expect("header must paint on a reasonably sized area");
|
||
let content = buf_to_text(&buf);
|
||
assert!(
|
||
content.contains("Add responsiveness to /context"),
|
||
"title must render, got: {content:?}",
|
||
);
|
||
for chip in ["[‹]", "[›]", "[Dashboard]"] {
|
||
assert!(
|
||
content.contains(chip),
|
||
"header must paint `{chip}`, got: {content:?}",
|
||
);
|
||
}
|
||
assert!(
|
||
content.contains("1/2"),
|
||
"header must paint the `1/2` position indicator, got: {content:?}",
|
||
);
|
||
// No bordered frame: none of the box-drawing glyphs the
|
||
// bordered overlay paints should appear.
|
||
for glyph in [
|
||
'\u{250c}', '\u{2510}', '\u{2514}', '\u{2518}', '\u{251c}', '\u{2524}', '\u{2502}',
|
||
] {
|
||
assert!(
|
||
!content.contains(glyph),
|
||
"header must NOT paint frame glyph `{glyph}`, got: {content:?}",
|
||
);
|
||
}
|
||
// Content is the full-width area below the header band
|
||
// (`PAD_TOP` blank rows + 1 title row).
|
||
assert_eq!(
|
||
chrome.content,
|
||
Rect::new(0, PAD_TOP + 1, 80, 10 - (PAD_TOP + 1))
|
||
);
|
||
assert!(chrome.prev_rect.is_some(), "prev_rect must be populated");
|
||
assert!(chrome.next_rect.is_some(), "next_rect must be populated");
|
||
assert!(chrome.close_rect.is_some(), "close_rect must be populated");
|
||
// All affordances live on the title row (below the top pad).
|
||
assert_eq!(chrome.close_rect.unwrap().y, PAD_TOP);
|
||
assert_eq!(chrome.prev_rect.unwrap().y, PAD_TOP);
|
||
assert_eq!(chrome.next_rect.unwrap().y, PAD_TOP);
|
||
// Left side spacing: the title's first glyph lands exactly at
|
||
// column `PAD_LEFT`, and the columns before it are blank.
|
||
assert_eq!(
|
||
buf[(PAD_LEFT, PAD_TOP)].symbol(),
|
||
"A",
|
||
"title must start at column PAD_LEFT",
|
||
);
|
||
for x in 0..PAD_LEFT {
|
||
assert_eq!(
|
||
buf[(x, PAD_TOP)].symbol(),
|
||
" ",
|
||
"columns before the title must be blank left padding",
|
||
);
|
||
}
|
||
// Right side spacing: the close chip's last glyph ends exactly
|
||
// `PAD_RIGHT` columns from the right edge.
|
||
let close = chrome.close_rect.unwrap();
|
||
assert_eq!(
|
||
close.x + close.width,
|
||
80 - PAD_RIGHT,
|
||
"close chip must end PAD_RIGHT columns from the right edge",
|
||
);
|
||
}
|
||
|
||
/// edge case 9: narrow-mode rendering truncates labels
|
||
/// and still registers row_rects.
|
||
#[test]
|
||
fn render_narrow_mode_registers_row_rects() {
|
||
use crate::app::agent::AgentId;
|
||
let mut buf = Buffer::empty(Rect::new(0, 0, 30, 5));
|
||
let mut state = DashboardState::new();
|
||
let row = DashboardRow {
|
||
id: DashboardRowId::TopLevel(AgentId(1)),
|
||
label: "abcdefghij ".repeat(10),
|
||
subtitle: None,
|
||
state: RowState::Working,
|
||
activity: None,
|
||
secondary_line: None,
|
||
cwd_display: String::new(),
|
||
cwd: std::path::PathBuf::from("/tmp"),
|
||
last_change_at: std::time::SystemTime::now(),
|
||
pinned: false,
|
||
is_active: false,
|
||
badges: Vec::new(),
|
||
context_pct: None,
|
||
indent: 0,
|
||
parent_label: None,
|
||
is_more_placeholder: false,
|
||
more_count: 0,
|
||
};
|
||
let rows = vec![row];
|
||
let theme = Theme::current();
|
||
render_narrow_rows(&mut buf, Rect::new(0, 0, 30, 5), &theme, &rows, &mut state);
|
||
assert!(!state.row_rects.is_empty());
|
||
}
|
||
|
||
/// Wide-mode hit rects include each item's trailing gap line and
|
||
/// tile the list contiguously, so hover/click never falls into a
|
||
/// dead zone between items.
|
||
#[test]
|
||
fn render_rows_hit_rects_leave_no_dead_zones() {
|
||
let rows = vec![
|
||
header_test_row(1, RowState::Working, "alpha"),
|
||
header_test_row(2, RowState::Working, "beta"),
|
||
header_test_row(3, RowState::Idle, "gamma"),
|
||
];
|
||
let area = Rect::new(0, 0, 60, 30);
|
||
let mut buf = Buffer::empty(area);
|
||
let mut state = DashboardState::new();
|
||
state.grouping = Grouping::State;
|
||
let theme = Theme::current();
|
||
render_rows(&mut buf, area, &theme, &rows, &mut state);
|
||
|
||
assert_eq!(state.row_rects.len(), 3);
|
||
for (id, rect) in &state.row_rects {
|
||
assert_eq!(rect.height, ROW_HEIGHT, "row {id:?} must be full-height");
|
||
}
|
||
assert_eq!(state.section_rects.len(), 2);
|
||
for (key, rect) in &state.section_rects {
|
||
assert_eq!(
|
||
rect.height, GROUP_HEADER_HEIGHT,
|
||
"section {key:?} must be full-height",
|
||
);
|
||
}
|
||
|
||
// Each hit rect starts exactly where the previous one ended.
|
||
let mut rects: Vec<Rect> = state
|
||
.row_rects
|
||
.iter()
|
||
.map(|(_, r)| *r)
|
||
.chain(state.section_rects.iter().map(|(_, r)| *r))
|
||
.collect();
|
||
rects.sort_by_key(|r| r.y);
|
||
for pair in rects.windows(2) {
|
||
assert_eq!(
|
||
pair[0].y + pair[0].height,
|
||
pair[1].y,
|
||
"hit rects must tile without gaps: {pair:?}",
|
||
);
|
||
}
|
||
|
||
// Hovering a row highlights its content line fully and paints
|
||
// half-cell halos on the spacer lines above and below, so the
|
||
// highlight reads as centered on the text. These rows are
|
||
// title-only, so the content line is the middle of the 3-cell
|
||
// rect. Use an unquantized theme: `Theme::current()` in the
|
||
// test environment collapses `bg_hover` onto `bg_base`, which
|
||
// (correctly) suppresses the halos.
|
||
let theme = Theme::groknight();
|
||
assert_ne!(theme.bg_hover, theme.bg_base);
|
||
let (id, rect) = state.row_rects[0].clone();
|
||
state.hovered_row = Some(id);
|
||
render_rows(&mut buf, area, &theme, &rows, &mut state);
|
||
let title_y = rect.y + 1;
|
||
assert_eq!(
|
||
buf[(rect.x, title_y)].style().bg,
|
||
Some(theme.bg_hover),
|
||
"hovered row must highlight its content line",
|
||
);
|
||
let above = &buf[(rect.x, title_y - 1)];
|
||
assert_eq!(above.symbol(), "\u{2580}", "spacer above must be a halo");
|
||
assert_eq!(
|
||
above.style().bg,
|
||
Some(theme.bg_hover),
|
||
"halo above must show the hover colour in its bottom half",
|
||
);
|
||
let below = &buf[(rect.x, title_y + 1)];
|
||
assert_eq!(below.symbol(), "\u{2580}", "spacer below must be a halo");
|
||
assert_eq!(
|
||
below.style().fg,
|
||
Some(theme.bg_hover),
|
||
"halo below must show the hover colour in its top half",
|
||
);
|
||
}
|
||
|
||
/// A row's content is vertically centered within its 3-cell rect:
|
||
/// a title-only row renders padding + title + padding, while a
|
||
/// title + secondary row stays top-aligned (2 lines cannot center
|
||
/// in 3 cells).
|
||
#[test]
|
||
fn render_row_centers_title_only_content() {
|
||
let theme = Theme::current();
|
||
let state = DashboardState::new();
|
||
|
||
// Title-only → centered on the middle line.
|
||
let row = header_test_row(1, RowState::Idle, "solo");
|
||
let mut buf = Buffer::empty(Rect::new(0, 0, 40, 3));
|
||
render_row(&mut buf, Rect::new(0, 0, 40, 3), &theme, &row, &state);
|
||
assert_eq!(buf[(4, 1)].symbol(), "s", "title must sit on line 1");
|
||
assert_eq!(buf[(4, 0)].symbol(), " ", "line 0 must be padding");
|
||
assert_eq!(buf[(4, 2)].symbol(), " ", "line 2 must be padding");
|
||
|
||
// Title + secondary → top-aligned.
|
||
let mut row = header_test_row(2, RowState::Working, "pair");
|
||
row.secondary_line = Some("Responding".to_string());
|
||
let mut buf = Buffer::empty(Rect::new(0, 0, 40, 3));
|
||
render_row(&mut buf, Rect::new(0, 0, 40, 3), &theme, &row, &state);
|
||
assert_eq!(buf[(4, 0)].symbol(), "p", "title must sit on line 0");
|
||
assert_eq!(buf[(4, 1)].symbol(), "R", "secondary must sit on line 1");
|
||
assert_eq!(buf[(4, 2)].symbol(), " ", "line 2 must be padding");
|
||
}
|
||
|
||
/// Empty area is a quick exit.
|
||
#[test]
|
||
fn render_empty_state_zero_area_is_no_op() {
|
||
let mut buf = Buffer::empty(Rect::new(0, 0, 10, 10));
|
||
let theme = Theme::current();
|
||
render_empty_state(&mut buf, Rect::new(0, 0, 0, 0), &theme, false);
|
||
// No-op assertion: nothing crashes.
|
||
}
|
||
|
||
/// no-match branch renders the filter feedback.
|
||
#[test]
|
||
fn render_no_match_paints_filter_hint() {
|
||
let mut buf = Buffer::empty(Rect::new(0, 0, 60, 5));
|
||
let theme = Theme::current();
|
||
render_no_match(
|
||
&mut buf,
|
||
Rect::new(0, 0, 60, 5),
|
||
&theme,
|
||
&Filter::Agent("reviewer".into()),
|
||
);
|
||
let content = buf_to_text(&buf);
|
||
assert!(
|
||
content.contains("reviewer"),
|
||
"no-match hint should embed the filter value, got: {content:?}"
|
||
);
|
||
}
|
||
|
||
// ── snap_offset_to_line_boundary unit tests ──────────────────────
|
||
|
||
/// An offset already on a boundary is returned unchanged.
|
||
#[test]
|
||
fn snap_offset_already_on_boundary_returns_input() {
|
||
let heights = vec![3u16, 3, 3];
|
||
assert_eq!(snap_offset_to_line_boundary(0, &heights), 0);
|
||
assert_eq!(snap_offset_to_line_boundary(3, &heights), 3);
|
||
assert_eq!(snap_offset_to_line_boundary(6, &heights), 6);
|
||
}
|
||
|
||
/// A sub-row offset (1 or 2 cells into a 3-cell row) snaps DOWN
|
||
/// to the row's starting cell — the topmost visible row always
|
||
/// paints from its first cell.
|
||
#[test]
|
||
fn snap_offset_subrow_clips_to_row_start() {
|
||
let heights = vec![3u16, 3, 3];
|
||
assert_eq!(snap_offset_to_line_boundary(1, &heights), 0);
|
||
assert_eq!(snap_offset_to_line_boundary(2, &heights), 0);
|
||
assert_eq!(snap_offset_to_line_boundary(4, &heights), 3);
|
||
assert_eq!(snap_offset_to_line_boundary(5, &heights), 3);
|
||
}
|
||
|
||
/// Headers (2 cells) and rows (3 cells) mix; the helper snaps
|
||
/// to whichever item-boundary precedes the offset.
|
||
#[test]
|
||
fn snap_offset_mixed_heights() {
|
||
// [header=2, row=3, row=3]
|
||
let heights = vec![2u16, 3, 3];
|
||
// Inside the header (0..2):
|
||
assert_eq!(snap_offset_to_line_boundary(0, &heights), 0);
|
||
assert_eq!(snap_offset_to_line_boundary(1, &heights), 0);
|
||
// At the row 0 start:
|
||
assert_eq!(snap_offset_to_line_boundary(2, &heights), 2);
|
||
// Inside row 0 (2..5):
|
||
assert_eq!(snap_offset_to_line_boundary(3, &heights), 2);
|
||
assert_eq!(snap_offset_to_line_boundary(4, &heights), 2);
|
||
// At the row 1 start:
|
||
assert_eq!(snap_offset_to_line_boundary(5, &heights), 5);
|
||
}
|
||
|
||
/// Offsets past the last item just stay clamped at the last
|
||
/// boundary — the bounds clamp in `clamp_viewport` is the layer
|
||
/// that prevents this in practice, but `snap_offset_to_line_boundary`
|
||
/// must be safe on its own to keep the contract local.
|
||
#[test]
|
||
fn snap_offset_past_last_item_returns_last_boundary() {
|
||
let heights = vec![3u16, 3, 3];
|
||
// Last boundary is at cell 6 (start of row index 2).
|
||
assert_eq!(snap_offset_to_line_boundary(7, &heights), 6);
|
||
assert_eq!(snap_offset_to_line_boundary(99, &heights), 6);
|
||
}
|
||
|
||
/// Empty heights → snap returns 0 regardless of offset.
|
||
#[test]
|
||
fn snap_offset_empty_heights_returns_zero() {
|
||
assert_eq!(snap_offset_to_line_boundary(0, &[]), 0);
|
||
assert_eq!(snap_offset_to_line_boundary(5, &[]), 0);
|
||
}
|
||
|
||
/// `popup_rect` takes the FULL bottom area
|
||
/// (no horizontal inset, no bottom inset) with only a top inset
|
||
/// reserved for the dashboard banner. Replaces the previous
|
||
/// centred-inset design which left the dashboard's own dispatch
|
||
/// input + footer visible below the popup, producing two
|
||
/// stacked input bars.
|
||
#[test]
|
||
fn popup_rect_takes_full_bottom_area_with_top_banner() {
|
||
let view = Rect::new(0, 0, 200, 80);
|
||
let popup = popup_rect(view);
|
||
// No horizontal inset — popup spans the full width.
|
||
assert_eq!(
|
||
popup.x, view.x,
|
||
"popup must start at view.x (no left inset)"
|
||
);
|
||
assert_eq!(
|
||
popup.width, view.width,
|
||
"popup must span the full view width",
|
||
);
|
||
// Top inset present — popup starts BELOW the banner.
|
||
assert!(
|
||
popup.y > view.y,
|
||
"popup must sit below the banner (y={} expected > view.y={})",
|
||
popup.y,
|
||
view.y,
|
||
);
|
||
// No bottom inset — popup extends to the bottom of the view.
|
||
assert_eq!(
|
||
popup.y + popup.height,
|
||
view.y + view.height,
|
||
"popup must extend to the bottom of the view",
|
||
);
|
||
}
|
||
|
||
/// Banner height is sized as ~1/3 of the screen
|
||
/// clamped into a sensible range (6-14 rows) so the rows are
|
||
/// readable on tall terminals without crowding the popup.
|
||
#[test]
|
||
fn popup_rect_leaves_room_for_banner_on_large_terminal() {
|
||
let view = Rect::new(0, 0, 200, 60);
|
||
let popup = popup_rect(view);
|
||
let banner_h = popup.y - view.y;
|
||
assert!(
|
||
(6..=14).contains(&banner_h),
|
||
"banner height {banner_h} must be in [6, 14] on a 60-row terminal",
|
||
);
|
||
// Popup still gets the majority of the screen height.
|
||
assert!(
|
||
popup.height as u32 * 100 >= view.height as u32 * 70,
|
||
"popup height {} <70% of {} — banner too tall",
|
||
popup.height,
|
||
view.height,
|
||
);
|
||
}
|
||
|
||
/// Very short terminals (height < banner_min +
|
||
/// 10) collapse the banner to 0 so the popup gets every available
|
||
/// row. Mirrors the agent view's "drop bottom_vpad on short
|
||
/// terminals" behaviour.
|
||
#[test]
|
||
fn popup_rect_collapses_banner_on_tiny_terminal() {
|
||
let view = Rect::new(0, 0, 12, 6);
|
||
let popup = popup_rect(view);
|
||
// Tiny terminals: zero banner, popup IS the view.
|
||
assert_eq!(popup.width, view.width);
|
||
assert_eq!(popup.height, view.height);
|
||
assert_eq!(popup.y, view.y);
|
||
}
|
||
|
||
/// Replacing the home-rolled chrome
|
||
/// with `picker::render_bordered_frame` means the divider sits
|
||
/// ABOVE the returned content rect. This test paints the chrome
|
||
/// plus a "fake agent" pattern in the inner rect and verifies
|
||
/// the divider's `─` glyph survives the inner paint.
|
||
#[test]
|
||
fn render_popup_overlay_divider_survives_inner_paint() {
|
||
let mut buf = Buffer::empty(Rect::new(0, 0, 60, 20));
|
||
let theme = Theme::current();
|
||
let mut state = DashboardState::new();
|
||
let (_cursor, _post_flush, drawn) = render_popup_overlay(
|
||
&mut buf,
|
||
Rect::new(0, 0, 60, 20),
|
||
&theme,
|
||
"Test session",
|
||
&mut state,
|
||
|inner, buf| {
|
||
// Fill the inner with a non-divider character so a
|
||
// regression where the inner paints over the divider
|
||
// would clobber the `─` glyphs.
|
||
for y in inner.y..inner.y + inner.height {
|
||
for x in inner.x..inner.x + inner.width {
|
||
buf.set_string(x, y, "x", Style::default());
|
||
}
|
||
}
|
||
(None, None)
|
||
},
|
||
);
|
||
assert!(drawn);
|
||
let content = buf_to_text(&buf);
|
||
// The divider glyph `─` (U+2500) must appear at least once
|
||
// somewhere AFTER the title row. If the inner paint
|
||
// overwrote it the count would be zero.
|
||
let divider_count = content.matches('\u{2500}').count();
|
||
assert!(
|
||
divider_count > 0,
|
||
"divider U+2500 missing after inner paint; got: {content:?}",
|
||
);
|
||
}
|
||
|
||
/// The popup paints a `[✗]` close
|
||
/// affordance and registers its hit rect on `DashboardState` so
|
||
/// `handle_mouse` can dispatch a popup close on click.
|
||
#[test]
|
||
fn render_popup_overlay_registers_close_hit_rect() {
|
||
let mut buf = Buffer::empty(Rect::new(0, 0, 60, 10));
|
||
let theme = Theme::current();
|
||
let mut state = DashboardState::new();
|
||
let _ = render_popup_overlay(
|
||
&mut buf,
|
||
Rect::new(0, 0, 60, 10),
|
||
&theme,
|
||
"Sample",
|
||
&mut state,
|
||
|_inner, _buf| (None, None),
|
||
);
|
||
let content = buf_to_text(&buf);
|
||
assert!(
|
||
content.contains('\u{2717}'),
|
||
"close affordance [✗] missing, got: {content:?}",
|
||
);
|
||
let close_rect = state
|
||
.popup_close_rect
|
||
.expect("popup_close_rect must be registered");
|
||
// The close rect should be on the title row (y == area.y + 1)
|
||
// and on the right edge of the popup.
|
||
assert_eq!(close_rect.y, 1);
|
||
assert!(close_rect.x > 50);
|
||
// outer rect should be set to the full popup area.
|
||
let outer = state
|
||
.popup_outer_rect
|
||
.expect("popup_outer_rect must be registered");
|
||
assert_eq!(outer, Rect::new(0, 0, 60, 10));
|
||
}
|
||
|
||
/// When the popup area is too small for
|
||
/// the canonical bordered frame, the overlay paints a fallback
|
||
/// hint inside an outlined box (rather than leaving the user
|
||
/// staring at an empty popup).
|
||
#[test]
|
||
fn render_popup_overlay_small_area_paints_fallback_hint() {
|
||
// 4 rows of height — below `picker::render_bordered_frame`'s
|
||
// 5-row minimum — triggers the fallback path.
|
||
let mut buf = Buffer::empty(Rect::new(0, 0, 40, 4));
|
||
let theme = Theme::current();
|
||
let mut state = DashboardState::new();
|
||
let (cursor, post_flush, drawn) = render_popup_overlay(
|
||
&mut buf,
|
||
Rect::new(0, 0, 40, 4),
|
||
&theme,
|
||
"Tiny",
|
||
&mut state,
|
||
|_inner, _buf| {
|
||
panic!("draw_agent must NOT be called on the fallback path");
|
||
},
|
||
);
|
||
assert!(cursor.is_none());
|
||
assert!(post_flush.is_none());
|
||
assert!(!drawn);
|
||
let content = buf_to_text(&buf);
|
||
assert!(
|
||
content.contains("too small"),
|
||
"fallback hint missing, got: {content:?}",
|
||
);
|
||
}
|
||
|
||
/// The footer hint always includes the rename
|
||
/// shortcut. Stops a regression where the footer dropped the
|
||
/// rename shortcut behind a feature flag or omitted it during a
|
||
/// conditional rebuild.
|
||
#[test]
|
||
fn render_footer_surfaces_shortcuts_link() {
|
||
// Trailing shortcuts chip must match the registry primary key.
|
||
let mut buf = Buffer::empty(Rect::new(0, 0, 200, 1));
|
||
let theme = Theme::current();
|
||
let state = DashboardState::new();
|
||
let registry = crate::actions::ActionRegistry::defaults();
|
||
render_footer(
|
||
&mut buf,
|
||
Rect::new(0, 0, 200, 1),
|
||
&theme,
|
||
&state,
|
||
®istry,
|
||
None,
|
||
false,
|
||
None,
|
||
);
|
||
let content = buf_to_text(&buf);
|
||
assert!(
|
||
content.contains("shortcuts"),
|
||
"footer must mention `shortcuts` (the help chip), got: {content:?}",
|
||
);
|
||
let primary = registry
|
||
.find(crate::actions::ActionId::DashboardShortcutsHelp)
|
||
.map(|d| d.default_key.display())
|
||
.unwrap_or_else(|| "Ctrl+.".into());
|
||
let expected = format!("{primary}:shortcuts");
|
||
assert!(
|
||
content.contains(&expected),
|
||
"footer must include `{expected}` chip (registry primary), got: {content:?}",
|
||
);
|
||
}
|
||
|
||
/// The location picker opens input-default, but under vim `Esc`
|
||
/// drops it to NAV — so its footer must surface the `i search` hint (and
|
||
/// hide it in input mode / when vim is off).
|
||
#[test]
|
||
fn location_picker_footer_shows_i_hint_in_vim_nav() {
|
||
use super::super::state::LocationPickerState;
|
||
let make = || {
|
||
LocationPickerState::new(
|
||
vec![],
|
||
std::path::PathBuf::from("/tmp"),
|
||
std::collections::HashMap::new(),
|
||
)
|
||
};
|
||
let area = Rect::new(0, 0, 160, 48);
|
||
let theme = Theme::current();
|
||
|
||
// vim on + NAV (search inactive) → hint present.
|
||
crate::appearance::cache::set_vim_mode(true);
|
||
let mut nav = make();
|
||
nav.picker.search_active = false;
|
||
let mut buf = Buffer::empty(area);
|
||
render_location_picker(&mut buf, area, &theme, &mut nav);
|
||
assert!(
|
||
buf_to_text(&buf).contains("i search"),
|
||
"location picker footer must show `i search` in vim nav mode",
|
||
);
|
||
|
||
// vim on + INPUT (the open default) → hint absent.
|
||
let mut input = make();
|
||
input.picker.search_active = true;
|
||
let mut buf_input = Buffer::empty(area);
|
||
render_location_picker(&mut buf_input, area, &theme, &mut input);
|
||
assert!(
|
||
!buf_to_text(&buf_input).contains("i search"),
|
||
"no `i search` hint while typing (input mode)",
|
||
);
|
||
|
||
// vim off → hint absent regardless of mode.
|
||
crate::appearance::cache::set_vim_mode(false);
|
||
let mut off = make();
|
||
off.picker.search_active = false;
|
||
let mut buf_off = Buffer::empty(area);
|
||
render_location_picker(&mut buf_off, area, &theme, &mut off);
|
||
assert!(
|
||
!buf_to_text(&buf_off).contains("i search"),
|
||
"no `i search` hint when vim-mode is off",
|
||
);
|
||
}
|
||
|
||
/// Pressing the help key returns the
|
||
/// `DashboardOpenShortcutsHelp` action so the dispatcher can
|
||
/// build the modal state. No `error_toast` is set (the
|
||
/// an earlier polish iteration surfaced a hint via the dispatch
|
||
/// input placeholder, which the user explicitly rejected
|
||
/// because it conflicted with their typing slot).
|
||
#[test]
|
||
fn dashboard_shortcuts_help_action_opens_modal() {
|
||
use super::super::state::DashboardState;
|
||
let mut state = DashboardState::new();
|
||
let registry = crate::actions::ActionRegistry::defaults();
|
||
use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
|
||
let key = KeyEvent {
|
||
code: KeyCode::Char('.'),
|
||
modifiers: KeyModifiers::CONTROL,
|
||
kind: KeyEventKind::Press,
|
||
state: crossterm::event::KeyEventState::NONE,
|
||
};
|
||
let outcome = state.handle_input(&Event::Key(key), ®istry);
|
||
assert!(
|
||
matches!(
|
||
outcome,
|
||
crate::app::app_view::InputOutcome::Action(
|
||
crate::app::actions::Action::DashboardOpenShortcutsHelp,
|
||
)
|
||
),
|
||
"shortcuts key must emit DashboardOpenShortcutsHelp, got: {outcome:?}",
|
||
);
|
||
assert!(
|
||
state.error_toast.is_none(),
|
||
"no error_toast should be set — the modal carries the help, \
|
||
not the dispatch input placeholder. Got: {:?}",
|
||
state.error_toast,
|
||
);
|
||
}
|
||
|
||
/// RenameDraft sanitation keeps control characters out of both render paths.
|
||
#[test]
|
||
fn sanitized_rename_draft_is_safe_in_both_render_paths() {
|
||
use crate::app::agent::AgentId;
|
||
let id = DashboardRowId::TopLevel(AgentId(7));
|
||
let row = DashboardRow {
|
||
id: id.clone(),
|
||
label: "row label".to_string(),
|
||
subtitle: None,
|
||
state: RowState::Working,
|
||
activity: None,
|
||
secondary_line: None,
|
||
cwd_display: String::new(),
|
||
cwd: std::path::PathBuf::from("/tmp"),
|
||
last_change_at: std::time::SystemTime::now(),
|
||
pinned: false,
|
||
is_active: false,
|
||
badges: Vec::new(),
|
||
context_pct: None,
|
||
indent: 0,
|
||
parent_label: None,
|
||
is_more_placeholder: false,
|
||
more_count: 0,
|
||
};
|
||
let rows = vec![row];
|
||
let theme = Theme::current();
|
||
|
||
// Wide path.
|
||
{
|
||
let mut buf = Buffer::empty(Rect::new(0, 0, 80, 3));
|
||
let mut state = DashboardState::new();
|
||
state.selected = Some(id.clone());
|
||
state.rename = Some(RenameDraft::new(id.clone(), "a\x1b[31m"));
|
||
render_rows(&mut buf, Rect::new(0, 0, 80, 3), &theme, &rows, &mut state);
|
||
let content = buf_to_text(&buf);
|
||
assert!(
|
||
!content.contains('\x1b'),
|
||
"wide rename overlay must not retain ESC: {content:?}",
|
||
);
|
||
assert!(
|
||
content.contains('a'),
|
||
"wide rename overlay must keep the visible draft char: {content:?}",
|
||
);
|
||
}
|
||
|
||
// Narrow path.
|
||
{
|
||
let mut buf = Buffer::empty(Rect::new(0, 0, 30, 3));
|
||
let mut state = DashboardState::new();
|
||
state.selected = Some(id.clone());
|
||
state.rename = Some(RenameDraft::new(id.clone(), "a\x1b[31m"));
|
||
render_narrow_rows(&mut buf, Rect::new(0, 0, 30, 3), &theme, &rows, &mut state);
|
||
let content = buf_to_text(&buf);
|
||
assert!(
|
||
!content.contains('\x1b'),
|
||
"narrow rename overlay must not retain ESC: {content:?}",
|
||
);
|
||
assert!(
|
||
content.contains('a'),
|
||
"narrow rename overlay must keep the visible draft char: {content:?}",
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Rename rendering preserves row chrome and title alignment in both layouts.
|
||
#[test]
|
||
fn render_rename_overlay_aligns_with_title_and_keeps_icon() {
|
||
use crate::app::agent::AgentId;
|
||
let id = DashboardRowId::TopLevel(AgentId(7));
|
||
let row = DashboardRow {
|
||
id: id.clone(),
|
||
label: "row label".to_string(),
|
||
subtitle: None,
|
||
state: RowState::Idle,
|
||
activity: None,
|
||
secondary_line: None,
|
||
cwd_display: String::new(),
|
||
cwd: std::path::PathBuf::from("/tmp"),
|
||
last_change_at: std::time::SystemTime::now(),
|
||
pinned: false,
|
||
is_active: false,
|
||
badges: Vec::new(),
|
||
context_pct: None,
|
||
indent: 0,
|
||
parent_label: None,
|
||
is_more_placeholder: false,
|
||
more_count: 0,
|
||
};
|
||
let rows = vec![row];
|
||
let theme = Theme::current();
|
||
let row_text = |buf: &Buffer, y: u16, w: u16| -> String {
|
||
(0..w).map(|x| buf[(x, y)].symbol().to_string()).collect()
|
||
};
|
||
|
||
// Wide path: this title-only row centers its title within its
|
||
// 3-cell rect, so the title sits 3 below the group header
|
||
// (header + gap + row top padding).
|
||
// `title_byte` is a byte offset (for `str::find` comparisons);
|
||
// `title_col` is the display column (the icon glyph is
|
||
// multi-byte UTF-8, so the two differ) for cursor math.
|
||
let (title_byte, title_col) = {
|
||
let mut buf = Buffer::empty(Rect::new(0, 0, 80, 5));
|
||
let mut state = DashboardState::new();
|
||
render_rows(&mut buf, Rect::new(0, 0, 80, 5), &theme, &rows, &mut state);
|
||
let line = row_text(&buf, 3, 80);
|
||
let byte = line.find("row label").expect("title must render");
|
||
(byte, line[..byte].chars().count() as u16)
|
||
};
|
||
{
|
||
let mut buf = Buffer::empty(Rect::new(0, 0, 80, 5));
|
||
let mut state = DashboardState::new();
|
||
state.rename = Some(RenameDraft::new(id.clone(), "new name"));
|
||
render_rows(&mut buf, Rect::new(0, 0, 80, 5), &theme, &rows, &mut state);
|
||
let line = row_text(&buf, 3, 80);
|
||
assert_eq!(
|
||
line.find("rename: new name"),
|
||
Some(title_byte),
|
||
"wide: `rename:` must start at the title column, got: {line:?}",
|
||
);
|
||
assert_eq!(
|
||
buf[(2, 3)].symbol(),
|
||
crate::glyphs::diamond_hollow(),
|
||
"wide: the state icon must stay in place while renaming",
|
||
);
|
||
// The cursor parks one cell past the typed draft — after
|
||
// the `rename: ` prefix, never overlapping it.
|
||
let prefix_w = "rename: ".len() as u16;
|
||
let draft_w = "new name".len() as u16;
|
||
assert_eq!(
|
||
rename_cursor_pos(&state, &rows),
|
||
Some((title_col + prefix_w + draft_w, 3)),
|
||
"cursor must sit one cell past the draft text",
|
||
);
|
||
// With an empty draft the cursor sits immediately after
|
||
// `rename: ` (the position typing lands at).
|
||
state.rename = Some(RenameDraft::new(id.clone(), ""));
|
||
assert_eq!(
|
||
rename_cursor_pos(&state, &rows),
|
||
Some((title_col + prefix_w, 3)),
|
||
"empty draft: cursor must sit right after `rename: `",
|
||
);
|
||
}
|
||
|
||
// Narrow path: row sits 1 below the group header (no gap).
|
||
let title_col = {
|
||
let mut buf = Buffer::empty(Rect::new(0, 0, 30, 3));
|
||
let mut state = DashboardState::new();
|
||
render_narrow_rows(&mut buf, Rect::new(0, 0, 30, 3), &theme, &rows, &mut state);
|
||
row_text(&buf, 1, 30)
|
||
.find("row label")
|
||
.expect("narrow title must render") as u16
|
||
};
|
||
{
|
||
let mut buf = Buffer::empty(Rect::new(0, 0, 30, 3));
|
||
let mut state = DashboardState::new();
|
||
state.rename = Some(RenameDraft::new(id.clone(), "nn"));
|
||
render_narrow_rows(&mut buf, Rect::new(0, 0, 30, 3), &theme, &rows, &mut state);
|
||
let line = row_text(&buf, 1, 30);
|
||
assert_eq!(
|
||
line.find("rename: nn").map(|c| c as u16),
|
||
Some(title_col),
|
||
"narrow: `rename:` must start at the title column, got: {line:?}",
|
||
);
|
||
assert_eq!(
|
||
buf[(2, 1)].symbol(),
|
||
crate::glyphs::diamond_hollow(),
|
||
"narrow: the state icon must stay in place while renaming",
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn rename_viewport_handles_long_unicode_in_wide_and_narrow_rows() {
|
||
use crate::app::agent::AgentId;
|
||
use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
|
||
|
||
let id = DashboardRowId::TopLevel(AgentId(7));
|
||
let row = DashboardRow {
|
||
id: id.clone(),
|
||
label: "row label".to_string(),
|
||
subtitle: None,
|
||
state: RowState::Idle,
|
||
activity: None,
|
||
secondary_line: None,
|
||
cwd_display: String::new(),
|
||
cwd: std::path::PathBuf::from("/tmp"),
|
||
last_change_at: std::time::SystemTime::now(),
|
||
pinned: false,
|
||
is_active: false,
|
||
badges: Vec::new(),
|
||
context_pct: None,
|
||
indent: 0,
|
||
parent_label: None,
|
||
is_more_placeholder: false,
|
||
more_count: 0,
|
||
};
|
||
let rows = vec![row];
|
||
let text = format!("{}中e\u{301}👩🏽\u{200d}💻", "x".repeat(90));
|
||
let theme = Theme::current();
|
||
let registry = crate::actions::ActionRegistry::defaults();
|
||
|
||
for (width, narrow, row_y) in [(80, false, 3), (30, true, 1)] {
|
||
let area = Rect::new(0, 0, width, if narrow { 3 } else { 5 });
|
||
let mut buffer = Buffer::empty(area);
|
||
let mut state = DashboardState::new();
|
||
state.rename = Some(RenameDraft::new(id.clone(), text.clone()));
|
||
if narrow {
|
||
render_narrow_rows(&mut buffer, area, &theme, &rows, &mut state);
|
||
} else {
|
||
render_rows(&mut buffer, area, &theme, &rows, &mut state);
|
||
}
|
||
let line = (0..width)
|
||
.map(|x| buffer[(x, row_y)].symbol().to_string())
|
||
.collect::<String>();
|
||
assert!(line.contains('中'), "CJK tail missing: {line:?}");
|
||
assert!(line.contains("e\u{301}"), "combining tail split: {line:?}");
|
||
assert!(line.contains("👩🏽\u{200d}💻"), "ZWJ tail split: {line:?}",);
|
||
let end_cursor = rename_cursor_pos(&state, &rows).expect("end cursor");
|
||
|
||
let _ = state.handle_input(
|
||
&Event::Key(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE)),
|
||
®istry,
|
||
);
|
||
for _ in 0..20 {
|
||
let _ = state.handle_input(
|
||
&Event::Key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)),
|
||
®istry,
|
||
);
|
||
}
|
||
let mut middle_buffer = Buffer::empty(area);
|
||
if narrow {
|
||
render_narrow_rows(&mut middle_buffer, area, &theme, &rows, &mut state);
|
||
} else {
|
||
render_rows(&mut middle_buffer, area, &theme, &rows, &mut state);
|
||
}
|
||
let middle_cursor = rename_cursor_pos(&state, &rows).expect("middle cursor");
|
||
assert_ne!(
|
||
state.rename.as_ref().expect("rename draft").cursor_byte(),
|
||
text.len()
|
||
);
|
||
if !narrow {
|
||
assert_ne!(middle_cursor, end_cursor);
|
||
}
|
||
let prefix_x = (0..width)
|
||
.find(|x| middle_buffer[(*x, row_y)].symbol() == "r")
|
||
.expect("rename prefix");
|
||
let row_rect = state
|
||
.row_rects
|
||
.iter()
|
||
.find(|(row_id, _)| row_id == &id)
|
||
.map(|(_, rect)| *rect)
|
||
.expect("rename row rect");
|
||
let editor_x = prefix_x + RENAME_PREFIX.len() as u16;
|
||
let editor_width = row_rect
|
||
.x
|
||
.saturating_add(row_rect.width)
|
||
.saturating_sub(editor_x);
|
||
let expected_cursor = editor_x + 20u16.min(editor_width.saturating_sub(1));
|
||
assert_eq!(middle_cursor, (expected_cursor, row_y));
|
||
}
|
||
}
|
||
|
||
/// On a 3-row rect the dispatch input
|
||
/// paints a rounded-box chrome so it reads as a real input
|
||
/// field. The text row contains the `❯` prefix.
|
||
#[test]
|
||
fn render_dispatch_paints_rounded_box_chrome() {
|
||
let mut buf = Buffer::empty(Rect::new(0, 0, 60, 3));
|
||
let theme = Theme::current();
|
||
let mut state = DashboardState::new();
|
||
let cursor = render_dispatch(&mut buf, Rect::new(0, 0, 60, 3), &theme, &mut state, None);
|
||
assert!(cursor.is_some(), "dispatch must return a cursor position");
|
||
let content = buf_to_text(&buf);
|
||
for corner in ['\u{256d}', '\u{2570}', '\u{256e}', '\u{256f}'] {
|
||
assert!(
|
||
content.contains(corner),
|
||
"dispatch chrome must paint corner `{corner}`, got: {content:?}",
|
||
);
|
||
}
|
||
assert!(
|
||
content.contains('\u{276F}'),
|
||
"dispatch must paint ❯ prefix inside the box, got: {content:?}",
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn render_search_mode_uses_textarea_cursor_not_text_end() {
|
||
let area = Rect::new(0, 0, 40, 3);
|
||
let mut buffer = Buffer::empty(area);
|
||
let theme = Theme::current();
|
||
let mut state = DashboardState::new();
|
||
state.search_mode = true;
|
||
state.dispatch.set_text("abcdef");
|
||
state.dispatch.set_cursor(2);
|
||
|
||
let cursor = render_dispatch(&mut buffer, area, &theme, &mut state, None)
|
||
.expect("focused search cursor");
|
||
let prefix_x = (0..area.width)
|
||
.find(|x| buffer[(*x, cursor.1)].symbol() == "S")
|
||
.expect("Search prefix");
|
||
assert_eq!(cursor.0, prefix_x + "Search: ".len() as u16 + 2);
|
||
}
|
||
|
||
#[test]
|
||
fn render_search_mode_clips_prefix_and_cursor_at_widths_one_through_nine() {
|
||
let theme = Theme::current();
|
||
for width in 1..=9 {
|
||
let full = Rect::new(0, 0, 14, 1);
|
||
let area = Rect::new(2, 0, width, 1);
|
||
let mut buffer = Buffer::empty(full);
|
||
buffer.set_string(0, 0, "#".repeat(full.width as usize), Style::default());
|
||
let mut state = DashboardState::new();
|
||
state.search_mode = true;
|
||
state.dispatch.set_text("abcdef");
|
||
state.dispatch.set_cursor(2);
|
||
|
||
let cursor = render_dispatch(&mut buffer, area, &theme, &mut state, None)
|
||
.expect("focused narrow search cursor");
|
||
assert!(cursor.0 >= area.x && cursor.0 < area.x + area.width);
|
||
for x in 0..full.width {
|
||
if x < area.x || x >= area.x + area.width {
|
||
assert_eq!(
|
||
buffer[(x, 0)].symbol(),
|
||
"#",
|
||
"width {width} wrote outside at column {x}",
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn render_dispatch_keeps_generic_paste_preview_but_suppresses_image_preview() {
|
||
let area = Rect::new(0, 17, 80, 3);
|
||
let overlay = Rect::new(0, 0, 80, 17);
|
||
let theme = Theme::current();
|
||
let mut state = DashboardState::new();
|
||
state.dispatch.handle_paste("alpha\nbeta\ngamma\ndelta");
|
||
let mut buf = Buffer::empty(Rect::new(0, 0, 80, 20));
|
||
let _ = render_dispatch(&mut buf, area, &theme, &mut state, Some(overlay));
|
||
let text = buf_to_text(&buf);
|
||
assert!(text.contains("alpha"));
|
||
assert!(text.contains("delta"));
|
||
|
||
state.dispatch.set_text("");
|
||
state
|
||
.dispatch
|
||
.insert_image(crate::prompt_images::from_clipboard_data(
|
||
&crate::clipboard::ImageData {
|
||
data: vec![1, 2, 3],
|
||
mime_type: "image/png".into(),
|
||
},
|
||
))
|
||
.unwrap();
|
||
let mut buf = Buffer::empty(Rect::new(0, 0, 80, 20));
|
||
let _ = render_dispatch(&mut buf, area, &theme, &mut state, Some(overlay));
|
||
let text = buf_to_text(&buf);
|
||
assert!(text.contains("Image #1"));
|
||
assert!(!text.contains("Format:"));
|
||
assert!(!text.contains("Preview pending"));
|
||
}
|
||
|
||
/// On a 1-row rect the dispatch falls back to a bare
|
||
/// `❯ {text}` line (no chrome) so the input stays usable on
|
||
/// terminals too short for the box.
|
||
#[test]
|
||
fn render_dispatch_falls_back_to_single_line_on_short_area() {
|
||
let mut buf = Buffer::empty(Rect::new(0, 0, 60, 1));
|
||
let theme = Theme::current();
|
||
let mut state = DashboardState::new();
|
||
let cursor = render_dispatch(&mut buf, Rect::new(0, 0, 60, 1), &theme, &mut state, None);
|
||
assert!(
|
||
cursor.is_some(),
|
||
"single-line fallback must return a cursor"
|
||
);
|
||
let content = buf_to_text(&buf);
|
||
assert!(
|
||
content.contains('\u{276F}'),
|
||
"fallback must paint ❯, got: {content:?}"
|
||
);
|
||
for corner in ['\u{256d}', '\u{2570}'] {
|
||
assert!(
|
||
!content.contains(corner),
|
||
"single-line fallback must NOT paint `{corner}`, got: {content:?}",
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Placeholder reads `Dispatch a new agent` — but only while the
|
||
/// input is UNFOCUSED (overview list holds focus). The dispatch
|
||
/// input always spawns a new session.
|
||
#[test]
|
||
fn render_dispatch_placeholder_paints_only_when_unfocused() {
|
||
// Unfocused input (list focused) → placeholder shows.
|
||
let mut buf = Buffer::empty(Rect::new(0, 0, 80, 3));
|
||
let theme = Theme::current();
|
||
let mut state = DashboardState::new();
|
||
state.list_focused = true;
|
||
let _ = render_dispatch(&mut buf, Rect::new(0, 0, 80, 3), &theme, &mut state, None);
|
||
let content = buf_to_text(&buf);
|
||
assert!(
|
||
content.contains("Dispatch a new agent"),
|
||
"unfocused placeholder missing, got: {content:?}",
|
||
);
|
||
}
|
||
|
||
/// Focused input (the default) suppresses the placeholder — the
|
||
/// visible caret is the affordance; the text area stays clear.
|
||
#[test]
|
||
fn render_dispatch_placeholder_hidden_when_focused() {
|
||
let mut buf = Buffer::empty(Rect::new(0, 0, 80, 3));
|
||
let theme = Theme::current();
|
||
let mut state = DashboardState::new();
|
||
assert!(!state.list_focused, "fresh state must focus the input");
|
||
let cursor = render_dispatch(&mut buf, Rect::new(0, 0, 80, 3), &theme, &mut state, None);
|
||
let content = buf_to_text(&buf);
|
||
assert!(
|
||
!content.contains("Dispatch a new agent"),
|
||
"focused input must not paint the placeholder, got: {content:?}",
|
||
);
|
||
// The `❯` prefix and the caret position survive.
|
||
assert!(
|
||
content.contains('\u{276F}'),
|
||
"prefix must still paint, got: {content:?}",
|
||
);
|
||
assert!(cursor.is_some(), "focused input must report a caret");
|
||
}
|
||
|
||
/// The placeholder stays `Dispatch a new agent` even when a row is
|
||
/// selected — the input never becomes a reply target (selection is
|
||
/// purely the overview navigation cursor; Enter on it OPENS the
|
||
/// agent). This is the regression guard for the "stuck replying to
|
||
/// the same agent" trap.
|
||
#[test]
|
||
fn render_dispatch_placeholder_stays_new_session_when_row_selected() {
|
||
let mut buf = Buffer::empty(Rect::new(0, 0, 80, 3));
|
||
let theme = Theme::current();
|
||
let mut state = DashboardState::new();
|
||
state.focus_row(super::super::state::DashboardRowId::TopLevel(
|
||
crate::app::agent::AgentId(0),
|
||
));
|
||
// Unfocus the input (placeholder only paints while unfocused).
|
||
state.list_focused = true;
|
||
let _ = render_dispatch(&mut buf, Rect::new(0, 0, 80, 3), &theme, &mut state, None);
|
||
let content = buf_to_text(&buf);
|
||
assert!(
|
||
content.contains("Dispatch a new agent"),
|
||
"placeholder must stay new-session even with a row selected, got: {content:?}",
|
||
);
|
||
assert!(
|
||
!content.contains("Reply to"),
|
||
"the dispatch input must never show a reply placeholder, got: {content:?}",
|
||
);
|
||
}
|
||
|
||
/// When a dispatch-validation toast is pending (e.g. the user
|
||
/// pressed Enter on a too-short prompt), the feedback is painted as
|
||
/// a badge on the box's TOP BORDER row — visible even while the
|
||
/// rejected text is still in the input — rather than only as an
|
||
/// empty-input placeholder. The placeholder itself stays the plain
|
||
/// new-session text.
|
||
#[test]
|
||
fn render_dispatch_paints_feedback_badge_on_top_border() {
|
||
let mut buf = Buffer::empty(Rect::new(0, 0, 80, 3));
|
||
let theme = Theme::current();
|
||
let mut state = DashboardState::new();
|
||
// Unfocus the input so the placeholder assertion below stays
|
||
// meaningful (placeholder only paints while unfocused).
|
||
state.list_focused = true;
|
||
state.error_toast = Some("Too short — describe the task (4+ chars)".to_string());
|
||
let _ = render_dispatch(&mut buf, Rect::new(0, 0, 80, 3), &theme, &mut state, None);
|
||
|
||
// Toast text lands on the TOP border row (y == 0).
|
||
let top_row: String = (0..80).map(|x| buf[(x, 0)].symbol().to_string()).collect();
|
||
assert!(
|
||
top_row.contains("Too short"),
|
||
"feedback badge must paint on the top border, got: {top_row:?}",
|
||
);
|
||
// The badge ends before the right `╮` corner (corner preserved).
|
||
assert_eq!(
|
||
buf[(79, 0)].symbol(),
|
||
"\u{256e}",
|
||
"right rounded corner must survive the badge",
|
||
);
|
||
// Placeholder remains the plain new-session text (error is NOT
|
||
// shown inline anymore).
|
||
let content = buf_to_text(&buf);
|
||
assert!(
|
||
content.contains("Dispatch a new agent"),
|
||
"placeholder must remain the new-session text, got: {content:?}",
|
||
);
|
||
}
|
||
|
||
/// The badge paints the toast VERBATIM in the neutral accent colour:
|
||
/// a message that already carries its own glyph (as the `show_toast`
|
||
/// builders produce, e.g. `✓ Theme: …`) keeps that single glyph — no
|
||
/// `✗` is prepended (regression guard for the `✗ ✓ …` doubling) — and
|
||
/// it is NOT painted in the error red.
|
||
#[test]
|
||
fn feedback_badge_renders_verbatim_in_neutral_color() {
|
||
let mut buf = Buffer::empty(Rect::new(0, 0, 80, 3));
|
||
let theme = Theme::current();
|
||
let mut state = DashboardState::new();
|
||
let check = crate::glyphs::check_mark();
|
||
state.error_toast = Some(format!("{check} Theme: Grok Day"));
|
||
let _ = render_dispatch(&mut buf, Rect::new(0, 0, 80, 3), &theme, &mut state, None);
|
||
|
||
let top_row: String = (0..80).map(|x| buf[(x, 0)].symbol().to_string()).collect();
|
||
assert!(
|
||
top_row.contains(&format!("{check} Theme: Grok Day")),
|
||
"badge must paint the message verbatim, got: {top_row:?}",
|
||
);
|
||
assert!(
|
||
!top_row.contains(crate::glyphs::ballot_x()),
|
||
"badge must not prepend a ✗ to a message that already has a glyph, got: {top_row:?}",
|
||
);
|
||
// Neutral colour — accent_user, never the error red.
|
||
let cx = (0..80)
|
||
.find(|&x| buf[(x, 0)].symbol() == check)
|
||
.expect("the ✓ glyph must be painted");
|
||
assert_eq!(
|
||
buf[(cx, 0)].fg,
|
||
theme.accent_user,
|
||
"badge must paint in the neutral accent_user colour (not the error red)",
|
||
);
|
||
}
|
||
|
||
/// Helper for the group-header tests: build a top-level row with
|
||
/// the given id + state, all other fields filled with sensible
|
||
/// defaults. Keeps the per-test setup compact.
|
||
fn header_test_row(id: u32, state: RowState, label: &str) -> DashboardRow {
|
||
use crate::app::agent::AgentId;
|
||
DashboardRow {
|
||
id: DashboardRowId::TopLevel(AgentId(id as usize)),
|
||
label: label.to_string(),
|
||
subtitle: None,
|
||
state,
|
||
activity: None,
|
||
secondary_line: None,
|
||
cwd_display: String::new(),
|
||
cwd: std::path::PathBuf::from("/tmp"),
|
||
last_change_at: std::time::SystemTime::now(),
|
||
pinned: false,
|
||
is_active: false,
|
||
badges: Vec::new(),
|
||
context_pct: None,
|
||
indent: 0,
|
||
parent_label: None,
|
||
is_more_placeholder: false,
|
||
more_count: 0,
|
||
}
|
||
}
|
||
|
||
/// A collapsed state section keeps its header (with the true count)
|
||
/// but hides its rows; other sections are unaffected.
|
||
#[test]
|
||
fn build_dashboard_lines_hides_collapsed_state_section() {
|
||
use std::collections::HashSet;
|
||
let rows = vec![
|
||
header_test_row(1, RowState::Working, "a"),
|
||
header_test_row(2, RowState::Working, "b"),
|
||
header_test_row(3, RowState::Idle, "c"),
|
||
];
|
||
|
||
// Nothing collapsed → both Working rows present.
|
||
let none: HashSet<SectionKey> = HashSet::new();
|
||
let lines =
|
||
build_dashboard_lines(&rows, Grouping::State, &Filter::None, &none, false, false);
|
||
let working_rows = lines
|
||
.iter()
|
||
.filter(|l| matches!(l, DashboardLine::Row(r) if r.state == RowState::Working))
|
||
.count();
|
||
assert_eq!(working_rows, 2, "expanded Working section shows both rows");
|
||
|
||
// Collapse Working → header stays (count 2), rows hidden.
|
||
let mut collapsed = HashSet::new();
|
||
collapsed.insert(SectionKey::State(RowState::Working));
|
||
let lines = build_dashboard_lines(
|
||
&rows,
|
||
Grouping::State,
|
||
&Filter::None,
|
||
&collapsed,
|
||
false,
|
||
false,
|
||
);
|
||
assert!(
|
||
lines.iter().any(|l| matches!(
|
||
l,
|
||
DashboardLine::Header { state, count } if *state == RowState::Working && *count == 2
|
||
)),
|
||
"collapsed Working header must still render with its true count",
|
||
);
|
||
let working_rows = lines
|
||
.iter()
|
||
.filter(|l| matches!(l, DashboardLine::Row(r) if r.state == RowState::Working))
|
||
.count();
|
||
assert_eq!(working_rows, 0, "collapsed Working section hides its rows");
|
||
// The Idle section is unaffected.
|
||
let idle_rows = lines
|
||
.iter()
|
||
.filter(|l| matches!(l, DashboardLine::Row(r) if r.state == RowState::Idle))
|
||
.count();
|
||
assert_eq!(idle_rows, 1, "other sections stay expanded");
|
||
}
|
||
|
||
/// A collapsed "Pinned" section keeps its header but hides the pinned
|
||
/// rows (grouping ON).
|
||
#[test]
|
||
fn build_dashboard_lines_hides_collapsed_pinned_section() {
|
||
use std::collections::HashSet;
|
||
let mut pinned_row = header_test_row(1, RowState::Idle, "pinned");
|
||
pinned_row.pinned = true;
|
||
let rows = vec![pinned_row, header_test_row(2, RowState::Working, "other")];
|
||
|
||
let mut collapsed = HashSet::new();
|
||
collapsed.insert(SectionKey::Pinned);
|
||
let lines = build_dashboard_lines(
|
||
&rows,
|
||
Grouping::State,
|
||
&Filter::None,
|
||
&collapsed,
|
||
false,
|
||
false,
|
||
);
|
||
assert!(
|
||
lines
|
||
.iter()
|
||
.any(|l| matches!(l, DashboardLine::PinnedHeader { count } if *count == 1)),
|
||
"collapsed Pinned header must still render",
|
||
);
|
||
// The pinned row is hidden; the (non-pinned) Working row remains.
|
||
let visible_rows = lines
|
||
.iter()
|
||
.filter(|l| matches!(l, DashboardLine::Row(_)))
|
||
.count();
|
||
assert_eq!(visible_rows, 1, "only the non-pinned row stays visible");
|
||
}
|
||
|
||
/// An idle row last active `secs_ago` seconds in the past.
|
||
fn aged_idle_row(id: u32, secs_ago: u64) -> DashboardRow {
|
||
let mut r = header_test_row(id, RowState::Idle, "idle");
|
||
r.last_change_at = std::time::SystemTime::now()
|
||
.checked_sub(std::time::Duration::from_secs(secs_ago))
|
||
.expect("test clock underflow");
|
||
r
|
||
}
|
||
|
||
/// Seconds clearly outside the 1h Idle freshness window.
|
||
const OLD_SECS: u64 = 2 * 60 * 60;
|
||
|
||
fn idle_row_count(lines: &[DashboardLine]) -> usize {
|
||
lines
|
||
.iter()
|
||
.filter(|l| matches!(l, DashboardLine::Row(r) if r.state == RowState::Idle))
|
||
.count()
|
||
}
|
||
|
||
fn overflow_of(lines: &[DashboardLine]) -> Option<(usize, bool)> {
|
||
lines.iter().find_map(|l| match l {
|
||
DashboardLine::IdleOverflow { hidden, expanded } => Some((*hidden, *expanded)),
|
||
_ => None,
|
||
})
|
||
}
|
||
|
||
/// Old idle agents beyond `MAX_VISIBLE_IDLE` fold into the overflow
|
||
/// row; the header still reports the true total.
|
||
#[test]
|
||
fn idle_cap_folds_old_agents() {
|
||
use std::collections::HashSet;
|
||
// (MAX_VISIBLE_IDLE + 3) OLD idle agents → cap shown, 3 folded.
|
||
let total = MAX_VISIBLE_IDLE as u32 + 3;
|
||
let rows: Vec<DashboardRow> = (0..total).map(|i| aged_idle_row(i, OLD_SECS)).collect();
|
||
let none: HashSet<SectionKey> = HashSet::new();
|
||
let lines =
|
||
build_dashboard_lines(&rows, Grouping::State, &Filter::None, &none, false, false);
|
||
|
||
assert_eq!(
|
||
idle_row_count(&lines),
|
||
MAX_VISIBLE_IDLE,
|
||
"caps to MAX_VISIBLE_IDLE"
|
||
);
|
||
assert_eq!(
|
||
overflow_of(&lines),
|
||
Some((total as usize - MAX_VISIBLE_IDLE, false)),
|
||
"overflow row reports the folded count, not expanded",
|
||
);
|
||
// Header still shows the TRUE total, not the visible count.
|
||
assert!(
|
||
lines.iter().any(|l| matches!(
|
||
l,
|
||
DashboardLine::Header { state, count } if *state == RowState::Idle && *count == total as usize
|
||
)),
|
||
"Idle header keeps the true total count",
|
||
);
|
||
}
|
||
|
||
/// Recent idle agents are never folded, even beyond the count cap —
|
||
/// the freshness window keeps a burst of new sessions visible.
|
||
#[test]
|
||
fn idle_cap_keeps_recent_beyond_count() {
|
||
use std::collections::HashSet;
|
||
// 9 RECENT idle agents (just now) → all shown, no overflow.
|
||
let rows: Vec<DashboardRow> = (0..9).map(|i| aged_idle_row(i, 0)).collect();
|
||
let none: HashSet<SectionKey> = HashSet::new();
|
||
let lines =
|
||
build_dashboard_lines(&rows, Grouping::State, &Filter::None, &none, false, false);
|
||
assert_eq!(
|
||
idle_row_count(&lines),
|
||
9,
|
||
"all recent idle agents stay visible"
|
||
);
|
||
assert_eq!(overflow_of(&lines), None, "no overflow when nothing is old");
|
||
}
|
||
|
||
/// Mixed freshness: recent agents always show; the oldest beyond the
|
||
/// cap fold. Rows arrive recent-first (matching the real sort).
|
||
#[test]
|
||
fn idle_cap_mixes_recent_and_old() {
|
||
use std::collections::HashSet;
|
||
// 4 recent + (cap - 1) old = cap + 3 total. base_limit = max(cap, 4) = cap → 3 folded.
|
||
let total = MAX_VISIBLE_IDLE as u32 + 3;
|
||
let mut rows: Vec<DashboardRow> = (0..4).map(|i| aged_idle_row(i, 0)).collect();
|
||
rows.extend((4..total).map(|i| aged_idle_row(i, OLD_SECS)));
|
||
let none: HashSet<SectionKey> = HashSet::new();
|
||
let lines =
|
||
build_dashboard_lines(&rows, Grouping::State, &Filter::None, &none, false, false);
|
||
assert_eq!(
|
||
idle_row_count(&lines),
|
||
MAX_VISIBLE_IDLE,
|
||
"shows cap (incl. all 4 recent)"
|
||
);
|
||
assert_eq!(
|
||
overflow_of(&lines),
|
||
Some((total as usize - MAX_VISIBLE_IDLE, false))
|
||
);
|
||
}
|
||
|
||
/// `idle_show_all` reveals every agent and flips the overflow row to
|
||
/// the "show fewer" (expanded) state.
|
||
#[test]
|
||
fn idle_cap_show_all_reveals_all() {
|
||
use std::collections::HashSet;
|
||
let total = MAX_VISIBLE_IDLE as u32 + 3;
|
||
let rows: Vec<DashboardRow> = (0..total).map(|i| aged_idle_row(i, OLD_SECS)).collect();
|
||
let none: HashSet<SectionKey> = HashSet::new();
|
||
let lines =
|
||
build_dashboard_lines(&rows, Grouping::State, &Filter::None, &none, true, false);
|
||
assert_eq!(
|
||
idle_row_count(&lines),
|
||
total as usize,
|
||
"show-all reveals every idle agent"
|
||
);
|
||
assert_eq!(
|
||
overflow_of(&lines),
|
||
Some((total as usize - MAX_VISIBLE_IDLE, true)),
|
||
"overflow stays as a 'show fewer' affordance when expanded",
|
||
);
|
||
}
|
||
|
||
/// Folding only kicks in at MIN_IDLE_FOLD (2): a single over-cap row
|
||
/// is shown rather than hidden behind a same-height overflow row.
|
||
#[test]
|
||
fn idle_cap_does_not_fold_a_single_row() {
|
||
use std::collections::HashSet;
|
||
let none: HashSet<SectionKey> = HashSet::new();
|
||
// MAX_VISIBLE_IDLE + 1 old → would hide 1 → no fold.
|
||
let rows: Vec<DashboardRow> = (0..MAX_VISIBLE_IDLE as u32 + 1)
|
||
.map(|i| aged_idle_row(i, OLD_SECS))
|
||
.collect();
|
||
let lines =
|
||
build_dashboard_lines(&rows, Grouping::State, &Filter::None, &none, false, false);
|
||
assert_eq!(
|
||
idle_row_count(&lines),
|
||
MAX_VISIBLE_IDLE + 1,
|
||
"1 over cap is not folded"
|
||
);
|
||
assert_eq!(overflow_of(&lines), None);
|
||
// MAX_VISIBLE_IDLE + 2 old → hides 2 → folds.
|
||
let rows: Vec<DashboardRow> = (0..MAX_VISIBLE_IDLE as u32 + 2)
|
||
.map(|i| aged_idle_row(i, OLD_SECS))
|
||
.collect();
|
||
let lines =
|
||
build_dashboard_lines(&rows, Grouping::State, &Filter::None, &none, false, false);
|
||
assert_eq!(overflow_of(&lines), Some((2, false)), "2 over cap fold");
|
||
}
|
||
|
||
/// The cap is suppressed under an active filter — when you search,
|
||
/// every match shows (no folding).
|
||
#[test]
|
||
fn idle_cap_disabled_under_filter() {
|
||
use std::collections::HashSet;
|
||
let total = MAX_VISIBLE_IDLE as u32 + 3;
|
||
let rows: Vec<DashboardRow> = (0..total).map(|i| aged_idle_row(i, OLD_SECS)).collect();
|
||
let none: HashSet<SectionKey> = HashSet::new();
|
||
let lines = build_dashboard_lines(
|
||
&rows,
|
||
Grouping::State,
|
||
&Filter::Substring("idle".into()),
|
||
&none,
|
||
false,
|
||
false,
|
||
);
|
||
assert_eq!(
|
||
overflow_of(&lines),
|
||
None,
|
||
"no fold under a substring filter"
|
||
);
|
||
|
||
// Search mode with an EMPTY query keeps `Filter::None`, but folding
|
||
// must still be suspended (search is active) — the doc'd rule.
|
||
let lines = build_dashboard_lines(
|
||
&rows,
|
||
Grouping::State,
|
||
&Filter::None,
|
||
&none,
|
||
false,
|
||
/* search_active */ true,
|
||
);
|
||
assert_eq!(
|
||
overflow_of(&lines),
|
||
None,
|
||
"no fold while search mode is active even with an empty query",
|
||
);
|
||
assert_eq!(
|
||
idle_row_count(&lines),
|
||
total as usize,
|
||
"every idle agent shows while searching",
|
||
);
|
||
}
|
||
|
||
/// A collapsed Idle section hides its rows (and the overflow) via the
|
||
/// section-collapse path — the cap and collapse don't double up.
|
||
#[test]
|
||
fn idle_cap_yields_to_section_collapse() {
|
||
use std::collections::HashSet;
|
||
let rows: Vec<DashboardRow> = (0..9).map(|i| aged_idle_row(i, OLD_SECS)).collect();
|
||
let mut collapsed = HashSet::new();
|
||
collapsed.insert(SectionKey::State(RowState::Idle));
|
||
let lines = build_dashboard_lines(
|
||
&rows,
|
||
Grouping::State,
|
||
&Filter::None,
|
||
&collapsed,
|
||
false,
|
||
false,
|
||
);
|
||
assert_eq!(idle_row_count(&lines), 0, "collapsed Idle hides all rows");
|
||
assert_eq!(overflow_of(&lines), None, "no overflow row under collapse");
|
||
}
|
||
|
||
/// The overflow row is a keyboard cursor target.
|
||
#[test]
|
||
fn idle_overflow_is_focusable_when_capped() {
|
||
let rows: Vec<DashboardRow> = (0..MAX_VISIBLE_IDLE as u32 + 3)
|
||
.map(|i| aged_idle_row(i, OLD_SECS))
|
||
.collect();
|
||
let none = std::collections::HashSet::new();
|
||
let f = focusables(&rows, Grouping::State, &Filter::None, &none, false, false);
|
||
assert!(
|
||
f.iter().any(|x| matches!(x, Focusable::IdleOverflow)),
|
||
"focusables must include the overflow toggle when capped",
|
||
);
|
||
// And the hidden idle rows are NOT focusable (capped away).
|
||
let row_targets = f.iter().filter(|x| matches!(x, Focusable::Row(_))).count();
|
||
assert_eq!(
|
||
row_targets, MAX_VISIBLE_IDLE,
|
||
"only the visible idle rows are focusable"
|
||
);
|
||
}
|
||
|
||
/// Pinned top-level agents are lifted into a dedicated "Pinned" section
|
||
/// at the very top — above the state groups — so a pinned (e.g. idle)
|
||
/// agent reads as pinned rather than landing under its state header. The
|
||
/// pinned rows are NOT counted in the state-group headers.
|
||
#[test]
|
||
fn render_rows_emits_pinned_section_at_top() {
|
||
let mut buf = Buffer::empty(Rect::new(0, 0, 80, 30));
|
||
let mut state = DashboardState::new();
|
||
assert_eq!(state.grouping, Grouping::State);
|
||
// Input is pre-sorted (pinned first), as `sort_rows` guarantees.
|
||
let mut pinned = header_test_row(1, RowState::Idle, "pinned idle row");
|
||
pinned.pinned = true;
|
||
let rows = vec![
|
||
pinned,
|
||
header_test_row(2, RowState::Working, "working row"),
|
||
header_test_row(3, RowState::Idle, "idle row"),
|
||
];
|
||
let theme = Theme::current();
|
||
render_rows(&mut buf, Rect::new(0, 0, 80, 30), &theme, &rows, &mut state);
|
||
let content = buf_to_text(&buf);
|
||
|
||
// A dedicated "Pinned" section header with a count of 1.
|
||
assert!(
|
||
content.contains("Pinned 1"),
|
||
"missing `Pinned` section header, got: {content:?}",
|
||
);
|
||
// It sits ABOVE the state groups.
|
||
let idx_pinned = content.find("Pinned").expect("Pinned header present");
|
||
let idx_working = content.find("Working").expect("Working header present");
|
||
assert!(
|
||
idx_pinned < idx_working,
|
||
"the Pinned section must be at the top, got: {content:?}",
|
||
);
|
||
// The remaining (unpinned) idle row still gets its own `Idle 1` header
|
||
// — the pinned idle row is NOT folded into it.
|
||
assert!(
|
||
content.contains("Idle 1"),
|
||
"unpinned idle row must keep its own `Idle 1` header, got: {content:?}",
|
||
);
|
||
assert!(
|
||
content.contains("pinned idle row"),
|
||
"pinned row label renders"
|
||
);
|
||
}
|
||
|
||
/// With grouping OFF (Directory) the "Pinned" text header is suppressed;
|
||
/// instead a textless divider (a horizontal rule, no label) separates the
|
||
/// pinned block from the rest. No state headers are emitted either.
|
||
#[test]
|
||
fn render_rows_groups_off_uses_divider_not_pinned_header() {
|
||
let mut buf = Buffer::empty(Rect::new(0, 0, 80, 30));
|
||
let mut state = DashboardState::new();
|
||
state.grouping = Grouping::Directory; // groups off (Ctrl+G)
|
||
let mut pinned = header_test_row(1, RowState::Idle, "pinned row");
|
||
pinned.pinned = true;
|
||
let rows = vec![pinned, header_test_row(2, RowState::Working, "working row")];
|
||
let theme = Theme::current();
|
||
render_rows(&mut buf, Rect::new(0, 0, 80, 30), &theme, &rows, &mut state);
|
||
let content = buf_to_text(&buf);
|
||
|
||
// No labelled "Pinned"/state headers in groups-off mode.
|
||
assert!(
|
||
!content.contains("Pinned"),
|
||
"the `Pinned` text header must be hidden when grouping is off, got: {content:?}",
|
||
);
|
||
assert!(
|
||
!content.contains("Working "),
|
||
"no state headers when grouping is off, got: {content:?}",
|
||
);
|
||
// A horizontal-rule divider separates the pinned block from the rest.
|
||
assert!(
|
||
content.contains('\u{2500}'),
|
||
"a divider rule must separate pinned from non-pinned, got: {content:?}",
|
||
);
|
||
// The pinned row still renders above the divider, which is above the rest.
|
||
let idx_pinned = content.find("pinned row").expect("pinned row present");
|
||
let idx_rule = content.find('\u{2500}').expect("divider present");
|
||
let idx_working = content.find("working row").expect("working row present");
|
||
assert!(
|
||
idx_pinned < idx_rule && idx_rule < idx_working,
|
||
"order must be pinned → divider → rest, got: {content:?}",
|
||
);
|
||
}
|
||
|
||
/// State group headers are emitted at every
|
||
/// top-level state transition when grouping is `State`. The
|
||
/// renderer must paint the headers in NeedsInput → Working →
|
||
/// Idle → Completed → Failed order (matching
|
||
/// `RowState::group_priority`).
|
||
///
|
||
/// Header chrome now uses Option A
|
||
/// (` ● Label (N)`): a 2-col indent, a state-coloured dot, then
|
||
/// the label + count in `gray_dim`. The previous full-row
|
||
/// `── Label (N) ────────────────` chrome was dropped (the
|
||
/// trailing dashes felt visually obnoxious — user complaint).
|
||
#[test]
|
||
fn render_rows_emits_group_headers_in_state_order() {
|
||
// Rows are 3 cells tall, headers 2 cells; 5 of each
|
||
// needs 25 cells of vertical room.
|
||
let mut buf = Buffer::empty(Rect::new(0, 0, 80, 30));
|
||
let mut state = DashboardState::new();
|
||
assert_eq!(state.grouping, Grouping::State);
|
||
let rows = vec![
|
||
header_test_row(1, RowState::NeedsInput, "needs-input row"),
|
||
header_test_row(2, RowState::Working, "working row"),
|
||
header_test_row(3, RowState::Idle, "idle row"),
|
||
header_test_row(4, RowState::Completed, "completed row"),
|
||
header_test_row(5, RowState::Failed, "failed row"),
|
||
];
|
||
let theme = Theme::current();
|
||
render_rows(&mut buf, Rect::new(0, 0, 80, 30), &theme, &rows, &mut state);
|
||
let content = buf_to_text(&buf);
|
||
// Group labels: Awaiting / Working / Idle / Done / Failed.
|
||
for label in ["Awaiting", "Working", "Idle", "Done", "Failed"] {
|
||
assert!(
|
||
content.contains(label),
|
||
"missing group header `{label}`, got: {content:?}",
|
||
);
|
||
}
|
||
// Headers appear in the canonical priority order.
|
||
let idx_aw = content.find("Awaiting").expect("Awaiting present");
|
||
let idx_wk = content.find("Working").expect("Working present");
|
||
let idx_id = content.find("Idle").expect("Idle present");
|
||
let idx_dn = content.find("Done").expect("Done present");
|
||
let idx_fl = content.find("Failed").expect("Failed present");
|
||
assert!(idx_aw < idx_wk, "Awaiting must precede Working");
|
||
assert!(idx_wk < idx_id, "Working must precede Idle");
|
||
assert!(idx_id < idx_dn, "Idle must precede Done");
|
||
assert!(idx_dn < idx_fl, "Done must precede Failed");
|
||
// Group headers read `Label N ──────…`.
|
||
assert!(
|
||
content.contains("Awaiting 1"),
|
||
"header label + count missing or malformed, got: {content:?}",
|
||
);
|
||
assert!(
|
||
content.contains('\u{2500}'),
|
||
"header must paint the trailing horizontal rule, got: {content:?}",
|
||
);
|
||
assert!(
|
||
!content.contains("Awaiting ("),
|
||
"old parenthesised `Awaiting (1)` form must be gone, got: {content:?}",
|
||
);
|
||
// Each row's label still renders.
|
||
for label in [
|
||
"needs-input row",
|
||
"working row",
|
||
"idle row",
|
||
"completed row",
|
||
"failed row",
|
||
] {
|
||
assert!(
|
||
content.contains(label),
|
||
"missing row label `{label}`, got: {content:?}",
|
||
);
|
||
}
|
||
}
|
||
|
||
/// The list scrollbar is a thick `█` thumb overlaid on the right edge,
|
||
/// and it does NOT reserve a column — the row content is byte-for-byte
|
||
/// identical whether or not the scrollbar shows (no layout shift).
|
||
#[test]
|
||
fn render_rows_scrollbar_is_thick_overlay_without_layout_shift() {
|
||
let theme = Theme::current();
|
||
// 6 working rows → 1 header (2 cells) + 6 rows (3 cells) = 20 cells.
|
||
let rows: Vec<_> = (0..6)
|
||
.map(|i| header_test_row(i, RowState::Working, "working task"))
|
||
.collect();
|
||
let w = 60u16;
|
||
|
||
// Tall viewport → everything fits, no scrollbar.
|
||
let mut buf_fit = Buffer::empty(Rect::new(0, 0, w, 24));
|
||
let mut state_fit = DashboardState::new();
|
||
render_rows(
|
||
&mut buf_fit,
|
||
Rect::new(0, 0, w, 24),
|
||
&theme,
|
||
&rows,
|
||
&mut state_fit,
|
||
);
|
||
|
||
// Short viewport → overflow, scrollbar overlays.
|
||
let h = 8u16;
|
||
let mut buf_scroll = Buffer::empty(Rect::new(0, 0, w, h));
|
||
let mut state_scroll = DashboardState::new();
|
||
render_rows(
|
||
&mut buf_scroll,
|
||
Rect::new(0, 0, w, h),
|
||
&theme,
|
||
&rows,
|
||
&mut state_scroll,
|
||
);
|
||
|
||
// The thick `█` thumb is painted on the rightmost column.
|
||
let last_x = w - 1;
|
||
let has_thumb = (0..h).any(|y| buf_scroll[(last_x, y)].symbol() == "\u{2588}");
|
||
assert!(has_thumb, "scrollbar thumb (█) must overlay the right edge");
|
||
// Old thin `│`-only thumb is gone.
|
||
let thin_only = (0..h).all(|y| buf_scroll[(last_x, y)].symbol() != "\u{2588}");
|
||
assert!(!thin_only, "thumb must be the thick block glyph");
|
||
|
||
// No layout shift: every content column (all but the overlaid right
|
||
// edge) matches the no-scrollbar render across the visible top.
|
||
for y in 0..h {
|
||
for x in 0..(w - 1) {
|
||
assert_eq!(
|
||
buf_scroll[(x, y)].symbol(),
|
||
buf_fit[(x, y)].symbol(),
|
||
"content shifted at ({x},{y}) when the scrollbar appeared",
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Row layout is two visual lines:
|
||
///
|
||
/// ```text
|
||
/// ◆ who are you? 4s <- title row
|
||
/// Responding <- secondary row
|
||
/// ```
|
||
///
|
||
/// - Col 0: selection marker (thin bar `▏` when selected, space
|
||
/// otherwise). The bar spans the full content height of a
|
||
/// selected row (title + secondary lines). No hover glyph.
|
||
/// - Col 1: 1-col gap.
|
||
/// - Col 2: state icon.
|
||
/// - Col 3: 1-col gap.
|
||
/// - Col 4: label starts on row 0, and the secondary text starts
|
||
/// at the same column on row 1.
|
||
/// - Right edge: age column (`{n}s/m/h`).
|
||
#[test]
|
||
fn render_row_two_line_layout_paints_title_and_secondary() {
|
||
use std::path::PathBuf;
|
||
use std::time::SystemTime;
|
||
let mut buf = Buffer::empty(Rect::new(0, 0, 100, 2));
|
||
let theme = Theme::current();
|
||
let mut state = DashboardState::new();
|
||
state.spinner_tick = 8; // → dot_spinner_frames()[2] = `⸬`.
|
||
let row = DashboardRow {
|
||
id: DashboardRowId::TopLevel(crate::app::agent::AgentId(1)),
|
||
label: "who are you?".to_string(),
|
||
subtitle: None,
|
||
state: RowState::Working,
|
||
activity: Some("Responding".to_string()),
|
||
secondary_line: Some("Responding".to_string()),
|
||
cwd_display: String::new(),
|
||
cwd: PathBuf::from("/tmp"),
|
||
last_change_at: SystemTime::now(),
|
||
pinned: false,
|
||
is_active: false,
|
||
badges: Vec::new(),
|
||
context_pct: None,
|
||
indent: 0,
|
||
parent_label: None,
|
||
is_more_placeholder: false,
|
||
more_count: 0,
|
||
};
|
||
render_row(&mut buf, Rect::new(0, 0, 100, 2), &theme, &row, &state);
|
||
|
||
// Title row.
|
||
assert_eq!(
|
||
buf[(0, 0)].symbol(),
|
||
" ",
|
||
"row 0 col 0 must be marker space"
|
||
);
|
||
assert_eq!(
|
||
buf[(1, 0)].symbol(),
|
||
" ",
|
||
"row 0 col 1 must be the post-marker gap"
|
||
);
|
||
assert_eq!(
|
||
buf[(2, 0)].symbol(),
|
||
"\u{2e2c}",
|
||
"row 0 col 2 must be the spinner glyph `⸬` at tick=8",
|
||
);
|
||
assert_eq!(
|
||
buf[(3, 0)].symbol(),
|
||
" ",
|
||
"row 0 col 3 must be the post-icon gap"
|
||
);
|
||
assert_eq!(
|
||
buf[(4, 0)].symbol(),
|
||
"w",
|
||
"row 0 col 4 must start the label"
|
||
);
|
||
|
||
// Secondary row — `Responding` starts at the same column as
|
||
// the title's label start (col 4).
|
||
assert_eq!(
|
||
buf[(4, 1)].symbol(),
|
||
"R",
|
||
"row 1 col 4 must start the secondary text",
|
||
);
|
||
|
||
// Age column right-aligns in the last few cells of row 0.
|
||
let mut saw_s_in_age_zone = false;
|
||
for x in (100 - 8)..100 {
|
||
if buf[(x, 0)].symbol() == "s" {
|
||
saw_s_in_age_zone = true;
|
||
break;
|
||
}
|
||
}
|
||
let content = buf_to_text(&buf);
|
||
assert!(
|
||
saw_s_in_age_zone,
|
||
"age column must paint a duration ending in `s`, got: {content:?}",
|
||
);
|
||
}
|
||
|
||
/// The SELECTED row brightens its secondary text from
|
||
/// `gray_dim` to `text_secondary` so the user can read what
|
||
/// the agent is doing without leaving the dashboard. The
|
||
/// unselected baseline stays dim — the row's metadata tail
|
||
/// shouldn't compete with the title for attention. Pins
|
||
/// both states in one test so a regression that flipped
|
||
/// either direction would fail.
|
||
#[test]
|
||
fn render_row_selected_brightens_secondary_text() {
|
||
use std::path::PathBuf;
|
||
use std::time::SystemTime;
|
||
let theme = Theme::current();
|
||
let id = DashboardRowId::TopLevel(crate::app::agent::AgentId(7));
|
||
let row = DashboardRow {
|
||
id: id.clone(),
|
||
label: "investigate caching".to_string(),
|
||
subtitle: None,
|
||
state: RowState::Working,
|
||
activity: Some("Responding".to_string()),
|
||
// The 'R' in "Responding" lives at column 4 (matches
|
||
// `render_row_two_line_layout_paints_title_and_secondary`),
|
||
// so we sample fg at (4, 1).
|
||
secondary_line: Some("Responding".to_string()),
|
||
cwd_display: String::new(),
|
||
cwd: PathBuf::from("/tmp"),
|
||
last_change_at: SystemTime::now(),
|
||
pinned: false,
|
||
is_active: false,
|
||
badges: Vec::new(),
|
||
context_pct: None,
|
||
indent: 0,
|
||
parent_label: None,
|
||
is_more_placeholder: false,
|
||
more_count: 0,
|
||
};
|
||
|
||
// Unselected → dim secondary.
|
||
let mut buf = Buffer::empty(Rect::new(0, 0, 100, 2));
|
||
let state_unselected = DashboardState::new();
|
||
render_row(
|
||
&mut buf,
|
||
Rect::new(0, 0, 100, 2),
|
||
&theme,
|
||
&row,
|
||
&state_unselected,
|
||
);
|
||
assert_eq!(
|
||
buf[(4, 1)].fg,
|
||
theme.gray_dim,
|
||
"unselected row's secondary must paint in `gray_dim`",
|
||
);
|
||
|
||
// Selected → brighter secondary.
|
||
let mut buf = Buffer::empty(Rect::new(0, 0, 100, 2));
|
||
let mut state_selected = DashboardState::new();
|
||
state_selected.focus_row(id);
|
||
render_row(
|
||
&mut buf,
|
||
Rect::new(0, 0, 100, 2),
|
||
&theme,
|
||
&row,
|
||
&state_selected,
|
||
);
|
||
assert_eq!(
|
||
buf[(4, 1)].fg,
|
||
theme.text_secondary,
|
||
"selected row's secondary must brighten to `text_secondary` \
|
||
so the response line is readable",
|
||
);
|
||
}
|
||
|
||
/// A `NeedsInput` row: the bullet is yellow (and blinks to a dimmer
|
||
/// yellow), the `[needs input]` badge is suppressed, and the `Pending:`
|
||
/// subtitle prefix is painted yellow.
|
||
#[test]
|
||
fn render_row_needs_input_yellow_blink_no_badge_pending_prefix() {
|
||
use std::path::PathBuf;
|
||
use std::time::SystemTime;
|
||
let theme = Theme::current();
|
||
let make_row = || DashboardRow {
|
||
id: DashboardRowId::TopLevel(crate::app::agent::AgentId(1)),
|
||
label: "ask me".to_string(),
|
||
subtitle: None,
|
||
state: RowState::NeedsInput,
|
||
activity: None,
|
||
secondary_line: Some("Pending: plan approval".to_string()),
|
||
cwd_display: String::new(),
|
||
cwd: PathBuf::from("/tmp"),
|
||
last_change_at: SystemTime::now(),
|
||
pinned: false,
|
||
is_active: false,
|
||
badges: vec![RowBadge::NeedsInput],
|
||
context_pct: None,
|
||
indent: 0,
|
||
parent_label: None,
|
||
is_more_placeholder: false,
|
||
more_count: 0,
|
||
};
|
||
let render = |tick: u64| {
|
||
let mut buf = Buffer::empty(Rect::new(0, 0, 100, 2));
|
||
let mut state = DashboardState::new();
|
||
state.spinner_tick = tick;
|
||
render_row(
|
||
&mut buf,
|
||
Rect::new(0, 0, 100, 2),
|
||
&theme,
|
||
&make_row(),
|
||
&state,
|
||
);
|
||
buf
|
||
};
|
||
|
||
// Bright phase (tick 0): the bullet is full yellow.
|
||
let bright = render(0);
|
||
assert_eq!(
|
||
bright[(2, 0)].symbol(),
|
||
crate::glyphs::diamond_filled(),
|
||
"bullet glyph"
|
||
);
|
||
assert_eq!(
|
||
bright[(2, 0)].fg,
|
||
theme.warning,
|
||
"bright needs-input bullet must be yellow (warning)",
|
||
);
|
||
|
||
// No `[needs input]` badge on the title row.
|
||
let mut title = String::new();
|
||
for x in 0..bright.area.width {
|
||
title.push_str(bright[(x, 0)].symbol());
|
||
}
|
||
assert!(
|
||
!title.contains("needs input"),
|
||
"needs-input badge must be hidden, got: {title:?}",
|
||
);
|
||
|
||
// `Pending:` subtitle prefix is painted yellow (the rest of the
|
||
// subtitle is painted separately in the dim secondary colour).
|
||
assert_eq!(
|
||
bright[(4, 1)].symbol(),
|
||
"P",
|
||
"secondary starts with `Pending:`"
|
||
);
|
||
assert_eq!(
|
||
bright[(4, 1)].fg,
|
||
theme.warning,
|
||
"`Pending:` prefix must be yellow",
|
||
);
|
||
|
||
// The dim blink phase fades the bullet (only assertable when the
|
||
// theme supports blending; non-truecolor falls back to full yellow).
|
||
if crate::render::color::blend_color(theme.bg_base, theme.warning, 0.5).is_some() {
|
||
let dim = render(NEEDS_INPUT_BLINK_DIVISOR);
|
||
assert_ne!(
|
||
dim[(2, 0)].fg,
|
||
theme.warning,
|
||
"dim blink phase must fade the bullet away from full yellow",
|
||
);
|
||
}
|
||
}
|
||
|
||
/// The `New session #<id>` fallback title is painted two-tone: the
|
||
/// `New session` head in the primary colour and the ` #id` suffix dim.
|
||
#[test]
|
||
fn render_row_new_session_fallback_label_is_two_tone() {
|
||
use std::path::PathBuf;
|
||
use std::time::SystemTime;
|
||
let mut buf = Buffer::empty(Rect::new(0, 0, 100, 2));
|
||
let theme = Theme::current();
|
||
let state = DashboardState::new();
|
||
let row = DashboardRow {
|
||
id: DashboardRowId::TopLevel(crate::app::agent::AgentId(1)),
|
||
label: "New session #abc12345".to_string(),
|
||
subtitle: None,
|
||
state: RowState::Idle,
|
||
activity: None,
|
||
secondary_line: None,
|
||
cwd_display: String::new(),
|
||
cwd: PathBuf::from("/tmp"),
|
||
last_change_at: SystemTime::now(),
|
||
pinned: false,
|
||
is_active: false,
|
||
badges: Vec::new(),
|
||
context_pct: None,
|
||
indent: 0,
|
||
parent_label: None,
|
||
is_more_placeholder: false,
|
||
more_count: 0,
|
||
};
|
||
render_row(&mut buf, Rect::new(0, 0, 100, 2), &theme, &row, &state);
|
||
|
||
// Title starts at col 4: "New session" (11 chars, cols 4..15) then
|
||
// " #abc12345" (suffix from col 15).
|
||
assert_eq!(
|
||
buf[(4, 0)].symbol(),
|
||
"N",
|
||
"title head starts with `New session`"
|
||
);
|
||
assert_eq!(
|
||
buf[(4, 0)].fg,
|
||
theme.text_primary,
|
||
"`New session` head must use the primary colour",
|
||
);
|
||
// The `#` of the suffix sits at col 16 and must be dim.
|
||
assert_eq!(buf[(16, 0)].symbol(), "#", "suffix must start with `#`");
|
||
assert_eq!(
|
||
buf[(16, 0)].fg,
|
||
theme.gray_dim,
|
||
"the `#id` suffix must be dim gray",
|
||
);
|
||
}
|
||
|
||
/// Group header (section title) leads with a disclosure glyph at
|
||
/// col 0, then the label at col 2, within the list area. Row content
|
||
/// below is indented (marker col 0, gap col 1, icon col 2). The
|
||
/// header is 2 visual cells tall (label + gap) and the title-only
|
||
/// row centers its title, so the title sits 3 rows below the
|
||
/// header in this fixture.
|
||
#[test]
|
||
fn render_group_header_leads_with_disclosure_glyph() {
|
||
let mut buf = Buffer::empty(Rect::new(0, 0, 80, 8));
|
||
let mut state = DashboardState::new();
|
||
let rows = vec![header_test_row(1, RowState::Idle, "session 019e5d9f")];
|
||
let theme = Theme::current();
|
||
render_rows(&mut buf, Rect::new(0, 0, 80, 8), &theme, &rows, &mut state);
|
||
|
||
// Col 0 is the (expanded) disclosure glyph; the label starts at
|
||
// col 2 (glyph + a space). Rows below have their marker/icon in
|
||
// the left columns and text indented.
|
||
assert_eq!(
|
||
buf[(0, 0)].symbol(),
|
||
crate::glyphs::disclosure_open(),
|
||
"section header must lead with the expanded disclosure glyph",
|
||
);
|
||
let header_label_x = buf[(2, 0)].symbol().to_string();
|
||
assert_eq!(
|
||
header_label_x, "I",
|
||
"section title `Idle …` must start after the disclosure glyph, got: {header_label_x:?}",
|
||
);
|
||
|
||
// Header gap → row 1 is blank. The title-only row centers its
|
||
// title within its 3-cell rect (y=2..5), so the title sits at
|
||
// y=3. Rows still render their marker/icon in the left chrome
|
||
// columns.
|
||
let row_col0 = buf[(0, 3)].symbol().to_string();
|
||
let row_col1 = buf[(1, 3)].symbol().to_string();
|
||
let row_col2 = buf[(2, 3)].symbol().to_string();
|
||
assert_eq!(
|
||
row_col0, " ",
|
||
"row's col 0 must be the marker space when nothing selected, got: {row_col0:?}",
|
||
);
|
||
assert_eq!(
|
||
row_col1, " ",
|
||
"row's col 1 must be the 1-col gap after the marker, got: {row_col1:?}",
|
||
);
|
||
assert_eq!(
|
||
row_col2,
|
||
crate::glyphs::diamond_hollow(),
|
||
"row's col 2 must be the hollow diamond for Idle, got: {row_col2:?}",
|
||
);
|
||
}
|
||
|
||
/// `Grouping::Directory` keeps cwd as the
|
||
/// grouping primitive, so state headers are suppressed.
|
||
///
|
||
/// Header chrome marker updated to match Option
|
||
/// A. The `(count)` parenthesis pattern is the new specific
|
||
/// fingerprint for a state header.
|
||
#[test]
|
||
fn render_rows_skips_headers_when_grouping_is_directory() {
|
||
let mut buf = Buffer::empty(Rect::new(0, 0, 80, 10));
|
||
let mut state = DashboardState::new();
|
||
state.grouping = Grouping::Directory;
|
||
let rows = vec![
|
||
header_test_row(1, RowState::Working, "a"),
|
||
header_test_row(2, RowState::Idle, "b"),
|
||
];
|
||
let theme = Theme::current();
|
||
render_rows(&mut buf, Rect::new(0, 0, 80, 10), &theme, &rows, &mut state);
|
||
let content = buf_to_text(&buf);
|
||
assert!(
|
||
!content.contains("Working ("),
|
||
"Directory grouping must suppress Working header, got: {content:?}",
|
||
);
|
||
assert!(
|
||
!content.contains("Idle ("),
|
||
"Directory grouping must suppress Idle header, got: {content:?}",
|
||
);
|
||
// Rows themselves still render.
|
||
assert!(
|
||
content.contains('a') && content.contains('b'),
|
||
"rows must still render under Directory grouping, got: {content:?}",
|
||
);
|
||
}
|
||
|
||
/// `Filter::State(_)` collapses the view to a
|
||
/// single state, so the header would be redundant chrome.
|
||
///
|
||
/// Header chrome marker updated to match Option
|
||
/// A (look for `Working (` instead of `── Working`).
|
||
#[test]
|
||
fn render_rows_skips_headers_when_filter_is_state() {
|
||
let mut buf = Buffer::empty(Rect::new(0, 0, 80, 10));
|
||
let mut state = DashboardState::new();
|
||
state.filter = Filter::State(RowState::Working);
|
||
let rows = vec![
|
||
header_test_row(1, RowState::Working, "first working"),
|
||
header_test_row(2, RowState::Working, "second working"),
|
||
];
|
||
let theme = Theme::current();
|
||
render_rows(&mut buf, Rect::new(0, 0, 80, 10), &theme, &rows, &mut state);
|
||
let content = buf_to_text(&buf);
|
||
assert!(
|
||
!content.contains("Working ("),
|
||
"state-filtered view must suppress Working header, got: {content:?}",
|
||
);
|
||
// Rows themselves still render.
|
||
assert!(
|
||
content.contains("first working"),
|
||
"first row must render, got: {content:?}",
|
||
);
|
||
assert!(
|
||
content.contains("second working"),
|
||
"second row must render, got: {content:?}",
|
||
);
|
||
}
|
||
|
||
/// Subagent rows (indent > 0) must NOT trigger
|
||
/// their own state header; they inherit their parent's group.
|
||
/// Test: a parent in `Working` followed by a finished
|
||
/// (`Completed`) subagent + a finished (`Failed`) subagent must
|
||
/// emit only the parent's `Working` header, not extra
|
||
/// `Completed` / `Failed` ones tied to the subagents.
|
||
#[test]
|
||
fn render_rows_subagents_do_not_trigger_their_own_headers() {
|
||
use crate::app::agent::AgentId;
|
||
let mut buf = Buffer::empty(Rect::new(0, 0, 80, 20));
|
||
let mut state = DashboardState::new();
|
||
let parent = DashboardRow {
|
||
id: DashboardRowId::TopLevel(AgentId(1)),
|
||
indent: 0,
|
||
..header_test_row(1, RowState::Working, "parent")
|
||
};
|
||
let sub_completed = DashboardRow {
|
||
id: DashboardRowId::Subagent {
|
||
parent: AgentId(1),
|
||
child_session_id: "c1".to_string(),
|
||
},
|
||
label: "sub-completed".to_string(),
|
||
indent: 1,
|
||
..header_test_row(11, RowState::Completed, "sub-completed")
|
||
};
|
||
let sub_failed = DashboardRow {
|
||
id: DashboardRowId::Subagent {
|
||
parent: AgentId(1),
|
||
child_session_id: "c2".to_string(),
|
||
},
|
||
label: "sub-failed".to_string(),
|
||
indent: 1,
|
||
..header_test_row(12, RowState::Failed, "sub-failed")
|
||
};
|
||
let rows = vec![parent, sub_completed, sub_failed];
|
||
let theme = Theme::current();
|
||
render_rows(&mut buf, Rect::new(0, 0, 80, 20), &theme, &rows, &mut state);
|
||
let content = buf_to_text(&buf);
|
||
// Group header reads `Working 1 ─────` (no
|
||
// parens; trailing rule fills the rest of the row).
|
||
assert!(
|
||
content.contains("Working 1"),
|
||
"parent's Working header must render, got: {content:?}",
|
||
);
|
||
// Subagents inherit their parent's group and must NOT emit
|
||
// their own headers. The trailing `\u{2500}` rule is the
|
||
// marker that distinguishes a header from a row.
|
||
assert!(
|
||
!content.contains("Completed 1"),
|
||
"subagent must NOT trigger a Completed header, got: {content:?}",
|
||
);
|
||
assert!(
|
||
!content.contains("Failed 1"),
|
||
"subagent must NOT trigger a Failed header, got: {content:?}",
|
||
);
|
||
}
|
||
|
||
/// Narrow mode emits a compact `Done 12` header
|
||
/// (no bullet, no parens, no trailing rule — narrow terminals
|
||
/// don't have the width budget).
|
||
#[test]
|
||
fn render_narrow_rows_emits_compact_group_headers() {
|
||
let mut buf = Buffer::empty(Rect::new(0, 0, 30, 10));
|
||
let mut state = DashboardState::new();
|
||
let rows = vec![
|
||
header_test_row(1, RowState::Working, "wrk"),
|
||
header_test_row(2, RowState::Idle, "idl"),
|
||
];
|
||
let theme = Theme::current();
|
||
render_narrow_rows(&mut buf, Rect::new(0, 0, 30, 10), &theme, &rows, &mut state);
|
||
let content = buf_to_text(&buf);
|
||
assert!(
|
||
content.contains("Working 1"),
|
||
"narrow header `Working 1` missing, got: {content:?}",
|
||
);
|
||
assert!(
|
||
content.contains("Idle 1"),
|
||
"narrow header `Idle 1` missing, got: {content:?}",
|
||
);
|
||
// Narrow mode must NOT paint the wide trailing rule.
|
||
assert!(
|
||
!content.contains("\u{2500}\u{2500}\u{2500}"),
|
||
"narrow header must not paint a trailing rule, got: {content:?}",
|
||
);
|
||
}
|
||
|
||
/// Narrow-layout regression — the viewport clamp must follow a
|
||
/// selected *section header*, not just a selected row. With the new
|
||
/// section cursor a header can become the keyboard target; if the
|
||
/// clamp only tracks `state.selected` (a row) that header stays
|
||
/// off-screen even though the wide layout scrolls it in. Here the
|
||
/// second group's "Idle" header lands below a 5-line viewport, so it
|
||
/// is off-screen at offset 0 and must be scrolled in once selected.
|
||
#[test]
|
||
fn render_narrow_viewport_follows_selected_section_header() {
|
||
let mut rows = Vec::new();
|
||
for i in 0..6 {
|
||
rows.push(header_test_row(i + 1, RowState::Working, "wrk"));
|
||
}
|
||
rows.push(header_test_row(99, RowState::Idle, "idl"));
|
||
let theme = Theme::current();
|
||
let area = Rect::new(0, 0, 30, 5);
|
||
|
||
// Control — nothing selected: the Idle header starts off-screen.
|
||
{
|
||
let mut buf = Buffer::empty(area);
|
||
let mut state = DashboardState::new();
|
||
render_narrow_rows(&mut buf, area, &theme, &rows, &mut state);
|
||
let content = buf_to_text(&buf);
|
||
assert!(
|
||
!content.contains("Idle"),
|
||
"fixture invalid — Idle header must start off-screen, got: {content:?}",
|
||
);
|
||
}
|
||
|
||
// Selecting the Idle section must scroll its header into view.
|
||
{
|
||
let mut buf = Buffer::empty(area);
|
||
let mut state = DashboardState::new();
|
||
state.selected_section = Some(SectionKey::State(RowState::Idle));
|
||
render_narrow_rows(&mut buf, area, &theme, &rows, &mut state);
|
||
let content = buf_to_text(&buf);
|
||
assert!(
|
||
content.contains("Idle 1"),
|
||
"selected Idle section header must be clamped into view, got: {content:?}",
|
||
);
|
||
assert!(
|
||
state.viewport_offset > 0,
|
||
"viewport must scroll to reveal the selected section header, got offset {}",
|
||
state.viewport_offset,
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Alt-screen polish — `render_dashboard` must paint the theme's
|
||
/// base background across the entire `area` before any
|
||
/// sub-renderer runs. Without this, cells untouched by the
|
||
/// header/list/dispatch/footer renderers keep stale paint from
|
||
/// the previous frame and the dashboard looks like it doesn't
|
||
/// cover the full panel.
|
||
///
|
||
/// The check pins a cell that no sub-renderer touches — the
|
||
/// trailing whitespace one column past the right edge of the
|
||
/// list — and asserts that its background matches
|
||
/// `theme.bg_base`. Pre-seed the buffer with a contrasting bg so
|
||
/// a regression that drops the fill is visible (otherwise the
|
||
/// default-empty buffer would already show the right colour).
|
||
#[test]
|
||
fn render_dashboard_paints_full_area_background() {
|
||
let theme = Theme::current();
|
||
let area = Rect::new(0, 0, 80, 20);
|
||
let mut buf = Buffer::empty(area);
|
||
// Seed every cell with a contrasting bg so a missing fill is
|
||
// detectable: any cell still carrying this seed colour after
|
||
// `render_dashboard` runs means the fill didn't reach it.
|
||
let seed = ratatui::style::Color::Rgb(0xFF, 0x00, 0xFF);
|
||
buf.set_style(area, Style::default().bg(seed));
|
||
|
||
let mut agents: IndexMap<AgentId, AgentView> = IndexMap::new();
|
||
let mut state = DashboardState::new();
|
||
let registry = crate::actions::ActionRegistry::defaults();
|
||
let _ = render_dashboard(
|
||
&mut buf,
|
||
area,
|
||
&mut state,
|
||
&mut agents,
|
||
®istry,
|
||
None,
|
||
&[],
|
||
false,
|
||
None,
|
||
);
|
||
|
||
// Sample cells across the area; none should retain the seed
|
||
// bg colour. The dashboard fills with `theme.bg_base`; the
|
||
// exact colour need not match a constant — we only assert
|
||
// the seed is gone.
|
||
for y in 0..area.height {
|
||
for x in 0..area.width {
|
||
let cell_bg = buf[(x, y)].bg;
|
||
assert_ne!(
|
||
cell_bg, seed,
|
||
"cell at ({x}, {y}) still carries the seed bg — render_dashboard must fill the entire area",
|
||
);
|
||
}
|
||
}
|
||
// And spot-check that at least one cell matches the theme bg,
|
||
// i.e., the fill actually used `theme.bg_base` (not just any
|
||
// non-seed colour).
|
||
let mut saw_bg_base = false;
|
||
for y in 0..area.height {
|
||
for x in 0..area.width {
|
||
if buf[(x, y)].bg == theme.bg_base {
|
||
saw_bg_base = true;
|
||
break;
|
||
}
|
||
}
|
||
if saw_bg_base {
|
||
break;
|
||
}
|
||
}
|
||
assert!(
|
||
saw_bg_base,
|
||
"render_dashboard must paint at least one cell with theme.bg_base",
|
||
);
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────
|
||
// Header redesign tests
|
||
// ─────────────────────────────────────────────────────────────────
|
||
|
||
/// Basename of the test process's cwd — the one deterministic
|
||
/// fragment of the header's location label. The full label depends
|
||
/// on global git caches (`git_info::*`) that parallel tests may
|
||
/// touch, but every fallback path renders a cwd display ending in
|
||
/// the current directory's basename.
|
||
fn cwd_basename() -> String {
|
||
std::env::current_dir()
|
||
.ok()
|
||
.and_then(|d| d.file_name().map(|n| n.to_string_lossy().into_owned()))
|
||
.expect("test process must have a cwd with a basename")
|
||
}
|
||
|
||
/// The header pairs the location label (cwd + git info) on the
|
||
/// left with right-aligned state-count chips.
|
||
#[test]
|
||
fn render_header_paints_label_and_state_chips() {
|
||
let theme = Theme::current();
|
||
// Wide rect so the location label never truncates regardless
|
||
// of how deep the test machine's checkout path is.
|
||
let area = Rect::new(0, 0, 400, 1);
|
||
let mut buf = Buffer::empty(area);
|
||
let mut state = DashboardState::new();
|
||
let rows = vec![
|
||
header_test_row(1, RowState::NeedsInput, "a"),
|
||
header_test_row(2, RowState::NeedsInput, "b"),
|
||
header_test_row(3, RowState::Working, "c"),
|
||
header_test_row(4, RowState::Idle, "d"),
|
||
];
|
||
render_header(&mut buf, area, &theme, &rows, &mut state, None);
|
||
let content = buf_to_text(&buf);
|
||
let basename = cwd_basename();
|
||
assert!(
|
||
content.contains(&basename),
|
||
"header must show the current location (`{basename}`), got: {content:?}",
|
||
);
|
||
for chip in ["2 awaiting", "1 working", "1 idle"] {
|
||
assert!(
|
||
content.contains(chip),
|
||
"header missing chip `{chip}`, got: {content:?}",
|
||
);
|
||
}
|
||
// The old static label and total count are gone.
|
||
assert!(
|
||
!content.contains("Agents"),
|
||
"header must not paint the old `Agents` label, got: {content:?}",
|
||
);
|
||
assert!(
|
||
!content.contains("4 agents"),
|
||
"total must not appear as right-side chip, got: {content:?}",
|
||
);
|
||
// The button form is gone.
|
||
assert!(
|
||
!content.contains("[New agent +]"),
|
||
"v2-round-2 header must not paint the `[New agent +]` button anymore, got: {content:?}",
|
||
);
|
||
}
|
||
|
||
/// The header records a click target for the location label so the
|
||
/// mouse handler can open the location picker.
|
||
#[test]
|
||
fn render_header_sets_location_click_target() {
|
||
let theme = Theme::current();
|
||
let area = Rect::new(0, 0, 120, 1);
|
||
let mut buf = Buffer::empty(area);
|
||
let mut state = DashboardState::new();
|
||
render_header(&mut buf, area, &theme, &[], &mut state, None);
|
||
assert!(
|
||
state.location_hit.rect.is_some(),
|
||
"render_header must record a click target for the location label",
|
||
);
|
||
}
|
||
|
||
/// On hover the location label underlines only its text — the leading
|
||
/// inset space (and other whitespace padding) stays un-underlined.
|
||
#[test]
|
||
fn render_header_hover_underlines_only_text() {
|
||
let theme = Theme::current();
|
||
let area = Rect::new(0, 0, 400, 1);
|
||
let mut buf = Buffer::empty(area);
|
||
let mut state = DashboardState::new();
|
||
state.location_hit.hovered = true;
|
||
render_header(&mut buf, area, &theme, &[], &mut state, None);
|
||
|
||
// Leading inset (x=0) is a space → must NOT be underlined.
|
||
let inset = buf.cell((0, 0)).expect("inset cell");
|
||
assert_eq!(inset.symbol(), " ", "x=0 is the leading inset space");
|
||
assert!(
|
||
!inset.style().add_modifier.contains(Modifier::UNDERLINED),
|
||
"the leading inset space must not be underlined",
|
||
);
|
||
|
||
// Within the location label (bounded by its recorded hit rect, so
|
||
// the right-side chips are excluded), the last visible glyph is cwd
|
||
// path text — never the branch icon — so it must be underlined.
|
||
// Bounding to the label keeps this robust whether or not a branch is
|
||
// present for the test's cwd.
|
||
let label_end = state
|
||
.location_hit
|
||
.rect
|
||
.expect("header records the location hit rect")
|
||
.width;
|
||
let last_text_x = (1..label_end)
|
||
.rev()
|
||
.find(|&x| {
|
||
buf.cell((x, 0))
|
||
.is_some_and(|c| !c.symbol().trim().is_empty())
|
||
})
|
||
.expect("header must paint visible location text");
|
||
assert!(
|
||
buf.cell((last_text_x, 0))
|
||
.unwrap()
|
||
.style()
|
||
.add_modifier
|
||
.contains(Modifier::UNDERLINED),
|
||
"the location path text must be underlined on hover",
|
||
);
|
||
}
|
||
|
||
/// The git span (`{icon} {branch}`) underlines only the branch *name*:
|
||
/// the branch icon and the space after it stay bare. Whitespace-only
|
||
/// spans (inset, separator) stay bare; the path is underlined.
|
||
#[test]
|
||
fn underline_location_on_hover_excludes_branch_icon() {
|
||
let icon = "\u{e0a0}";
|
||
let plain = Style::default();
|
||
let spans = vec![
|
||
Span::styled(" ".to_string(), plain), // leading inset
|
||
Span::styled(format!("{icon} main"), plain), // git: icon + branch
|
||
Span::styled(" ".to_string(), plain), // git↔path separator
|
||
Span::styled("/home/me/repo".to_string(), plain), // path
|
||
];
|
||
let out = underline_location_on_hover(spans, icon);
|
||
|
||
let underlined = |s: &Span<'static>| s.style.add_modifier.contains(Modifier::UNDERLINED);
|
||
|
||
// The git span split into `{icon} ` (bare) + `main` (underlined).
|
||
let icon_part = out
|
||
.iter()
|
||
.find(|s| s.content.starts_with(icon))
|
||
.expect("icon span present");
|
||
assert_eq!(icon_part.content.as_ref(), format!("{icon} "));
|
||
assert!(
|
||
!underlined(icon_part),
|
||
"the branch icon and the space after it must not be underlined",
|
||
);
|
||
let branch = out
|
||
.iter()
|
||
.find(|s| &*s.content == "main")
|
||
.expect("branch span present");
|
||
assert!(underlined(branch), "the branch name must be underlined");
|
||
|
||
// Whitespace-only spans stay bare.
|
||
for s in &out {
|
||
if s.content.chars().all(char::is_whitespace) {
|
||
assert!(
|
||
!underlined(s),
|
||
"whitespace span must not be underlined: {:?}",
|
||
s.content,
|
||
);
|
||
}
|
||
}
|
||
// The path is underlined.
|
||
let path = out
|
||
.iter()
|
||
.find(|s| &*s.content == "/home/me/repo")
|
||
.expect("path span present");
|
||
assert!(underlined(path), "the path must be underlined");
|
||
}
|
||
|
||
/// The location picker modal paints its title + candidate rows and
|
||
/// records the content hit areas for mouse handling.
|
||
#[test]
|
||
fn render_location_picker_shows_candidates() {
|
||
let theme = Theme::current();
|
||
let area = Rect::new(0, 0, 80, 24);
|
||
let mut buf = Buffer::empty(area);
|
||
let mut modal = LocationPickerState::new(
|
||
vec![crate::views::dashboard::LocationCandidate {
|
||
path: std::path::PathBuf::from("/home/me/frontend"),
|
||
label: "frontend".to_string(),
|
||
detail: "~/me/frontend".to_string(),
|
||
worktree: None,
|
||
}],
|
||
std::path::PathBuf::from("/base"),
|
||
std::collections::HashMap::new(),
|
||
);
|
||
render_location_picker(&mut buf, area, &theme, &mut modal);
|
||
let content = buf_to_text(&buf);
|
||
assert!(
|
||
content.contains("Change directory"),
|
||
"modal title missing, got: {content:?}",
|
||
);
|
||
assert!(
|
||
content.contains("path:"),
|
||
"path input field missing, got: {content:?}",
|
||
);
|
||
assert!(
|
||
content.contains("frontend"),
|
||
"candidate label missing, got: {content:?}",
|
||
);
|
||
assert!(
|
||
modal.content_hits.is_some(),
|
||
"content hit areas must be recorded for the mouse handler",
|
||
);
|
||
}
|
||
|
||
/// In a git repo the path row paints a worktree toggle reflecting the
|
||
/// modal's `worktree_mode`, and records its hit rect for click handling.
|
||
#[test]
|
||
fn render_location_picker_shows_worktree_toggle_in_repo() {
|
||
let theme = Theme::current();
|
||
let area = Rect::new(0, 0, 80, 24);
|
||
// A temp dir with a `.git` child so the toggle is eligible (hermetic,
|
||
// unlike depending on the test's real cwd being a repo).
|
||
let repo = std::env::temp_dir().join("grok-loc-wt-toggle-repo-test");
|
||
std::fs::create_dir_all(repo.join(".git")).expect("mk .git");
|
||
let mut modal =
|
||
LocationPickerState::new(vec![], repo.clone(), std::collections::HashMap::new());
|
||
|
||
// Off by default.
|
||
let mut buf = Buffer::empty(area);
|
||
render_location_picker(&mut buf, area, &theme, &mut modal);
|
||
let content = buf_to_text(&buf);
|
||
assert!(
|
||
content.contains("worktree:off"),
|
||
"off-state button missing, got: {content:?}",
|
||
);
|
||
assert!(
|
||
modal.worktree_hit.rect.is_some(),
|
||
"the worktree button hit rect must be recorded",
|
||
);
|
||
|
||
// On after toggling.
|
||
modal.worktree_mode = true;
|
||
let mut buf = Buffer::empty(area);
|
||
render_location_picker(&mut buf, area, &theme, &mut modal);
|
||
let content = buf_to_text(&buf);
|
||
assert!(
|
||
content.contains("worktree:on"),
|
||
"on-state button missing, got: {content:?}",
|
||
);
|
||
// The "on" word is recolored green (accent_success).
|
||
let hit = modal.worktree_hit.rect.expect("hit rect recorded");
|
||
let on_x = hit.x + "[worktree:".len() as u16;
|
||
let cell = buf.cell((on_x, hit.y)).expect("on cell");
|
||
assert_eq!(cell.symbol(), "o", "expected the 'o' of \"on\"");
|
||
assert_eq!(
|
||
cell.style().fg,
|
||
Some(theme.accent_success),
|
||
"the \"on\" word must be green",
|
||
);
|
||
|
||
// Hovering brightens the label text (higher-contrast fg), like other
|
||
// clickable buttons.
|
||
modal.worktree_hit.hovered = true;
|
||
let mut buf = Buffer::empty(area);
|
||
render_location_picker(&mut buf, area, &theme, &mut modal);
|
||
let cell = buf.cell((hit.x, hit.y)).expect("button cell");
|
||
assert_eq!(
|
||
cell.style().fg,
|
||
Some(theme.text_primary),
|
||
"hovered button must brighten the label text",
|
||
);
|
||
let _ = std::fs::remove_dir_all(&repo);
|
||
}
|
||
|
||
/// Outside a git repo the worktree toggle is hidden (and no hit rect is
|
||
/// recorded) so dispatch proceeds as a normal session.
|
||
#[test]
|
||
fn render_location_picker_hides_worktree_toggle_outside_repo() {
|
||
let theme = Theme::current();
|
||
let area = Rect::new(0, 0, 80, 24);
|
||
let mut modal = LocationPickerState::new(
|
||
vec![],
|
||
std::path::PathBuf::from("/grok-not-a-repo-xyz-12345"),
|
||
std::collections::HashMap::new(),
|
||
);
|
||
let mut buf = Buffer::empty(area);
|
||
render_location_picker(&mut buf, area, &theme, &mut modal);
|
||
let content = buf_to_text(&buf);
|
||
assert!(
|
||
!content.contains("worktree"),
|
||
"the worktree toggle must be hidden outside a repo, got: {content:?}",
|
||
);
|
||
assert!(
|
||
modal.worktree_hit.rect.is_none(),
|
||
"no hit rect when the toggle is hidden",
|
||
);
|
||
}
|
||
|
||
/// Worktree directories render a styled `worktree: <name>` badge.
|
||
#[test]
|
||
fn render_location_picker_tags_worktree() {
|
||
let theme = Theme::current();
|
||
let area = Rect::new(0, 0, 80, 24);
|
||
let mut buf = Buffer::empty(area);
|
||
let mut modal = LocationPickerState::new(
|
||
vec![crate::views::dashboard::LocationCandidate {
|
||
path: std::path::PathBuf::from("/home/me/wt"),
|
||
label: "wt".to_string(),
|
||
detail: "~/me/wt".to_string(),
|
||
worktree: Some("my-feature".to_string()),
|
||
}],
|
||
std::path::PathBuf::from("/base"),
|
||
std::collections::HashMap::new(),
|
||
);
|
||
render_location_picker(&mut buf, area, &theme, &mut modal);
|
||
let content = buf_to_text(&buf);
|
||
assert!(
|
||
content.contains("worktree: my-feature"),
|
||
"worktree badge missing, got: {content:?}",
|
||
);
|
||
}
|
||
|
||
/// Truncation priority: the directory name (label) is shown in full
|
||
/// and the path (right label) is truncated first.
|
||
#[test]
|
||
fn render_location_picker_truncates_path_not_label() {
|
||
let theme = Theme::current();
|
||
let area = Rect::new(0, 0, 54, 20);
|
||
let mut buf = Buffer::empty(area);
|
||
let mut modal = LocationPickerState::new(
|
||
vec![crate::views::dashboard::LocationCandidate {
|
||
path: std::path::PathBuf::from("/home/me/myproj"),
|
||
label: "myproj".to_string(),
|
||
detail: "~/very/long/path/that/keeps/going/UNIQUETAIL".to_string(),
|
||
worktree: None,
|
||
}],
|
||
std::path::PathBuf::from("/base"),
|
||
std::collections::HashMap::new(),
|
||
);
|
||
render_location_picker(&mut buf, area, &theme, &mut modal);
|
||
let content = buf_to_text(&buf);
|
||
assert!(
|
||
content.contains("myproj"),
|
||
"full label must be shown, got: {content:?}",
|
||
);
|
||
assert!(
|
||
!content.contains("UNIQUETAIL"),
|
||
"long path must be truncated, got: {content:?}",
|
||
);
|
||
}
|
||
|
||
/// The path input echoes what the user types, even when it matches
|
||
/// no candidate (the list then shows "No matches").
|
||
#[test]
|
||
fn render_location_picker_echoes_typed_path() {
|
||
let theme = Theme::current();
|
||
let area = Rect::new(0, 0, 80, 24);
|
||
let mut buf = Buffer::empty(area);
|
||
let mut modal = LocationPickerState::new(
|
||
vec![crate::views::dashboard::LocationCandidate {
|
||
path: std::path::PathBuf::from("/home/me/frontend"),
|
||
label: "frontend".to_string(),
|
||
detail: "~/me/frontend".to_string(),
|
||
worktree: None,
|
||
}],
|
||
std::path::PathBuf::from("/base"),
|
||
std::collections::HashMap::new(),
|
||
);
|
||
modal.picker.set_query("/tmp/zzz");
|
||
render_location_picker(&mut buf, area, &theme, &mut modal);
|
||
let content = buf_to_text(&buf);
|
||
assert!(
|
||
content.contains("/tmp/zzz"),
|
||
"typed path must echo in the input field, got: {content:?}",
|
||
);
|
||
}
|
||
|
||
/// Zero-count states are suppressed.
|
||
#[test]
|
||
fn render_header_suppresses_zero_count_chips() {
|
||
let theme = Theme::current();
|
||
let mut buf = Buffer::empty(Rect::new(0, 0, 120, 1));
|
||
let mut state = DashboardState::new();
|
||
// Only one Idle row — no awaiting/working/done/failed chips.
|
||
let rows = vec![header_test_row(1, RowState::Idle, "x")];
|
||
render_header(
|
||
&mut buf,
|
||
Rect::new(0, 0, 120, 1),
|
||
&theme,
|
||
&rows,
|
||
&mut state,
|
||
None,
|
||
);
|
||
let content = buf_to_text(&buf);
|
||
assert!(
|
||
content.contains("1 idle"),
|
||
"expected `1 idle`, got: {content:?}"
|
||
);
|
||
for absent in ["0 awaiting", "0 working", "0 done", "0 failed", "0 blocked"] {
|
||
assert!(
|
||
!content.contains(absent),
|
||
"zero-count chip `{absent}` must be suppressed, got: {content:?}",
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Inactive (roster-only) rows get no header chip — only the
|
||
/// section header carries their count.
|
||
#[test]
|
||
fn render_header_has_no_inactive_chip() {
|
||
let theme = Theme::current();
|
||
let mut buf = Buffer::empty(Rect::new(0, 0, 120, 1));
|
||
let mut state = DashboardState::new();
|
||
let rows = vec![
|
||
header_test_row(1, RowState::Inactive, "a"),
|
||
header_test_row(2, RowState::Idle, "b"),
|
||
];
|
||
render_header(
|
||
&mut buf,
|
||
Rect::new(0, 0, 120, 1),
|
||
&theme,
|
||
&rows,
|
||
&mut state,
|
||
None,
|
||
);
|
||
let content = buf_to_text(&buf);
|
||
assert!(
|
||
content.contains("1 idle"),
|
||
"idle chip must still render, got: {content:?}"
|
||
);
|
||
assert!(
|
||
!content.contains("inactive"),
|
||
"no chip for Inactive rows, got: {content:?}"
|
||
);
|
||
}
|
||
|
||
/// The left title is the current location (cwd display) — shown
|
||
/// with and without agent rows, mirroring the session views'
|
||
/// top-bar location line.
|
||
#[test]
|
||
fn render_header_shows_location_label() {
|
||
let theme = Theme::current();
|
||
// Wide rect so the location label never truncates regardless
|
||
// of how deep the test machine's checkout path is.
|
||
let area = Rect::new(0, 0, 400, 1);
|
||
let mut state = DashboardState::new();
|
||
let basename = cwd_basename();
|
||
|
||
// 0 agents — the location still shows.
|
||
let mut buf = Buffer::empty(area);
|
||
render_header(&mut buf, area, &theme, &[], &mut state, None);
|
||
let c = buf_to_text(&buf);
|
||
assert!(
|
||
c.contains(&basename),
|
||
"0-agent header must show the location (`{basename}`), got: {c:?}"
|
||
);
|
||
|
||
// 1 agent.
|
||
let mut buf = Buffer::empty(area);
|
||
let rows = vec![header_test_row(1, RowState::Idle, "x")];
|
||
render_header(&mut buf, area, &theme, &rows, &mut state, None);
|
||
let c = buf_to_text(&buf);
|
||
assert!(
|
||
c.contains(&basename),
|
||
"header must show the location (`{basename}`), got: {c:?}"
|
||
);
|
||
}
|
||
|
||
/// On a narrow header the location label truncates against the
|
||
/// leftmost chip's separator instead of painting over the chips
|
||
/// or the `[+ New Agent]` button.
|
||
#[test]
|
||
fn render_header_location_label_never_overlaps_chips() {
|
||
let theme = Theme::current();
|
||
// Narrow enough that any realistic checkout path overflows the
|
||
// label budget once three chips + the button are reserved.
|
||
let area = Rect::new(0, 0, 70, 1);
|
||
let mut buf = Buffer::empty(area);
|
||
let mut state = DashboardState::new();
|
||
let rows = vec![
|
||
header_test_row(1, RowState::NeedsInput, "a"),
|
||
header_test_row(2, RowState::Working, "b"),
|
||
header_test_row(3, RowState::Idle, "c"),
|
||
];
|
||
render_header(&mut buf, area, &theme, &rows, &mut state, None);
|
||
let content = buf_to_text(&buf);
|
||
// Chips and button must survive the (long) location label.
|
||
for chunk in ["1 awaiting", "1 working", "1 idle", "[+ New Agent]"] {
|
||
assert!(
|
||
content.contains(chunk),
|
||
"`{chunk}` must not be overpainted by the location label, got: {content:?}",
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Footer chips use the shared `ShortcutsBar` styling
|
||
/// (`Key:label` separated by ` │ `).
|
||
#[test]
|
||
fn render_footer_uses_shared_shortcuts_bar_styling() {
|
||
let mut buf = Buffer::empty(Rect::new(0, 0, 200, 1));
|
||
let theme = Theme::current();
|
||
let state = DashboardState::new();
|
||
let registry = crate::actions::ActionRegistry::defaults();
|
||
render_footer(
|
||
&mut buf,
|
||
Rect::new(0, 0, 200, 1),
|
||
&theme,
|
||
&state,
|
||
®istry,
|
||
None,
|
||
false,
|
||
None,
|
||
);
|
||
let content = buf_to_text(&buf);
|
||
assert!(
|
||
content.contains(":create") || content.contains(":attach"),
|
||
"footer must use `Key:label` chip format, got: {content:?}",
|
||
);
|
||
assert!(
|
||
content.contains(" \u{2502} "),
|
||
"footer must use ` │ ` separator, got: {content:?}",
|
||
);
|
||
}
|
||
|
||
/// Fresh `DashboardState` defaults to button-focused
|
||
/// with an empty prompt. The footer surfaces `Enter:create`
|
||
/// (single primary action) and the trailing shortcuts chip. The
|
||
/// ↑/↓ nav chip is no longer shown (dropped to save space), and no
|
||
/// send / send+open chip is shown because there's nothing to send.
|
||
#[test]
|
||
fn render_footer_default_compact_hints() {
|
||
let mut buf = Buffer::empty(Rect::new(0, 0, 200, 1));
|
||
let theme = Theme::current();
|
||
let state = DashboardState::new();
|
||
let registry = crate::actions::ActionRegistry::defaults();
|
||
render_footer(
|
||
&mut buf,
|
||
Rect::new(0, 0, 200, 1),
|
||
&theme,
|
||
&state,
|
||
®istry,
|
||
None,
|
||
false,
|
||
None,
|
||
);
|
||
let content = buf_to_text(&buf);
|
||
for chip in [":create", ":shortcuts"] {
|
||
assert!(
|
||
content.contains(chip),
|
||
"footer must contain `{chip}` chip, got: {content:?}",
|
||
);
|
||
}
|
||
// The nav chip is dropped from the bottom bar to save space.
|
||
assert!(
|
||
!content.contains(":nav"),
|
||
"footer must NOT include the ↑/↓ nav chip, got: {content:?}",
|
||
);
|
||
// Empty prompt: no send / send+open chip (`:send` is a prefix
|
||
// of `:send+open`, so one check covers both).
|
||
assert!(
|
||
!content.contains(":send"),
|
||
"empty-prompt footer must NOT include send / send+open chips, \
|
||
got: {content:?}",
|
||
);
|
||
}
|
||
|
||
/// The dispatch input grows for multi-line drafts: a single-line
|
||
/// prompt wants 1 text row, a 3-line prompt (Shift/Alt+Enter
|
||
/// newlines) wants 3, and growth saturates at the cap so the box
|
||
/// never starves the row list.
|
||
#[test]
|
||
fn dispatch_text_rows_grows_with_newlines() {
|
||
let mut state = DashboardState::new();
|
||
let width = 80;
|
||
let height = 30;
|
||
state.dispatch.set_text("just one line");
|
||
assert_eq!(
|
||
dispatch_text_rows(&state, width, height),
|
||
1,
|
||
"single-line prompt wants 1 text row",
|
||
);
|
||
state.dispatch.set_text("line one\nline two\nline three");
|
||
assert_eq!(
|
||
dispatch_text_rows(&state, width, height),
|
||
3,
|
||
"3-line prompt wants 3 text rows",
|
||
);
|
||
// Past the cap ((height/3).clamp(1,8) = 8 here) growth saturates.
|
||
state.dispatch.set_text(&"x\n".repeat(40));
|
||
assert_eq!(
|
||
dispatch_text_rows(&state, width, height),
|
||
8,
|
||
"dispatch text rows saturate at the cap",
|
||
);
|
||
}
|
||
|
||
/// Overview list focused (via Tab) with vim on → the nav chip is
|
||
/// dropped from the bottom bar (to save space); neither the vim
|
||
/// `j/k` nor the arrow nav is advertised. The action chips (open)
|
||
/// remain.
|
||
#[test]
|
||
fn render_footer_list_focused_vim_on_omits_nav() {
|
||
let mut buf = Buffer::empty(Rect::new(0, 0, 200, 1));
|
||
let theme = Theme::current();
|
||
let mut state = DashboardState::new();
|
||
state.focus_row(DashboardRowId::TopLevel(crate::app::agent::AgentId(0)));
|
||
state.list_focused = true;
|
||
let registry = crate::actions::ActionRegistry::defaults();
|
||
crate::appearance::cache::set_vim_mode(true);
|
||
render_footer(
|
||
&mut buf,
|
||
Rect::new(0, 0, 200, 1),
|
||
&theme,
|
||
&state,
|
||
®istry,
|
||
Some(RowState::Idle),
|
||
false,
|
||
None,
|
||
);
|
||
let content = buf_to_text(&buf);
|
||
// Reset before asserting so a failure doesn't leak vim state
|
||
// into the next test sharing this thread's cache.
|
||
crate::appearance::cache::set_vim_mode(false);
|
||
assert!(
|
||
!content.contains(":nav") && !content.contains("j/k"),
|
||
"list-focused footer must omit the nav chip, got: {content:?}",
|
||
);
|
||
assert!(
|
||
content.contains(":open"),
|
||
"list-focused footer keeps the open chip, got: {content:?}",
|
||
);
|
||
}
|
||
|
||
/// Overview list focused with vim off → the nav chip is likewise
|
||
/// dropped (no arrow nav advertised), saving bottom-bar space for
|
||
/// the action chips.
|
||
#[test]
|
||
fn render_footer_list_focused_vim_off_omits_nav() {
|
||
let mut buf = Buffer::empty(Rect::new(0, 0, 200, 1));
|
||
let theme = Theme::current();
|
||
let mut state = DashboardState::new();
|
||
state.focus_row(DashboardRowId::TopLevel(crate::app::agent::AgentId(0)));
|
||
state.list_focused = true;
|
||
let registry = crate::actions::ActionRegistry::defaults();
|
||
crate::appearance::cache::set_vim_mode(false);
|
||
render_footer(
|
||
&mut buf,
|
||
Rect::new(0, 0, 200, 1),
|
||
&theme,
|
||
&state,
|
||
®istry,
|
||
Some(RowState::Idle),
|
||
false,
|
||
None,
|
||
);
|
||
let content = buf_to_text(&buf);
|
||
assert!(
|
||
!content.contains(":nav") && !content.contains('\u{2191}'),
|
||
"list-focused footer must omit the nav chip, got: {content:?}",
|
||
);
|
||
}
|
||
|
||
/// When peek is active, the footer flips to peek-
|
||
/// mode hints (`enter:open · esc:New Agent · ctrl+x:close`). The
|
||
/// nav chip is dropped (saving space) and the Ctrl+x chip stays
|
||
/// visible while the agent is selected.
|
||
#[test]
|
||
fn render_footer_peek_mode_shows_peek_hints() {
|
||
let mut buf = Buffer::empty(Rect::new(0, 0, 200, 1));
|
||
let theme = Theme::current();
|
||
let state = DashboardState::new();
|
||
let registry = crate::actions::ActionRegistry::defaults();
|
||
render_footer(
|
||
&mut buf,
|
||
Rect::new(0, 0, 200, 1),
|
||
&theme,
|
||
&state,
|
||
®istry,
|
||
None,
|
||
true, // peek_active
|
||
None,
|
||
);
|
||
let content = buf_to_text(&buf);
|
||
assert!(
|
||
content.contains(":open") && content.contains(":New Agent"),
|
||
"peek-mode footer must include open + New Agent (unselect) hints, got: {content:?}",
|
||
);
|
||
// The stop chip stays visible while an agent is selected. With
|
||
// no row state passed (None) the label is the idle-style `close`.
|
||
assert!(
|
||
content.contains(":close"),
|
||
"peek-mode footer must keep the Ctrl+x stop chip, got: {content:?}",
|
||
);
|
||
// The nav chip is dropped to save bottom-bar space.
|
||
assert!(
|
||
!content.contains(":nav"),
|
||
"peek-mode footer must NOT show the nav chip, got: {content:?}",
|
||
);
|
||
assert!(
|
||
!content.contains(":switch"),
|
||
"peek-mode footer must NOT use the old `switch` label, got: {content:?}",
|
||
);
|
||
}
|
||
|
||
/// Peek footer flips to send affordances once the reply has text
|
||
/// and is focused: `enter:send · ctrl+s:send+open · esc:back`.
|
||
#[test]
|
||
fn render_footer_peek_with_reply_text_shows_send() {
|
||
crate::appearance::cache::set_vim_mode(false);
|
||
let mut buf = Buffer::empty(Rect::new(0, 0, 200, 1));
|
||
let theme = Theme::current();
|
||
let mut state = DashboardState::new();
|
||
state.peek = Some(crate::views::dashboard::peek::PeekPanelState::new(
|
||
DashboardRowId::TopLevel(crate::app::agent::AgentId(0)),
|
||
crate::views::dashboard::peek::PeekFields {
|
||
label: "label".into(),
|
||
time_ago: String::new(),
|
||
response_type: "Idle".into(),
|
||
last_user_message: None,
|
||
question: None,
|
||
options: Vec::new(),
|
||
request_id: None,
|
||
reject_option: None,
|
||
},
|
||
));
|
||
state.peek.as_mut().unwrap().focused = true;
|
||
state.peek_reply.set_text("hi there");
|
||
let registry = crate::actions::ActionRegistry::defaults();
|
||
render_footer(
|
||
&mut buf,
|
||
Rect::new(0, 0, 200, 1),
|
||
&theme,
|
||
&state,
|
||
®istry,
|
||
None,
|
||
true, // peek_active
|
||
None,
|
||
);
|
||
let content = buf_to_text(&buf);
|
||
assert!(
|
||
content.contains(":send"),
|
||
"peek footer with a typed reply must show `send`, got: {content:?}",
|
||
);
|
||
}
|
||
|
||
/// Vim + unfocused peek: Enter focuses the reply (`input`), not open/send.
|
||
#[test]
|
||
fn render_footer_vim_unfocused_peek_enter_shows_input() {
|
||
crate::appearance::cache::set_vim_mode(true);
|
||
let mut buf = Buffer::empty(Rect::new(0, 0, 200, 1));
|
||
let theme = Theme::current();
|
||
let mut state = DashboardState::new();
|
||
state.list_focused = true; // used to steal the footer before the peek fix
|
||
state.peek = Some(crate::views::dashboard::peek::PeekPanelState::new(
|
||
DashboardRowId::TopLevel(crate::app::agent::AgentId(0)),
|
||
crate::views::dashboard::peek::PeekFields {
|
||
label: "label".into(),
|
||
time_ago: String::new(),
|
||
response_type: "Idle".into(),
|
||
last_user_message: None,
|
||
question: None,
|
||
options: Vec::new(),
|
||
request_id: None,
|
||
reject_option: None,
|
||
},
|
||
));
|
||
assert!(!state.peek.as_ref().unwrap().focused);
|
||
state.peek_reply.set_text("draft");
|
||
let registry = crate::actions::ActionRegistry::defaults();
|
||
render_footer(
|
||
&mut buf,
|
||
Rect::new(0, 0, 200, 1),
|
||
&theme,
|
||
&state,
|
||
®istry,
|
||
Some(RowState::Idle),
|
||
true,
|
||
None,
|
||
);
|
||
let content = buf_to_text(&buf);
|
||
assert!(
|
||
content.contains(":input"),
|
||
"vim unfocused peek must label Enter as input, got: {content:?}",
|
||
);
|
||
assert!(
|
||
content.contains(":open"),
|
||
"vim unfocused peek must surface Right:open for attach, got: {content:?}",
|
||
);
|
||
assert!(
|
||
content.contains(":back"),
|
||
"non-empty draft must label Esc as back, got: {content:?}",
|
||
);
|
||
crate::appearance::cache::set_vim_mode(false);
|
||
}
|
||
|
||
/// Non-vim unfocused peek with a typed draft: Esc clears the draft first
|
||
/// (`back`), not New Agent.
|
||
#[test]
|
||
fn render_footer_peek_unfocused_with_draft_esc_is_back() {
|
||
crate::appearance::cache::set_vim_mode(false);
|
||
let mut buf = Buffer::empty(Rect::new(0, 0, 200, 1));
|
||
let theme = Theme::current();
|
||
let mut state = DashboardState::new();
|
||
let mut peek = crate::views::dashboard::peek::PeekPanelState::new(
|
||
DashboardRowId::TopLevel(crate::app::agent::AgentId(0)),
|
||
crate::views::dashboard::peek::PeekFields {
|
||
label: "label".into(),
|
||
time_ago: String::new(),
|
||
response_type: "Idle".into(),
|
||
last_user_message: None,
|
||
question: None,
|
||
options: Vec::new(),
|
||
request_id: None,
|
||
reject_option: None,
|
||
},
|
||
);
|
||
peek.focused = false;
|
||
state.peek = Some(peek);
|
||
state.peek_reply.set_text("draft");
|
||
let registry = crate::actions::ActionRegistry::defaults();
|
||
render_footer(
|
||
&mut buf,
|
||
Rect::new(0, 0, 200, 1),
|
||
&theme,
|
||
&state,
|
||
®istry,
|
||
Some(RowState::Idle),
|
||
true,
|
||
None,
|
||
);
|
||
let content = buf_to_text(&buf);
|
||
assert!(
|
||
content.contains(":back"),
|
||
"unfocused peek with draft must show Esc:back, got: {content:?}",
|
||
);
|
||
assert!(
|
||
!content.contains("New Agent"),
|
||
"must not label Esc as New Agent while a draft remains, got: {content:?}",
|
||
);
|
||
}
|
||
|
||
/// A pending question is an ANSWER surface only when focused AND an
|
||
/// option is selected. Focused + selected → `enter:answer` (+ Tab `list`);
|
||
/// focused + no selection → `enter:open` + `1-9:select`; unfocused still
|
||
/// keeps `1-9:select` (digits work). None of the non-answer states show `answer`.
|
||
#[test]
|
||
fn render_footer_peek_question_focus_flips_answer_vs_open() {
|
||
crate::appearance::cache::set_vim_mode(false);
|
||
let theme = Theme::current();
|
||
let registry = crate::actions::ActionRegistry::defaults();
|
||
let make_state = |focused: bool, selected: Option<usize>| {
|
||
let mut state = DashboardState::new();
|
||
let mut peek = crate::views::dashboard::peek::PeekPanelState::new(
|
||
DashboardRowId::TopLevel(crate::app::agent::AgentId(0)),
|
||
crate::views::dashboard::peek::PeekFields {
|
||
label: "label".into(),
|
||
time_ago: String::new(),
|
||
response_type: "NeedsInput".into(),
|
||
last_user_message: None,
|
||
question: Some("Allow?".into()),
|
||
options: vec![
|
||
("allow".into(), "Allow".into()),
|
||
("deny".into(), "Deny".into()),
|
||
],
|
||
request_id: None,
|
||
reject_option: None,
|
||
},
|
||
);
|
||
peek.focused = focused;
|
||
peek.selected_option = selected;
|
||
state.peek = Some(peek);
|
||
state
|
||
};
|
||
let render = |state: &DashboardState| {
|
||
let mut buf = Buffer::empty(Rect::new(0, 0, 200, 1));
|
||
render_footer(
|
||
&mut buf,
|
||
Rect::new(0, 0, 200, 1),
|
||
&theme,
|
||
state,
|
||
®istry,
|
||
None,
|
||
true, // peek_active
|
||
None,
|
||
);
|
||
buf_to_text(&buf)
|
||
};
|
||
|
||
// Focused + an option selected → answer surface (+ Tab `list`).
|
||
let answering = render(&make_state(true, Some(0)));
|
||
assert!(
|
||
answering.contains(":answer"),
|
||
"focused + selected footer must show `answer`, got: {answering:?}",
|
||
);
|
||
assert!(
|
||
answering.contains(":list"),
|
||
"answer footer must surface the Tab `list` hint, got: {answering:?}",
|
||
);
|
||
|
||
// Focused + nothing selected → navigation surface: open + `1-9 select`.
|
||
let picking = render(&make_state(true, None));
|
||
assert!(
|
||
picking.contains(":open"),
|
||
"focused + no selection footer must show `open`, got: {picking:?}",
|
||
);
|
||
assert!(
|
||
picking.contains(":select"),
|
||
"focused + no selection footer must surface the `1-9 select` hint, got: {picking:?}",
|
||
);
|
||
assert!(
|
||
!picking.contains(":answer"),
|
||
"no-selection footer must NOT show `answer`, got: {picking:?}",
|
||
);
|
||
|
||
// Unfocused → Enter opens; 1-9 select still shown (digits work).
|
||
let unfocused = render(&make_state(false, None));
|
||
assert!(
|
||
unfocused.contains(":open"),
|
||
"unfocused question footer must show `open`, got: {unfocused:?}",
|
||
);
|
||
assert!(
|
||
unfocused.contains(":select"),
|
||
"unfocused question footer must keep 1-9 select, got: {unfocused:?}",
|
||
);
|
||
assert!(
|
||
!unfocused.contains(":answer"),
|
||
"unfocused question footer must NOT show `answer`, got: {unfocused:?}",
|
||
);
|
||
|
||
// Vim unfocused + question: Enter:input, Right:open, still 1-9 select.
|
||
crate::appearance::cache::set_vim_mode(true);
|
||
let mut vim_q = make_state(false, None);
|
||
// Rebuild under vim so focused defaults false.
|
||
vim_q.peek = Some({
|
||
let mut peek = crate::views::dashboard::peek::PeekPanelState::new(
|
||
DashboardRowId::TopLevel(crate::app::agent::AgentId(0)),
|
||
crate::views::dashboard::peek::PeekFields {
|
||
label: "label".into(),
|
||
time_ago: String::new(),
|
||
response_type: "NeedsInput".into(),
|
||
last_user_message: None,
|
||
question: Some("Allow?".into()),
|
||
options: vec![
|
||
("allow".into(), "Allow".into()),
|
||
("deny".into(), "Deny".into()),
|
||
],
|
||
request_id: None,
|
||
reject_option: None,
|
||
},
|
||
);
|
||
peek.focused = false;
|
||
peek
|
||
});
|
||
let vim_unfocused = render(&vim_q);
|
||
assert!(
|
||
vim_unfocused.contains(":input"),
|
||
"vim unfocused question must label Enter as input, got: {vim_unfocused:?}",
|
||
);
|
||
assert!(
|
||
vim_unfocused.contains(":select"),
|
||
"vim unfocused question must keep 1-9 select, got: {vim_unfocused:?}",
|
||
);
|
||
crate::appearance::cache::set_vim_mode(false);
|
||
}
|
||
|
||
/// When a row (NeedsInput or otherwise) is selected
|
||
/// with an empty prompt, the footer shows `Enter:open`.
|
||
/// The previous `see details` label is folded into the
|
||
/// unified "row selected → open" semantics — every row's
|
||
/// detail view is the answer surface for any user-input
|
||
/// state, including `NeedsInput`.
|
||
#[test]
|
||
fn render_footer_row_selected_empty_prompt_shows_enter_open() {
|
||
let mut buf = Buffer::empty(Rect::new(0, 0, 200, 1));
|
||
let theme = Theme::current();
|
||
let mut state = DashboardState::new();
|
||
state.focus_row(DashboardRowId::TopLevel(crate::app::agent::AgentId(0)));
|
||
let registry = crate::actions::ActionRegistry::defaults();
|
||
render_footer(
|
||
&mut buf,
|
||
Rect::new(0, 0, 200, 1),
|
||
&theme,
|
||
&state,
|
||
®istry,
|
||
Some(RowState::NeedsInput),
|
||
false,
|
||
None,
|
||
);
|
||
let content = buf_to_text(&buf);
|
||
assert!(
|
||
content.contains(":open"),
|
||
"row-selected + empty prompt footer must hint `Enter:open`, got: {content:?}",
|
||
);
|
||
assert!(
|
||
!content.contains(":send"),
|
||
"empty-prompt footer must NOT include `:send` chip, got: {content:?}",
|
||
);
|
||
// Two-focus discoverability — mirrors the `[+ New Agent]`
|
||
// button's empty-prompt chip: Tab hands focus to the list.
|
||
assert!(
|
||
content.contains(":list"),
|
||
"row-selected + empty prompt footer must hint `Tab:list`, got: {content:?}",
|
||
);
|
||
}
|
||
|
||
/// An Inactive (roster-only) selection has nothing running to stop —
|
||
/// the stop chip is suppressed in both focus modes.
|
||
#[test]
|
||
fn render_footer_inactive_row_hides_stop() {
|
||
let theme = Theme::current();
|
||
let registry = crate::actions::ActionRegistry::defaults();
|
||
|
||
// Input focused, row selected.
|
||
let mut state = DashboardState::new();
|
||
state.focus_row(DashboardRowId::TopLevel(crate::app::agent::AgentId(0)));
|
||
let mut buf = Buffer::empty(Rect::new(0, 0, 200, 1));
|
||
render_footer(
|
||
&mut buf,
|
||
Rect::new(0, 0, 200, 1),
|
||
&theme,
|
||
&state,
|
||
®istry,
|
||
Some(RowState::Inactive),
|
||
false,
|
||
None,
|
||
);
|
||
let content = buf_to_text(&buf);
|
||
assert!(
|
||
!content.contains(":stop") && !content.contains(":close"),
|
||
"inactive row footer must NOT show the stop chip, got: {content:?}",
|
||
);
|
||
assert!(
|
||
content.contains(":open"),
|
||
"inactive row footer keeps the open chip, got: {content:?}",
|
||
);
|
||
|
||
// List focused (Tab) — same suppression.
|
||
state.list_focused = true;
|
||
let mut buf2 = Buffer::empty(Rect::new(0, 0, 200, 1));
|
||
render_footer(
|
||
&mut buf2,
|
||
Rect::new(0, 0, 200, 1),
|
||
&theme,
|
||
&state,
|
||
®istry,
|
||
Some(RowState::Inactive),
|
||
false,
|
||
None,
|
||
);
|
||
let content2 = buf_to_text(&buf2);
|
||
assert!(
|
||
!content2.contains(":stop") && !content2.contains(":close"),
|
||
"list-focused inactive footer must NOT show the stop chip, got: {content2:?}",
|
||
);
|
||
|
||
// Control: an Idle selection keeps the stop chip in both modes —
|
||
// labelled `close` (the session is idle, so Ctrl+x closes it).
|
||
state.list_focused = false;
|
||
let mut buf3 = Buffer::empty(Rect::new(0, 0, 200, 1));
|
||
render_footer(
|
||
&mut buf3,
|
||
Rect::new(0, 0, 200, 1),
|
||
&theme,
|
||
&state,
|
||
®istry,
|
||
Some(RowState::Idle),
|
||
false,
|
||
None,
|
||
);
|
||
let content3 = buf_to_text(&buf3);
|
||
assert!(
|
||
content3.contains(":close"),
|
||
"idle row footer must keep the stop chip labelled `close`, got: {content3:?}",
|
||
);
|
||
}
|
||
|
||
/// The Ctrl+x chip label follows the selected agent's state: a
|
||
/// Working or NeedsInput agent shows `stop` (cancel the turn — a
|
||
/// NeedsInput row keeps a paused-but-running turn), while an idle /
|
||
/// quiet one shows `close` (close the session).
|
||
#[test]
|
||
fn render_footer_stop_label_follows_state() {
|
||
let theme = Theme::current();
|
||
let registry = crate::actions::ActionRegistry::defaults();
|
||
let mut state = DashboardState::new();
|
||
state.focus_row(DashboardRowId::TopLevel(crate::app::agent::AgentId(0)));
|
||
|
||
// Working → `stop`.
|
||
let mut buf = Buffer::empty(Rect::new(0, 0, 200, 1));
|
||
render_footer(
|
||
&mut buf,
|
||
Rect::new(0, 0, 200, 1),
|
||
&theme,
|
||
&state,
|
||
®istry,
|
||
Some(RowState::Working),
|
||
false,
|
||
None,
|
||
);
|
||
let working = buf_to_text(&buf);
|
||
assert!(
|
||
working.contains(":stop") && !working.contains(":close"),
|
||
"Working agent footer must label Ctrl+x as `stop`, got: {working:?}",
|
||
);
|
||
|
||
// NeedsInput → `stop` (a paused-but-running turn — first Ctrl+x
|
||
// cancels, mirroring `dispatch_dashboard_stop`).
|
||
let mut buf_ni = Buffer::empty(Rect::new(0, 0, 200, 1));
|
||
render_footer(
|
||
&mut buf_ni,
|
||
Rect::new(0, 0, 200, 1),
|
||
&theme,
|
||
&state,
|
||
®istry,
|
||
Some(RowState::NeedsInput),
|
||
false,
|
||
None,
|
||
);
|
||
let needs_input = buf_to_text(&buf_ni);
|
||
assert!(
|
||
needs_input.contains(":stop") && !needs_input.contains(":close"),
|
||
"NeedsInput agent footer must label Ctrl+x as `stop`, got: {needs_input:?}",
|
||
);
|
||
|
||
// Idle → `close`.
|
||
let mut buf2 = Buffer::empty(Rect::new(0, 0, 200, 1));
|
||
render_footer(
|
||
&mut buf2,
|
||
Rect::new(0, 0, 200, 1),
|
||
&theme,
|
||
&state,
|
||
®istry,
|
||
Some(RowState::Idle),
|
||
false,
|
||
None,
|
||
);
|
||
let idle = buf_to_text(&buf2);
|
||
assert!(
|
||
idle.contains(":close") && !idle.contains(":stop"),
|
||
"Idle agent footer must label Ctrl+x as `close`, got: {idle:?}",
|
||
);
|
||
}
|
||
|
||
/// When a section header is selected the footer shows the toggle
|
||
/// (Enter:collapse / :expand) and `Esc:New Agent`, and omits the
|
||
/// stop chip (no session under a header).
|
||
#[test]
|
||
fn render_footer_section_selected_shows_toggle_no_stop() {
|
||
let theme = Theme::current();
|
||
let registry = crate::actions::ActionRegistry::defaults();
|
||
|
||
// Expanded section → Enter collapses.
|
||
let mut state = DashboardState::new();
|
||
state.focus_section(SectionKey::State(RowState::Working));
|
||
let mut buf = Buffer::empty(Rect::new(0, 0, 200, 1));
|
||
render_footer(
|
||
&mut buf,
|
||
Rect::new(0, 0, 200, 1),
|
||
&theme,
|
||
&state,
|
||
®istry,
|
||
None,
|
||
false,
|
||
None,
|
||
);
|
||
let content = buf_to_text(&buf);
|
||
assert!(
|
||
content.contains(":collapse"),
|
||
"expanded section footer must hint Enter:collapse, got: {content:?}",
|
||
);
|
||
assert!(
|
||
content.contains("New Agent"),
|
||
"section footer must hint Esc:New Agent, got: {content:?}",
|
||
);
|
||
assert!(
|
||
!content.contains(":stop") && !content.contains(":close"),
|
||
"section footer must NOT show the stop chip, got: {content:?}",
|
||
);
|
||
|
||
// Collapsed section → the toggle label flips to expand.
|
||
state.set_section_collapsed(SectionKey::State(RowState::Working), true);
|
||
let mut buf2 = Buffer::empty(Rect::new(0, 0, 200, 1));
|
||
render_footer(
|
||
&mut buf2,
|
||
Rect::new(0, 0, 200, 1),
|
||
&theme,
|
||
&state,
|
||
®istry,
|
||
None,
|
||
false,
|
||
None,
|
||
);
|
||
let content2 = buf_to_text(&buf2);
|
||
assert!(
|
||
content2.contains(":expand"),
|
||
"collapsed section footer must hint Enter:expand, got: {content2:?}",
|
||
);
|
||
}
|
||
|
||
/// Section header selected while the LIST is focused (Tab) — the
|
||
/// footer shows the section's own hints (collapse / Tab:input)
|
||
/// instead of the generic row chips (`open` / `stop` would lie:
|
||
/// Enter toggles the section, and there's no session to stop).
|
||
#[test]
|
||
fn render_footer_list_focused_section_shows_toggle() {
|
||
let theme = Theme::current();
|
||
let registry = crate::actions::ActionRegistry::defaults();
|
||
let mut state = DashboardState::new();
|
||
state.focus_section(SectionKey::State(RowState::Working));
|
||
state.list_focused = true;
|
||
let mut buf = Buffer::empty(Rect::new(0, 0, 200, 1));
|
||
render_footer(
|
||
&mut buf,
|
||
Rect::new(0, 0, 200, 1),
|
||
&theme,
|
||
&state,
|
||
®istry,
|
||
None,
|
||
false,
|
||
None,
|
||
);
|
||
let content = buf_to_text(&buf);
|
||
assert!(
|
||
content.contains(":collapse"),
|
||
"list-focused section footer must hint Enter:collapse, got: {content:?}",
|
||
);
|
||
assert!(
|
||
content.contains(":input"),
|
||
"list-focused section footer must hint Tab:input, got: {content:?}",
|
||
);
|
||
assert!(
|
||
!content.contains(":open") && !content.contains(":stop") && !content.contains(":close"),
|
||
"list-focused section footer must NOT show open/stop chips, got: {content:?}",
|
||
);
|
||
assert!(
|
||
content.contains(":shortcuts"),
|
||
"list-focused section footer keeps the shortcuts chip, got: {content:?}",
|
||
);
|
||
}
|
||
|
||
/// Section header selected + typed text in the (focused) dispatch
|
||
/// input — the draft dispatches a NEW agent (a section header is
|
||
/// never a reply target), so the footer flips to the dispatch
|
||
/// chips: send / send+open / mode. The collapse toggle is gone
|
||
/// (it only fires on an empty prompt).
|
||
#[test]
|
||
fn render_footer_section_selected_with_prompt_shows_dispatch_chips() {
|
||
let theme = Theme::current();
|
||
let registry = crate::actions::ActionRegistry::defaults();
|
||
let mut state = DashboardState::new();
|
||
state.focus_section(SectionKey::State(RowState::Working));
|
||
state.dispatch.set_text("kick off a fresh session");
|
||
let mut buf = Buffer::empty(Rect::new(0, 0, 200, 1));
|
||
render_footer(
|
||
&mut buf,
|
||
Rect::new(0, 0, 200, 1),
|
||
&theme,
|
||
&state,
|
||
®istry,
|
||
None,
|
||
false,
|
||
None,
|
||
);
|
||
let content = buf_to_text(&buf);
|
||
assert!(
|
||
content.contains(":send") && content.contains(":send+open"),
|
||
"section + typed prompt footer must hint send / send+open, got: {content:?}",
|
||
);
|
||
assert!(
|
||
content.contains(":mode"),
|
||
"section + typed prompt footer must hint Shift+Tab:mode, got: {content:?}",
|
||
);
|
||
assert!(
|
||
!content.contains(":collapse") && !content.contains(":expand"),
|
||
"section + typed prompt footer must NOT show the toggle chip, got: {content:?}",
|
||
);
|
||
assert!(
|
||
!content.contains(":stop") && !content.contains(":close"),
|
||
"section footer never shows the stop chip, got: {content:?}",
|
||
);
|
||
}
|
||
|
||
/// Rename mode shows only save and cancel actions.
|
||
#[test]
|
||
fn render_footer_rename_shows_save_and_cancel() {
|
||
use crate::app::agent::AgentId;
|
||
let theme = Theme::current();
|
||
let registry = crate::actions::ActionRegistry::defaults();
|
||
let mut state = DashboardState::new();
|
||
let id = DashboardRowId::TopLevel(AgentId(0));
|
||
state.focus_row(id.clone());
|
||
state.rename = Some(RenameDraft::new(id, ""));
|
||
let mut buf = Buffer::empty(Rect::new(0, 0, 200, 1));
|
||
render_footer(
|
||
&mut buf,
|
||
Rect::new(0, 0, 200, 1),
|
||
&theme,
|
||
&state,
|
||
®istry,
|
||
None,
|
||
false,
|
||
None,
|
||
);
|
||
let content = buf_to_text(&buf);
|
||
assert!(
|
||
content.contains(":save"),
|
||
"rename footer must hint Enter:save, got: {content:?}",
|
||
);
|
||
assert!(
|
||
content.contains(":cancel"),
|
||
"rename footer must hint Esc:cancel, got: {content:?}",
|
||
);
|
||
assert!(
|
||
!content.contains(":stop") && !content.contains(":close") && !content.contains(":open"),
|
||
"rename footer must hide the normal nav/stop chips, got: {content:?}",
|
||
);
|
||
}
|
||
|
||
/// When a row is selected AND the user has typed,
|
||
/// Enter sends (reply, stays on dashboard) and Ctrl+S
|
||
/// sends + opens detail. The footer surfaces both chips so
|
||
/// the chord is discoverable.
|
||
#[test]
|
||
fn render_footer_row_selected_with_prompt_shows_send_and_send_open() {
|
||
let mut buf = Buffer::empty(Rect::new(0, 0, 200, 1));
|
||
let theme = Theme::current();
|
||
let mut state = DashboardState::new();
|
||
state.focus_row(DashboardRowId::TopLevel(crate::app::agent::AgentId(0)));
|
||
state.dispatch.set_text("reply text");
|
||
let registry = crate::actions::ActionRegistry::defaults();
|
||
render_footer(
|
||
&mut buf,
|
||
Rect::new(0, 0, 200, 1),
|
||
&theme,
|
||
&state,
|
||
®istry,
|
||
Some(RowState::Idle),
|
||
false,
|
||
None,
|
||
);
|
||
let content = buf_to_text(&buf);
|
||
for chip in [":send", ":send+open"] {
|
||
assert!(
|
||
content.contains(chip),
|
||
"row-selected + non-empty footer must contain `{chip}`, got: {content:?}",
|
||
);
|
||
}
|
||
assert!(
|
||
!content.contains(":open "),
|
||
"send/send+open footer must NOT include the empty-prompt `:open` chip, \
|
||
got: {content:?}",
|
||
);
|
||
}
|
||
|
||
/// When the `[+ New Agent]` button is focused AND the
|
||
/// user has typed, Enter sends (stays on dashboard) and
|
||
/// Ctrl+S sends + opens detail. Stop chip is suppressed
|
||
/// because the button has no underlying session to close.
|
||
#[test]
|
||
fn render_footer_button_focused_with_prompt_shows_send_and_send_open_no_stop() {
|
||
let mut buf = Buffer::empty(Rect::new(0, 0, 200, 1));
|
||
let theme = Theme::current();
|
||
let mut state = DashboardState::new();
|
||
// Default state: button focused. Plant typed text.
|
||
assert!(state.new_agent_button_focused);
|
||
state.dispatch.set_text("kick off a fresh session");
|
||
let registry = crate::actions::ActionRegistry::defaults();
|
||
render_footer(
|
||
&mut buf,
|
||
Rect::new(0, 0, 200, 1),
|
||
&theme,
|
||
&state,
|
||
®istry,
|
||
None,
|
||
false,
|
||
None,
|
||
);
|
||
let content = buf_to_text(&buf);
|
||
for chip in [":send", ":send+open"] {
|
||
assert!(
|
||
content.contains(chip),
|
||
"button-focused + non-empty footer must contain `{chip}`, \
|
||
got: {content:?}",
|
||
);
|
||
}
|
||
assert!(
|
||
content.contains("Enter:send"),
|
||
"default compose: bare Enter is send, got: {content:?}",
|
||
);
|
||
assert!(
|
||
!content.contains(":stop") && !content.contains(":close"),
|
||
"button-focused footer must NOT show the stop chip (no row to close), \
|
||
got: {content:?}",
|
||
);
|
||
}
|
||
|
||
/// Multiline compose swaps the submit chord in the footer so it matches
|
||
/// the Enter ↔ Shift/Alt+Enter behavior (agent keybar parity).
|
||
#[test]
|
||
fn render_footer_multiline_mode_send_uses_shift_or_alt_enter() {
|
||
let mut buf = Buffer::empty(Rect::new(0, 0, 200, 1));
|
||
let theme = Theme::current();
|
||
let mut state = DashboardState::new();
|
||
state.multiline_mode = true;
|
||
state.dispatch.set_text("multi\nline draft");
|
||
let registry = crate::actions::ActionRegistry::defaults();
|
||
render_footer(
|
||
&mut buf,
|
||
Rect::new(0, 0, 200, 1),
|
||
&theme,
|
||
&state,
|
||
®istry,
|
||
None,
|
||
false,
|
||
None,
|
||
);
|
||
let content = buf_to_text(&buf);
|
||
assert!(
|
||
content.contains("Shift+Enter:send") || content.contains("Alt+Enter:send"),
|
||
"multiline footer must advertise Shift/Alt+Enter as send, got: {content:?}",
|
||
);
|
||
// Bare Enter:send would appear as " Enter:send" (footer pad); the
|
||
// modified chords contain the substring "Enter:send" so avoid that.
|
||
assert!(
|
||
!content.contains(" Enter:send"),
|
||
"multiline footer must not claim bare Enter:send, got: {content:?}",
|
||
);
|
||
assert!(
|
||
content.contains(":send+open"),
|
||
"Ctrl+S send+open must remain, got: {content:?}",
|
||
);
|
||
}
|
||
|
||
/// Empty draft under multiline: create is on the submit chord, not bare Enter.
|
||
#[test]
|
||
fn render_footer_multiline_empty_create_uses_shift_or_alt_enter() {
|
||
let mut buf = Buffer::empty(Rect::new(0, 0, 200, 1));
|
||
let theme = Theme::current();
|
||
let mut state = DashboardState::new();
|
||
state.multiline_mode = true;
|
||
assert!(state.new_agent_button_focused);
|
||
assert!(state.dispatch.text().trim().is_empty());
|
||
let registry = crate::actions::ActionRegistry::defaults();
|
||
render_footer(
|
||
&mut buf,
|
||
Rect::new(0, 0, 200, 1),
|
||
&theme,
|
||
&state,
|
||
®istry,
|
||
None,
|
||
false,
|
||
None,
|
||
);
|
||
let content = buf_to_text(&buf);
|
||
assert!(
|
||
content.contains("Shift+Enter:create") || content.contains("Alt+Enter:create"),
|
||
"multiline empty footer must advertise Shift/Alt+Enter as create, got: {content:?}",
|
||
);
|
||
assert!(
|
||
!content.contains(" Enter:create"),
|
||
"multiline empty footer must not claim bare Enter:create, got: {content:?}",
|
||
);
|
||
}
|
||
|
||
/// Stop-confirm armed routes through `ShortcutsBar::with_pending`.
|
||
#[test]
|
||
fn render_footer_stop_confirm_uses_pending_hint() {
|
||
use std::time::Instant;
|
||
let mut buf = Buffer::empty(Rect::new(0, 0, 200, 1));
|
||
let theme = Theme::current();
|
||
let mut state = DashboardState::new();
|
||
state.stop_confirm = Some((
|
||
DashboardRowId::TopLevel(crate::app::agent::AgentId(1)),
|
||
Instant::now(),
|
||
));
|
||
let registry = crate::actions::ActionRegistry::defaults();
|
||
render_footer(
|
||
&mut buf,
|
||
Rect::new(0, 0, 200, 1),
|
||
&theme,
|
||
&state,
|
||
®istry,
|
||
None,
|
||
false,
|
||
None,
|
||
);
|
||
let content = buf_to_text(&buf);
|
||
assert!(
|
||
content.to_lowercase().contains("press again"),
|
||
"stop-confirm footer must say `press again`, got: {content:?}",
|
||
);
|
||
assert!(
|
||
content.to_lowercase().contains("close this session"),
|
||
"stop-confirm footer must mention closing the session, got: {content:?}",
|
||
);
|
||
}
|
||
|
||
/// An EXPIRED stop-confirm (older than `STOP_CONFIRM_WINDOW`) must
|
||
/// not claim the footer — the dispatcher would re-arm rather than
|
||
/// close on the next press, so "press again" would lie. Regular
|
||
/// hints render instead (e.g. after a mouse click moved the
|
||
/// selection without a keypress to disarm the confirm).
|
||
#[test]
|
||
fn render_footer_expired_stop_confirm_shows_regular_hints() {
|
||
use std::time::{Duration, Instant};
|
||
let mut buf = Buffer::empty(Rect::new(0, 0, 200, 1));
|
||
let theme = Theme::current();
|
||
let mut state = DashboardState::new();
|
||
state.focus_row(DashboardRowId::TopLevel(crate::app::agent::AgentId(1)));
|
||
state.stop_confirm = Some((
|
||
DashboardRowId::TopLevel(crate::app::agent::AgentId(1)),
|
||
Instant::now() - (super::super::state::STOP_CONFIRM_WINDOW + Duration::from_secs(1)),
|
||
));
|
||
let registry = crate::actions::ActionRegistry::defaults();
|
||
render_footer(
|
||
&mut buf,
|
||
Rect::new(0, 0, 200, 1),
|
||
&theme,
|
||
&state,
|
||
®istry,
|
||
None,
|
||
false,
|
||
None,
|
||
);
|
||
let content = buf_to_text(&buf);
|
||
assert!(
|
||
!content.to_lowercase().contains("press again"),
|
||
"expired stop-confirm must not paint the pending hint, got: {content:?}",
|
||
);
|
||
assert!(
|
||
content.contains(":open"),
|
||
"expired stop-confirm must fall back to the regular hints, got: {content:?}",
|
||
);
|
||
}
|
||
|
||
/// Subagents inherit their parent's
|
||
/// state and must NOT inflate the header chip tallies. The
|
||
/// header counts top-level rows only.
|
||
#[test]
|
||
fn render_header_counts_top_level_rows_only() {
|
||
let theme = Theme::current();
|
||
let mut buf = Buffer::empty(Rect::new(0, 0, 160, 1));
|
||
let mut state = DashboardState::new();
|
||
let parent = DashboardRow {
|
||
indent: 0,
|
||
..header_test_row(1, RowState::Working, "parent")
|
||
};
|
||
let sub_completed = DashboardRow {
|
||
id: DashboardRowId::Subagent {
|
||
parent: crate::app::agent::AgentId(1),
|
||
child_session_id: "c1".to_string(),
|
||
},
|
||
indent: 1,
|
||
..header_test_row(11, RowState::Completed, "child")
|
||
};
|
||
let rows = vec![parent, sub_completed];
|
||
render_header(
|
||
&mut buf,
|
||
Rect::new(0, 0, 160, 1),
|
||
&theme,
|
||
&rows,
|
||
&mut state,
|
||
None,
|
||
);
|
||
let content = buf_to_text(&buf);
|
||
// Only the top-level parent counts: its Working chip shows.
|
||
assert!(
|
||
content.contains("1 working"),
|
||
"expected `1 working` chip for the top-level parent, got: {content:?}"
|
||
);
|
||
// Subagent's Completed must NOT show up as `1 done`.
|
||
assert!(
|
||
!content.contains("1 done"),
|
||
"header must not count subagent state, got: {content:?}",
|
||
);
|
||
}
|
||
}
|