//! Agent view layout and rendering helpers.
//!
//! This module provides:
//! - [`AgentViewLayout`] — pure layout computation (screen area → pane rects)
//! - [`ActivePane`] / [`PaneAreas`] — pane identity and hit-testing
//! - Overlay helpers — small focused functions for selection/hover chrome
//! - [`build_hints`] — shortcuts bar hint generation
//!
//! The actual rendering orchestration lives in [`AgentView::draw()`](crate::app::agent_view::AgentView::draw),
//! which calls shared widgets (StatusBar, ScrollbackPane, PromptWidget,
//! ShortcutsBar) and uses these helpers for the agent-specific glue.
use crate::actions::{ActionId, ActionRegistry, When};
use crate::appearance::{LayoutConfig, ScrollbarConfig};
use crate::render::SafeBuf;
use crate::render::scrollbar::render_scrollbar_styled;
use crate::scrollback::layout::HorizontalLayout;
use crate::scrollback::search::ScrollbackSearchState;
use crate::scrollback::selection::SelectionBox;
use crate::scrollback::state::ScrollbackState;
use crate::theme::Theme;
use crate::views::prompt_widget::PromptWidget;
use crate::views::shortcuts_bar::HintItem;
use ratatui::buffer::Buffer;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::Style;
use ratatui::text::Span;
use ratatui::widgets::{Block, Padding, Widget};
/// Which pane is currently active in the agent view.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ActivePane {
    #[default]
    Scrollback,
    Todo,
    Queue,
    Prompt,
    Tasks,
    Catalog,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub enum InputMode {
    #[default]
    Vim,
    Simple,
}
/// Cached pane areas from the last render, used for mouse hit-testing.
#[derive(Debug, Clone, Copy, Default)]
pub struct PaneAreas {
    pub scrollback: Rect,
    pub todo: Rect,
    pub queue: Rect,
    pub prompt: Rect,
    pub tasks: Rect,
    pub catalog: Rect,
}
impl PaneAreas {
    /// Determine which pane a screen position falls in, if any.
    pub fn hit_test(&self, col: u16, row: u16) -> Option<ActivePane> {
        let pos = (col, row).into();
        if self.tasks.area() > 0 && self.tasks.contains(pos) {
            return Some(ActivePane::Tasks);
        }
        if self.catalog.area() > 0 && self.catalog.contains(pos) {
            return Some(ActivePane::Catalog);
        }
        if self.todo.area() > 0 && self.todo.contains(pos) {
            return Some(ActivePane::Todo);
        }
        if self.queue.area() > 0 && self.queue.contains(pos) {
            return Some(ActivePane::Queue);
        }
        if self.scrollback.contains(pos) {
            return Some(ActivePane::Scrollback);
        }
        if self.prompt.contains(pos) {
            return Some(ActivePane::Prompt);
        }
        None
    }
}
/// Terminals at or below this height suppress the optional rows above the
/// prompt (plugin CTA, follow-ups, banner/tip) so the prompt and scrollback
/// are never starved.
pub const SHORT_TERMINAL_ROWS: u16 = 16;
/// Auto-compact threshold: at or below this height the render-value compact
/// flag is forced on. Deliberately above [`SHORT_TERMINAL_ROWS`], which stays
/// the hard-degradation gate (tip-row renderability, CTA/follow-up trims).
pub const AUTO_COMPACT_MAX_ROWS: u16 = 20;
const _: () = assert!(SHORT_TERMINAL_ROWS < AUTO_COMPACT_MAX_ROWS);
/// Render-value derivation for compact mode: the user setting, force-enabled
/// while the terminal is [`AUTO_COMPACT_MAX_ROWS`] or shorter (auto-compact).
///
/// Derived only — never persisted and never written back to the user setting
/// (`current_ui.compact_mode` / the render cache / disk), so growing the
/// window restores the user's choice. `terminal_rows == 0` means "not yet
/// measured" and never forces compact.
pub fn effective_compact(user_compact: bool, terminal_rows: u16) -> bool {
    user_compact || (terminal_rows > 0 && terminal_rows <= AUTO_COMPACT_MAX_ROWS)
}
/// Computed screen layout for the agent view.
///
/// Pure data — no rendering. Computed from screen area + appearance config +
/// prompt height + todo height. Shared widgets use these rects to render into.
pub struct AgentViewLayout {
    pub status_bar: Rect,
    /// Startup terminal-warning banner (between status bar and bg tasks/scrollback).
    pub startup_warnings: Rect,
    pub tasks: Rect,
    pub catalog: Rect,
    pub scrollback: Rect,
    pub todo: Rect,
    pub queue: Rect,
    /// Inline /btw side question panel (above queue / turn status / prompt).
    pub btw: Rect,
    pub turn_status: Rect,
    /// Banner rect above the prompt (mode-switch banner, ephemeral tips).
    pub banner: Rect,
    /// Follow-up suggestion chips row (below the plugin CTA, above the prompt).
    pub follow_ups: Rect,
    pub prompt: Rect,
    pub shortcuts: Rect,
    /// Scrollback area narrowed for scrollbar (content rendering uses this).
    pub scrollback_content: Rect,
    /// Scrollbar track position (x coordinate).
    pub scrollbar_x: u16,
    /// Timeline rail left edge (only meaningful when `timeline_width > 0`;
    /// the rail's right edge lands on the scrollbar column, which the rail
    /// replaces).
    pub timeline_x: u16,
    /// Columns reserved for the timeline rail (0 = rail hidden). Non-zero
    /// also means the scrollbar does not render this frame.
    pub timeline_width: u16,
}
impl AgentViewLayout {
    /// Compute layout from screen area, appearance config, prompt height,
    /// todo pane height, turn status height, and prompt gap.
    ///
    /// When `todo_height` is 0, the todo pane and its gap are omitted.
    /// When `turn_status_height` is 0, the turn status line is omitted.
    /// When `banner_height` is 0, the reserved banner row is omitted.
    /// When `follow_ups_height` is 0, the follow-up chips row is omitted; it
    /// is force-suppressed on short terminals (`area.height <=
    /// SHORT_TERMINAL_ROWS`) so the prompt and scrollback are never starved.
    /// When `startup_warning_height` is 0, the startup warning area is omitted.
    /// `prompt_gap` is 0 or 1 — controls the gap row between turn status
    /// (or scrollback) and the prompt widget.
    /// `timeline_width` reserves rail columns for the timeline sidebar in
    /// place of the scrollbar (0 = hidden); it requires the scrollbar's
    /// gutter geometry, so a disabled scrollbar forces it to 0.
    #[allow(clippy::too_many_arguments)]
    pub fn compute(
        area: Rect,
        layout_cfg: &LayoutConfig,
        scrollbar_cfg: &ScrollbarConfig,
        timeline_width: u16,
        prompt_height: u16,
        tasks_height: u16,
        catalog_height: u16,
        todo_height: u16,
        queue_height: u16,
        btw_height: u16,
        turn_status_height: u16,
        banner_height: u16,
        follow_ups_height: u16,
        startup_warning_height: u16,
        prompt_gap: u16,
        shortcuts_height: u16,
        compact: bool,
    ) -> Self {
        let outer_vpad = layout_cfg.eff_outer_vpad(compact);
        let bottom_vpad = if area.height <= SHORT_TERMINAL_ROWS {
            0
        } else {
            outer_vpad
        };
        let follow_ups_height = if area.height <= SHORT_TERMINAL_ROWS {
            0
        } else {
            follow_ups_height
        };
        let top_vpad = outer_vpad;
        let outer_block = Block::default().padding(Padding::new(
            layout_cfg.eff_hpad_left(compact),
            layout_cfg.eff_hpad_right(compact),
            top_vpad,
            bottom_vpad,
        ));
        let inner_area = outer_block.inner(area);
        let mut constraints = vec![Constraint::Length(1)];
        if startup_warning_height > 0 {
            constraints.push(Constraint::Length(startup_warning_height));
        }
        let pane_gap = if top_vpad == 0 { 0u16 } else { 1 };
        if tasks_height > 0 {
            constraints.push(Constraint::Length(pane_gap));
            constraints.push(Constraint::Length(tasks_height));
        }
        if catalog_height > 0 {
            constraints.push(Constraint::Length(pane_gap));
            constraints.push(Constraint::Length(catalog_height));
        }
        if todo_height > 0 {
            constraints.push(Constraint::Length(pane_gap));
            constraints.push(Constraint::Length(todo_height));
        }
        let status_gap = if top_vpad == 0 { 0u16 } else { 1 };
        constraints.push(Constraint::Length(status_gap));
        constraints.push(Constraint::Min(5));
        if btw_height > 0 {
            constraints.push(Constraint::Length(1));
            constraints.push(Constraint::Length(btw_height));
        }
        if queue_height > 0 {
            constraints.push(Constraint::Length(1));
            constraints.push(Constraint::Length(queue_height));
        }
        if turn_status_height > 0 {
            constraints.push(Constraint::Length(1));
            constraints.push(Constraint::Length(turn_status_height));
        }
        if banner_height > 0 {
            constraints.push(Constraint::Length(1));
            constraints.push(Constraint::Length(banner_height));
        }
        if follow_ups_height > 0 {
            constraints.push(Constraint::Length(1));
            constraints.push(Constraint::Length(follow_ups_height));
        }
        if prompt_gap > 0 {
            constraints.push(Constraint::Length(prompt_gap));
        }
        constraints.push(Constraint::Length(prompt_height));
        let shortcuts_gap = if bottom_vpad == 0 { 0u16 } else { 1 };
        if shortcuts_gap > 0 {
            constraints.push(Constraint::Length(shortcuts_gap));
        }
        constraints.push(Constraint::Length(shortcuts_height));
        let chunks = Layout::vertical(constraints).split(inner_area);
        let mut i = 0;
        let status_bar = chunks[i];
        i += 1;
        let startup_warnings = if startup_warning_height > 0 {
            let r = chunks[i];
            i += 1;
            r
        } else {
            Rect::default()
        };
        let tasks = if tasks_height > 0 {
            i += 1;
            let r = chunks[i];
            i += 1;
            r
        } else {
            Rect::default()
        };
        let catalog = if catalog_height > 0 {
            i += 1;
            let r = chunks[i];
            i += 1;
            r
        } else {
            Rect::default()
        };
        let todo = if todo_height > 0 {
            i += 1;
            let r = chunks[i];
            i += 1;
            r
        } else {
            Rect::default()
        };
        i += 1;
        let scrollback = chunks[i];
        i += 1;
        let btw = if btw_height > 0 {
            i += 1;
            let r = chunks[i];
            i += 1;
            r
        } else {
            Rect::default()
        };
        let queue = if queue_height > 0 {
            i += 1;
            let r = chunks[i];
            i += 1;
            r
        } else {
            Rect::default()
        };
        let turn_status = if turn_status_height > 0 {
            i += 1;
            let r = chunks[i];
            i += 1;
            r
        } else {
            Rect::default()
        };
        let banner = if banner_height > 0 {
            i += 1;
            let r = chunks[i];
            i += 1;
            r
        } else {
            Rect::default()
        };
        let follow_ups = if follow_ups_height > 0 {
            i += 1;
            let r = chunks[i];
            i += 1;
            r
        } else {
            Rect::default()
        };
        if prompt_gap > 0 {
            i += 1;
        }
        let prompt = chunks[i];
        i += 1;
        if shortcuts_gap > 0 {
            i += 1;
        }
        let shortcuts = chunks[i];
        let scrollbar_x = area.right().saturating_sub(scrollbar_cfg.gap_right + 1);
        let timeline_width = if scrollbar_cfg.enabled {
            timeline_width
        } else {
            0
        };
        let timeline_x = (scrollbar_x + 1).saturating_sub(timeline_width);
        let content_end_x = if timeline_width > 0 {
            timeline_x.saturating_sub(scrollbar_cfg.gap_left)
        } else {
            scrollbar_x.saturating_sub(scrollbar_cfg.gap_left)
        };
        let scrollback_right = scrollback.x + scrollback.width;
        let scrollback_content = if !scrollbar_cfg.enabled || content_end_x >= scrollback_right {
            scrollback
        } else {
            Rect {
                width: content_end_x.saturating_sub(scrollback.x),
                ..scrollback
            }
        };
        Self {
            status_bar,
            startup_warnings,
            tasks,
            catalog,
            scrollback,
            todo,
            queue,
            btw,
            turn_status,
            banner,
            follow_ups,
            prompt,
            shortcuts,
            scrollback_content,
            scrollbar_x,
            timeline_x,
            timeline_width,
        }
    }
    /// Inner area width (for prompt height computation before full layout).
    ///
    /// This computes just the inner width without the full layout split,
    /// since prompt height is needed as input to `compute()`.
    pub fn inner_width(area: Rect, layout_cfg: &LayoutConfig, compact: bool) -> u16 {
        let vpad = layout_cfg.eff_outer_vpad(compact);
        let outer_block = Block::default().padding(Padding::new(
            layout_cfg.eff_hpad_left(compact),
            layout_cfg.eff_hpad_right(compact),
            vpad,
            vpad,
        ));
        outer_block.inner(area).width
    }
    /// Convert to PaneAreas for mouse hit-testing.
    pub fn pane_areas(&self) -> PaneAreas {
        PaneAreas {
            scrollback: self.scrollback,
            todo: self.todo,
            queue: self.queue,
            prompt: self.prompt,
            tasks: self.tasks,
            catalog: self.catalog,
        }
    }
}
/// Fill the screen area with base background and outer padding.
pub fn fill_background(
    buf: &mut Buffer,
    area: Rect,
    layout_cfg: &LayoutConfig,
    compact: bool,
    theme: &Theme,
) {
    buf.set_style(area, Style::default().bg(theme.bg_base));
    let vpad = layout_cfg.eff_outer_vpad(compact);
    let outer_styled = Block::default()
        .padding(Padding::new(
            layout_cfg.eff_hpad_left(compact),
            layout_cfg.eff_hpad_right(compact),
            vpad,
            vpad,
        ))
        .style(Style::default().bg(theme.bg_base));
    outer_styled.render(area, buf);
}
/// Render follow-up suggestion chips into a single row, returning the
/// clickable rect of each rendered chip.
///
/// Chips render left-to-right and rendering STOPS at the first chip that does
/// not fit the row width — the result is a rendered prefix of `suggestions`
/// (index-aligned), not a filtered subset. A transient, mouse-clickable
/// affordance above the prompt — the same row slot family as the plugin CTA.
/// Suggestion text is server-controlled and already sanitized at ingestion;
/// here it is additionally length-clamped per chip and written through
/// `set_span_safe`, so a label can neither overflow the row nor inject
/// terminal escape sequences.
///
/// `hovered` highlights the chip under the mouse (`theme.bg_hover` + primary text).
pub(crate) fn render_follow_ups(
    area: Rect,
    buf: &mut Buffer,
    theme: &Theme,
    suggestions: &[String],
    hovered: Option<usize>,
) -> Vec<Rect> {
    use crate::render::line_utils::truncate_str;
    use unicode_width::UnicodeWidthStr;
    let mut rects = Vec::new();
    if area.height == 0 || area.width == 0 {
        return rects;
    }
    for col in 0..area.width {
        if let Some(cell) = buf.cell_mut((area.x + col, area.y)) {
            cell.set_char(' ');
            cell.fg = theme.text_secondary;
            cell.bg = theme.bg_base;
        }
    }
    const MAX_CHIP_LABEL: usize = 48;
    let chip_style = Style::default().fg(theme.link_fg);
    let hover_style = Style::default().fg(theme.text_primary).bg(theme.bg_hover);
    let row_end = area.x + area.width;
    let mut x = area.x;
    for (i, label) in suggestions.iter().enumerate() {
        let label = truncate_str(label, MAX_CHIP_LABEL);
        let chip = format!("[ {label} ]");
        let chip_w = chip.width() as u16;
        if x >= row_end || chip_w > row_end - x {
            break;
        }
        let style = if hovered == Some(i) {
            hover_style
        } else {
            chip_style
        };
        buf.set_span_safe(x, area.y, &Span::styled(chip, style), chip_w);
        rects.push(Rect::new(x, area.y, chip_w, 1));
        x = x.saturating_add(chip_w).saturating_add(1);
    }
    rects
}
/// Render hover box around a scrollback entry (dimmed selection border).
///
/// Skipped if the hovered entry is also the selected entry (selection box wins).
pub fn render_entry_hover(
    buf: &mut Buffer,
    scrollback_area: Rect,
    scrollback: &ScrollbackState,
    hovered_entry: Option<usize>,
    theme: &Theme,
) {
    let Some(hover_idx) = hovered_entry else {
        return;
    };
    if scrollback.selected() == Some(hover_idx) {
        return;
    }
    let split_mode = scrollback
        .appearance()
        .scrollback
        .display
        .group_selection_split;
    let group = scrollback.group_range_of(hover_idx, split_mode);
    if group.len() <= 1 {
        if let Some((area, top_clipped, bottom_clipped)) =
            scrollback.entry_screen_area(hover_idx, scrollback_area)
        {
            SelectionBox::new(area, Style::default().fg(theme.hover_border))
                .with_top_clipped(top_clipped)
                .with_bottom_clipped(bottom_clipped)
                .render(buf);
        }
    } else {
        let first_area = scrollback.entry_screen_area(group.start, scrollback_area);
        let last_area = scrollback.entry_screen_area(group.end - 1, scrollback_area);
        if let (
            Some((first_rect, first_top_clipped, _)),
            Some((last_rect, _, last_bottom_clipped)),
        ) = (first_area, last_area)
        {
            let y_start = first_rect.y;
            let y_end = last_rect.y + last_rect.height;
            let combined = Rect::new(first_rect.x, y_start, first_rect.width, y_end - y_start);
            SelectionBox::new(combined, Style::default().fg(theme.hover_border))
                .with_top_clipped(first_top_clipped)
                .with_bottom_clipped(last_bottom_clipped)
                .render(buf);
        }
    }
}
/// Render a floating popup showing hook details when hovering over
/// a collapsed tool call entry that has hooks.
pub fn render_hook_hover_popup(
    buf: &mut Buffer,
    scrollback_area: Rect,
    scrollback: &ScrollbackState,
    hovered_entry: Option<usize>,
    mouse_pos: (u16, u16),
    theme: &Theme,
) {
    let Some(hover_idx) = hovered_entry else {
        return;
    };
    let Some(entry) = scrollback.get(hover_idx) else {
        return;
    };
    if entry.display_mode != crate::scrollback::types::DisplayMode::Collapsed {
        return;
    }
    let Some(ref hd) = entry.hook_data else {
        return;
    };
    if !hd.has_content() {
        return;
    }
    use crate::scrollback::blocks::tool::hook::render_hooks_for_mode;
    let mode = crate::scrollback::types::DisplayMode::Expanded;
    let mut lines = Vec::new();
    let pre = render_hooks_for_mode("pre_tool_use", &hd.pre_hooks, mode);
    let post = render_hooks_for_mode("post_tool_use", &hd.post_hooks, mode);
    lines.extend(pre);
    lines.extend(post);
    for (event_name, runs) in &hd.lifecycle {
        lines.extend(render_hooks_for_mode(event_name, runs, mode));
    }
    if lines.is_empty() {
        return;
    }
    let Some((entry_area, _, _)) = scrollback.entry_screen_area(hover_idx, scrollback_area) else {
        return;
    };
    let (mouse_col, mouse_row) = mouse_pos;
    if mouse_row < entry_area.y || mouse_row >= entry_area.y + entry_area.height {
        return;
    }
    let badge_row = entry_area.y;
    let row_start = scrollback_area.x;
    let row_end = scrollback_area.x + scrollback_area.width;
    let row_text: String = (row_start..row_end)
        .filter_map(|col| {
            buf.cell(ratatui::layout::Position::new(col, badge_row))
                .map(|c| c.symbol().to_string())
        })
        .collect();
    let Some(badge_start_in_text) = row_text.find("[hooks:") else {
        return;
    };
    let badge_end_in_text = row_text[badge_start_in_text..]
        .find(']')
        .map(|i| badge_start_in_text + i + 1)
        .unwrap_or(row_text.len());
    let badge_start_col = row_start + badge_start_in_text as u16;
    let badge_end_col = row_start + badge_end_in_text as u16;
    if mouse_row != badge_row || mouse_col < badge_start_col || mouse_col >= badge_end_col {
        return;
    }
    let buf_height = buf.area.height;
    let buf_width = buf.area.width;
    let popup_height = (lines.len() as u16 + 2).min(15).min(buf_height);
    let popup_width = scrollback_area
        .width
        .saturating_sub(8)
        .max(40)
        .min(buf_width);
    let popup_y = if entry_area.y + entry_area.height + popup_height
        <= scrollback_area.y + scrollback_area.height
    {
        entry_area.y + entry_area.height
    } else {
        entry_area.y.saturating_sub(popup_height)
    };
    let popup_x = entry_area.x + 4;
    let popup_area = Rect::new(
        popup_x.min(scrollback_area.x + scrollback_area.width.saturating_sub(popup_width)),
        popup_y,
        popup_width,
        popup_height.min(buf_height.saturating_sub(popup_y)),
    );
    let bg = theme.bg_base;
    let clear_style = ratatui::style::Style::default().bg(bg);
    for y in popup_area.y..popup_area.y + popup_area.height {
        for x in popup_area.x..popup_area.x + popup_area.width {
            if let Some(cell) = buf.cell_mut(ratatui::layout::Position::new(x, y)) {
                cell.reset();
                cell.set_style(clear_style);
            }
        }
    }
    let border_style = ratatui::style::Style::default().fg(theme.gray);
    let block = ratatui::widgets::Block::default()
        .borders(ratatui::widgets::Borders::ALL)
        .border_type(ratatui::widgets::BorderType::Rounded)
        .border_style(border_style)
        .style(ratatui::style::Style::default().bg(bg));
    let inner = block.inner(popup_area);
    ratatui::widgets::Widget::render(block, popup_area, buf);
    for (i, line) in lines.iter().enumerate() {
        let y = inner.y + i as u16;
        if y >= inner.y + inner.height {
            break;
        }
        buf.set_line_safe(inner.x, y, &line.content, inner.width);
    }
}
/// Selection/hover chrome for a side pane (todo / queue / tasks). Focused panes get a dismiss control.
pub fn render_todo_chrome(
    buf: &mut Buffer,
    todo_area: Rect,
    layout_cfg: &LayoutConfig,
    focused: bool,
    hovered: bool,
    close_hovered: bool,
    theme: &Theme,
) -> Option<SelectionBox> {
    render_todo_chrome_with_close_label(
        buf,
        todo_area,
        layout_cfg,
        focused,
        hovered,
        close_hovered,
        theme,
        None,
    )
}
/// Like [`render_todo_chrome`], with optional close label (queue uses `[close]`).
#[allow(clippy::too_many_arguments)]
pub fn render_todo_chrome_with_close_label(
    buf: &mut Buffer,
    todo_area: Rect,
    layout_cfg: &LayoutConfig,
    focused: bool,
    hovered: bool,
    close_hovered: bool,
    theme: &Theme,
    close_label: Option<&'static str>,
) -> Option<SelectionBox> {
    if todo_area.area() == 0 {
        return None;
    }
    let color = if focused {
        theme.selection_border
    } else if hovered {
        theme.hover_border
    } else {
        return None;
    };
    let layout = HorizontalLayout::new(todo_area, layout_cfg);
    let mut sel = SelectionBox::new(layout.selection_area(), Style::default().fg(color))
        .with_closable(focused, close_hovered);
    if focused && let Some(label) = close_label {
        sel = sel.with_close_label(Some(label));
    }
    sel.render(buf);
    Some(sel)
}
/// Render the scrollbar track and thumb.
///
/// When `is_following` is true, the scrollbar thumb is dimmed to indicate
/// the viewport is locked to the bottom. This makes G / follow state
/// immediately visible in the scrollbar.
pub fn render_scrollbar(
    buf: &mut Buffer,
    scrollback_area: Rect,
    scrollbar_x: u16,
    scrollbar_cfg: &ScrollbarConfig,
    scroll_info: Option<crate::scrollback::selection::ScrollInfo>,
    is_following: bool,
    theme: &Theme,
) {
    if scrollbar_cfg.enabled
        && let Some(scroll_info) = scroll_info
    {
        let scrollbar_area = Rect {
            x: scrollbar_x,
            y: scrollback_area.y,
            width: 1,
            height: scrollback_area.height,
        };
        let scrollbar_bg = scrollbar_cfg.scrollbar_bg.unwrap_or(theme.scrollbar_bg);
        let scrollbar_fg = scrollbar_cfg.scrollbar_fg.unwrap_or(theme.scrollbar_fg);
        let thumb_fg = if is_following {
            crate::render::color::blend_color(scrollbar_bg, scrollbar_fg, 0.4)
                .unwrap_or(scrollbar_fg)
        } else {
            scrollbar_fg
        };
        let track_style = Style::default().bg(scrollbar_bg);
        let thumb_style = Style::default().fg(thumb_fg).bg(scrollbar_bg);
        let scale = if scroll_info.total_height > u16::MAX as usize {
            (scroll_info.total_height / u16::MAX as usize) + 1
        } else {
            1
        };
        let scaled_total = (scroll_info.total_height / scale) as u16;
        let scaled_offset = (scroll_info.scroll_offset / scale) as u16;
        render_scrollbar_styled(
            buf,
            Some(scrollbar_area),
            scaled_total,
            scroll_info.viewport_height,
            scaled_offset,
            track_style,
            thumb_style,
        );
    }
}
use crate::appearance::TodoBadgeFormat;
/// Build the todo badge as styled spans (without rendering).
///
/// Returns `None` if there are no items to display.
pub fn render_todo_badge_spans(
    counts: &super::todo_pane::TodoCounts,
    hovered: bool,
    flash: bool,
    format: TodoBadgeFormat,
    theme: &Theme,
) -> Option<Vec<Span<'static>>> {
    use ratatui::style::Modifier;
    if counts.total() == 0 {
        return None;
    }
    let highlighted = hovered || flash;
    let count_style = if highlighted {
        Style::default()
            .fg(theme.text_primary)
            .bg(theme.bg_base)
            .add_modifier(Modifier::BOLD)
    } else {
        Style::default().fg(theme.text_secondary).bg(theme.bg_base)
    };
    let dim_style = if highlighted {
        Style::default().fg(theme.text_secondary).bg(theme.bg_base)
    } else {
        Style::default().fg(theme.gray_dim).bg(theme.bg_base)
    };
    if matches!(format, TodoBadgeFormat::Default) {
        let total = counts.total_excluding_cancelled();
        if total == 0 {
            return None;
        }
        return Some(vec![
            Span::styled(counts.completed.to_string(), count_style),
            Span::styled("/", dim_style),
            Span::styled(total.to_string(), count_style),
            Span::styled(
                format!(" {}", crate::glyphs::check_mark()),
                Style::default().fg(theme.text_secondary).bg(theme.bg_base),
            ),
        ]);
    }
    let icon_mod = if highlighted {
        Modifier::BOLD
    } else {
        Modifier::empty()
    };
    let statuses: [(usize, &str, ratatui::style::Color); 4] = [
        (counts.in_progress, "▶", theme.warning),
        (counts.pending, "□", theme.text_secondary),
        (
            counts.completed,
            crate::glyphs::check_mark(),
            theme.accent_success,
        ),
        (
            counts.cancelled,
            crate::glyphs::ballot_x(),
            theme.accent_error,
        ),
    ];
    let parts: Vec<(usize, &str, ratatui::style::Color)> =
        statuses.into_iter().filter(|(n, _, _)| *n > 0).collect();
    if parts.is_empty() {
        return None;
    }
    let mut spans: Vec<Span<'static>> = Vec::with_capacity(parts.len() * 4);
    for (i, (count, icon, color)) in parts.iter().enumerate() {
        let icon_style = Style::default()
            .fg(*color)
            .bg(theme.bg_base)
            .add_modifier(icon_mod);
        let num_style = match format {
            TodoBadgeFormat::Default => {
                let mut s = Style::default().fg(*color).bg(theme.bg_base);
                if highlighted {
                    s = s.add_modifier(Modifier::BOLD);
                }
                s
            }
            _ => count_style,
        };
        match format {
            TodoBadgeFormat::Default => {
                if i > 0 {
                    spans.push(Span::styled(" ", dim_style));
                }
                spans.push(Span::styled(format!("{count}"), num_style));
            }
            TodoBadgeFormat::Colon => {
                if i > 0 {
                    spans.push(Span::styled(" ", dim_style));
                }
                spans.push(Span::styled(*icon, icon_style));
                spans.push(Span::styled(":", dim_style));
                spans.push(Span::styled(format!("{count}"), num_style));
            }
            TodoBadgeFormat::Comma => {
                if i > 0 {
                    spans.push(Span::styled(", ", dim_style));
                }
                spans.push(Span::styled(format!("{count}"), num_style));
                spans.push(Span::styled(" ", dim_style));
                spans.push(Span::styled(*icon, icon_style));
            }
        }
    }
    spans.push(Span::styled(
        format!(" {}", crate::glyphs::check_mark()),
        Style::default().fg(theme.text_secondary).bg(theme.bg_base),
    ));
    Some(spans)
}
/// Space:prompt hint — shared across multiple scrollback hint branches.
fn space_prompt_hint() -> HintItem {
    use crate::input::key::KeyShortcut;
    use crossterm::event::{KeyCode, KeyModifiers};
    HintItem {
        keys: vec![KeyShortcut::new(KeyCode::Char(' '), KeyModifiers::NONE)],
        label: "prompt".into(),
        custom_display: Some("Space"),
        description: None,
        pinned: false,
    }
}
/// Build the hints list for the shortcuts bar based on current state.
///
/// Each pane contributes its own hints dynamically. The registry provides
/// the key bindings; the view decides which ones are visible.
///
/// `fold_label` is the dynamic label for the fold action based on selected
/// entry state: "expand", "collapse", or "fold" (no foldable entry selected).
///
/// `group_header_label` ("expand"/"collapse") marks a selected group header;
/// it replaces the fold and Enter:open hints with a single Enter toggle hint.
#[allow(clippy::too_many_arguments)]
pub fn build_hints(
    active_pane: ActivePane,
    prompt: &PromptWidget,
    registry: &ActionRegistry,
    is_editing_queued: bool,
    fold_label: Option<&'static str>,
    group_header_label: Option<&'static str>,
    thinking_label: &'static str,
    show_done: bool,
    selected_supports_copy: bool,
    selected_meta_label: Option<&'static str>,
    selected_supports_fullscreen: bool,
    can_demote: bool,
    selected_can_kill: bool,
    multiline_mode: bool,
    vim_mode: bool,
    is_subagent_view: bool,
    is_turn_running: bool,
    has_queued_follow_up: bool,
    selected_is_user_prompt: bool,
    selected_is_agent_message: bool,
    shift_enter_unavailable: bool,
    scrollback_search: Option<&ScrollbackSearchState>,
) -> Vec<HintItem> {
    let mut hints = match active_pane {
        ActivePane::Todo => {
            let mut hints = Vec::new();
            hints.push(HintItem::new(
                crate::key!('h'),
                if show_done { "hide done" } else { "show done" },
            ));
            hints
        }
        ActivePane::Queue => {
            let mut hints = vec![
                HintItem::new(crate::key!('x'), "delete row"),
                HintItem::new(crate::key!('e'), "edit"),
                HintItem::paired(crate::key!('J'), crate::key!('K'), "reorder"),
                HintItem::new(crate::key!('y'), "copy"),
            ];
            if is_turn_running && let Some(def) = registry.find(ActionId::InterjectPrompt) {
                hints.push(def.hint());
            }
            hints
        }
        ActivePane::Prompt if is_editing_queued => {
            let mut hints = Vec::new();
            if prompt.can_send() {
                hints.push(HintItem::new(crate::key!(Enter), "save"));
            }
            hints.push(HintItem::new(crate::key!(Esc), "cancel"));
            hints
        }
        ActivePane::Prompt if prompt.history_search.is_active() => {
            use crate::input::key::KeyShortcut;
            use crossterm::event::KeyCode;
            vec![
                HintItem::paired(
                    KeyShortcut::key(KeyCode::Up),
                    KeyShortcut::key(KeyCode::Down),
                    "nav",
                ),
                HintItem::paired(
                    KeyShortcut::key(KeyCode::PageUp),
                    KeyShortcut::key(KeyCode::PageDown),
                    "page",
                ),
                HintItem::new(KeyShortcut::key(KeyCode::Enter), "select"),
                HintItem::new(KeyShortcut::key(KeyCode::Esc), "cancel"),
            ]
        }
        ActivePane::Prompt => {
            let mut hints = Vec::new();
            let newline_key = if shift_enter_unavailable {
                crate::key!(Enter, ALT)
            } else {
                crate::key!(Enter, SHIFT)
            };
            let submit_label = if is_turn_running { "queue" } else { "send" };
            if let Some(key) = registry.key_for(ActionId::SendPrompt) {
                if prompt.paste_element_at_cursor().is_some() {
                    hints.push(HintItem::new(key, "expand"));
                } else if multiline_mode && prompt.can_send() {
                    hints.push(HintItem::new(newline_key, submit_label));
                } else if prompt.can_send() {
                    hints.push(HintItem::new(key, submit_label));
                } else if is_turn_running && has_queued_follow_up {
                    hints.push(HintItem::new(key, "send now"));
                }
            }
            if shift_enter_unavailable && !multiline_mode && prompt.can_send() {
                hints.push(HintItem::new(crate::key!(Enter, ALT), "newline"));
            }
            if prompt.file_ref_near_cursor() {
                hints.push(HintItem::new(crate::key!(':'), "lines"));
            }
            if prompt.prompt_suggestion_visible() {
                hints.push(
                    HintItem::paired(crate::key!(Tab), crate::key!(Right), "accept suggestion")
                        .pinned(),
                );
            }
            hints.push(HintItem::new(crate::key!(BackTab), "mode"));
            for def in registry.hints(&[When::PromptFocused, When::AgentScreen, When::Always]) {
                if def.id == ActionId::SendPrompt
                    || def.id == ActionId::CommandPalette
                    || def.id == ActionId::Quit
                {
                    continue;
                }
                hints.push(def.hint());
            }
            hints
        }
        ActivePane::Tasks => {
            let mut hints = Vec::new();
            if selected_supports_fullscreen {
                hints.push(HintItem::new(crate::key!(Enter), "view"));
            }
            if selected_supports_copy {
                hints.push(HintItem::new(crate::key!('y'), "copy output"));
            }
            if selected_can_kill {
                hints.push(HintItem::new(crate::key!('x'), "kill"));
            }
            hints.push(HintItem::new(
                crate::key!('h'),
                if show_done { "hide done" } else { "show done" },
            ));
            hints
        }
        ActivePane::Catalog => vec![],
        ActivePane::Scrollback if scrollback_search.is_some() => {
            let mut hints = Vec::new();
            if vim_mode {
                if scrollback_search.is_some_and(|s| s.is_composing()) {
                    hints.push(HintItem::new(crate::key!(Enter), "go"));
                } else {
                    hints.push(HintItem::paired(
                        crate::key!('n'),
                        crate::key!('N'),
                        "next/prev",
                    ));
                }
            } else {
                use crate::input::key::KeyShortcut;
                use crossterm::event::KeyCode;
                hints.push(HintItem::paired(
                    KeyShortcut::key(KeyCode::Down),
                    KeyShortcut::key(KeyCode::Up),
                    "next/prev",
                ));
            }
            hints.push(HintItem::new(crate::key!(Esc), "cancel"));
            hints
        }
        ActivePane::Scrollback => {
            let mut hints = Vec::new();
            let nothing_special = !selected_is_agent_message
                && !selected_is_user_prompt
                && fold_label.is_none()
                && group_header_label.is_none()
                && !selected_supports_fullscreen;
            if nothing_special {
                hints.push(space_prompt_hint());
            }
            if selected_is_agent_message {
                if vim_mode
                    && selected_supports_copy
                    && let Some(key) = registry.key_for(ActionId::CopyBlockContent)
                {
                    hints.push(HintItem::new(key, "copy"));
                }
                hints.push(space_prompt_hint());
            }
            if selected_is_user_prompt {
                let user_collapsed = fold_label == Some("expand");
                if user_collapsed {
                    let key = registry
                        .key_for_mode(ActionId::ToggleFold, vim_mode)
                        .or_else(|| registry.key_for_mode(ActionId::Expand, vim_mode));
                    if let Some(key) = key {
                        hints.push(HintItem::new(key, "expand"));
                    }
                }
                if let Some(key) = registry.key_for(ActionId::ExpandAllThinking) {
                    hints.push(HintItem::new(key, thinking_label));
                }
                if !user_collapsed {
                    hints.push(space_prompt_hint());
                }
            }
            let user_collapsed_already_pushed =
                selected_is_user_prompt && fold_label == Some("expand");
            if let Some(label) = group_header_label {
                if let Some(key) = registry.key_for(ActionId::OpenBlockViewer) {
                    hints.push(HintItem::new(key, label));
                }
            } else if !user_collapsed_already_pushed && let Some(label) = fold_label {
                let directional = if label == "expand" {
                    ActionId::Expand
                } else {
                    ActionId::Collapse
                };
                let key = registry
                    .key_for_mode(ActionId::ToggleFold, vim_mode)
                    .or_else(|| registry.key_for_mode(directional, vim_mode));
                if let Some(key) = key {
                    hints.push(HintItem::new(key, label));
                }
            }
            if group_header_label.is_none()
                && selected_supports_fullscreen
                && let Some(key) = registry.key_for(ActionId::OpenBlockViewer)
            {
                hints.push(HintItem::new(key, "open"));
            }
            if vim_mode
                && let (Some(j), Some(k)) = (
                    registry.key_for(ActionId::SelectNext),
                    registry.key_for(ActionId::SelectPrev),
                )
            {
                hints.push(HintItem::paired(j, k, "nav").pinned());
            }
            if vim_mode
                && let (Some(h), Some(l)) = (
                    registry.key_for(ActionId::PrevTurn),
                    registry.key_for(ActionId::NextTurn),
                )
            {
                let mut hint = HintItem::paired(l, h, "turn").pinned();
                hint.custom_display = Some("Shift+l/h");
                hints.push(hint);
            }
            if !selected_is_user_prompt
                && let Some(key) = registry.key_for(ActionId::ExpandAllThinking)
            {
                hints.push(HintItem::new(key, thinking_label));
            }
            if vim_mode
                && let (Some(g), Some(bg)) = (
                    registry.key_for(ActionId::GotoTop),
                    registry.key_for(ActionId::GotoBottom),
                )
            {
                hints.push(HintItem::paired(g, bg, "top/btm"));
            }
            if vim_mode
                && !selected_is_agent_message
                && selected_supports_copy
                && let Some(key) = registry.key_for(ActionId::CopyBlockContent)
            {
                hints.push(HintItem::new(key, "copy"));
            }
            if vim_mode
                && let Some(label) = selected_meta_label
                && let Some(key) = registry.key_for(ActionId::CopyBlockMeta)
            {
                hints.push(HintItem::new(key, label));
            }
            if selected_can_kill {
                hints.push(HintItem::new(crate::key!('x'), "kill"));
            }
            if is_subagent_view {
                hints.push(HintItem::paired(crate::key!('q'), crate::key!(Esc), "back"));
            }
            hints
        }
    };
    if is_turn_running && let Some(def) = registry.find(ActionId::CancelTurn) {
        hints.push(def.hint());
    }
    let has_composer_payload = !prompt.text().trim().is_empty() || is_editing_queued;
    if matches!(active_pane, ActivePane::Prompt)
        && ActionRegistry::interjection_possible(is_turn_running, has_composer_payload)
        && let Some(def) = registry.find(ActionId::InterjectPrompt)
    {
        hints.push(def.hint());
    }
    if can_demote {
        hints.push(HintItem::new(crate::key!('g', CONTROL), "send to bg"));
    }
    hints
}
#[cfg(test)]
mod tests {
    use super::*;
    use crate::actions::ActionRegistry;
    /// Convenience: build hints for the Scrollback pane with sensible defaults.
    /// Override only what each test cares about.
    #[allow(clippy::too_many_arguments)]
    fn scrollback_hints(
        registry: &ActionRegistry,
        fold_label: Option<&'static str>,
        selected_supports_copy: bool,
        selected_supports_fullscreen: bool,
        selected_is_user_prompt: bool,
        selected_is_agent_message: bool,
    ) -> Vec<HintItem> {
        scrollback_hints_with_vim_mode(
            registry,
            fold_label,
            selected_supports_copy,
            selected_supports_fullscreen,
            selected_is_user_prompt,
            selected_is_agent_message,
            true,
        )
    }
    fn scrollback_hints_with_vim_mode(
        registry: &ActionRegistry,
        fold_label: Option<&'static str>,
        selected_supports_copy: bool,
        selected_supports_fullscreen: bool,
        selected_is_user_prompt: bool,
        selected_is_agent_message: bool,
        vim_mode: bool,
    ) -> Vec<HintItem> {
        build_hints(
            ActivePane::Scrollback,
            &PromptWidget::default(),
            registry,
            false,
            fold_label,
            None,
            "expand thinking",
            false,
            selected_supports_copy,
            None,
            selected_supports_fullscreen,
            false,
            false,
            false,
            vim_mode,
            false,
            false,
            false,
            selected_is_user_prompt,
            selected_is_agent_message,
            false,
            None,
        )
    }
    fn first_two_labels(hints: &[HintItem]) -> Vec<&str> {
        hints.iter().take(2).map(|h| h.label.as_ref()).collect()
    }
    #[test]
    fn group_header_shows_enter_toggle_hint_instead_of_open_and_fold() {
        let registry = ActionRegistry::defaults();
        let hints = build_hints(
            ActivePane::Scrollback,
            &PromptWidget::default(),
            &registry,
            false,
            Some("expand"),
            Some("expand"),
            "expand thinking",
            false,
            false,
            None,
            true,
            false,
            false,
            false,
            true,
            false,
            false,
            false,
            false,
            false,
            false,
            None,
        );
        let labels: Vec<&str> = hints.iter().map(|h| h.label.as_ref()).collect();
        assert!(
            !labels.contains(&"open"),
            "no Enter:open hint on a group header, got {labels:?}"
        );
        let expand_hints: Vec<&HintItem> = hints.iter().filter(|h| h.label == "expand").collect();
        assert_eq!(
            expand_hints.len(),
            1,
            "exactly one expand hint (Enter toggle, no separate fold hint), got {labels:?}"
        );
        let enter_key = registry
            .key_for(crate::actions::ActionId::OpenBlockViewer)
            .expect("OpenBlockViewer has a default key");
        assert_eq!(
            expand_hints[0].keys,
            vec![enter_key],
            "the header expand hint must be bound to Enter (OpenBlockViewer)"
        );
        assert_eq!(
            labels.first(),
            Some(&"expand"),
            "the Enter toggle takes the first compact slot, got {labels:?}"
        );
    }
    #[test]
    fn scrollback_user_prompt_collapsed_hoists_expand_then_thinking() {
        let registry = ActionRegistry::defaults();
        let hints = scrollback_hints(&registry, Some("expand"), false, false, true, false);
        assert_eq!(first_two_labels(&hints), vec!["expand", "expand thinking"]);
    }
    #[test]
    fn scrollback_user_prompt_expanded_hoists_thinking_then_space() {
        let registry = ActionRegistry::defaults();
        let hints = scrollback_hints(&registry, Some("collapse"), false, false, true, false);
        assert_eq!(first_two_labels(&hints), vec!["expand thinking", "prompt"]);
    }
    #[test]
    fn scrollback_agent_message_hoists_copy_then_space() {
        let registry = ActionRegistry::defaults();
        let hints = scrollback_hints(&registry, None, true, false, false, true);
        assert_eq!(first_two_labels(&hints), vec!["copy", "prompt"]);
    }
    #[test]
    fn scrollback_agent_message_no_duplicate_y_copy_in_full_list() {
        let registry = ActionRegistry::defaults();
        let hints = scrollback_hints(&registry, None, true, false, false, true);
        let copy_count = hints.iter().filter(|h| h.label == "copy").count();
        assert_eq!(copy_count, 1, "copy should appear exactly once");
    }
    #[test]
    fn scrollback_default_block_shows_prompt_first() {
        let registry = ActionRegistry::defaults();
        let hints = scrollback_hints(&registry, None, false, false, false, false);
        assert_eq!(first_two_labels(&hints), vec!["prompt", "nav"]);
    }
    #[test]
    fn scrollback_foldable_non_user_block_hoists_fold_then_open() {
        let registry = ActionRegistry::defaults();
        let hints = scrollback_hints(&registry, Some("fold"), false, true, false, false);
        assert_eq!(first_two_labels(&hints), vec!["fold", "open"]);
    }
    #[test]
    fn scrollback_vim_mode_on_shows_nav_and_turn_hints() {
        let registry = ActionRegistry::defaults();
        let hints =
            scrollback_hints_with_vim_mode(&registry, None, false, false, false, false, true);
        let labels: Vec<&str> = hints.iter().map(|h| h.label.as_ref()).collect();
        assert!(
            labels.contains(&"nav"),
            "vim mode should show j/k nav hint; got {labels:?}"
        );
        assert!(
            labels.contains(&"turn"),
            "vim mode should show Shift+l/h turn hint; got {labels:?}"
        );
        assert!(
            labels.contains(&"top/btm"),
            "vim mode should show g/G top/btm hint; got {labels:?}"
        );
    }
    #[test]
    fn scrollback_vim_mode_off_hides_nav_turn_and_topbtm() {
        let registry = ActionRegistry::defaults();
        let hints =
            scrollback_hints_with_vim_mode(&registry, None, false, false, false, false, false);
        let labels: Vec<&str> = hints.iter().map(|h| h.label.as_ref()).collect();
        assert!(
            !labels.contains(&"nav"),
            "vim-off must hide j/k nav hint; got {labels:?}"
        );
        assert!(
            !labels.contains(&"turn"),
            "vim-off must hide Shift+l/h turn hint; got {labels:?}"
        );
        assert!(
            !labels.contains(&"top/btm"),
            "vim-off must hide g/G top/btm hint; got {labels:?}"
        );
    }
    #[test]
    fn scrollback_vim_mode_off_hides_y_copy_on_agent_message() {
        let registry = ActionRegistry::defaults();
        let hints =
            scrollback_hints_with_vim_mode(&registry, None, true, false, false, true, false);
        assert!(
            !hints.iter().any(|h| h.label == "copy"),
            "vim-off must hide y:copy hint on agent message"
        );
    }
    #[test]
    fn scrollback_never_shows_rewind_hint_on_user_prompt() {
        let registry = ActionRegistry::defaults();
        for vim_mode in [true, false] {
            let hints = scrollback_hints_with_vim_mode(
                &registry,
                Some("expand"),
                false,
                false,
                true,
                false,
                vim_mode,
            );
            assert!(
                !hints.iter().any(|h| h.label == "rewind"),
                "rewind is slash-command only (vim_mode={vim_mode})"
            );
        }
    }
    /// Build scrollback hints with an open search session in the given phase.
    fn scrollback_search_hints(
        registry: &ActionRegistry,
        vim_mode: bool,
        composing: bool,
    ) -> Vec<HintItem> {
        let mut search = ScrollbackSearchState::open();
        if !composing {
            search.accept();
        }
        build_hints(
            ActivePane::Scrollback,
            &PromptWidget::default(),
            registry,
            false,
            None,
            None,
            "expand thinking",
            false,
            false,
            None,
            false,
            false,
            false,
            false,
            vim_mode,
            false,
            false,
            false,
            false,
            false,
            false,
            Some(&search),
        )
    }
    #[test]
    fn scrollback_search_hint_not_in_bottom_bar() {
        let registry = ActionRegistry::defaults();
        for vim_mode in [true, false] {
            let hints = scrollback_hints_with_vim_mode(
                &registry, None, false, false, false, false, vim_mode,
            );
            assert!(
                !hints.iter().any(|h| h.label == "search"),
                "bottom bar must not advertise / search (vim_mode={vim_mode})"
            );
        }
    }
    #[test]
    fn scrollback_search_composing_shows_go_and_cancel_only() {
        let registry = ActionRegistry::defaults();
        let labels: Vec<String> = scrollback_search_hints(&registry, true, true)
            .iter()
            .map(|h| h.label.to_string())
            .collect();
        assert!(labels.contains(&"go".to_string()), "got {labels:?}");
        assert!(labels.contains(&"cancel".to_string()), "got {labels:?}");
        assert!(!labels.contains(&"next/prev".to_string()), "got {labels:?}");
        assert!(!labels.contains(&"nav".to_string()), "got {labels:?}");
        assert!(!labels.contains(&"search".to_string()), "got {labels:?}");
    }
    #[test]
    fn scrollback_search_browsing_shows_next_prev_and_cancel() {
        let registry = ActionRegistry::defaults();
        let labels: Vec<String> = scrollback_search_hints(&registry, true, false)
            .iter()
            .map(|h| h.label.to_string())
            .collect();
        assert!(labels.contains(&"next/prev".to_string()), "got {labels:?}");
        assert!(labels.contains(&"cancel".to_string()), "got {labels:?}");
        assert!(!labels.contains(&"go".to_string()), "got {labels:?}");
        assert!(!labels.contains(&"nav".to_string()), "got {labels:?}");
    }
    /// The keys behind the `next/prev` hint for a search in the given phase.
    fn next_prev_keys(
        registry: &ActionRegistry,
        vim_mode: bool,
        composing: bool,
    ) -> Vec<crate::input::key::KeyShortcut> {
        scrollback_search_hints(registry, vim_mode, composing)
            .into_iter()
            .find(|h| h.label == "next/prev")
            .map(|h| h.keys)
            .unwrap_or_default()
    }
    #[test]
    fn scrollback_search_vim_browsing_uses_n_keys() {
        use crossterm::event::KeyCode;
        let registry = ActionRegistry::defaults();
        let keys = next_prev_keys(&registry, true, false);
        let codes: Vec<KeyCode> = keys.iter().map(|k| k.code).collect();
        assert_eq!(codes, vec![KeyCode::Char('n'), KeyCode::Char('N')]);
    }
    #[test]
    fn scrollback_search_simple_mode_uses_arrow_keys_in_both_phases() {
        use crossterm::event::KeyCode;
        let registry = ActionRegistry::defaults();
        for composing in [true, false] {
            let codes: Vec<KeyCode> = next_prev_keys(&registry, false, composing)
                .iter()
                .map(|k| k.code)
                .collect();
            assert_eq!(
                codes,
                vec![KeyCode::Down, KeyCode::Up],
                "simple mode next/prev should be arrows (composing={composing})"
            );
        }
    }
    #[test]
    fn prompt_branch_excludes_exit_session_home_hint() {
        let registry = ActionRegistry::defaults();
        let hints = build_hints(
            ActivePane::Prompt,
            &PromptWidget::default(),
            &registry,
            false,
            None,
            None,
            "expand thinking",
            false,
            false,
            None,
            false,
            false,
            false,
            false,
            true,
            false,
            false,
            false,
            false,
            false,
            false,
            None,
        );
        assert!(
            !hints.iter().any(|h| h.label == "home"),
            "ExitSession (home) must not appear in prompt-focused bar"
        );
    }
    fn prompt_hints_with_text(
        multiline_mode: bool,
        shift_enter_unavailable: bool,
    ) -> Vec<HintItem> {
        prompt_hints_with_text_and_turn(multiline_mode, shift_enter_unavailable, false)
    }
    fn prompt_hints_with_text_and_turn(
        multiline_mode: bool,
        shift_enter_unavailable: bool,
        is_turn_running: bool,
    ) -> Vec<HintItem> {
        let mut prompt = PromptWidget::default();
        prompt.textarea.insert_str("hello");
        let registry = ActionRegistry::defaults();
        build_hints(
            ActivePane::Prompt,
            &prompt,
            &registry,
            false,
            None,
            None,
            "expand thinking",
            false,
            false,
            None,
            false,
            false,
            false,
            multiline_mode,
            true,
            false,
            is_turn_running,
            false,
            false,
            false,
            shift_enter_unavailable,
            None,
        )
    }
    #[test]
    fn prompt_idle_submit_hint_is_send() {
        let hints = prompt_hints_with_text_and_turn(false, false, false);
        let labels: Vec<&str> = hints.iter().map(|h| h.label.as_ref()).collect();
        assert!(
            labels.contains(&"send") && !labels.contains(&"queue"),
            "idle prompt must advertise Enter:send; got {labels:?}"
        );
    }
    #[test]
    fn prompt_running_submit_hint_is_queue_and_send_now() {
        let hints = prompt_hints_with_text_and_turn(false, false, true);
        let labels: Vec<&str> = hints.iter().map(|h| h.label.as_ref()).collect();
        assert!(
            labels.contains(&"queue"),
            "mid-turn follow-up must advertise Enter:queue (not send); got {labels:?}"
        );
        assert!(
            !labels.contains(&"send"),
            "mid-turn must not mislabel Enter as send; got {labels:?}"
        );
        assert!(
            labels.contains(&"send now"),
            "mid-turn with composer text must advertise the send-now (interject) chord; got {labels:?}"
        );
    }
    /// Empty composer + mid-turn queue: bare Enter is send-now in both normal
    /// and multiline modes (multiline only inserts newline when there is text).
    #[test]
    fn prompt_empty_mid_turn_queue_advertises_send_now_including_multiline() {
        for multiline in [false, true] {
            let prompt = PromptWidget::default();
            let registry = ActionRegistry::defaults();
            let hints = build_hints(
                ActivePane::Prompt,
                &prompt,
                &registry,
                false,
                None,
                None,
                "expand thinking",
                false,
                false,
                None,
                false,
                false,
                false,
                multiline,
                true,
                false,
                true,
                true,
                false,
                false,
                false,
                None,
            );
            let labels: Vec<&str> = hints.iter().map(|h| h.label.as_ref()).collect();
            assert!(
                labels.contains(&"send now"),
                "empty composer mid-turn with queue must advertise Enter:send now \
                 (multiline={multiline}); got {labels:?}"
            );
        }
    }
    #[test]
    fn prompt_legacy_vte_adds_alt_enter_newline_hint() {
        let hints = prompt_hints_with_text(false, true);
        let labels: Vec<&str> = hints.iter().map(|h| h.label.as_ref()).collect();
        assert!(
            labels.contains(&"newline"),
            "legacy VTE must surface an explicit Alt+Enter newline hint; \
             got {labels:?}"
        );
    }
    #[test]
    fn prompt_modern_terminal_no_newline_hint() {
        let hints = prompt_hints_with_text(false, false);
        let labels: Vec<&str> = hints.iter().map(|h| h.label.as_ref()).collect();
        assert!(
            !labels.contains(&"newline"),
            "modern terminals must not show the legacy-VTE newline hint \
             (Shift+Enter works natively); got {labels:?}"
        );
    }
    #[test]
    fn prompt_multiline_mode_no_extra_newline_hint() {
        let hints = prompt_hints_with_text(true, true);
        let labels: Vec<&str> = hints.iter().map(|h| h.label.as_ref()).collect();
        assert!(
            !labels.contains(&"newline"),
            "multiline mode must not show the legacy-VTE newline hint \
             (Enter already inserts a newline); got {labels:?}"
        );
    }
    #[test]
    fn prompt_multiline_send_hint_uses_alt_on_legacy_vte() {
        use crossterm::event::KeyModifiers;
        let hints = prompt_hints_with_text(true, true);
        let send_hint = hints
            .iter()
            .find(|h| h.label == "send")
            .expect("multiline mode with text must show a send hint");
        let key = send_hint
            .keys
            .first()
            .expect("send hint must have at least one key");
        assert!(
            key.modifiers.contains(KeyModifiers::ALT),
            "legacy VTE multiline send hint must advertise Alt+Enter, \
             got modifiers {:?}",
            key.modifiers
        );
        assert!(
            !key.modifiers.contains(KeyModifiers::SHIFT),
            "legacy VTE multiline send hint must NOT advertise Shift+Enter, \
             got modifiers {:?}",
            key.modifiers
        );
    }
    #[test]
    fn prompt_multiline_send_hint_uses_shift_on_modern_terminal() {
        use crossterm::event::KeyModifiers;
        let hints = prompt_hints_with_text(true, false);
        let send_hint = hints
            .iter()
            .find(|h| h.label == "send")
            .expect("multiline mode with text must show a send hint");
        let key = send_hint
            .keys
            .first()
            .expect("send hint must have at least one key");
        assert!(
            key.modifiers.contains(KeyModifiers::SHIFT),
            "modern terminal multiline send hint must advertise Shift+Enter, \
             got modifiers {:?}",
            key.modifiers
        );
    }
    fn layout_with_rows(area: Rect, banner_height: u16, follow_ups_height: u16) -> AgentViewLayout {
        let layout_cfg = LayoutConfig::default();
        let scrollbar_cfg = ScrollbarConfig::default();
        AgentViewLayout::compute(
            area,
            &layout_cfg,
            &scrollbar_cfg,
            0,
            2,
            0,
            0,
            0,
            0,
            0,
            0,
            banner_height,
            follow_ups_height,
            0,
            0,
            1,
            false,
        )
    }
    /// Minimal layout with a timeline rail request — hides the cfg-dependent
    /// arity of `compute` like `layout_with_rows` does.
    fn layout_with_rail(
        area: Rect,
        timeline_width: u16,
        scrollbar_cfg: &ScrollbarConfig,
    ) -> AgentViewLayout {
        let layout_cfg = LayoutConfig::default();
        AgentViewLayout::compute(
            area,
            &layout_cfg,
            scrollbar_cfg,
            timeline_width,
            2,
            0,
            0,
            0,
            0,
            0,
            0,
            0,
            0,
            0,
            0,
            1,
            false,
        )
    }
    #[test]
    fn timeline_rail_replaces_the_scrollbar_column() {
        let area = Rect::new(0, 0, 80, 40);
        let scrollbar_cfg = ScrollbarConfig::default();
        let without = layout_with_rail(area, 0, &scrollbar_cfg);
        let with_rail = layout_with_rail(area, 3, &scrollbar_cfg);
        assert_eq!(with_rail.timeline_width, 3);
        assert_eq!(with_rail.scrollbar_x, without.scrollbar_x);
        assert_eq!(with_rail.timeline_x, with_rail.scrollbar_x + 1 - 3);
        assert!(
            with_rail.scrollback_content.x + with_rail.scrollback_content.width
                <= with_rail.timeline_x,
            "content (ends {}) must not overlap the rail (starts {})",
            with_rail.scrollback_content.x + with_rail.scrollback_content.width,
            with_rail.timeline_x,
        );
        assert!(
            with_rail.scrollback_content.width <= without.scrollback_content.width,
            "the rail never widens the content"
        );
        let no_scrollbar = ScrollbarConfig {
            enabled: false,
            ..ScrollbarConfig::default()
        };
        let forced_off = layout_with_rail(area, 3, &no_scrollbar);
        assert_eq!(forced_off.timeline_width, 0);
        assert_eq!(
            forced_off.scrollback_content.width, forced_off.scrollback.width,
            "no carve-out without the scrollbar gutter"
        );
    }
    /// Banner row (mode banner / ephemeral tip slot): height 1 reserves a
    /// one-row rect directly above the prompt (gap row in between).
    #[test]
    fn banner_row_present_above_prompt() {
        let area = Rect::new(0, 0, 80, 40);
        let layout = layout_with_rows(area, 1, 0);
        assert_eq!(layout.banner.height, 1);
        assert_eq!(layout.prompt.y, layout.banner.y + 1);
        assert!(layout.banner.y >= layout.scrollback.y + layout.scrollback.height);
    }
    #[test]
    fn banner_row_absent_when_height_zero() {
        let area = Rect::new(0, 0, 80, 40);
        let layout = layout_with_rows(area, 0, 0);
        assert_eq!(layout.banner, Rect::default());
    }
    fn row_text(buf: &Buffer, area: Rect) -> String {
        (0..area.width)
            .filter_map(|x| buf.cell((x, 0)).map(|c| c.symbol().to_string()))
            .collect()
    }
    #[test]
    fn render_follow_ups_emits_clickable_chips() {
        let area = Rect::new(0, 0, 40, 1);
        let mut buf = Buffer::empty(area);
        let theme = Theme::current();
        let suggestions = vec!["Yes".to_string(), "No".to_string()];
        let rects = render_follow_ups(area, &mut buf, &theme, &suggestions, None);
        assert_eq!(rects.len(), 2, "both chips fit on a wide row");
        let row = row_text(&buf, area);
        assert!(row.contains("Yes"), "row = {row:?}");
        assert!(row.contains("No"), "row = {row:?}");
        assert!(rects[0].x + rects[0].width <= rects[1].x);
        assert!(rects[1].x + rects[1].width <= area.width);
    }
    #[test]
    fn render_follow_ups_drops_chips_that_do_not_fit() {
        let area = Rect::new(0, 0, 10, 1);
        let mut buf = Buffer::empty(area);
        let theme = Theme::current();
        let suggestions = vec!["abc".to_string(), "way too long to fit here".to_string()];
        let rects = render_follow_ups(area, &mut buf, &theme, &suggestions, None);
        assert_eq!(rects.len(), 1, "only the first chip fits");
    }
    #[test]
    fn render_follow_ups_zero_area_is_noop() {
        let mut buf = Buffer::empty(Rect::new(0, 0, 10, 1));
        let theme = Theme::current();
        let rects = render_follow_ups(
            Rect::new(0, 0, 0, 0),
            &mut buf,
            &theme,
            &["x".to_string()],
            None,
        );
        assert!(rects.is_empty());
    }
    #[test]
    fn render_follow_ups_clamps_long_label() {
        let area = Rect::new(0, 0, 120, 1);
        let mut buf = Buffer::empty(area);
        let theme = Theme::current();
        let long = "a".repeat(200);
        let rects = render_follow_ups(area, &mut buf, &theme, &[long], None);
        assert_eq!(rects.len(), 1);
        assert!(
            rects[0].width <= 52,
            "chip width {} not clamped",
            rects[0].width
        );
    }
    #[test]
    fn render_follow_ups_applies_hover_style() {
        let area = Rect::new(0, 0, 40, 1);
        let mut buf = Buffer::empty(area);
        let theme = Theme::current();
        let suggestions = vec!["Yes".to_string(), "No".to_string()];
        let rects = render_follow_ups(area, &mut buf, &theme, &suggestions, Some(0));
        assert_eq!(rects.len(), 2);
        let r0 = rects[0];
        let cell = buf.cell((r0.x + 1, r0.y)).expect("hovered chip cell");
        assert_eq!(
            cell.fg, theme.text_primary,
            "hovered chip should use primary text"
        );
        assert_eq!(
            cell.bg, theme.bg_hover,
            "hovered chip should use theme.bg_hover"
        );
        let r1 = rects[1];
        let cell1 = buf.cell((r1.x + 1, r1.y)).expect("non-hovered chip cell");
        assert_eq!(cell1.fg, theme.link_fg, "non-hovered chip keeps link color");
    }
    #[test]
    fn follow_ups_row_present_above_prompt() {
        let area = Rect::new(0, 0, 80, 40);
        let layout = layout_with_rows(area, 0, 1);
        assert_eq!(layout.follow_ups.height, 1);
        assert!(layout.follow_ups.y < layout.prompt.y);
        assert!(layout.follow_ups.y >= layout.scrollback.y + layout.scrollback.height);
    }
    #[test]
    fn follow_ups_row_absent_when_height_zero() {
        let area = Rect::new(0, 0, 80, 40);
        let layout = layout_with_rows(area, 0, 0);
        assert_eq!(layout.follow_ups, Rect::default());
    }
    #[test]
    fn follow_ups_row_suppressed_on_short_terminal() {
        let area = Rect::new(0, 0, 80, 16);
        let layout = layout_with_rows(area, 0, 1);
        assert_eq!(layout.follow_ups, Rect::default());
        assert!(layout.scrollback.height >= 5);
    }
    /// Default todo badge is a `done/total` fraction: numerator = completed,
    /// denominator = all tasks except cancelled.
    #[test]
    fn todo_badge_default_renders_done_over_total_fraction() {
        let theme = Theme::current();
        let counts = super::super::todo_pane::TodoCounts {
            in_progress: 1,
            pending: 2,
            completed: 2,
            cancelled: 0,
        };
        let spans =
            render_todo_badge_spans(&counts, false, false, TodoBadgeFormat::Default, &theme)
                .expect("badge renders when there are todos");
        let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
        assert!(
            text.starts_with("2/5"),
            "expected a 2/5 done/total fraction, got {text:?}"
        );
        let with_cancelled = super::super::todo_pane::TodoCounts {
            in_progress: 0,
            pending: 1,
            completed: 2,
            cancelled: 1,
        };
        let spans = render_todo_badge_spans(
            &with_cancelled,
            false,
            false,
            TodoBadgeFormat::Default,
            &theme,
        )
        .expect("badge renders");
        let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
        assert!(
            text.starts_with("2/3"),
            "cancelled tasks are excluded from the total, got {text:?}"
        );
    }
    /// No todos → no badge.
    #[test]
    fn todo_badge_absent_when_no_todos() {
        let theme = Theme::current();
        let empty = super::super::todo_pane::TodoCounts::default();
        assert!(
            render_todo_badge_spans(&empty, false, false, TodoBadgeFormat::Default, &theme)
                .is_none()
        );
    }
    /// All-cancelled list → no badge (denominator would be 0; nothing to track).
    #[test]
    fn todo_badge_absent_when_all_cancelled() {
        let theme = Theme::current();
        let all_cancelled = super::super::todo_pane::TodoCounts {
            in_progress: 0,
            pending: 0,
            completed: 0,
            cancelled: 3,
        };
        assert!(
            render_todo_badge_spans(
                &all_cancelled,
                false,
                false,
                TodoBadgeFormat::Default,
                &theme
            )
            .is_none()
        );
    }
}
