//! Sidebar rendering — Work / Tasks / Agents / Context panels.
//!
//! Extracted from `tui/ui.rs` (P1.2). The sidebar appears to the right of
//! the chat transcript when the available width allows it. Each section
//! reads from `App` snapshots; mutation lives in the main app loop.

use std::fmt::Write;
use std::time::{Duration, Instant};

use crate::tui::app::HuntVerdict;

use ratatui::{
    Frame,
    layout::{Constraint, Direction, Layout, Rect},
    prelude::Widget,
    style::Style,
    text::{Line, Span},
    widgets::{Block, Paragraph, Wrap},
};

use crate::deepseek_theme::Theme;
use crate::palette;
use crate::tools::plan::StepStatus;
use crate::tools::subagent::SubAgentStatus;
use crate::tools::todo::TodoStatus;

use super::app::{
    App, SidebarFocus, SidebarHoverRow, SidebarHoverSection, SidebarHoverState, TaskPanelEntry,
    TaskPanelEntryKind,
};
use super::history::{GenericToolCell, HistoryCell, ToolCell, ToolStatus, summarize_tool_output};
use super::subagent_routing::active_fanout_counts;
use super::ui_text::{concise_shell_command_label, truncate_line_to_width};
use crate::config::provider_capability;

/// Tolerance for floating-point cost comparison in the sidebar breakdown.
/// Must be large enough that accumulated f64 error across hundreds of turns
/// does not prematurely hide the session+agents breakdown.
const COST_EQ_TOLERANCE: f64 = 1e-6;
const RECENT_TOOL_SCAN_LIMIT: usize = 24;
const ACTIVE_TOOL_COMPLETED_ROW_TTL: Duration = Duration::from_secs(8);
const ACTIVE_TOOL_STALE_RUNNING_ROW_TTL: Duration = Duration::from_secs(600);

pub fn render_sidebar(f: &mut Frame, area: Rect, app: &mut App) {
    // Clear hover state at the start of each render
    app.sidebar_hover = SidebarHoverState::default();
    if area.width < 24 || area.height < 8 {
        // Paint a styled block over the area so stale cells from a previous
        // (wider) frame don't persist as bleed-through artifacts (#400).
        Block::default()
            .style(Style::default().bg(app.ui_theme.surface_bg))
            .render(area, f.buffer_mut());
        return;
    }

    match app.sidebar_focus {
        SidebarFocus::Auto => render_sidebar_auto(f, area, app),
        SidebarFocus::Work => render_sidebar_work(f, area, app),
        SidebarFocus::Tasks => render_sidebar_tasks(f, area, app),
        SidebarFocus::Agents => render_sidebar_subagents(f, area, app),
        SidebarFocus::Context => render_context_panel(f, area, app),
        SidebarFocus::Hidden => Block::default()
            .style(Style::default().bg(app.ui_theme.surface_bg))
            .render(area, f.buffer_mut()),
    }
}

/// Build the Auto-mode panel stack. Empty panels collapse to zero height so
/// non-empty ones get the full sidebar real estate. Work appears when it has
/// useful content, or as the one quiet empty state when nothing else is active.
fn render_sidebar_auto(f: &mut Frame, area: Rect, app: &mut App) {
    let work_has_content = sidebar_work_summary(app).has_useful_content();
    let tasks_empty = app.runtime_turn_id.is_none() && app.task_panel.is_empty();
    let agents_empty = app.subagent_cache.is_empty()
        && app.agent_progress.is_empty()
        && active_fanout_counts(app).is_none()
        && !foreground_rlm_running(app);

    let visible = auto_sidebar_panels(AutoSidebarState {
        work_has_content,
        tasks_empty,
        agents_empty,
        context_enabled: app.context_panel,
    });

    let constraints: Vec<Constraint> = match visible.len() {
        1 => vec![Constraint::Min(0)],
        2 => vec![Constraint::Percentage(50), Constraint::Min(0)],
        3 => vec![
            Constraint::Percentage(34),
            Constraint::Percentage(33),
            Constraint::Min(0),
        ],
        4 => vec![
            Constraint::Percentage(25),
            Constraint::Percentage(25),
            Constraint::Percentage(25),
            Constraint::Min(6),
        ],
        _ => vec![
            Constraint::Percentage(20),
            Constraint::Percentage(20),
            Constraint::Percentage(20),
            Constraint::Percentage(20),
            Constraint::Min(6),
        ],
    };

    let sections = Layout::default()
        .direction(Direction::Vertical)
        .constraints(constraints)
        .split(area);

    for (panel, rect) in visible.iter().zip(sections.iter()) {
        match panel {
            AutoSidebarPanel::Work => render_sidebar_work(f, *rect, app),
            AutoSidebarPanel::Tasks => render_sidebar_tasks(f, *rect, app),
            AutoSidebarPanel::Agents => render_sidebar_subagents(f, *rect, app),
            AutoSidebarPanel::Context => render_context_panel(f, *rect, app),
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum AutoSidebarPanel {
    Work,
    Tasks,
    Agents,
    Context,
}

#[derive(Debug, Clone, Copy)]
struct AutoSidebarState {
    work_has_content: bool,
    tasks_empty: bool,
    agents_empty: bool,
    context_enabled: bool,
}

fn auto_sidebar_panels(state: AutoSidebarState) -> Vec<AutoSidebarPanel> {
    let nothing_else_active = state.tasks_empty && state.agents_empty && !state.context_enabled;
    let mut visible = Vec::with_capacity(4);

    if state.work_has_content || nothing_else_active {
        visible.push(AutoSidebarPanel::Work);
    }
    if !state.tasks_empty {
        visible.push(AutoSidebarPanel::Tasks);
    }
    if !state.agents_empty {
        visible.push(AutoSidebarPanel::Agents);
    }
    if state.context_enabled {
        visible.push(AutoSidebarPanel::Context);
    }

    visible
}

#[derive(Debug, Clone)]
struct SidebarWorkChecklistItem {
    id: u32,
    content: String,
    status: TodoStatus,
}

#[derive(Debug, Clone)]
struct SidebarWorkStrategyStep {
    text: String,
    status: StepStatus,
    elapsed: String,
}

#[derive(Debug, Clone, Default)]
pub(crate) struct SidebarWorkSummary {
    goal_objective: Option<String>,
    goal_token_budget: Option<u32>,
    goal_completed: bool,
    goal_started_at: Option<Instant>,
    tokens_used: u32,
    checklist_completion_pct: u8,
    checklist_items: Vec<SidebarWorkChecklistItem>,
    strategy_explanation: Option<String>,
    strategy_steps: Vec<SidebarWorkStrategyStep>,
    state_updating: bool,
}

impl SidebarWorkSummary {
    fn checklist_is_primary(&self) -> bool {
        !self.checklist_items.is_empty()
    }

    fn has_strategy(&self) -> bool {
        self.strategy_explanation
            .as_deref()
            .is_some_and(|s| !s.trim().is_empty())
            || !self.strategy_steps.is_empty()
    }

    fn has_useful_content(&self) -> bool {
        self.goal_objective
            .as_deref()
            .is_some_and(|s| !s.trim().is_empty())
            || !self.checklist_items.is_empty()
            || self.has_strategy()
            || self.state_updating
    }

    fn strategy_counts(&self) -> (usize, usize, usize) {
        let mut pending = 0;
        let mut in_progress = 0;
        let mut completed = 0;
        for step in &self.strategy_steps {
            match step.status {
                StepStatus::Pending => pending += 1,
                StepStatus::InProgress => in_progress += 1,
                StepStatus::Completed => completed += 1,
            }
        }
        (pending, in_progress, completed)
    }

    fn strategy_progress_percent(&self) -> u8 {
        if self.strategy_steps.is_empty() {
            return 0;
        }
        let completed = self
            .strategy_steps
            .iter()
            .filter(|step| step.status == StepStatus::Completed)
            .count();
        let percent = completed.saturating_mul(100) / self.strategy_steps.len();
        u8::try_from(percent).unwrap_or(u8::MAX)
    }
}

fn sidebar_work_summary(app: &mut App) -> SidebarWorkSummary {
    let fresh = (|| {
        let todos = app.todos.try_lock().ok()?;
        let plan = app.plan_state.try_lock().ok()?;

        let snapshot = todos.snapshot();
        let checklist_completion_pct = snapshot.completion_pct;
        let checklist_items = snapshot
            .items
            .into_iter()
            .map(|item| SidebarWorkChecklistItem {
                id: item.id,
                content: item.content,
                status: item.status,
            })
            .collect();

        let (strategy_explanation, strategy_steps) = if plan.is_empty() {
            (None, Vec::new())
        } else {
            (
                plan.explanation().map(str::to_string),
                plan.steps()
                    .iter()
                    .map(|step| SidebarWorkStrategyStep {
                        text: step.text.clone(),
                        status: step.status.clone(),
                        elapsed: step.elapsed_str(),
                    })
                    .collect(),
            )
        };

        Some(SidebarWorkSummary {
            goal_objective: app.hunt.quarry.clone(),
            goal_token_budget: app.hunt.token_budget,
            goal_completed: app.hunt.verdict == HuntVerdict::Hunted,
            goal_started_at: app.hunt.started_at,
            tokens_used: app.session.total_conversation_tokens,
            checklist_completion_pct,
            checklist_items,
            strategy_explanation,
            strategy_steps,
            state_updating: false,
        })
    })();

    if let Some(summary) = fresh {
        app.cached_work_summary = Some(summary.clone());
        return summary;
    }

    if let Some(cached) = app.cached_work_summary.as_ref() {
        let mut summary = cached.clone();
        summary.goal_objective = app.hunt.quarry.clone();
        summary.goal_token_budget = app.hunt.token_budget;
        summary.goal_completed = app.hunt.verdict == HuntVerdict::Hunted;
        summary.goal_started_at = app.hunt.started_at;
        summary.tokens_used = app.session.total_conversation_tokens;
        return summary;
    }

    SidebarWorkSummary {
        goal_objective: app.hunt.quarry.clone(),
        goal_token_budget: app.hunt.token_budget,
        goal_completed: app.hunt.verdict == HuntVerdict::Hunted,
        goal_started_at: app.hunt.started_at,
        tokens_used: app.session.total_conversation_tokens,
        state_updating: true,
        ..SidebarWorkSummary::default()
    }
}

fn work_panel_lines(
    summary: &SidebarWorkSummary,
    content_width: usize,
    max_rows: usize,
    palette_mode: palette::PaletteMode,
    ui_theme: &palette::UiTheme,
) -> Vec<Line<'static>> {
    let theme = Theme::for_palette_mode(palette_mode);
    let mut lines: Vec<Line<'static>> = Vec::with_capacity(max_rows.max(4));

    push_work_goal_lines(summary, content_width, max_rows, &mut lines, ui_theme);

    if summary.state_updating && lines.len() < max_rows {
        lines.push(Line::from(Span::styled(
            "Work state updating...",
            Style::default().fg(ui_theme.text_muted),
        )));
    }

    push_work_checklist_lines(summary, content_width, max_rows, &mut lines, ui_theme);
    push_work_strategy_lines(summary, content_width, max_rows, &mut lines, &theme);

    if lines.is_empty() {
        lines.push(Line::from(Span::styled(
            work_panel_empty_hint(content_width),
            Style::default().fg(ui_theme.text_muted).italic(),
        )));
    }

    lines
}

fn work_panel_hover_texts(
    summary: &SidebarWorkSummary,
    content_width: usize,
    max_rows: usize,
) -> Vec<String> {
    let mut texts = Vec::with_capacity(max_rows.max(4));

    if let Some(objective) = summary.goal_objective.as_deref()
        && !objective.trim().is_empty()
        && texts.len() < max_rows
    {
        let icon = if summary.goal_completed { "✓" } else { "◆" };
        texts.push(format!("{icon} {objective}"));

        if let Some(started) = summary.goal_started_at
            && texts.len() < max_rows
        {
            let elapsed = crate::tui::notifications::humanize_duration(started.elapsed());
            let elapsed_str = if summary.goal_completed {
                format!("completed in {elapsed}")
            } else {
                format!("elapsed: {elapsed}")
            };
            texts.push(elapsed_str);
        }

        if let Some(budget) = summary.goal_token_budget
            && texts.len() < max_rows
        {
            let pct = if budget > 0 {
                ((summary.tokens_used as f64 / budget as f64) * 100.0).min(100.0)
            } else {
                0.0
            };
            let bar_width = content_width.min(20);
            let filled = ((pct / 100.0) * bar_width as f64) as usize;
            let bar = format!(
                "[{}{}] {:.0}%",
                "█".repeat(filled),
                "░".repeat(bar_width.saturating_sub(filled)),
                pct
            );
            texts.push(format!(
                "tokens: {}/{} {}",
                summary.tokens_used, budget, bar
            ));
        }
    }

    if summary.state_updating && texts.len() < max_rows {
        texts.push("Work state updating...".to_string());
    }

    if !summary.checklist_items.is_empty() && texts.len() < max_rows {
        let total = summary.checklist_items.len();
        let completed = summary
            .checklist_items
            .iter()
            .filter(|item| item.status == TodoStatus::Completed)
            .count();
        texts.push(format!(
            "{}% complete ({completed}/{total})",
            summary.checklist_completion_pct
        ));

        let reserve_for_strategy = if summary.has_strategy() { 2 } else { 0 };
        let available_item_rows = max_rows
            .saturating_sub(texts.len())
            .saturating_sub(reserve_for_strategy)
            .min(summary.checklist_items.len());
        let max_items =
            if summary.checklist_items.len() > available_item_rows && available_item_rows > 1 {
                available_item_rows - 1
            } else {
                available_item_rows
            };
        let start = checklist_window_start(&summary.checklist_items, max_items);
        let end = start
            .saturating_add(max_items)
            .min(summary.checklist_items.len());
        for item in summary.checklist_items[start..end].iter() {
            let prefix = match item.status {
                TodoStatus::Pending => "[ ]",
                TodoStatus::InProgress => "[~]",
                TodoStatus::Completed => "[✓]",
            };
            texts.push(format!("{prefix} #{} {}", item.id, item.content));
        }

        let earlier = start;
        let later = summary.checklist_items.len().saturating_sub(end);
        let remaining = earlier.saturating_add(later);
        if remaining > 0 && texts.len() < max_rows {
            let mut label = match (earlier, later) {
                (0, later) => format!("+{later} more checklist items"),
                (earlier, 0) => format!("+{earlier} earlier checklist items"),
                (earlier, later) => format!("+{earlier} earlier, +{later} later"),
            };
            // Hovering the overflow row reveals the omitted items, since
            // the compact panel gives no other way to inspect them (#3063).
            let omitted = summary.checklist_items[..start]
                .iter()
                .chain(summary.checklist_items[end..].iter());
            for item in omitted {
                let prefix = match item.status {
                    TodoStatus::Pending => "[ ]",
                    TodoStatus::InProgress => "[~]",
                    TodoStatus::Completed => "[✓]",
                };
                let _ = write!(label, "\n{prefix} #{} {}", item.id, item.content);
            }
            texts.push(label);
        }
    }

    if summary.has_strategy() && texts.len() < max_rows {
        if !summary.checklist_is_primary() && !summary.strategy_steps.is_empty() {
            let (pending, in_progress, completed) = summary.strategy_counts();
            let total = pending + in_progress + completed;
            texts.push(format!(
                "Strategy metadata {}% complete ({completed}/{total})",
                summary.strategy_progress_percent()
            ));
        } else {
            texts.push(work_strategy_context_label(summary).to_string());
        }

        if let Some(explanation) = summary.strategy_explanation.as_deref()
            && texts.len() < max_rows
        {
            texts.push(explanation.to_string());
        }

        let max_steps = max_rows
            .saturating_sub(texts.len())
            .min(summary.strategy_steps.len());
        for step in summary.strategy_steps.iter().take(max_steps) {
            let prefix = match step.status {
                StepStatus::Pending => "[ ]",
                StepStatus::InProgress => "[~]",
                StepStatus::Completed => "[✓]",
            };
            let mut text = if summary.checklist_is_primary() {
                format!(
                    "{} {}",
                    strategy_context_step_prefix(&step.status),
                    step.text
                )
            } else {
                format!("{prefix} {}", step.text)
            };
            if !step.elapsed.is_empty() {
                let _ = write!(text, " ({})", step.elapsed);
            }
            texts.push(text);
        }

        let remaining = summary.strategy_steps.len().saturating_sub(max_steps);
        if remaining > 0 && texts.len() < max_rows {
            texts.push(format!("+{remaining} more strategy steps"));
        }
    }

    if texts.is_empty() {
        texts.push("No active work".to_string());
    }

    texts
}

fn push_work_goal_lines(
    summary: &SidebarWorkSummary,
    content_width: usize,
    max_rows: usize,
    lines: &mut Vec<Line<'static>>,
    theme: &palette::UiTheme,
) {
    let Some(objective) = summary.goal_objective.as_deref() else {
        return;
    };
    if objective.trim().is_empty() || lines.len() >= max_rows {
        return;
    }

    let icon = if summary.goal_completed { "✓" } else { "◆" };
    let status_style = if summary.goal_completed {
        Style::default()
            .fg(theme.success)
            .add_modifier(ratatui::style::Modifier::BOLD)
    } else {
        Style::default()
            .fg(theme.warning)
            .add_modifier(ratatui::style::Modifier::BOLD)
    };

    lines.push(Line::from(Span::styled(
        format!(
            "{} {}",
            icon,
            truncate_line_to_width(objective, content_width.saturating_sub(2).max(1))
        ),
        status_style,
    )));

    // Elapsed time
    if let Some(started) = summary.goal_started_at
        && lines.len() < max_rows
    {
        let elapsed = crate::tui::notifications::humanize_duration(started.elapsed());
        let elapsed_str = if summary.goal_completed {
            format!("completed in {elapsed}")
        } else {
            format!("elapsed: {elapsed}")
        };
        lines.push(Line::from(Span::styled(
            truncate_line_to_width(&elapsed_str, content_width),
            Style::default().fg(theme.text_muted),
        )));
    }

    if let Some(budget) = summary.goal_token_budget
        && lines.len() < max_rows
    {
        let pct = if budget > 0 {
            ((summary.tokens_used as f64 / budget as f64) * 100.0).min(100.0)
        } else {
            0.0
        };
        let bar_width = content_width.min(20);
        let filled = ((pct / 100.0) * bar_width as f64) as usize;
        let bar = format!(
            "[{}{}] {:.0}%",
            "█".repeat(filled),
            "░".repeat(bar_width.saturating_sub(filled)),
            pct
        );
        lines.push(Line::from(Span::styled(
            truncate_line_to_width(
                &format!("tokens: {}/{} {}", summary.tokens_used, budget, bar),
                content_width,
            ),
            Style::default().fg(theme.text_muted),
        )));
    }
}

fn push_work_checklist_lines(
    summary: &SidebarWorkSummary,
    content_width: usize,
    max_rows: usize,
    lines: &mut Vec<Line<'static>>,
    theme: &palette::UiTheme,
) {
    if summary.checklist_items.is_empty() || lines.len() >= max_rows {
        return;
    }

    let total = summary.checklist_items.len();
    let completed = summary
        .checklist_items
        .iter()
        .filter(|item| item.status == TodoStatus::Completed)
        .count();
    lines.push(Line::from(vec![
        Span::styled(
            format!("{}%", summary.checklist_completion_pct),
            Style::default().fg(theme.success).bold(),
        ),
        Span::styled(
            format!(" complete ({completed}/{total})"),
            Style::default().fg(theme.text_muted),
        ),
    ]));

    let reserve_for_strategy = if summary.has_strategy() { 2 } else { 0 };
    let available_item_rows = max_rows
        .saturating_sub(lines.len())
        .saturating_sub(reserve_for_strategy)
        .min(summary.checklist_items.len());
    let max_items =
        if summary.checklist_items.len() > available_item_rows && available_item_rows > 1 {
            available_item_rows - 1
        } else {
            available_item_rows
        };
    let start = checklist_window_start(&summary.checklist_items, max_items);
    let end = start
        .saturating_add(max_items)
        .min(summary.checklist_items.len());
    for item in summary.checklist_items[start..end].iter() {
        let (prefix, color) = match item.status {
            TodoStatus::Pending => ("[ ]", theme.text_muted),
            TodoStatus::InProgress => ("[~]", theme.warning),
            TodoStatus::Completed => ("[✓]", theme.success),
        };
        let text = format!("{prefix} #{} {}", item.id, item.content);
        lines.push(Line::from(Span::styled(
            truncate_line_to_width(&text, content_width),
            Style::default().fg(color),
        )));
    }

    let earlier = start;
    let later = summary.checklist_items.len().saturating_sub(end);
    let remaining = earlier.saturating_add(later);
    if remaining > 0 && lines.len() < max_rows {
        let label = match (earlier, later) {
            (0, later) => format!("+{later} more checklist items"),
            (earlier, 0) => format!("+{earlier} earlier checklist items"),
            (earlier, later) => format!("+{earlier} earlier, +{later} later"),
        };
        lines.push(Line::from(Span::styled(
            label,
            Style::default().fg(theme.text_muted),
        )));
    }
}

fn checklist_window_start(items: &[SidebarWorkChecklistItem], max_items: usize) -> usize {
    if max_items >= items.len() {
        return 0;
    }
    let Some(active_idx) = items
        .iter()
        .position(|item| item.status == TodoStatus::InProgress)
    else {
        return 0;
    };
    active_idx
        .saturating_sub(max_items / 2)
        .min(items.len().saturating_sub(max_items))
}

fn push_work_strategy_lines(
    summary: &SidebarWorkSummary,
    content_width: usize,
    max_rows: usize,
    lines: &mut Vec<Line<'static>>,
    theme: &Theme,
) {
    if !summary.has_strategy() || lines.len() >= max_rows {
        return;
    }

    let checklist_is_primary = summary.checklist_is_primary();
    if !checklist_is_primary && !summary.strategy_steps.is_empty() {
        let (pending, in_progress, completed) = summary.strategy_counts();
        let total = pending + in_progress + completed;
        lines.push(Line::from(vec![
            Span::styled(
                "Strategy metadata ",
                Style::default().fg(theme.plan_summary_color).bold(),
            ),
            Span::styled(
                format!("{}%", summary.strategy_progress_percent()),
                Style::default().fg(theme.plan_progress_color).bold(),
            ),
            Span::styled(
                format!(" complete ({completed}/{total})"),
                Style::default().fg(theme.plan_summary_color),
            ),
        ]));
    } else {
        lines.push(Line::from(Span::styled(
            work_strategy_context_label(summary),
            Style::default().fg(theme.plan_summary_color).bold(),
        )));
    }

    if let Some(explanation) = summary.strategy_explanation.as_deref()
        && lines.len() < max_rows
    {
        lines.push(Line::from(Span::styled(
            truncate_line_to_width(explanation, content_width),
            Style::default().fg(theme.plan_explanation_color),
        )));
    }

    let max_steps = max_rows
        .saturating_sub(lines.len())
        .min(summary.strategy_steps.len());
    for step in summary.strategy_steps.iter().take(max_steps) {
        let (prefix, color) = match step.status {
            StepStatus::Pending => ("[ ]", theme.plan_pending_color),
            StepStatus::InProgress => ("[~]", theme.plan_in_progress_color),
            StepStatus::Completed => ("[✓]", theme.plan_completed_color),
        };
        let (text_prefix, color) = if checklist_is_primary {
            (
                strategy_context_step_prefix(&step.status),
                strategy_context_step_color(&step.status, theme),
            )
        } else {
            (prefix, color)
        };
        let mut text = format!("{text_prefix} {}", step.text);
        if !step.elapsed.is_empty() {
            let _ = write!(text, " ({})", step.elapsed);
        }
        lines.push(Line::from(Span::styled(
            truncate_line_to_width(&text, content_width),
            Style::default().fg(color),
        )));
    }

    let remaining = summary.strategy_steps.len().saturating_sub(max_steps);
    if remaining > 0 && lines.len() < max_rows {
        lines.push(Line::from(Span::styled(
            format!("+{remaining} more strategy steps"),
            Style::default().fg(theme.plan_summary_color),
        )));
    }
}

fn work_strategy_context_label(summary: &SidebarWorkSummary) -> &'static str {
    if summary.checklist_is_primary() {
        "Strategy context"
    } else {
        "Strategy metadata"
    }
}

fn strategy_context_step_prefix(status: &StepStatus) -> &'static str {
    match status {
        StepStatus::Pending => "phase next:",
        StepStatus::InProgress => "phase now:",
        StepStatus::Completed => "phase done:",
    }
}

