//! 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, ) { 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, 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, // 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>, ) -> 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 = 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 = 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>, ) { 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>, icon: &str) -> Vec> { 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> = 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: `. Built into a // parallel Vec so the `&str` badges outlive the entries. let badges: Vec = 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 = { 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> = 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, idle_show_all: bool, search_active: bool, ) -> Vec> { 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> = 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 = 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 = 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, idle_show_all: bool, search_active: bool, ) -> Vec { 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 { let none_collapsed = std::collections::HashSet::new(); let mut current: Option = 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 = 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> = 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>, 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], 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 #" 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 = 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, ) -> 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, ) -> Option { 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 { 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, peek_active: bool, pending_hint: Option, ) { 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 = 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 = 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 = 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> = 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, ), ) -> ( Option<(u16, u16)>, Option, 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] │ /// ├─────────────────────────────────────────────────────────┤ /// │ │ /// │ │ /// │ │ /// ╰──────────────────────────────────────────────────────────╯ /// ``` /// /// 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 { 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 { // 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, Option, Option) { // `‹` / `›` / `✗` 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, pub prev_rect: Option, pub next_rect: Option, } #[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 = 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 = 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 = 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 = 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 = 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 = 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::(); 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 = 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 = (0..total).map(|i| aged_idle_row(i, OLD_SECS)).collect(); let none: HashSet = 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 = (0..9).map(|i| aged_idle_row(i, 0)).collect(); let none: HashSet = 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 = (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 = 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 = (0..total).map(|i| aged_idle_row(i, OLD_SECS)).collect(); let none: HashSet = 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 = HashSet::new(); // MAX_VISIBLE_IDLE + 1 old → would hide 1 → no fold. let rows: Vec = (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 = (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 = (0..total).map(|i| aged_idle_row(i, OLD_SECS)).collect(); let none: HashSet = 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 = (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 = (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 #` 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 = 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: ` 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| { 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:?}", ); } }