fn strategy_context_step_color(status: &StepStatus, theme: &Theme) -> ratatui::style::Color {
    match status {
        StepStatus::Pending => theme.plan_pending_color,
        StepStatus::InProgress => theme.plan_in_progress_color,
        StepStatus::Completed => theme.plan_summary_color,
    }
}

#[must_use]
fn work_panel_empty_hint(content_width: usize) -> String {
    truncate_line_to_width("No active work", content_width)
}

fn render_sidebar_work(f: &mut Frame, area: Rect, app: &mut App) {
    if area.height < 3 {
        return;
    }

    let content_width = area.width.saturating_sub(4) as usize;
    let usable_rows = area.height.saturating_sub(3) as usize;
    let summary = sidebar_work_summary(app);
    let lines = work_panel_lines(
        &summary,
        content_width.max(1),
        usable_rows,
        app.ui_theme.mode,
        &app.ui_theme,
    );

    let full_texts = work_panel_hover_texts(&summary, content_width.max(1), usable_rows);
    render_sidebar_section(f, area, "Work", lines, full_texts, Vec::new(), app);
}

/// Click actions for one background job row pair (#3028).
///
/// Returns `(show, detail)` where `show` opens the job and `detail` cancels
/// it while it is still running (finished jobs make the detail row a second
/// show target instead — cancel would only error). `shell_*` ids belong to
/// the shell job manager and route through `/jobs`; everything else routes
/// through `/task`.
fn background_task_click_actions(task: &TaskPanelEntry) -> (String, String) {
    let namespace = if task.id.starts_with("shell_") {
        "jobs"
    } else {
        "task"
    };
    let show = format!("/{namespace} show {}", task.id);
    let detail = if matches!(task.status.as_str(), "running" | "queued") {
        format!("/{namespace} cancel {}", task.id)
    } else {
        show.clone()
    };
    (show, detail)
}

fn render_sidebar_tasks(f: &mut Frame, area: Rect, app: &mut App) {
    if area.height < 3 {
        return;
    }

    let content_width = area.width.saturating_sub(4) as usize;
    let usable_rows = area.height.saturating_sub(3) as usize;
    let (lines, row_actions) = task_panel_rows(app, content_width.max(1), usable_rows.max(1));

    let full_texts = task_panel_hover_texts(app, usable_rows.max(1));
    render_sidebar_section(f, area, "Tasks", lines, full_texts, row_actions, app);
}

#[derive(Debug, Clone)]
struct SidebarToolRow {
    name: String,
    status: ToolStatus,
    summary: String,
    duration_ms: Option<u64>,
}

#[cfg(test)]
fn task_panel_lines(app: &App, content_width: usize, max_rows: usize) -> Vec<Line<'static>> {
    task_panel_rows(app, content_width, max_rows).0
}

/// Build the Tasks panel lines together with a parallel per-line click-action
/// vector (#3028). Producing both in a single pass keeps the action indices
/// aligned with the rendered lines no matter how the layout evolves.
fn task_panel_rows(
    app: &App,
    content_width: usize,
    max_rows: usize,
) -> (Vec<Line<'static>>, Vec<Option<String>>) {
    let theme = &app.ui_theme;
    let mut lines: Vec<Line<'static>> = Vec::with_capacity(max_rows.max(4));
    let mut actions: Vec<Option<String>> = Vec::with_capacity(max_rows.max(4));

    if app.runtime_turn_id.is_some() {
        let status = app
            .runtime_turn_status
            .as_deref()
            .unwrap_or("unknown")
            .to_string();
        // #3030: Use a stable turn number ("Turn 1") instead of the raw
        // UUID prefix.  The full UUID is preserved in the hover text
        // (task_panel_hover_texts) for inspection.
        let turn_label = if app.turn_counter > 0 {
            format!("Turn {} ({status})", app.turn_counter)
        } else {
            format!("Current turn ({status})")
        };
        lines.push(Line::from(Span::styled(
            truncate_line_to_width(&turn_label, content_width.max(1)),
            Style::default().fg(theme.accent_primary),
        )));
    }

    let active_rows = active_tool_rows(app);
    if !active_rows.is_empty() && lines.len() < max_rows {
        push_sidebar_label_theme(&mut lines, "Live tools", theme);
        push_tool_rows(&mut lines, &active_rows, content_width, max_rows, theme);
    }

    let reasoning_rows = reasoning_task_rows(app);
    if !reasoning_rows.is_empty() && lines.len() < max_rows {
        push_sidebar_label_theme(&mut lines, "Model reasoning", theme);
        push_reasoning_rows(&mut lines, &reasoning_rows, content_width, max_rows, theme);
    }

    let background_rows = background_task_rows(app, &active_rows);
    // Lines pushed so far (turn label, Live tools header, live tool rows)
    // are not clickable — backfill their action slots.
    actions.resize(lines.len(), None);
    if !background_rows.is_empty() && lines.len() < max_rows {
        let running = background_rows
            .iter()
            .filter(|task| task.status == "running")
            .count();
        let done = background_rows.len().saturating_sub(running);
        let label = if running == 0 {
            format!("Bash jobs: {done} completed")
        } else if done == 0 {
            format!("Bash jobs: {running} running")
        } else {
            format!("Bash jobs: {running} running, {done} completed")
        };
        lines.push(Line::from(Span::styled(
            label,
            Style::default().fg(theme.accent_primary).bold(),
        )));
        actions.push(None);

        let max_items = max_rows.saturating_sub(lines.len());
        for task in background_rows.iter().take(max_items) {
            let color = match task.status.as_str() {
                "queued" => theme.text_muted,
                "running" => theme.warning,
                "completed" => theme.success,
                "failed" => theme.error_fg,
                "canceled" => theme.text_dim,
                _ => theme.text_muted,
            };
            let duration = task
                .duration_ms
                .map(format_duration_ms)
                .unwrap_or_else(|| "-".to_string());
            let (label, detail) = background_task_labels(task, &duration);
            let (show_action, detail_action) = background_task_click_actions(task);
            lines.push(Line::from(Span::styled(
                truncate_line_to_width(&label, content_width.max(1)),
                Style::default().fg(color),
            )));
            actions.push(Some(show_action));
            lines.push(Line::from(Span::styled(
                format!(
                    "  {}",
                    truncate_line_to_width(&detail, content_width.saturating_sub(2).max(1))
                ),
                Style::default().fg(theme.text_dim),
            )));
            actions.push(Some(detail_action));
        }

        if lines.len() < max_rows
            && background_rows
                .iter()
                .any(|task| task.id.starts_with("shell_") && task.status == "running")
        {
            lines.push(Line::from(Span::styled(
                truncate_line_to_width("Ctrl+K -> /jobs cancel-all", content_width.max(1)),
                Style::default()
                    .fg(theme.text_muted)
                    .add_modifier(ratatui::style::Modifier::ITALIC),
            )));
            actions.push(Some("/jobs cancel-all".to_string()));
        }
    }

    if lines.len() < max_rows {
        let recent_rows = recent_tool_rows(app, 4);
        if !recent_rows.is_empty() {
            push_sidebar_label_theme(&mut lines, "Recent tools", theme);
            push_tool_rows(&mut lines, &recent_rows, content_width, max_rows, theme);
        }
    }

    // Yank hint: surface the keyboard path for copying the focused task/turn ID.
    if lines.len() + 1 < max_rows
        && app.runtime_turn_id.is_some()
        && app.sidebar_focus == SidebarFocus::Tasks
    {
        lines.push(Line::from(Span::styled(
            "y → copy turn id  ·  Y → copy full status",
            Style::default()
                .fg(theme.text_dim)
                .add_modifier(ratatui::style::Modifier::ITALIC),
        )));
    }

    if lines.is_empty()
        || (lines.len() == 1
            && app.runtime_turn_id.is_some()
            && active_rows.is_empty()
            && reasoning_rows.is_empty()
            && background_rows.is_empty())
    {
        lines.push(Line::from(Span::styled(
            "No live tools or background jobs",
            Style::default().fg(theme.text_muted),
        )));
    }

    // Backfill action slots for the trailing non-clickable lines (Recent
    // tools, yank hint, empty-state notice).
    actions.resize(lines.len(), None);
    (lines, actions)
}

fn task_panel_hover_texts(app: &App, max_rows: usize) -> Vec<String> {
    let mut texts = Vec::with_capacity(max_rows.max(4));

    if let Some(turn_id) = app.runtime_turn_id.as_ref() {
        let status = app.runtime_turn_status.as_deref().unwrap_or("unknown");
        texts.push(format!("turn {turn_id} ({status})"));
    }

    let active_rows = active_tool_rows(app);
    if !active_rows.is_empty() && texts.len() < max_rows {
        texts.push("Live tools".to_string());
        push_tool_row_hover_texts(&mut texts, &active_rows, max_rows);
    }

    let reasoning_rows = reasoning_task_rows(app);
    if !reasoning_rows.is_empty() && texts.len() < max_rows {
        texts.push("Model reasoning".to_string());
        push_reasoning_row_hover_texts(&mut texts, &reasoning_rows, max_rows);
    }

    let background_rows = background_task_rows(app, &active_rows);
    if !background_rows.is_empty() && texts.len() < max_rows {
        let running = background_rows
            .iter()
            .filter(|task| task.status == "running")
            .count();
        let done = background_rows.len().saturating_sub(running);
        let label = if running == 0 {
            format!("Bash jobs: {done} completed")
        } else if done == 0 {
            format!("Bash jobs: {running} running")
        } else {
            format!("Bash jobs: {running} running, {done} completed")
        };
        texts.push(label);

        let max_items = max_rows.saturating_sub(texts.len());
        for task in background_rows.iter().take(max_items) {
            let duration = task
                .duration_ms
                .map(format_duration_ms)
                .unwrap_or_else(|| "-".to_string());
            let (label, detail) = background_task_labels(task, &duration);
            texts.push(label);
            if texts.len() >= max_rows {
                break;
            }
            texts.push(format!("  {detail}"));
        }

        if texts.len() < max_rows
            && background_rows
                .iter()
                .any(|task| task.id.starts_with("shell_") && task.status == "running")
        {
            texts.push("Ctrl+K -> /jobs cancel-all".to_string());
        }
    }

    if texts.len() < max_rows {
        let recent_rows = recent_tool_rows(app, 4);
        if !recent_rows.is_empty() {
            texts.push("Recent tools".to_string());
            push_tool_row_hover_texts(&mut texts, &recent_rows, max_rows);
        }
    }

    if texts.len() + 1 < max_rows
        && app.runtime_turn_id.is_some()
        && app.sidebar_focus == SidebarFocus::Tasks
    {
        texts.push("y -> copy turn id  ·  Y -> copy full status".to_string());
    }

    if texts.is_empty()
        || (texts.len() == 1
            && app.runtime_turn_id.is_some()
            && active_rows.is_empty()
            && reasoning_rows.is_empty()
            && background_rows.is_empty())
    {
        texts.push("No live tools or background jobs".to_string());
    }

    texts
}

fn push_sidebar_label_theme(lines: &mut Vec<Line<'static>>, label: &str, theme: &palette::UiTheme) {
    lines.push(Line::from(Span::styled(
        label.to_string(),
        Style::default().fg(theme.accent_primary).bold(),
    )));
}

fn push_tool_row_hover_texts(texts: &mut Vec<String>, rows: &[SidebarToolRow], max_rows: usize) {
    for row in rows {
        if texts.len() >= max_rows {
            break;
        }
        let (marker, _) = tool_status_marker(row.status, &palette::UI_THEME);
        let label = if let Some(duration_ms) = row.duration_ms {
            format!("{marker} {} {}", row.name, format_duration_ms(duration_ms))
        } else {
            format!("{marker} {}", row.name)
        };
        texts.push(label);
        if !row.summary.trim().is_empty() && texts.len() < max_rows {
            texts.push(format!("  {}", row.summary));
        }
    }
}

fn push_reasoning_rows(
    lines: &mut Vec<Line<'static>>,
    rows: &[TaskPanelEntry],
    content_width: usize,
    max_rows: usize,
    theme: &palette::UiTheme,
) {
    for task in rows {
        if lines.len() >= max_rows {
            break;
        }
        let color = match task.status.as_str() {
            "running" => theme.warning,
            "completed" => theme.success,
            "failed" => theme.error_fg,
            _ => theme.text_muted,
        };
        let duration = task
            .duration_ms
            .map(format_duration_ms)
            .unwrap_or_else(|| "-".to_string());
        lines.push(Line::from(Span::styled(
            truncate_line_to_width(
                &format!("thinking {} {duration}", task.status),
                content_width,
            ),
            Style::default().fg(color),
        )));
        if !task.prompt_summary.trim().is_empty() && lines.len() < max_rows {
            lines.push(Line::from(Span::styled(
                format!(
                    "  {}",
                    truncate_line_to_width(
                        &task.prompt_summary,
                        content_width.saturating_sub(2).max(1)
                    )
                ),
                Style::default().fg(theme.text_dim),
            )));
        }
    }
}

fn push_reasoning_row_hover_texts(
    texts: &mut Vec<String>,
    rows: &[TaskPanelEntry],
    max_rows: usize,
) {
    for task in rows {
        if texts.len() >= max_rows {
            break;
        }
        let duration = task
            .duration_ms
            .map(format_duration_ms)
            .unwrap_or_else(|| "-".to_string());
        texts.push(format!("thinking {} {duration}", task.status));
        if !task.prompt_summary.trim().is_empty() && texts.len() < max_rows {
            texts.push(format!("  {}", task.prompt_summary));
        }
    }
}

fn background_task_labels(task: &TaskPanelEntry, duration: &str) -> (String, String) {
    if let Some(command) = task.prompt_summary.strip_prefix("shell: ") {
        let command = concise_shell_command_label(command, 96);
        return (
            format!("Bash {} {} {}", task.status, command, duration),
            format!("{} \u{00B7} Bash", task.id),
        );
    }

    (
        format!(
            "{} {} {}",
            truncate_line_to_width(&task.id, 10),
            task.status,
            duration
        ),
        task.prompt_summary.clone(),
    )
}

fn active_tool_rows(app: &App) -> Vec<SidebarToolRow> {
    let Some(active) = app.active_cell.as_ref() else {
        return Vec::new();
    };
    let mut rows: Vec<SidebarToolRow> = Vec::new();
    let mut stale_running: Vec<SidebarToolRow> = Vec::new();
    for (entry_idx, cell) in active.entries().iter().enumerate() {
        let Some(row) = sidebar_tool_row_from_cell(cell) else {
            continue;
        };
        match active_tool_row_visibility(app, entry_idx, &row) {
            ActiveToolRowVisibility::Visible => rows.push(row),
            ActiveToolRowVisibility::StaleRunning => stale_running.push(row),
            ActiveToolRowVisibility::Hidden => {}
        }
    }
    if !stale_running.is_empty() {
        rows.push(collapsed_stale_running_row(stale_running));
    }
    editorial_tool_rows(rows, usize::MAX, ToolRowOrder::OldestFirst)
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ActiveToolRowVisibility {
    Visible,
    StaleRunning,
    Hidden,
}

fn active_tool_row_visibility(
    app: &App,
    entry_idx: usize,
    row: &SidebarToolRow,
) -> ActiveToolRowVisibility {
    if row.status == ToolStatus::Running {
        return if row
            .duration_ms
            .is_some_and(|ms| ms >= duration_ms(ACTIVE_TOOL_STALE_RUNNING_ROW_TTL))
        {
            ActiveToolRowVisibility::StaleRunning
        } else {
            ActiveToolRowVisibility::Visible
        };
    }

    let Some(completed_at) = app.active_tool_entry_completed_at.get(&entry_idx) else {
        return ActiveToolRowVisibility::Hidden;
    };
    if completed_at.elapsed() <= ACTIVE_TOOL_COMPLETED_ROW_TTL {
        ActiveToolRowVisibility::Visible
    } else {
        ActiveToolRowVisibility::Hidden
    }
}

fn collapsed_stale_running_row(rows: Vec<SidebarToolRow>) -> SidebarToolRow {
    let count = rows.len();
    let oldest_ms = rows
        .iter()
        .filter_map(|row| row.duration_ms)
        .max()
        .unwrap_or_default();
    let first_summary = rows
        .iter()
        .find_map(|row| (!row.summary.trim().is_empty()).then(|| row.summary.clone()))
        .unwrap_or_else(|| "open Activity Detail".to_string());
    SidebarToolRow {
        name: if count == 1 {
            "run".to_string()
        } else {
            format!("run x{count}")
        },
        status: ToolStatus::Running,
        summary: format!("long-running · {first_summary}"),
        duration_ms: (oldest_ms > 0).then_some(oldest_ms),
    }
}

fn recent_tool_rows(app: &App, limit: usize) -> Vec<SidebarToolRow> {
    let rows: Vec<SidebarToolRow> = app
        .history
        .iter()
        .rev()
        .filter_map(sidebar_tool_row_from_cell)
        .take(RECENT_TOOL_SCAN_LIMIT)
        .collect();
    editorial_tool_rows(rows, limit, ToolRowOrder::NewestFirst)
}

fn push_tool_rows(
    lines: &mut Vec<Line<'static>>,
    rows: &[SidebarToolRow],
    content_width: usize,
    max_rows: usize,
    theme: &palette::UiTheme,
) {
    for row in rows {
        if lines.len() >= max_rows {
            break;
        }
        let (marker, color) = tool_status_marker(row.status, theme);
        let label = if let Some(duration_ms) = row.duration_ms {
            format!("{marker} {} {}", row.name, format_duration_ms(duration_ms))
        } else {
            format!("{marker} {}", row.name)
        };
        lines.push(Line::from(Span::styled(
            truncate_line_to_width(&label, content_width),
            Style::default().fg(color),
        )));
        if !row.summary.trim().is_empty() && lines.len() < max_rows {
            lines.push(Line::from(Span::styled(
                format!(
                    "  {}",
                    truncate_line_to_width(&row.summary, content_width.saturating_sub(2).max(1))
                ),
                Style::default().fg(theme.text_dim),
            )));
        }
    }
}

fn sidebar_tool_row_from_cell(cell: &HistoryCell) -> Option<SidebarToolRow> {
    let HistoryCell::Tool(tool) = cell else {
        return None;
    };
    match tool {
        ToolCell::Exec(exec) => Some(SidebarToolRow {
            name: concise_shell_command_label(&exec.command, 48),
            status: shell_status_for_sidebar(
                &exec.command,
                exec.status,
                exec.output_summary.as_deref(),
                exec.output.as_deref(),
            ),
            summary: shell_summary_for_sidebar(
                &exec.command,
                exec.status,
                exec.output_summary.as_deref(),
                exec.output.as_deref(),
            ),
            duration_ms: exec.duration_ms.or_else(|| {
                (exec.status == ToolStatus::Running).then(|| {
                    u64::try_from(
                        exec.started_at
                            .map(|started| started.elapsed().as_millis())
                            .unwrap_or_default(),
                    )
                    .unwrap_or(u64::MAX)
                })
            }),
        }),
        ToolCell::Exploring(explore) => {
            let running = explore
                .entries
                .iter()
                .filter(|entry| entry.status == ToolStatus::Running)
                .count();
            let status = if running > 0 {
                ToolStatus::Running
            } else if explore
                .entries
                .iter()
                .any(|entry| entry.status == ToolStatus::Failed)
            {
                ToolStatus::Failed
            } else {
                ToolStatus::Success
            };
            let first = explore.entries.first().map(|entry| entry.label.as_str());
            Some(SidebarToolRow {
                name: "workspace".to_string(),
                status,
                summary: compact_join([
                    format!("{} item(s), {running} running", explore.entries.len()),
                    first.unwrap_or_default().to_string(),
                ]),
                duration_ms: None,
            })
        }
        ToolCell::PlanUpdate(plan) => Some(SidebarToolRow {
            name: "update_plan".to_string(),
            status: plan.status,
            summary: plan
                .snapshot
                .objective
                .as_deref()
                .or(plan.snapshot.title.as_deref())
                .or(plan.snapshot.explanation.as_deref())
                .or(plan.snapshot.recommended_approach.as_deref())
                .or_else(|| plan.snapshot.items.first().map(|step| step.step.as_str()))
                .unwrap_or("")
                .to_string(),
            duration_ms: None,
        }),
        ToolCell::PatchSummary(patch) => Some(SidebarToolRow {
            name: "patch".to_string(),
            status: patch.status,
            summary: compact_join([patch.path.clone(), patch.summary.clone()]),
            duration_ms: None,
        }),
        ToolCell::Review(review) => Some(SidebarToolRow {
            name: "review".to_string(),
            status: review.status,
            summary: review.target.clone(),
            duration_ms: None,
        }),
        ToolCell::DiffPreview(diff) => Some(SidebarToolRow {
            name: "diff".to_string(),
            status: ToolStatus::Success,
            summary: diff.title.clone(),
            duration_ms: None,
        }),
        ToolCell::Mcp(mcp) => Some(SidebarToolRow {
            name: mcp.tool.clone(),
            status: mcp.status,
            summary: mcp
                .content
                .as_deref()
                .map(summarize_tool_output)
                .unwrap_or_default(),
            duration_ms: None,
        }),
        ToolCell::ViewImage(image) => Some(SidebarToolRow {
            name: "image".to_string(),
            status: ToolStatus::Success,
            summary: image.path.display().to_string(),
            duration_ms: None,
        }),
        ToolCell::WebSearch(search) => Some(SidebarToolRow {
            name: "web_search".to_string(),
            status: search.status,
            summary: compact_join([
                search.query.clone(),
                search.summary.clone().unwrap_or_default(),
            ]),
            duration_ms: None,
        }),
        ToolCell::Generic(generic) => Some(SidebarToolRow {
            name: friendly_generic_tool_name(&generic.name).to_string(),
            status: generic.status,
            summary: generic_tool_sidebar_summary(generic),
            duration_ms: None,
        }),
    }
}

fn shell_status_for_sidebar(
    command: &str,
    status: ToolStatus,
    output_summary: Option<&str>,
    output: Option<&str>,
) -> ToolStatus {
    if status == ToolStatus::Failed && looks_like_pending_ci(command, output_summary, output) {
        ToolStatus::Running
    } else {
        status
    }
}

fn shell_summary_for_sidebar(
    command: &str,
    status: ToolStatus,
    output_summary: Option<&str>,
    output: Option<&str>,
) -> String {
    if status == ToolStatus::Failed && looks_like_pending_ci(command, output_summary, output) {
        return format!(
            "Waiting for CI \u{00B7} {} details",
            crate::tui::key_shortcuts::tool_details_shortcut_label()
        );
    }

    let summary = compact_join([
        output_summary.unwrap_or_default().to_string(),
        output
            .map(first_nonempty_line)
            .unwrap_or_default()
            .to_string(),
    ]);
    if status == ToolStatus::Failed {
        failure_summary_with_hint(&summary)
    } else {
        summary
    }
}

fn looks_like_pending_ci(
    command: &str,
    output_summary: Option<&str>,
    output: Option<&str>,
) -> bool {
    let command_label = concise_shell_command_label(command, 80).to_ascii_lowercase();
    if !command_label.starts_with("gh pr checks") && !command_label.starts_with("gh run watch") {
        return false;
    }

    let text = compact_join([
        output_summary.unwrap_or_default().to_string(),
        output.unwrap_or_default().to_string(),
    ])
    .to_ascii_lowercase();
    if text.is_empty() {
        return false;
    }
    let pending = ["pending", "queued", "in_progress", "in progress", "waiting"]
        .iter()
        .any(|needle| text.contains(needle));
    let hard_failure = ["failed", "failure", "error", "cancelled", "canceled"]
        .iter()
        .any(|needle| text.contains(needle));
    pending && !hard_failure
}

fn failure_summary_with_hint(summary: &str) -> String {
    let hint = format!(
        "inspect details with {}",
        crate::tui::key_shortcuts::tool_details_shortcut_label()
    );
    if summary.trim().is_empty() {
        hint
    } else if summary.contains(&hint) {
        summary.to_string()
    } else {
        format!("{summary} \u{00B7} {hint}")
    }
}

fn friendly_generic_tool_name(name: &str) -> &str {
    match name {
        "task_shell_start" => "start Bash",
        "task_shell_wait" => "wait Bash",
        "task_shell_write" => "write Bash",
        _ => name,
    }
}

fn generic_tool_sidebar_summary(generic: &GenericToolCell) -> String {
    match generic.name.as_str() {
        "task_shell_start" => compact_join([
            generic.input_summary.clone().unwrap_or_default(),
            "background Bash".to_string(),
        ]),
        "task_shell_wait" => compact_join([
            generic.input_summary.clone().unwrap_or_default(),
            generic.output_summary.clone().unwrap_or_default(),
        ]),
        _ => compact_join([
            generic.input_summary.clone().unwrap_or_default(),
            generic.output_summary.clone().unwrap_or_default(),
            generic
                .output
                .as_deref()
                .map(summarize_tool_output)
                .unwrap_or_default(),
        ]),
    }
}

fn background_task_rows(app: &App, active_rows: &[SidebarToolRow]) -> Vec<TaskPanelEntry> {
    let mut rows: Vec<TaskPanelEntry> = app
        .task_panel
        .iter()
        .filter(|task| task.kind == TaskPanelEntryKind::Background)
        .filter(|task| !background_task_duplicates_live_tool(task, active_rows))
        .cloned()
        .collect();
    rows.sort_by_key(|task| (task_status_rank(task.status.as_str()), task.id.clone()));
    rows
}

fn reasoning_task_rows(app: &App) -> Vec<TaskPanelEntry> {
    let mut rows: Vec<TaskPanelEntry> = app
        .task_panel
        .iter()
        .filter(|task| task.kind == TaskPanelEntryKind::ModelReasoning)
        .cloned()
        .collect();
    rows.sort_by_key(|task| (task_status_rank(task.status.as_str()), task.id.clone()));
    rows
}

fn background_task_duplicates_live_tool(
    task: &TaskPanelEntry,
    active_rows: &[SidebarToolRow],
) -> bool {
    if task.status != "running" {
        return false;
    }

    if task.id.starts_with("rlm-") || task.prompt_summary.starts_with("RLM: ") {
        return active_rows
            .iter()
            .any(|row| row.status == ToolStatus::Running && row.name.starts_with("rlm_"));
    }

    let Some(command) = task.prompt_summary.strip_prefix("shell: ") else {
        return false;
    };
    let command = normalize_activity_text(command);
    !command.is_empty()
        && active_rows.iter().any(|row| {
            row.status == ToolStatus::Running
                && normalize_activity_text(&format!("{} {}", row.name, row.summary))
                    .contains(&command)
        })
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ToolRowOrder {
    OldestFirst,
    NewestFirst,
}

fn editorial_tool_rows(
    rows: Vec<SidebarToolRow>,
    limit: usize,
    order_mode: ToolRowOrder,
) -> Vec<SidebarToolRow> {
    #[derive(Clone)]
    struct Candidate {
        rank: u8,
        order: usize,
        row: SidebarToolRow,
    }

    let mut candidates: Vec<Candidate> = Vec::new();
    let mut low_value_groups: Vec<(usize, SidebarToolRow, usize)> = Vec::new();
    let mut ci_poll_groups: Vec<(usize, SidebarToolRow, usize)> = Vec::new();
    let mut shell_wait_groups: Vec<(usize, SidebarToolRow, usize, String)> = Vec::new();
    let mut seen_success: Vec<String> = Vec::new();
    let mut seen_success_tool_names: Vec<String> = Vec::new();
    let mut seen_failures: Vec<String> = Vec::new();
    let mut visible_failure_count: usize = 0;
    const MAX_VISIBLE_FAILURES: usize = 2;

    for (order, mut row) in rows.into_iter().enumerate() {
        if row.status == ToolStatus::Failed {
            // Deduplicate failures for the same tool name: keep only the most
            // recent failure per tool. Fixes #1884 — stale failures from
            // tools that have since succeeded no longer crowd the sidebar.
            let fail_key = row.name.trim().to_ascii_lowercase();
            if order_mode == ToolRowOrder::NewestFirst
                && seen_success_tool_names.contains(&fail_key)
            {
                continue;
            }
            if seen_failures.contains(&fail_key) {
                continue;
            }
            seen_failures.push(fail_key);
            row.summary = failure_summary_with_hint(&row.summary);
        }

        if is_ci_poll_row(&row) {
            if let Some((_, grouped, count)) = ci_poll_groups
                .iter_mut()
                .find(|(_, grouped, _)| grouped.name == row.name)
            {
                *count += 1;
                if grouped.duration_ms.is_none() {
                    grouped.duration_ms = row.duration_ms;
                }
            } else {
                ci_poll_groups.push((order, row, 1));
            }
            continue;
        }

        if is_shell_wait_poll_row(&row) {
            let key = shell_wait_poll_key(&row);
            if let Some((_, grouped, count, _)) = shell_wait_groups
                .iter_mut()
                .find(|(_, _, _, existing_key)| existing_key == &key)
            {
                *count += 1;
                if !row.summary.trim().is_empty() {
                    grouped.summary = row.summary;
                }
            } else {
                shell_wait_groups.push((order, row, 1, key));
            }
            continue;
        }

        if is_low_value_tool(&row.name) && row.status == ToolStatus::Success {
            if let Some((_, grouped, count)) = low_value_groups
                .iter_mut()
                .find(|(_, grouped, _)| grouped.name == row.name)
            {
                *count += 1;
                if grouped.summary.trim().is_empty() && !row.summary.trim().is_empty() {
                    grouped.summary = row.summary;
                }
            } else {
                low_value_groups.push((order, row, 1));
            }
            continue;
        }

        let key = sidebar_row_identity(&row);
        if row.status == ToolStatus::Success && seen_success.iter().any(|seen| seen == &key) {
            continue;
        }
        if row.status == ToolStatus::Success {
            seen_success.push(key);
            let normalized = row.name.trim().to_ascii_lowercase();
            if !seen_success_tool_names.contains(&normalized) {
                seen_success_tool_names.push(normalized.clone());
            }

            // Active rows are oldest-first, so a success means any candidate
            // failure for the same tool is stale. Recent history rows are
            // newest-first; in that path the success is older than any
            // already-seen failure and must not remove it.
            if order_mode == ToolRowOrder::OldestFirst {
                let mut removed_visible_failures = 0usize;
                let mut removed_any_failure = false;
                candidates.retain(|c| {
                    let remove = c.row.status == ToolStatus::Failed
                        && c.row.name.trim().eq_ignore_ascii_case(&normalized);
                    if remove {
                        removed_any_failure = true;
                        if c.rank == 0 {
                            removed_visible_failures += 1;
                        }
                    }
                    !remove
                });
                if removed_any_failure {
                    seen_failures.retain(|seen| seen != &normalized);
                    visible_failure_count =
                        visible_failure_count.saturating_sub(removed_visible_failures);
                }
            }
        }

        // Cap visible failures at MAX_VISIBLE_FAILURES. Excess failures
        // get demoted to rank 3 so they don't crowd the top of the
        // sidebar. (#1884)
        let rank = if row.status == ToolStatus::Failed {
            if visible_failure_count >= MAX_VISIBLE_FAILURES {
                3
            } else {
                visible_failure_count += 1;
                0
            }
        } else {
            tool_row_rank(&row)
        };

        candidates.push(Candidate { rank, order, row });
    }

    for (order, mut row, count) in ci_poll_groups {
        if count > 1 {
            let command = row.name.clone();
            row.name = "Waiting for CI".to_string();
            row.summary = format!(
                "{command} \u{00B7} {count} polls collapsed \u{00B7} {} details",
                crate::tui::key_shortcuts::tool_details_shortcut_label()
            );
            row.status = ToolStatus::Running;
        }
        candidates.push(Candidate {
            rank: tool_row_rank(&row),
            order,
            row,
        });
    }

    for (order, mut row, count, key) in shell_wait_groups {
        if count > 1 {
            row.summary = compact_join([
                format!("{key} \u{00B7} {count} waits collapsed"),
                row.summary.clone(),
            ]);
        }
        candidates.push(Candidate {
            rank: tool_row_rank(&row),
            order,
            row,
        });
    }

    for (order, mut row, count) in low_value_groups {
        if count > 1 {
            row.name = format!("{} x{count}", row.name);
            if !row.summary.trim().is_empty() {
                row.summary = format!("latest: {}", row.summary);
            }
        }
        candidates.push(Candidate {
            rank: tool_row_rank(&row).saturating_add(1),
            order,
            row,
        });
    }

    candidates.sort_by_key(|candidate| (candidate.rank, candidate.order));
    candidates
        .into_iter()
        .take(limit)
        .map(|candidate| candidate.row)
        .collect()
}

fn sidebar_row_identity(row: &SidebarToolRow) -> String {
    format!(
        "{}\n{}",
        row.name.trim(),
        normalize_activity_text(row.summary.as_str())
    )
}

fn is_ci_poll_row(row: &SidebarToolRow) -> bool {
    row.name.starts_with("gh pr checks") || row.name.starts_with("gh run watch")
}

fn is_shell_wait_poll_row(row: &SidebarToolRow) -> bool {
    row.status == ToolStatus::Running && row.name == "wait Bash"
}

fn shell_wait_poll_key(row: &SidebarToolRow) -> String {
    const MARKER: &str = "task_id:";
    if let Some((_, rest)) = row.summary.split_once(MARKER) {
        let task_id = rest
            .trim_start()
            .split(|ch: char| ch.is_whitespace() || ch == ',' || ch == '\u{00B7}')
            .next()
            .unwrap_or_default()
            .trim();
        if !task_id.is_empty() {
            return task_id.to_string();
        }
    }

    normalize_activity_text(&row.summary)
}

fn normalize_activity_text(text: &str) -> String {
    let mut cleaned = String::with_capacity(text.len());
    crate::tui::osc8::strip_ansi_into(text, &mut cleaned);
    cleaned.split_whitespace().collect::<Vec<_>>().join(" ")
}

fn tool_row_rank(row: &SidebarToolRow) -> u8 {
    match row.status {
        ToolStatus::Failed => 0,
        // A schema-hydrated deferred tool is not "run done" — it must be
        // retried — so it ranks with active work, not completed successes.
        ToolStatus::Running | ToolStatus::Hydrated => 1,
        ToolStatus::Success if is_low_value_tool(&row.name) => 3,
        ToolStatus::Success => 2,
    }
}

fn task_status_rank(status: &str) -> u8 {
    match status {
        "running" => 0,
        "failed" => 1,
        "queued" => 2,
        "completed" => 3,
        "canceled" => 4,
        _ => 5,
    }
}

fn is_low_value_tool(name: &str) -> bool {
    let base = name.split_whitespace().next().unwrap_or(name);
    matches!(
        base,
        "read_file" | "grep_files" | "file_search" | "find" | "checklist_update"
    )
}

fn compact_join(parts: impl IntoIterator<Item = String>) -> String {
    let mut out: Vec<String> = Vec::new();
    for part in parts {
        let part = part.trim();
        if !part.is_empty() && !out.iter().any(|seen| seen == part) {
            out.push(part.to_string());
        }
    }
    out.join(" · ")
}

fn first_nonempty_line(text: &str) -> &str {
    text.lines()
        .map(str::trim)
        .find(|line| !line.is_empty())
        .unwrap_or("")
}

fn tool_status_marker(
    status: ToolStatus,
    theme: &palette::UiTheme,
) -> (&'static str, ratatui::style::Color) {
    match status {
        ToolStatus::Running => ("[~]", theme.warning),
        ToolStatus::Success => ("[✓]", theme.success),
        ToolStatus::Hydrated => ("[~]", theme.warning),
        ToolStatus::Failed => ("[!]", theme.error_fg),
    }
}

fn format_duration_ms(ms: u64) -> String {
    if ms < 1000 {
        format!("{ms}ms")
    } else {
        format!("{:.1}s", ms as f64 / 1000.0)
    }
}

fn duration_ms(duration: Duration) -> u64 {
    u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
}

fn render_sidebar_subagents(f: &mut Frame, area: Rect, app: &mut App) {
    if area.height < 3 {
        return;
    }

    let content_width = area.width.saturating_sub(4) as usize;
    let usable_rows = area.height.saturating_sub(3) as usize;
    let cached_ids: std::collections::HashSet<&str> = app
        .subagent_cache
        .iter()
        .map(|agent| agent.agent_id.as_str())
        .collect();
    let progress_only_count = app
        .agent_progress
        .keys()
        .filter(|id| !cached_ids.contains(id.as_str()))
        .count();
    let cached_running = app
        .subagent_cache
        .iter()
        .filter(|agent| matches!(agent.status, SubAgentStatus::Running))
        .count();
    let role_counts: std::collections::BTreeMap<String, usize> =
        app.subagent_cache
            .iter()
            .fold(std::collections::BTreeMap::new(), |mut acc, agent| {
                *acc.entry(agent.agent_type.as_str().to_string())
                    .or_insert(0) += 1;
                acc
            });
    let (fanout_running, fanout_total) = active_fanout_counts(app)
        .map(|(running, total)| (running, Some(total)))
        .unwrap_or((0, None));
    let foreground_rlm_running = foreground_rlm_running(app);

    let summary = SidebarSubagentSummary {
        cached_total: app.subagent_cache.len(),
        cached_running,
        progress_only_count,
        fanout_total,
        fanout_running,
        foreground_rlm_running,
        role_counts,
    };
    let rows = sidebar_agent_rows(app);
    let (lines, row_actions) = subagent_panel_rows(
        &summary,
        &rows,
        content_width,
        usable_rows.max(1),
        &app.ui_theme,
    );
    let full_texts = subagent_panel_hover_texts(&summary, &rows, usable_rows.max(1));

    render_sidebar_section(f, area, "Agents", lines, full_texts, row_actions, app);
}

/// Minimal projection of the data the sub-agent sidebar needs. Lifted out
/// of `render_sidebar_subagents` so the rendering can be snapshot-tested
/// without a full `App`.
#[derive(Debug, Clone, Default)]
pub struct SidebarSubagentSummary {
    pub cached_total: usize,
    pub cached_running: usize,
    pub progress_only_count: usize,
    pub fanout_total: Option<usize>,
    pub fanout_running: usize,
    pub foreground_rlm_running: bool,
    pub role_counts: std::collections::BTreeMap<String, usize>,
}

#[derive(Debug, Clone)]
pub struct SidebarAgentRow {
    pub id: String,
    pub name: String,
    pub role: String,
    pub status: String,
    pub objective: Option<String>,
    pub git_branch: Option<String>,
    pub progress: Option<String>,
    pub steps_taken: u32,
    pub duration_ms: Option<u64>,
}

fn foreground_rlm_running(app: &App) -> bool {
    app.active_cell.as_ref().is_some_and(|active| {
        active.entries().iter().any(|entry| {
            matches!(
                entry,
                HistoryCell::Tool(ToolCell::Generic(generic))
                    if matches!(
                        generic.name.as_str(),
                        "rlm_open" | "rlm_eval" | "rlm_configure" | "rlm_close" | "rlm"
                    ) && generic.status == ToolStatus::Running
            )
        })
    })
}

fn sidebar_agent_rows(app: &App) -> Vec<SidebarAgentRow> {
    let mut rows: Vec<SidebarAgentRow> = app
        .subagent_cache
        .iter()
        .map(|agent| {
            let progress = app
                .agent_progress
                .get(&agent.agent_id)
                .cloned()
                .or_else(|| {
                    agent
                        .result
                        .as_deref()
                        .map(summarize_tool_output)
                        .filter(|summary| !summary.trim().is_empty())
                });
            // #3030: Prefer the user-assigned nickname > stable label
            // ("Agent 1") > raw name. Every spawned agent gets a label-map
            // entry, so the generated label must not shadow nicknames.
            let display_name = agent
                .nickname
                .clone()
                .or_else(|| app.agent_label_map.get(&agent.agent_id).cloned())
                .unwrap_or_else(|| agent.name.clone());
            SidebarAgentRow {
                id: agent.agent_id.clone(),
                name: display_name,
                role: agent.agent_type.as_str().to_string(),
                status: subagent_status_text(&agent.status).to_string(),
                objective: Some(agent.assignment.objective.clone())
                    .filter(|objective| !objective.trim().is_empty()),
                git_branch: agent.git_branch.clone(),
                progress,
                steps_taken: agent.steps_taken,
                duration_ms: Some(agent.duration_ms),
            }
        })
        .collect();

    let cached_ids: std::collections::HashSet<&str> = app
        .subagent_cache
        .iter()
        .map(|agent| agent.agent_id.as_str())
        .collect();
    rows.extend(
        app.agent_progress
            .iter()
            .filter(|(id, _)| !cached_ids.contains(id.as_str()))
            .map(|(id, progress)| {
                // #3030: Prefer stable label for progress-only agents too.
                let display_name = app
                    .agent_label_map
                    .get(id.as_str())
                    .cloned()
                    .unwrap_or_else(|| id.clone());
                SidebarAgentRow {
                    id: id.clone(),
                    name: display_name,
                    role: "agent".to_string(),
                    status: "running".to_string(),
                    objective: None,
                    git_branch: None,
                    progress: Some(progress.clone()),
                    steps_taken: 0,
                    duration_ms: app.agent_activity_started_at.map(|started| {
                        u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX)
                    }),
                }
            }),
    );

    rows
}

fn subagent_status_text(status: &SubAgentStatus) -> &'static str {
    match status {
        SubAgentStatus::Running => "running",
        SubAgentStatus::Completed => "done",
        SubAgentStatus::Interrupted(_) => "interrupted",
        SubAgentStatus::Failed(_) => "failed",
        SubAgentStatus::Cancelled => "canceled",
    }
}

/// Build sub-agent sidebar lines from summary + per-agent rows. Public
/// for the snapshot tests in this module.
#[cfg(test)]
pub fn subagent_panel_lines(
    summary: &SidebarSubagentSummary,
    rows: &[SidebarAgentRow],
    content_width: usize,
    max_rows: usize,
    theme: &palette::UiTheme,
) -> Vec<Line<'static>> {
    subagent_panel_rows(summary, rows, content_width, max_rows, theme).0
}

/// Build the Agents panel lines together with a parallel per-line
/// click-action vector (#3028). Agent label rows open the agents view via
/// `/subagents`; header, role-mix, detail, and RLM lines are not clickable.
fn subagent_panel_rows(
    summary: &SidebarSubagentSummary,
    rows: &[SidebarAgentRow],
    content_width: usize,
    max_rows: usize,
    theme: &palette::UiTheme,
) -> (Vec<Line<'static>>, Vec<Option<String>>) {
    let mut lines: Vec<Line<'static>> = Vec::with_capacity(max_rows.max(4));
    let mut actions: Vec<Option<String>> = Vec::with_capacity(max_rows.max(4));

    let fanout_total = summary.fanout_total.unwrap_or(0);
    if summary.cached_total == 0
        && summary.progress_only_count == 0
        && fanout_total == 0
        && !summary.foreground_rlm_running
    {
        lines.push(Line::from(Span::styled(
            "No agents",
            Style::default().fg(theme.text_muted),
        )));
        actions.push(None);
        return (lines, actions);
    }

    let (live_running, total) = if let Some(total) = summary.fanout_total {
        (summary.fanout_running, total)
    } else {
        (
            summary.cached_running + summary.progress_only_count,
            summary.cached_total + summary.progress_only_count,
        )
    };
    let done = total.saturating_sub(live_running);
    let header = if live_running > 0 {
        vec![
            Span::styled(
                format!("{live_running} running"),
                Style::default().fg(theme.accent_primary).bold(),
            ),
            Span::styled(format!(" / {total}"), Style::default().fg(theme.text_muted)),
        ]
    } else {
        vec![Span::styled(
            format!("{done} done"),
            Style::default().fg(theme.success),
        )]
    };
    lines.push(Line::from(header));
    actions.push(None);

    if !summary.role_counts.is_empty() {
        let mix: Vec<String> = summary
            .role_counts
            .iter()
            .map(|(role, count)| format!("{count} {role}"))
            .collect();
        let role_line = mix.join(" \u{00B7} ");
        lines.push(Line::from(Span::styled(
            truncate_line_to_width(&role_line, content_width.max(1)),
            Style::default().fg(theme.text_dim),
        )));
        actions.push(None);
    }

    for row in rows {
        if lines.len() >= max_rows {
            break;
        }
        let (marker, color) = agent_status_marker(row.status.as_str(), theme);
        let label = format!("{marker} {} {}", row.role, row.name);
        lines.push(Line::from(Span::styled(
            truncate_line_to_width(&label, content_width.max(1)),
            Style::default().fg(color),
        )));
        actions.push(Some("/subagents".to_string()));

        // Auto-collapse finished sub-agents: hide detail lines for completed
        // agents so the sidebar stays compact when work is done.
        if row.status == "done" {
            continue;
        }

        if lines.len() >= max_rows {
            break;
        }
        // #3030: keep raw agent ids out of the compact detail line — the
        // full id remains available in the hover text.
        let mut detail_parts = Vec::new();
        if row.steps_taken > 0 {
            detail_parts.push(format!("{} step(s)", row.steps_taken));
        }
        if let Some(progress) = row.progress.as_deref()
            && !progress.trim().is_empty()
        {
            detail_parts.push(summarize_tool_output(progress));
        }
        if let Some(branch) = row.git_branch.as_deref() {
            detail_parts.push(format!("branch {branch}"));
        }
        if let Some(duration) = row.duration_ms {
            detail_parts.push(format_duration_ms(duration));
        }
        if detail_parts.is_empty() {
            detail_parts.push(row.status.clone());
        }
        lines.push(Line::from(Span::styled(
            format!(
                "  {}",
                truncate_line_to_width(
                    &detail_parts.join(" · "),
                    content_width.saturating_sub(2).max(1)
                )
            ),
            Style::default().fg(theme.text_dim),
        )));
        actions.push(None);
    }

    if summary.foreground_rlm_running {
        lines.push(Line::from(vec![
            Span::styled("RLM", Style::default().fg(theme.accent_primary).bold()),
            Span::styled(
                " foreground work active",
                Style::default().fg(theme.text_dim),
            ),
        ]));
        actions.push(None);
    }

    debug_assert_eq!(lines.len(), actions.len());
    (lines, actions)
}

fn subagent_panel_hover_texts(
    summary: &SidebarSubagentSummary,
    rows: &[SidebarAgentRow],
    max_rows: usize,
) -> Vec<String> {
    let mut texts = Vec::with_capacity(max_rows.max(4));

    let fanout_total = summary.fanout_total.unwrap_or(0);
    if summary.cached_total == 0
        && summary.progress_only_count == 0
        && fanout_total == 0
        && !summary.foreground_rlm_running
    {
        texts.push("No agents".to_string());
        return texts;
    }

    let (live_running, total) = if let Some(total) = summary.fanout_total {
        (summary.fanout_running, total)
    } else {
        (
            summary.cached_running + summary.progress_only_count,
            summary.cached_total + summary.progress_only_count,
        )
    };
    let done = total.saturating_sub(live_running);
    if live_running > 0 {
        texts.push(format!("{live_running} running / {total}"));
    } else {
        texts.push(format!("{done} done"));
    }

    if !summary.role_counts.is_empty() && texts.len() < max_rows {
        let mix: Vec<String> = summary
            .role_counts
            .iter()
            .map(|(role, count)| format!("{count} {role}"))
            .collect();
        texts.push(mix.join(" · "));
    }

    for row in rows {
        if texts.len() >= max_rows {
            break;
        }
        // The compact label row truncates aggressively, so its hover text
        // carries the full agent dossier: id, role, status, elapsed,
        // objective, branch, and untruncated progress (#3063).
        texts.push(agent_row_hover_text(row));

        if row.status == "done" {
            continue;
        }

        if texts.len() >= max_rows {
            break;
        }
        let mut detail_parts = Vec::new();
        detail_parts.push(row.id.clone());
        if row.steps_taken > 0 {
            detail_parts.push(format!("{} step(s)", row.steps_taken));
        }
        if let Some(progress) = row.progress.as_deref()
            && !progress.trim().is_empty()
        {
            detail_parts.push(progress.trim().to_string());
        }
        if let Some(branch) = row.git_branch.as_deref() {
            detail_parts.push(format!("branch {branch}"));
        }
        if let Some(duration) = row.duration_ms {
            detail_parts.push(format_duration_ms(duration));
        }
        texts.push(format!("  {}", detail_parts.join(" · ")));
    }

    if summary.foreground_rlm_running && texts.len() < max_rows {
        texts.push("RLM foreground work active".to_string());
    }

    texts
}

/// Full hover dossier for one Agents-panel label row (#3063). The compact
/// row only shows `marker role name`, so hovering reveals everything else
/// without spamming raw ids into the normal view.
fn agent_row_hover_text(row: &SidebarAgentRow) -> String {
    let (marker, _) = agent_status_marker(row.status.as_str(), &palette::UI_THEME);
    let mut text = format!("{marker} {} {}", row.role, row.name);
    let _ = write!(text, "\nid: {}", row.id);
    let mut status_line = format!("status: {}", row.status);
    if let Some(duration) = row.duration_ms {
        let _ = write!(status_line, " · elapsed {}", format_duration_ms(duration));
    }
    if row.steps_taken > 0 {
        let _ = write!(status_line, " · {} step(s)", row.steps_taken);
    }
    let _ = write!(text, "\n{status_line}");
    if let Some(objective) = row.objective.as_deref() {
        let _ = write!(text, "\nobjective: {}", objective.trim());
    }
    if let Some(branch) = row.git_branch.as_deref() {
        let _ = write!(text, "\nbranch: {branch}");
    }
    if let Some(progress) = row.progress.as_deref()
        && !progress.trim().is_empty()
    {
        let _ = write!(text, "\nprogress: {}", progress.trim());
    }
    text
}

fn agent_status_marker(
    status: &str,
    theme: &palette::UiTheme,
) -> (&'static str, ratatui::style::Color) {
    match status {
        "running" => ("[~]", theme.warning),
        "done" => ("[✓]", theme.success),
        "failed" => ("[!]", theme.error_fg),
        "canceled" | "interrupted" => ("[-]", theme.text_muted),
        _ => ("[ ]", theme.text_muted),
    }
}

/// Session-context panel (#504) — consolidated session state overview.
///
/// Surfaces at-a-glance: working set, token usage / context %, running
/// cost, MCP server count, LSP toggle state, cycle count, and memory
/// file size + mtime. Each section is a compact one-liner so the panel
/// reads as a dashboard rather than a scrolling list.
fn render_context_panel(f: &mut Frame, area: Rect, app: &mut App) {
    if area.height < 3 {
        return;
    }

    let theme = &app.ui_theme;
    let content_width = area.width.saturating_sub(4) as usize;
    let mut lines: Vec<Line<'static>> = Vec::with_capacity(usize::from(area.height).max(4));

    // ── Working set ──────────────────────────────────────────────
    let ws_name = app
        .workspace
        .file_name()
        .and_then(|s| s.to_str())
        .unwrap_or("(root)")
        .to_string();
    lines.push(Line::from(vec![
        Span::styled(
            truncate_line_to_width(&ws_name, content_width.max(1)),
            Style::default().fg(theme.accent_primary).bold(),
        ),
        Span::styled(
            format!("  {}", app.workspace_context.as_deref().unwrap_or("")),
            Style::default().fg(theme.text_dim),
        ),
    ]));

    // ── Token usage ──────────────────────────────────────────────
    let total_tokens = app.session.total_conversation_tokens;
    let window = provider_capability(app.api_provider, &app.model).context_window;
    let pct = if window > 0 {
        ((total_tokens as f64 / window as f64) * 100.0).clamp(0.0, 100.0)
    } else {
        0.0
    };
    let bar_width = content_width.min(20);
    let filled = ((pct / 100.0) * bar_width as f64) as usize;
    let bar = format!(
        "[{}{}] {:.0}%",
        "█".repeat(filled),
        "░".repeat(bar_width.saturating_sub(filled)),
        pct
    );
    lines.push(Line::from(Span::styled(
        format!(
            "context: {}/{} tokens  {}",
            total_tokens,
            window,
            truncate_line_to_width(&bar, content_width.saturating_sub(32).max(8))
        ),
        Style::default().fg(theme.text_muted),
    )));

    // ── Session cost ─────────────────────────────────────────────
    let displayed_total = app.displayed_session_cost_for_currency(app.cost_currency);
    let session_cost = app.session_cost_for_currency(app.cost_currency);
    let agent_cost = app.subagent_cost_for_currency(app.cost_currency);
    let real_total = session_cost + agent_cost;
    // Only show the additive breakdown when it matches the displayed
    // total; when the high-water mark is in effect (post-reconciliation),
    // the breakdown would not sum to the displayed value (#244).
    let cost_line = if (displayed_total - real_total).abs() < COST_EQ_TOLERANCE {
        format!(
            "cost: {} (session {} + agents {})",
            app.format_cost_amount(displayed_total),
            app.format_cost_amount(session_cost),
            app.format_cost_amount(agent_cost)
        )
    } else {
        format!("cost: {}", app.format_cost_amount(displayed_total))
    };
    lines.push(Line::from(Span::styled(
        cost_line,
        Style::default().fg(theme.text_muted),
    )));

    // ── MCP servers ──────────────────────────────────────────────
    if app.mcp_configured_count > 0 {
        let restart_hint = if app.mcp_restart_required {
            " (restart needed)"
        } else {
            ""
        };
        lines.push(Line::from(Span::styled(
            format!(
                "mcp: {} server(s){}",
                app.mcp_configured_count, restart_hint
            ),
            Style::default().fg(theme.text_muted),
        )));
    }

    // ── LSP ──────────────────────────────────────────────────────
    let lsp_label = if app.lsp_enabled { "on" } else { "off" };
    lines.push(Line::from(Span::styled(
        format!("lsp: {lsp_label}"),
        Style::default().fg(theme.text_muted),
    )));

    // ── Memory ───────────────────────────────────────────────────
    if app.use_memory {
        let size_hint = std::fs::metadata(&app.memory_path)
            .map(|m| m.len())
            .map(|bytes| {
                if bytes >= 1024 * 1024 {
                    format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
                } else if bytes >= 1024 {
                    format!("{:.1} KB", bytes as f64 / 1024.0)
                } else {
                    format!("{bytes} B")
                }
            })
            .unwrap_or_else(|_| "—".to_string());
        lines.push(Line::from(Span::styled(
            format!("memory: {} ({})", app.memory_path.display(), size_hint),
            Style::default().fg(theme.text_muted),
        )));
    }

    render_sidebar_section(f, area, "Session", lines, Vec::new(), Vec::new(), app);
}

fn spans_to_text(spans: &[Span<'_>]) -> String {
    let mut s = String::new();
    for span in spans {
        s.push_str(span.content.as_ref());
    }
    s
}

fn render_sidebar_section(
    f: &mut Frame,
    area: Rect,
    title: &str,
    lines: Vec<Line<'static>>,
    full_texts: Vec<String>,
    row_actions: Vec<Option<String>>,
    app: &mut App,
) {
    if area.width < 4 || area.height < 3 {
        // Clear stale cells before bailing out (#400).
        Block::default()
            .style(Style::default().bg(app.ui_theme.surface_bg))
            .render(area, f.buffer_mut());
        return;
    }

    let theme = Theme::for_palette_mode(app.ui_theme.mode);

    // Record hover metadata for mouse tooltip support.
    let padding = theme.section_padding;
    let content_area = Rect {
        x: area.x + 1 + padding.left,
        y: area.y + 1 + padding.top,
        width: area.width.saturating_sub(2 + padding.left + padding.right),
        height: area.height.saturating_sub(2 + padding.top + padding.bottom),
    };
    let display_texts: Vec<String> = lines
        .iter()
        .map(|line| spans_to_text(&line.spans))
        .collect();
    let hover_texts: Vec<String> = display_texts
        .iter()
        .enumerate()
        .map(|(idx, display)| {
            full_texts
                .get(idx)
                .filter(|text| !text.trim().is_empty())
                .cloned()
                .unwrap_or_else(|| display.clone())
        })
        .collect();
    let rows = sidebar_hover_rows(content_area, &display_texts, &hover_texts, &row_actions);
    app.sidebar_hover.sections.push(SidebarHoverSection {
        content_area,
        lines: hover_texts,
        rows,
    });
    // Truncate the panel title so it always fits within the section width
    // even after a resize. The title occupies up to 4 chars of border chrome
    // (two spaces + one space on each side), so the max title length is
    // area.width.saturating_sub(4) when borders are enabled.
    let max_title_width = area.width.saturating_sub(4).max(1) as usize;
    let display_title = truncate_line_to_width(title, max_title_width);

    // Constrain lines to the visible section area so a Paragraph wrap
    // overflow can't write cells outside the Block bounds (#400). The
    // border + padding consume 2 rows; budget the rest for content.
    let visible_content_rows = area
        .height
        .saturating_sub(2) // top + bottom border
        .saturating_sub(theme.section_padding.top + theme.section_padding.bottom)
        as usize;
    let lines: Vec<Line<'static>> =
        if lines.len() > visible_content_rows && visible_content_rows > 0 {
            lines.into_iter().take(visible_content_rows).collect()
        } else {
            lines
        };

    let section = Paragraph::new(lines).wrap(Wrap { trim: true }).block(
        Block::default()
            .title(Line::from(vec![Span::styled(
                format!(" {display_title} "),
                Style::default().fg(theme.section_title_color).bold(),
            )]))
            .borders(theme.section_borders)
            .border_type(theme.section_border_type)
            .border_style(Style::default().fg(theme.section_border_color))
            .style(Style::default().bg(theme.section_bg))
            .padding(theme.section_padding),
    );

    f.render_widget(section, area);
}

fn sidebar_hover_rows(
    content_area: Rect,
    display_texts: &[String],
    hover_texts: &[String],
    row_actions: &[Option<String>],
) -> Vec<SidebarHoverRow> {
    display_texts
        .iter()
        .zip(hover_texts.iter())
        .enumerate()
        .map(|(idx, (display_text, full_text))| {
            let row_y = content_area.y.saturating_add(idx as u16);
            let display_width = unicode_width::UnicodeWidthStr::width(display_text.as_str());
            let full_width = unicode_width::UnicodeWidthStr::width(full_text.as_str());
            let click_action = row_actions.get(idx).and_then(|a| a.clone());
            SidebarHoverRow {
                row_y,
                display_text: display_text.clone(),
                full_text: full_text.clone(),
                detail: None,
                is_truncated: display_width > content_area.width as usize
                    || full_width > content_area.width as usize
                    || display_text != full_text,
                click_action,
            }
        })
        .collect()
}

#[cfg(test)]
mod tests {
    use super::{
        ACTIVE_TOOL_COMPLETED_ROW_TTL, ACTIVE_TOOL_STALE_RUNNING_ROW_TTL, AutoSidebarPanel,
        AutoSidebarState, SidebarAgentRow, SidebarHoverRow, SidebarHoverSection, SidebarHoverState,
        SidebarSubagentSummary, SidebarToolRow, SidebarWorkChecklistItem, SidebarWorkStrategyStep,
        SidebarWorkSummary, ToolRowOrder, auto_sidebar_panels, editorial_tool_rows,
        normalize_activity_text, sidebar_hover_rows, sidebar_work_summary,
        subagent_panel_hover_texts, subagent_panel_lines, subagent_panel_rows,
        task_panel_hover_texts, task_panel_lines, task_panel_rows, work_panel_empty_hint,
        work_panel_hover_texts, work_panel_lines,
    };
    use crate::config::Config;
    use crate::palette;
    use crate::palette::PaletteMode;
    use crate::tools::plan::StepStatus;
    use crate::tools::todo::TodoStatus;
    use crate::tui::active_cell::ActiveCell;
    use crate::tui::app::{App, HuntVerdict, TaskPanelEntry, TaskPanelEntryKind, TuiOptions};
    use crate::tui::history::{
        ExecCell, ExecSource, GenericToolCell, HistoryCell, ToolCell, ToolStatus,
    };
    use ratatui::text::Line;
    use std::path::PathBuf;
    use std::time::{Duration, Instant};

    fn create_test_app() -> App {
        let options = TuiOptions {
            model: "deepseek-v4-pro".to_string(),
            workspace: PathBuf::from("."),
            config_path: None,
            config_profile: None,
            allow_shell: false,
            use_alt_screen: true,
            use_mouse_capture: false,
            use_bracketed_paste: true,
            max_subagents: 1,
            skills_dir: PathBuf::from("."),
            memory_path: PathBuf::from("memory.md"),
            notes_path: PathBuf::from("notes.txt"),
            mcp_config_path: PathBuf::from("mcp.json"),
            use_memory: false,
            start_in_agent_mode: false,
            skip_onboarding: true,
            yolo: false,
            resume_session_id: None,
            initial_input: None,
        };
        App::new(options, &Config::default())
    }

    fn sidebar_tool_row(name: &str, status: ToolStatus) -> SidebarToolRow {
        SidebarToolRow {
            name: name.to_string(),
            status,
            summary: String::new(),
            duration_ms: None,
        }
    }

    fn lines_to_text(lines: &[Line<'static>]) -> Vec<String> {
        lines
            .iter()
            .map(|line| {
                line.spans
                    .iter()
                    .map(|s| s.content.as_ref())
                    .collect::<String>()
            })
            .collect()
    }

    #[test]
    fn editorial_rows_keep_newer_failure_when_older_success_is_seen_later() {
        let rows = vec![
            sidebar_tool_row("gh issue create", ToolStatus::Failed),
            sidebar_tool_row("gh issue create", ToolStatus::Success),
        ];

        let rendered = editorial_tool_rows(rows, 4, ToolRowOrder::NewestFirst);

        assert!(
            rendered
                .iter()
                .any(|row| row.name == "gh issue create" && row.status == ToolStatus::Failed),
            "newest-first rows must keep a failure newer than a later-seen success: {rendered:?}"
        );
    }

    #[test]
    fn normalize_activity_text_strips_ansi_before_collapsing_text() {
        let text = normalize_activity_text("running \x1b[48;2;10;17;32mtool\x1b[0m now");
        assert_eq!(text, "running tool now");
        assert!(!text.contains("48;2"));
    }

    #[test]
    fn editorial_rows_hide_older_failure_after_newer_success() {
        let rows = vec![
            sidebar_tool_row("gh issue create", ToolStatus::Success),
            sidebar_tool_row("gh issue create", ToolStatus::Failed),
        ];

        let rendered = editorial_tool_rows(rows, 4, ToolRowOrder::NewestFirst);

        assert!(
            !rendered
                .iter()
                .any(|row| row.name == "gh issue create" && row.status == ToolStatus::Failed),
            "newest-first rows should hide stale failures older than success: {rendered:?}"
        );
    }

    #[test]
    fn editorial_rows_reclaim_failure_slot_after_oldest_first_success() {
        let rows = vec![
            sidebar_tool_row("gh issue create", ToolStatus::Failed),
            sidebar_tool_row("grep_files", ToolStatus::Failed),
            sidebar_tool_row("gh issue create", ToolStatus::Success),
            sidebar_tool_row("cargo test", ToolStatus::Failed),
        ];

        let rendered = editorial_tool_rows(rows, 2, ToolRowOrder::OldestFirst);

        assert_eq!(
            rendered
                .iter()
                .filter(|row| row.status == ToolStatus::Failed)
                .map(|row| row.name.as_str())
                .collect::<Vec<_>>(),
            vec!["grep_files", "cargo test"],
            "success should clear its stale failure and free a visible failure slot"
        );
    }

    #[test]
    fn auto_sidebar_does_not_reserve_empty_work_when_other_panels_are_active() {
        let panels = auto_sidebar_panels(AutoSidebarState {
            work_has_content: false,
            tasks_empty: false,
            agents_empty: true,
            context_enabled: false,
        });

        assert_eq!(panels, vec![AutoSidebarPanel::Tasks]);
    }

    #[test]
    fn auto_sidebar_uses_work_as_single_empty_state() {
        let panels = auto_sidebar_panels(AutoSidebarState {
            work_has_content: false,
            tasks_empty: true,
            agents_empty: true,
            context_enabled: false,
        });

        assert_eq!(panels, vec![AutoSidebarPanel::Work]);
    }

    #[test]
    fn work_panel_empty_hint_stays_quiet_and_truncates() {
        let hint = work_panel_empty_hint(10);
        assert!(
            hint.chars().count() <= 10,
            "hint width {} > 10: {hint:?}",
            hint.chars().count()
        );
        assert!(
            !hint.contains("update_plan"),
            "hint should be quiet: {hint:?}"
        );
    }

    #[test]
    fn work_panel_renders_checklist_as_primary_progress_surface() {
        let summary = SidebarWorkSummary {
            checklist_completion_pct: 33,
            checklist_items: vec![
                SidebarWorkChecklistItem {
                    id: 1,
                    content: "Plan it out".to_string(),
                    status: TodoStatus::Completed,
                },
                SidebarWorkChecklistItem {
                    id: 2,
                    content: "Wire the thing".to_string(),
                    status: TodoStatus::InProgress,
                },
                SidebarWorkChecklistItem {
                    id: 3,
                    content: "Run gates".to_string(),
                    status: TodoStatus::Pending,
                },
            ],
            strategy_explanation: Some("Keep the UI unified".to_string()),
            strategy_steps: vec![
                SidebarWorkStrategyStep {
                    text: "Simplify sidebar".to_string(),
                    status: StepStatus::Completed,
                    elapsed: String::new(),
                },
                SidebarWorkStrategyStep {
                    text: "Update prompts".to_string(),
                    status: StepStatus::Pending,
                    elapsed: String::new(),
                },
            ],
            ..SidebarWorkSummary::default()
        };

        let text = lines_to_text(&work_panel_lines(
            &summary,
            80,
            16,
            PaletteMode::Dark,
            &palette::UI_THEME,
        ));

        assert!(
            text[0].starts_with("33% complete (1/3)"),
            "checklist should lead: {text:?}"
        );
        assert!(
            text.iter().any(|line| line.contains("[~] #2 Wire")),
            "in-progress checklist item should be visible: {text:?}"
        );
        assert!(
            !text.iter().any(|line| line.contains("50% complete")),
            "strategy progress must not render as a second progress bar when checklist exists: {text:?}"
        );
        assert!(
            text.iter().any(|line| line == "Strategy context"),
            "strategy should be grouped as context for the checklist: {text:?}"
        );
        assert!(
            text.iter()
                .any(|line| line.contains("phase done: Simplify sidebar")),
            "completed strategy steps should render as phase context: {text:?}"
        );
        assert!(
            text.iter()
                .any(|line| line.contains("phase next: Update prompts")),
            "pending strategy steps should render as phase context: {text:?}"
        );
        assert!(
            !text
                .iter()
                .any(|line| line.contains("[✓] Simplify sidebar"))
                && !text.iter().any(|line| line.contains("[ ] Update prompts")),
            "strategy rows must not look like a second checklist when Work checklist exists: {text:?}"
        );
    }

    #[test]
    fn work_panel_hover_renders_strategy_as_context_when_checklist_exists() {
        let summary = SidebarWorkSummary {
            checklist_completion_pct: 0,
            checklist_items: vec![SidebarWorkChecklistItem {
                id: 1,
                content: "Wire tool execution".to_string(),
                status: TodoStatus::InProgress,
            }],
            strategy_explanation: Some("Keep strategy and checklist linked".to_string()),
            strategy_steps: vec![
                SidebarWorkStrategyStep {
                    text: "Map phase boundaries".to_string(),
                    status: StepStatus::Completed,
                    elapsed: String::new(),
                },
                SidebarWorkStrategyStep {
                    text: "Implement counted work".to_string(),
                    status: StepStatus::InProgress,
                    elapsed: String::new(),
                },
            ],
            ..SidebarWorkSummary::default()
        };

        let hover = work_panel_hover_texts(&summary, 80, 16);

        assert!(
            hover.iter().any(|line| line == "Strategy context"),
            "hover should name strategy as context when checklist exists: {hover:?}"
        );
        assert!(
            hover
                .iter()
                .any(|line| line.contains("phase done: Map phase boundaries")),
            "hover strategy rows should be phase context: {hover:?}"
        );
        assert!(
            hover
                .iter()
                .any(|line| line.contains("phase now: Implement counted work")),
            "hover should expose the active strategy phase without checklist markers: {hover:?}"
        );
        assert!(
            !hover
                .iter()
                .any(|line| line.contains("[✓] Map phase boundaries")),
            "hover strategy rows must not look like a second checklist: {hover:?}"
        );
    }

    #[test]
    fn work_panel_keeps_active_checklist_item_visible_when_truncated() {
        let summary = SidebarWorkSummary {
            checklist_completion_pct: 38,
            checklist_items: (1..=8)
                .map(|id| SidebarWorkChecklistItem {
                    id,
                    content: format!("Release task {id}"),
                    status: if id <= 3 {
                        TodoStatus::Completed
                    } else if id == 5 {
                        TodoStatus::InProgress
                    } else {
                        TodoStatus::Pending
                    },
                })
                .collect(),
            ..SidebarWorkSummary::default()
        };

        let text = lines_to_text(&work_panel_lines(
            &summary,
            80,
            6,
            PaletteMode::Dark,
            &palette::UI_THEME,
        ));

        assert!(
            text.iter()
                .any(|line| line.contains("[~] #5 Release task 5")),
            "active checklist item should stay visible in a short Work panel: {text:?}"
        );
        assert!(
            text.iter().any(|line| line.contains("earlier"))
                || text.iter().any(|line| line.contains("later")),
            "truncation should explain omitted checklist rows: {text:?}"
        );
    }

    #[test]
    fn work_panel_overflow_hover_lists_omitted_checklist_items() {
        let summary = SidebarWorkSummary {
            checklist_completion_pct: 38,
            checklist_items: (1..=8)
                .map(|id| SidebarWorkChecklistItem {
                    id,
                    content: format!("Release task {id}"),
                    status: if id <= 3 {
                        TodoStatus::Completed
                    } else if id == 5 {
                        TodoStatus::InProgress
                    } else {
                        TodoStatus::Pending
                    },
                })
                .collect(),
            ..SidebarWorkSummary::default()
        };

        let hover = work_panel_hover_texts(&summary, 80, 6);
        let overflow = hover
            .iter()
            .find(|text| text.starts_with('+'))
            .expect("overflow hover row should exist");

        // Every checklist item is reachable: either as its own hover row or
        // listed inside the overflow row's hover text (#3063).
        for id in 1..=8 {
            let needle = format!("#{id} Release task {id}");
            assert!(
                hover.iter().any(|text| text.contains(&needle)),
                "item {id} should be inspectable via hover: {hover:?}"
            );
        }
        assert!(
            overflow.lines().count() > 1,
            "overflow hover should enumerate omitted items: {overflow:?}"
        );
    }

    #[test]
    fn work_panel_includes_strategy_only_when_plan_state_is_non_empty() {
        let empty_text = lines_to_text(&work_panel_lines(
            &SidebarWorkSummary::default(),
            80,
            16,
            PaletteMode::Dark,
            &palette::UI_THEME,
        ));
        assert!(
            !empty_text.iter().any(|line| line.contains("Strategy")),
            "empty plan state should not show strategy: {empty_text:?}"
        );

        let summary = SidebarWorkSummary {
            strategy_explanation: Some("High-level sequencing".to_string()),
            ..SidebarWorkSummary::default()
        };
        let text = lines_to_text(&work_panel_lines(
            &summary,
            80,
            16,
            PaletteMode::Dark,
            &palette::UI_THEME,
        ));
        assert!(
            text.iter().any(|line| line == "Strategy metadata"),
            "non-empty plan should show strategy label: {text:?}"
        );
        assert!(
            text.iter()
                .any(|line| line.contains("High-level sequencing")),
            "non-empty plan explanation should render: {text:?}"
        );
    }

    #[test]
    fn sidebar_work_summary_caches_on_success() {
        let mut app = create_test_app();
        {
            let mut todos = app.todos.try_lock().expect("todos lock");
            todos.add("cache test".to_string(), TodoStatus::InProgress);
        }

        let summary = sidebar_work_summary(&mut app);

        assert!(!summary.state_updating, "should not be updating");
        assert_eq!(summary.checklist_items.len(), 1);
        assert!(
            app.cached_work_summary.is_some(),
            "cache should be populated"
        );
    }

    #[test]
    fn sidebar_work_summary_falls_back_to_cache_when_todos_lock_busy() {
        let mut app = create_test_app();
        {
            let mut todos = app.todos.try_lock().expect("todos lock");
            todos.add("will be cached".to_string(), TodoStatus::Completed);
        }
        let _first = sidebar_work_summary(&mut app);
        assert!(app.cached_work_summary.is_some());

        let held_arc = app.todos.clone();
        let _held = held_arc.try_lock().expect("hold todos lock");

        let summary = sidebar_work_summary(&mut app);

        assert!(!summary.state_updating, "should fall back to cache");
        assert!(
            summary
                .checklist_items
                .iter()
                .any(|item| item.content == "will be cached"),
            "cached item should be present"
        );
    }

    #[test]
    fn sidebar_work_summary_returns_updating_when_no_cache_and_locks_busy() {
        let mut app = create_test_app();
        let held_arc = app.todos.clone();
        let _held = held_arc.try_lock().expect("hold todos lock");

        let summary = sidebar_work_summary(&mut app);

        assert!(summary.state_updating, "should be updating without cache");
    }

    #[test]
    fn sidebar_work_summary_keeps_live_fields_on_cache_fallback() {
        let mut app = create_test_app();
        app.hunt.quarry = Some("test quarry".to_string());
        app.hunt.verdict = HuntVerdict::Hunted;
        {
            let mut todos = app.todos.try_lock().expect("todos lock");
            todos.add("item".to_string(), TodoStatus::Pending);
        }
        let _first = sidebar_work_summary(&mut app);

        app.hunt.quarry = Some("updated quarry".to_string());
        app.hunt.verdict = HuntVerdict::Hunting;
        let held_arc = app.todos.clone();
        let _held = held_arc.try_lock().expect("hold todos lock");

        let summary = sidebar_work_summary(&mut app);

        assert_eq!(summary.goal_objective.as_deref(), Some("updated quarry"));
        assert!(!summary.goal_completed, "verdict should be live");
    }

    #[test]
    fn tasks_panel_renders_active_tool_rows_before_background_empty_state() {
        let mut app = create_test_app();
        let mut active = ActiveCell::new();
        active.push_tool(
            "tool-1",
            HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
                name: "agent_eval".to_string(),
                status: ToolStatus::Running,
                input_summary: Some("agent_id: agent_af58ba3a".to_string()),
                output: None,
                prompts: None,
                spillover_path: None,
                output_summary: None,
                is_diff: false,
            })),
        );
        app.active_cell = Some(active);
        app.runtime_turn_id = Some("turn_abcdef123456".to_string());
        app.runtime_turn_status = Some("in_progress".to_string());

        let text = lines_to_text(&task_panel_lines(&app, 64, 8));

        assert!(text[0].contains("turn "));
        assert!(text[0].contains("in_progress"));
        assert!(
            text.iter().any(|line| line == "Live tools"),
            "live section missing: {text:?}"
        );
        assert!(
            text.iter().any(|line| line.contains("[~] agent_eval")),
            "active agent_eval row missing: {text:?}"
        );
        assert!(
            !text.iter().any(|line| line.contains("No active tasks")),
            "old empty state should not render during active tools: {text:?}"
        );
    }

    #[test]
    fn tasks_panel_renders_recent_completed_tool_rows() {
        let mut app = create_test_app();
        app.history
            .push(HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
                name: "read_file".to_string(),
                status: ToolStatus::Success,
                input_summary: Some("codewhale-tui/CHANGELOG.md".to_string()),
                output: Some("done".to_string()),
                prompts: None,
                spillover_path: None,
                output_summary: Some("Reading CHANGELOG.md".to_string()),
                is_diff: false,
            })));

        let text = lines_to_text(&task_panel_lines(&app, 64, 8));

        assert!(
            text.iter().any(|line| line == "Recent tools"),
            "recent section missing: {text:?}"
        );
        assert!(
            text.iter().any(|line| line.contains("[✓] read_file")),
            "recent read_file row missing: {text:?}"
        );
    }

    #[test]
    fn tasks_panel_expires_completed_active_tool_rows() {
        let mut app = create_test_app();
        let mut active = ActiveCell::new();
        active.push_tool(
            "tool-1",
            HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
                name: "read_file".to_string(),
                status: ToolStatus::Success,
                input_summary: Some("src/main.rs".to_string()),
                output: Some("done".to_string()),
                prompts: None,
                spillover_path: None,
                output_summary: Some("done".to_string()),
                is_diff: false,
            })),
        );
        app.active_cell = Some(active);
        let expired_at = instant_older_than(ACTIVE_TOOL_COMPLETED_ROW_TTL + Duration::from_secs(1));
        app.active_tool_entry_completed_at.insert(0, expired_at);

        let text = lines_to_text(&task_panel_lines(&app, 64, 8));

        assert!(
            !text.iter().any(|line| line.contains("[✓] read_file")),
            "expired completed active row should leave the sidebar: {text:?}"
        );
    }

    fn instant_older_than(age: Duration) -> Instant {
        if let Some(instant) = Instant::now().checked_sub(age) {
            return instant;
        }

        let instant = Instant::now();
        std::thread::sleep(age);
        instant
    }

    #[test]
    fn tasks_panel_lingers_fresh_completed_active_tool_rows() {
        let mut app = create_test_app();
        let mut active = ActiveCell::new();
        active.push_tool(
            "tool-1",
            HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
                name: "read_file".to_string(),
                status: ToolStatus::Success,
                input_summary: Some("src/main.rs".to_string()),
                output: Some("done".to_string()),
                prompts: None,
                spillover_path: None,
                output_summary: Some("done".to_string()),
                is_diff: false,
            })),
        );
        app.active_cell = Some(active);
        app.active_tool_entry_completed_at.insert(0, Instant::now());

        let text = lines_to_text(&task_panel_lines(&app, 64, 8));

        assert!(
            text.iter().any(|line| line.contains("[✓] read_file")),
            "fresh completed active row should linger briefly: {text:?}"
        );
    }

    #[test]
    fn tasks_panel_collapses_stale_running_tool_rows() {
        let mut app = create_test_app();
        let mut active = ActiveCell::new();
        for (idx, command) in ["long one", "long two"].into_iter().enumerate() {
            active.push_tool(
                format!("shell-{idx}"),
                HistoryCell::Tool(ToolCell::Exec(ExecCell {
                    command: command.to_string(),
                    status: ToolStatus::Running,
                    output: None,
                    live_output: None,
                    shell_task_id: None,
                    started_at: None,
                    duration_ms: Some(ACTIVE_TOOL_STALE_RUNNING_ROW_TTL.as_millis() as u64 + 1),
                    source: ExecSource::Assistant,
                    interaction: None,
                    output_summary: None,
                })),
            );
        }
        app.active_cell = Some(active);

        let text = lines_to_text(&task_panel_lines(&app, 80, 8));

        assert!(
            text.iter().any(|line| line.contains("[~] run x2")),
            "stale running rows should collapse into one sidebar row: {text:?}"
        );
        assert!(
            !text.iter().any(|line| line.contains("long two")),
            "second stale command should not take another row: {text:?}"
        );
    }

    #[test]
    fn tasks_panel_does_not_double_count_running_shell_job_as_live_and_background() {
        let mut app = create_test_app();
        let mut active = ActiveCell::new();
        active.push_tool(
            "shell-1",
            HistoryCell::Tool(ToolCell::Exec(ExecCell {
                command: "cargo test --workspace".to_string(),
                status: ToolStatus::Running,
                output: None,
                live_output: None,
                shell_task_id: None,
                started_at: Some(std::time::Instant::now()),
                duration_ms: None,
                source: ExecSource::Assistant,
                interaction: None,
                output_summary: None,
            })),
        );
        app.active_cell = Some(active);
        app.task_panel.push(TaskPanelEntry {
            id: "job_123".to_string(),
            status: "running".to_string(),
            prompt_summary: "shell: cargo test --workspace".to_string(),
            duration_ms: Some(12_000),
            kind: TaskPanelEntryKind::Background,
        });

        let text = lines_to_text(&task_panel_lines(&app, 80, 10));
        let command_lines = text
            .iter()
            .filter(|line| line.contains("cargo test --workspace"))
            .count();

        assert!(
            text.iter().any(|line| line == "Live tools"),
            "live shell row missing: {text:?}"
        );
        assert_eq!(
            command_lines, 1,
            "running shell command should not render as both live and background: {text:?}"
        );
        assert!(
            !text.iter().any(|line| line.contains("Bash jobs")),
            "duplicate background shell row should be hidden: {text:?}"
        );
    }

    #[test]
    fn tasks_panel_puts_background_shell_command_on_primary_row() {
        let mut app = create_test_app();
        app.task_panel.push(TaskPanelEntry {
            id: "shell_33a08c3c".to_string(),
            status: "running".to_string(),
            prompt_summary: "shell: cd /tmp/repo && cargo test --workspace --all-features"
                .to_string(),
            duration_ms: Some(178_000),
            kind: TaskPanelEntryKind::Background,
        });

        let text = lines_to_text(&task_panel_lines(&app, 96, 8));

        assert!(
            text.iter()
                .any(|line| line.contains("running cargo test --workspace --all-features")),
            "background shell headline should show the command, not only the shell id: {text:?}"
        );
        assert!(
            text.iter().any(|line| line.contains("shell_33a08c3c")),
            "shell id should remain available as detail: {text:?}"
        );
    }

    #[test]
    fn tasks_panel_renders_model_reasoning_outside_background_commands() {
        let mut app = create_test_app();
        app.task_panel.push(TaskPanelEntry {
            id: "reasoning-1".to_string(),
            status: "running".to_string(),
            prompt_summary: "model reasoning".to_string(),
            duration_ms: Some(4_200),
            kind: TaskPanelEntryKind::ModelReasoning,
        });

        let text = lines_to_text(&task_panel_lines(&app, 80, 8));

        assert!(
            text.iter().any(|line| line == "Model reasoning"),
            "reasoning section missing: {text:?}"
        );
        assert!(
            text.iter()
                .any(|line| line.contains("thinking running 4.2s")),
            "reasoning row should show live thinking duration: {text:?}"
        );
        assert!(
            !text.iter().any(|line| line.contains("Bash jobs")),
            "reasoning must not be counted as a background command: {text:?}"
        );
    }

    #[test]
    fn task_panel_actions_make_single_background_job_clickable() {
        let mut app = create_test_app();
        app.task_panel.push(TaskPanelEntry {
            id: "shell_only".to_string(),
            status: "running".to_string(),
            prompt_summary: "shell: cargo build".to_string(),
            duration_ms: Some(1_000),
            kind: TaskPanelEntryKind::Background,
        });

        let (lines, actions) = task_panel_rows(&app, 80, 12);
        let text = lines_to_text(&lines);
        assert_eq!(lines.len(), actions.len());

        let label_idx = text
            .iter()
            .position(|line| line.contains("cargo build"))
            .expect("background job label row");
        assert_eq!(
            actions[label_idx].as_deref(),
            Some("/jobs show shell_only"),
            "single-job label row must be clickable: {actions:?}"
        );
        assert_eq!(
            actions[label_idx + 1].as_deref(),
            Some("/jobs cancel shell_only"),
            "single-job detail row must cancel that job: {actions:?}"
        );
    }

    #[test]
    fn task_panel_actions_route_each_job_to_its_own_id() {
        let mut app = create_test_app();
        app.task_panel.push(TaskPanelEntry {
            id: "shell_aaa".to_string(),
            status: "running".to_string(),
            prompt_summary: "shell: cargo test --workspace".to_string(),
            duration_ms: Some(2_000),
            kind: TaskPanelEntryKind::Background,
        });
        app.task_panel.push(TaskPanelEntry {
            id: "task_bbb".to_string(),
            status: "running".to_string(),
            prompt_summary: "summarize the release notes".to_string(),
            duration_ms: Some(3_000),
            kind: TaskPanelEntryKind::Background,
        });

        let (lines, actions) = task_panel_rows(&app, 96, 16);
        let text = lines_to_text(&lines);
        assert_eq!(lines.len(), actions.len());

        let header_idx = text
            .iter()
            .position(|line| line.starts_with("Bash jobs"))
            .expect("background header row");
        assert!(actions[header_idx].is_none(), "header is not clickable");

        let shell_idx = text
            .iter()
            .position(|line| line.contains("cargo test --workspace"))
            .expect("shell job label row");
        assert_eq!(
            actions[shell_idx].as_deref(),
            Some("/jobs show shell_aaa"),
            "shell jobs route through /jobs: {actions:?}"
        );
        assert_eq!(
            actions[shell_idx + 1].as_deref(),
            Some("/jobs cancel shell_aaa"),
            "shell job detail row cancels the SAME job: {actions:?}"
        );

        let task_idx = text
            .iter()
            .position(|line| line.contains("task_bbb"))
            .expect("task job label row");
        assert_eq!(
            actions[task_idx].as_deref(),
            Some("/task show task_bbb"),
            "task-manager jobs route through /task: {actions:?}"
        );
        assert_eq!(
            actions[task_idx + 1].as_deref(),
            Some("/task cancel task_bbb"),
            "task job detail row cancels the SAME job: {actions:?}"
        );

        let hint_idx = text
            .iter()
            .position(|line| line.contains("Ctrl+K"))
            .expect("cancel-all hint row");
        assert_eq!(actions[hint_idx].as_deref(), Some("/jobs cancel-all"));
    }

    #[test]
    fn task_panel_finished_job_detail_row_shows_instead_of_cancels() {
        let mut app = create_test_app();
        app.task_panel.push(TaskPanelEntry {
            id: "shell_done".to_string(),
            status: "completed".to_string(),
            prompt_summary: "shell: cargo fmt".to_string(),
            duration_ms: Some(500),
            kind: TaskPanelEntryKind::Background,
        });

        let (lines, actions) = task_panel_rows(&app, 80, 12);
        let text = lines_to_text(&lines);

        let label_idx = text
            .iter()
            .position(|line| line.contains("cargo fmt"))
            .expect("completed job label row");
        assert_eq!(actions[label_idx].as_deref(), Some("/jobs show shell_done"));
        assert_eq!(
            actions[label_idx + 1].as_deref(),
            Some("/jobs show shell_done"),
            "finished jobs must not expose a cancel click target: {actions:?}"
        );
    }

    #[test]
    fn task_panel_actions_align_with_lines_when_live_tools_present() {
        let mut app = create_test_app();
        app.runtime_turn_id = Some("0196f0a3-aaaa-bbbb-cccc-ddddeeee0000".to_string());
        let mut active = ActiveCell::new();
        active.push_tool(
            "shell-1",
            HistoryCell::Tool(ToolCell::Exec(ExecCell {
                command: "sleep 600".to_string(),
                status: ToolStatus::Running,
                output: None,
                live_output: None,
                shell_task_id: None,
                started_at: Some(Instant::now()),
                duration_ms: None,
                source: ExecSource::Assistant,
                interaction: None,
                output_summary: None,
            })),
        );
        app.active_cell = Some(active);
        app.task_panel.push(TaskPanelEntry {
            id: "task_q".to_string(),
            status: "running".to_string(),
            prompt_summary: "investigate flaky test".to_string(),
            duration_ms: Some(9_000),
            kind: TaskPanelEntryKind::Background,
        });

        let (lines, actions) = task_panel_rows(&app, 96, 16);
        let text = lines_to_text(&lines);
        assert_eq!(
            lines.len(),
            actions.len(),
            "actions must stay index-aligned with lines: {text:?}"
        );

        // Turn label and live-tool rows are not clickable.
        assert!(actions[0].is_none(), "turn label row has no action");
        let live_idx = text
            .iter()
            .position(|line| line == "Live tools")
            .expect("live tools header");
        assert!(actions[live_idx].is_none());

        let task_idx = text
            .iter()
            .position(|line| line.contains("task_q"))
            .expect("background job label row");
        assert_eq!(actions[task_idx].as_deref(), Some("/task show task_q"));
    }

    #[test]
    fn subagent_panel_actions_mark_agent_rows_with_role_mix_header() {
        let mut role_counts = std::collections::BTreeMap::new();
        role_counts.insert("worker".to_string(), 1);
        let summary = SidebarSubagentSummary {
            cached_total: 1,
            cached_running: 1,
            role_counts,
            ..SidebarSubagentSummary::default()
        };
        let rows = vec![SidebarAgentRow {
            id: "agent_0123456789".to_string(),
            name: "investigator".to_string(),
            role: "worker".to_string(),
            status: "running".to_string(),
            objective: None,
            git_branch: None,
            progress: Some("scanning".to_string()),
            steps_taken: 2,
            duration_ms: Some(1_000),
        }];

        let (lines, actions) = subagent_panel_rows(&summary, &rows, 48, 8, &palette::UI_THEME);
        let text = lines_to_text(&lines);
        assert_eq!(lines.len(), actions.len());

        assert!(actions[0].is_none(), "count header has no action");
        assert!(actions[1].is_none(), "role-mix header has no action");
        let agent_idx = text
            .iter()
            .position(|line| line.contains("investigator"))
            .expect("agent label row");
        assert_eq!(actions[agent_idx].as_deref(), Some("/subagents"));
        assert!(
            actions[agent_idx + 1].is_none(),
            "agent detail row has no action"
        );
    }

    #[test]
    fn subagent_panel_actions_skip_role_mix_slot_for_progress_only_agents() {
        // Progress-only agents have no cached role counts, so there is no
        // role-mix line — the first agent row sits directly under the count
        // header and must still resolve to /subagents (#3028 audit fix).
        let summary = SidebarSubagentSummary {
            progress_only_count: 1,
            ..SidebarSubagentSummary::default()
        };
        let rows = vec![SidebarAgentRow {
            id: "agent_fedcba987654".to_string(),
            name: "scout".to_string(),
            role: "explorer".to_string(),
            status: "running".to_string(),
            objective: None,
            git_branch: None,
            progress: Some("reading".to_string()),
            steps_taken: 1,
            duration_ms: None,
        }];

        let (lines, actions) = subagent_panel_rows(&summary, &rows, 48, 8, &palette::UI_THEME);
        let text = lines_to_text(&lines);
        assert_eq!(lines.len(), actions.len());

        assert!(actions[0].is_none(), "count header has no action");
        let agent_idx = text
            .iter()
            .position(|line| line.contains("scout"))
            .expect("agent label row");
        assert_eq!(
            agent_idx, 1,
            "no role-mix line should be emitted without role counts: {text:?}"
        );
        assert_eq!(actions[agent_idx].as_deref(), Some("/subagents"));
    }

    #[test]
    fn tasks_panel_collapses_repeated_low_value_recent_tools_after_failures() {
        let mut app = create_test_app();
        for path in ["src/a.rs", "src/b.rs", "src/c.rs"] {
            app.history
                .push(HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
                    name: "read_file".to_string(),
                    status: ToolStatus::Success,
                    input_summary: Some(path.to_string()),
                    output: Some("ok".to_string()),
                    prompts: None,
                    spillover_path: None,
                    output_summary: None,
                    is_diff: false,
                })));
        }
        app.history
            .push(HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
                name: "checklist_update".to_string(),
                status: ToolStatus::Success,
                input_summary: Some("mark item 2 done".to_string()),
                output: Some("updated".to_string()),
                prompts: None,
                spillover_path: None,
                output_summary: None,
                is_diff: false,
            })));
        app.history
            .push(HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
                name: "grep_files".to_string(),
                status: ToolStatus::Failed,
                input_summary: Some("pattern: Activity Detail".to_string()),
                output: Some("regex parse error".to_string()),
                prompts: None,
                spillover_path: None,
                output_summary: Some("regex parse error".to_string()),
                is_diff: false,
            })));

        let text = lines_to_text(&task_panel_lines(&app, 80, 12));
        let failed_index = text
            .iter()
            .position(|line| line.contains("[!] grep_files"))
            .expect("failed grep row should stay visible");
        let read_group_index = text
            .iter()
            .position(|line| line.contains("[✓] read_file x3"))
            .expect("repeated read_file rows should collapse");

        assert!(
            failed_index < read_group_index,
            "failure should sort above low-value success noise: {text:?}"
        );
        assert_eq!(
            text.iter()
                .filter(|line| line.contains("[✓] read_file"))
                .count(),
            1,
            "read_file should render once after grouping: {text:?}"
        );
        assert!(
            text.iter().any(|line| line.contains("regex parse error")),
            "failure detail should remain visible: {text:?}"
        );
    }

    #[test]
    fn tasks_panel_collapses_repeated_pending_ci_polls() {
        let mut app = create_test_app();
        for _ in 0..3 {
            app.history.push(HistoryCell::Tool(ToolCell::Exec(ExecCell {
                command: "cd /tmp/repo && sleep 15 && gh pr checks 1616 --repo Hmbown/CodeWhale"
                    .to_string(),
                status: ToolStatus::Failed,
                output: Some("Lint pending\nTest pending".to_string()),
                live_output: None,
                shell_task_id: None,
                started_at: None,
                duration_ms: Some(15_000),
                source: ExecSource::Assistant,
                interaction: None,
                output_summary: Some("2 checks pending".to_string()),
            })));
        }

        let text = lines_to_text(&task_panel_lines(&app, 80, 12));

        assert!(
            text.iter().any(|line| line.contains("[~] Waiting for CI")),
            "pending CI should not render as a hard failure: {text:?}"
        );
        assert!(
            text.iter().any(|line| line.contains("gh pr checks 1616")),
            "concise command label should remain visible: {text:?}"
        );
        assert!(
            text.iter().any(|line| line.contains("3 polls collapsed")),
            "repeated polling should collapse into one row: {text:?}"
        );
        assert!(
            text.iter()
                .any(|line| line.contains(crate::tui::key_shortcuts::tool_details_shortcut_label())),
            "collapsed CI row should point to details: {text:?}"
        );
        assert!(
            !text.iter().any(|line| line.contains("[!] gh pr checks")),
            "pending CI should not look like a real failure: {text:?}"
        );
    }

    #[test]
    fn tasks_panel_failed_shell_rows_point_to_activity_details() {
        let mut app = create_test_app();
        app.history.push(HistoryCell::Tool(ToolCell::Exec(ExecCell {
            command: "cargo test -p codewhale-tui".to_string(),
            status: ToolStatus::Failed,
            output: Some("test failed".to_string()),
            live_output: None,
            shell_task_id: None,
            started_at: None,
            duration_ms: Some(1_250),
            source: ExecSource::Assistant,
            interaction: None,
            output_summary: Some("test failed".to_string()),
        })));

        let text = lines_to_text(&task_panel_lines(&app, 80, 8));

        assert!(
            text.iter().any(|line| line.contains("[!] cargo test")),
            "failed shell command should keep its concise label: {text:?}"
        );
        assert!(
            text.iter().any(|line| line.contains(&format!(
                "inspect details with {}",
                crate::tui::key_shortcuts::tool_details_shortcut_label()
            ))),
            "failed row should include the next action: {text:?}"
        );
    }

    #[test]
    fn tasks_panel_keeps_duration_and_status_on_recent_shell_rows() {
        let mut app = create_test_app();
        app.history.push(HistoryCell::Tool(ToolCell::Exec(ExecCell {
            command: "cargo check".to_string(),
            status: ToolStatus::Success,
            output: Some("Finished".to_string()),
            live_output: None,
            shell_task_id: None,
            started_at: None,
            duration_ms: Some(1_250),
            source: ExecSource::Assistant,
            interaction: None,
            output_summary: None,
        })));

        let text = lines_to_text(&task_panel_lines(&app, 80, 8));

        assert!(
            text.iter()
                .any(|line| line.contains("[✓] cargo check 1.2s")),
            "status marker and duration should stay in the row label: {text:?}"
        );
        assert!(
            text.iter().any(|line| line.contains("cargo check")),
            "current command summary should stay visible: {text:?}"
        );
    }

    #[test]
    fn tasks_panel_uses_plain_names_for_shell_background_helpers() {
        let mut app = create_test_app();
        let mut active = ActiveCell::new();
        active.push_tool(
            "shell-wait",
            HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
                name: "task_shell_wait".to_string(),
                status: ToolStatus::Running,
                input_summary: Some("task_id: shell_33a08c3c".to_string()),
                output: None,
                prompts: None,
                spillover_path: None,
                output_summary: None,
                is_diff: false,
            })),
        );
        app.active_cell = Some(active);

        let text = lines_to_text(&task_panel_lines(&app, 80, 6));

        assert!(
            text.iter().any(|line| line.contains("[~] wait Bash")),
            "shell helper should render as a user-facing activity: {text:?}"
        );
        assert!(
            !text.iter().any(|line| line.contains("task_shell_wait")),
            "internal helper name should not leak into sidebar: {text:?}"
        );
    }

    #[test]
    fn tasks_panel_collapses_repeated_shell_waits_for_same_job() {
        let mut app = create_test_app();
        let mut active = ActiveCell::new();
        for id in ["shell-wait-1", "shell-wait-2"] {
            active.push_tool(
                id,
                HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
                    name: "task_shell_wait".to_string(),
                    status: ToolStatus::Running,
                    input_summary: Some("task_id: shell_33a08c3c".to_string()),
                    output: None,
                    prompts: None,
                    spillover_path: None,
                    output_summary: Some("Background task running (no new output).".to_string()),
                    is_diff: false,
                })),
            );
        }
        app.active_cell = Some(active);

        let text = lines_to_text(&task_panel_lines(&app, 100, 8));

        assert_eq!(
            text.iter()
                .filter(|line| line.contains("[~] wait Bash"))
                .count(),
            1,
            "duplicate waits for the same shell job should collapse: {text:?}"
        );
        assert!(
            text.iter().any(|line| line.contains("2 waits collapsed")),
            "collapsed row should explain why only one wait is visible: {text:?}"
        );
    }

    #[test]
    fn navigator_empty_state_says_no_agents() {
        let summary = SidebarSubagentSummary::default();
        let lines = subagent_panel_lines(&summary, &[], 32, 8, &palette::UI_THEME);
        let text = lines_to_text(&lines);
        assert_eq!(text, vec!["No agents".to_string()]);
    }

    #[test]
    fn agents_panel_running_state_renders_count_role_and_rows() {
        // Two general agents (one running, one done) + one explore (running).
        let mut role_counts = std::collections::BTreeMap::new();
        role_counts.insert("general".to_string(), 2);
        role_counts.insert("explore".to_string(), 1);
        let summary = SidebarSubagentSummary {
            cached_total: 3,
            cached_running: 2,
            progress_only_count: 0,
            fanout_total: None,
            fanout_running: 0,
            foreground_rlm_running: false,
            role_counts,
        };
        let rows = vec![
            SidebarAgentRow {
                id: "agent_a5e674dc".to_string(),
                name: "check-docs-mcp".to_string(),
                role: "explore".to_string(),
                status: "running".to_string(),
                objective: None,
                git_branch: Some("feature/docs".to_string()),
                progress: Some("step 2/3: running tool 'read_file'".to_string()),
                steps_taken: 2,
                duration_ms: Some(22_000),
            },
            SidebarAgentRow {
                id: "agent_850aa63f".to_string(),
                name: "check-install-docs".to_string(),
                role: "general".to_string(),
                status: "done".to_string(),
                objective: None,
                git_branch: None,
                progress: Some("SUMMARY: docs checked".to_string()),
                steps_taken: 5,
                duration_ms: Some(21_000),
            },
        ];
        let text = lines_to_text(&subagent_panel_lines(
            &summary,
            &rows,
            64,
            12,
            &palette::UI_THEME,
        ));
        assert!(text[0].contains("2 running"), "header: {:?}", text[0]);
        assert!(text[0].contains("/ 3"), "total in header: {:?}", text[0]);
        assert!(
            text[1].contains("1 explore") && text[1].contains("2 general"),
            "role mix line: {:?}",
            text[1]
        );
        assert!(
            text.iter()
                .any(|l| l.contains("[~] explore check-docs-mcp")),
            "running row missing: {text:?}",
        );
        assert!(
            text.iter().any(|l| l.contains("step 2/3")),
            "progress detail missing: {text:?}",
        );
        let wide_text = lines_to_text(&subagent_panel_lines(
            &summary,
            &rows,
            96,
            12,
            &palette::UI_THEME,
        ));
        assert!(
            wide_text.iter().any(|l| l.contains("branch feature/docs")),
            "branch detail missing at wide width: {wide_text:?}",
        );
    }

    #[test]
    fn navigator_uses_fanout_total_when_fanout_has_seeded_slots() {
        let summary = SidebarSubagentSummary {
            cached_total: 1,
            cached_running: 1,
            progress_only_count: 0,
            fanout_total: Some(6),
            fanout_running: 1,
            foreground_rlm_running: false,
            role_counts: std::collections::BTreeMap::new(),
        };

        let text = lines_to_text(&subagent_panel_lines(
            &summary,
            &[],
            64,
            8,
            &palette::UI_THEME,
        ));

        assert!(text[0].contains("1 running"), "header: {:?}", text[0]);
        assert!(text[0].contains("/ 6"), "fanout total: {:?}", text[0]);
    }

    #[test]
    fn navigator_settled_state_says_done() {
        let mut role_counts = std::collections::BTreeMap::new();
        role_counts.insert("general".to_string(), 1);
        let summary = SidebarSubagentSummary {
            cached_total: 1,
            cached_running: 0,
            progress_only_count: 0,
            fanout_total: None,
            fanout_running: 0,
            foreground_rlm_running: false,
            role_counts,
        };
        let text = lines_to_text(&subagent_panel_lines(
            &summary,
            &[],
            32,
            8,
            &palette::UI_THEME,
        ));
        assert!(text[0].contains("1 done"), "settled header: {:?}", text[0]);
    }

    #[test]
    fn navigator_truncates_long_role_mix_to_content_width() {
        // Build a wide role mix; assert it doesn't blow past content_width.
        let mut role_counts = std::collections::BTreeMap::new();
        for role in ["general", "explore", "plan", "review", "custom", "extra"] {
            role_counts.insert(role.to_string(), 1);
        }
        let summary = SidebarSubagentSummary {
            cached_total: 6,
            cached_running: 6,
            progress_only_count: 0,
            fanout_total: None,
            fanout_running: 0,
            foreground_rlm_running: false,
            role_counts,
        };
        let lines = subagent_panel_lines(&summary, &[], 16, 8, &palette::UI_THEME);
        let role_line: &str = lines[1]
            .spans
            .first()
            .map(|s| s.content.as_ref())
            .unwrap_or("");
        assert!(
            role_line.chars().count() <= 16,
            "role line {role_line:?} exceeded content_width"
        );
    }

    #[test]
    fn navigator_shows_foreground_rlm_work_when_no_subagents_exist() {
        let summary = SidebarSubagentSummary {
            foreground_rlm_running: true,
            ..SidebarSubagentSummary::default()
        };
        let text = lines_to_text(&subagent_panel_lines(
            &summary,
            &[],
            64,
            8,
            &palette::UI_THEME,
        ));

        assert!(!text[0].contains("No agents"), "header: {text:?}");
        assert!(
            text.iter()
                .any(|line| line.contains("RLM foreground work active")),
            "RLM work must be visible in Agents panel: {text:?}"
        );
    }

    // ---- Sidebar hover tooltip tests ----

    #[test]
    fn sidebar_hover_state_default_is_empty() {
        let state = SidebarHoverState::default();
        assert!(state.sections.is_empty());
    }

    #[test]
    fn sidebar_hover_section_stores_lines() {
        use ratatui::layout::Rect;
        let section = SidebarHoverSection {
            content_area: Rect::new(1, 1, 38, 8),
            lines: vec!["line 1".to_string(), "line 2".to_string()],
            rows: vec![],
        };
        assert_eq!(section.lines.len(), 2);
        assert_eq!(section.lines[0], "line 1");
        assert!(section.content_area.x > 0);
    }

    #[test]
    fn hover_line_matching_respects_content_area_offset() {
        use ratatui::layout::Rect;
        let section = SidebarHoverSection {
            content_area: Rect::new(62, 2, 36, 6),
            lines: vec![
                "first".to_string(),
                "second".to_string(),
                "third".to_string(),
            ],
            rows: vec![],
        };

        // Mouse within content area, first line
        let line_idx = (2u16.saturating_sub(section.content_area.y)) as usize;
        assert_eq!(section.lines[line_idx], "first");

        // Mouse within content area, second line
        let line_idx = (3u16.saturating_sub(section.content_area.y)) as usize;
        assert_eq!(section.lines[line_idx], "second");

        // Mouse outside content area (above) — row < content_area.y
        assert!((1u16) < section.content_area.y);
    }

    #[test]
    fn work_hover_text_preserves_full_checklist_item() {
        let long_item =
            "Add ProviderKind::HuggingFace direct route with all auth and docs coverage";
        let summary = SidebarWorkSummary {
            checklist_completion_pct: 0,
            checklist_items: vec![SidebarWorkChecklistItem {
                id: 7,
                content: long_item.to_string(),
                status: TodoStatus::InProgress,
            }],
            ..SidebarWorkSummary::default()
        };

        let display = lines_to_text(&work_panel_lines(
            &summary,
            18,
            4,
            PaletteMode::Dark,
            &palette::UI_THEME,
        ));
        let hover = work_panel_hover_texts(&summary, 18, 4);

        assert!(
            display.iter().any(|line| line.contains("...")),
            "compact Work row should be ellipsized in this fixture: {display:?}"
        );
        assert!(
            hover.iter().any(|line| line.contains(long_item)),
            "hover text should retain the full checklist item: {hover:?}"
        );
    }

    #[test]
    fn sidebar_hover_rows_mark_source_text_diff_as_truncated() {
        use ratatui::layout::Rect;
        let display = vec!["[~] agent imple…".to_string()];
        let full = vec!["[~] agent implementation-worker-for-sidebar-detail-popover".to_string()];
        let rows = sidebar_hover_rows(Rect::new(62, 5, 16, 4), &display, &full, &[]);

        let expected = SidebarHoverRow {
            row_y: 5,
            display_text: display[0].clone(),
            full_text: full[0].clone(),
            detail: None,
            is_truncated: true,
            click_action: None,
        };
        assert_eq!(rows, vec![expected]);
    }

    #[test]
    fn subagent_hover_text_preserves_full_agent_id_and_progress() {
        let mut role_counts = std::collections::BTreeMap::new();
        role_counts.insert("worker".to_string(), 1);
        let summary = SidebarSubagentSummary {
            cached_total: 1,
            cached_running: 1,
            role_counts,
            ..SidebarSubagentSummary::default()
        };
        let long_id = "019e9142-83f6-7713-87f1-28902e74bf05";
        let long_progress =
            "currently reviewing sidebar hover popover wrapping and hitbox metadata";
        let rows = vec![SidebarAgentRow {
            id: long_id.to_string(),
            name: "sidebar-detail-worker-with-long-name".to_string(),
            role: "worker".to_string(),
            status: "running".to_string(),
            objective: None,
            git_branch: Some("codex/sidebar-hover".to_string()),
            progress: Some(long_progress.to_string()),
            steps_taken: 9,
            duration_ms: Some(12_345),
        }];

        let hover = subagent_panel_hover_texts(&summary, &rows, 5);
        assert!(
            hover.iter().any(|line| line.contains(long_id)),
            "hover text should include the full agent id: {hover:?}"
        );
        assert!(
            hover.iter().any(|line| line.contains(long_progress)),
            "hover text should include the full progress before popover wrapping: {hover:?}"
        );
    }

    #[test]
    fn subagent_label_hover_carries_full_agent_dossier() {
        let mut role_counts = std::collections::BTreeMap::new();
        role_counts.insert("worker".to_string(), 1);
        let summary = SidebarSubagentSummary {
            cached_total: 1,
            cached_running: 1,
            role_counts,
            ..SidebarSubagentSummary::default()
        };
        let rows = vec![SidebarAgentRow {
            id: "019e9142-83f6-7713-87f1-28902e74bf05".to_string(),
            name: "doc-checker".to_string(),
            role: "worker".to_string(),
            status: "running".to_string(),
            objective: Some("Verify install docs against the release notes".to_string()),
            git_branch: Some("codex/doc-check".to_string()),
            progress: Some("step 2/3: running tool 'read_file'".to_string()),
            steps_taken: 2,
            duration_ms: Some(22_000),
        }];

        let hover = subagent_panel_hover_texts(&summary, &rows, 6);
        let label = hover
            .iter()
            .find(|text| text.contains("doc-checker"))
            .expect("label hover row should exist");

        assert!(
            label.contains("id: 019e9142-83f6-7713-87f1-28902e74bf05"),
            "label hover should carry the full id: {label:?}"
        );
        assert!(
            label.contains("status: running") && label.contains("elapsed"),
            "label hover should carry status and elapsed time: {label:?}"
        );
        assert!(
            label.contains("objective: Verify install docs against the release notes"),
            "label hover should carry the objective: {label:?}"
        );
        assert!(
            label.contains("branch: codex/doc-check"),
            "label hover should carry the branch: {label:?}"
        );
        assert!(
            label.contains("progress: step 2/3: running tool 'read_file'"),
            "label hover should carry untruncated progress: {label:?}"
        );
    }

    // ── #3030: stable labels instead of raw internal ids ───────────────────

    #[test]
    fn tasks_panel_shows_stable_turn_label_not_uuid() {
        let mut app = create_test_app();
        app.runtime_turn_id = Some("0196f0a3-1111-2222-3333-444455556666".to_string());
        app.runtime_turn_status = Some("in_progress".to_string());
        app.turn_counter = 3;

        let text = lines_to_text(&task_panel_lines(&app, 64, 8));
        assert!(
            text[0].contains("Turn 3 (in_progress)"),
            "compact row must show the stable turn label: {text:?}"
        );
        assert!(
            !text[0].contains("0196f0a3"),
            "raw turn UUID must stay out of the compact row: {text:?}"
        );

        let hover = task_panel_hover_texts(&app, 8);
        assert!(
            hover[0].contains("0196f0a3-1111-2222-3333-444455556666"),
            "full turn UUID must remain available in hover text: {hover:?}"
        );
    }

    #[test]
    fn tasks_panel_turn_label_falls_back_before_first_counted_turn() {
        let mut app = create_test_app();
        app.runtime_turn_id = Some("0196f0a3-1111-2222-3333-444455556666".to_string());
        app.runtime_turn_status = Some("in_progress".to_string());
        app.turn_counter = 0;

        let text = lines_to_text(&task_panel_lines(&app, 64, 8));
        assert!(
            text[0].contains("Current turn (in_progress)"),
            "zero counter falls back to a generic label: {text:?}"
        );
    }

    #[test]
    fn ensure_agent_label_assigns_stable_sequential_labels() {
        let mut app = create_test_app();
        assert_eq!(app.ensure_agent_label("agent_aaa111"), "Agent 1");
        assert_eq!(app.ensure_agent_label("agent_bbb222"), "Agent 2");
        // Re-seeing a known agent keeps its original label.
        assert_eq!(app.ensure_agent_label("agent_aaa111"), "Agent 1");
        assert_eq!(app.agent_counter, 2);
        // Read-only lookup falls back to the raw id for unknown agents.
        assert_eq!(app.agent_display_label("agent_bbb222"), "Agent 2");
        assert_eq!(app.agent_display_label("agent_zzz999"), "agent_zzz999");
    }

    fn cached_agent(
        agent_id: &str,
        nickname: Option<&str>,
    ) -> crate::tools::subagent::SubAgentResult {
        crate::tools::subagent::SubAgentResult {
            name: "implementation-worker".to_string(),
            agent_id: agent_id.to_string(),
            context_mode: "fresh".to_string(),
            fork_context: false,
            workspace: None,
            git_branch: None,
            agent_type: crate::tools::subagent::SubAgentType::General,
            assignment: crate::tools::subagent::SubAgentAssignment {
                objective: "task".to_string(),
                role: Some("worker".to_string()),
            },
            model: String::new(),
            nickname: nickname.map(str::to_string),
            status: crate::tools::subagent::SubAgentStatus::Running,
            result: None,
            steps_taken: 1,
            checkpoint: None,
            duration_ms: 100,
            from_prior_session: false,
        }
    }

    #[test]
    fn sidebar_agent_rows_prefer_nickname_over_generated_label() {
        let mut app = create_test_app();
        let agent_id = "agent_cafe0123";
        app.ensure_agent_label(agent_id);
        app.subagent_cache
            .push(cached_agent(agent_id, Some("doc-fixer")));

        let rows = super::sidebar_agent_rows(&app);
        assert_eq!(
            rows[0].name, "doc-fixer",
            "user nickname must beat the generated Agent-N label"
        );

        // Without a nickname the generated label is used.
        app.subagent_cache[0].nickname = None;
        let rows = super::sidebar_agent_rows(&app);
        assert_eq!(rows[0].name, "Agent 1");
    }
}
