//! Transcript/history cells for the LHA TUI.
//!
//! A `HistoryCell` is the unit of display in the conversation UI, representing both committed
//! transcript entries and, transiently, an in-flight active cell that can mutate in place while
//! streaming.
//!
//! The transcript overlay (`Ctrl+T`) appends a cached live tail derived from the active cell, and
//! that cached tail is refreshed based on an active-cell cache key. Cells that change based on
//! elapsed time expose `transcript_animation_tick()`, and code that mutates the active cell in place
//! bumps the active-cell revision tracked by `ChatWidget`, so the cache key changes whenever the
//! rendered transcript output can change.

use crate::product::agent::config::Config;
use crate::product::agent::config::types::BuddyRarity;
use crate::product::agent::config::types::McpServerTransportConfig;
use crate::product::agent::config::types::TuiBuddy;
use crate::product::agent::protocol::FileChange;
use crate::product::agent::protocol::McpAuthStatus;
use crate::product::agent::protocol::McpInvocation;
use crate::product::agent::protocol::SessionConfiguredEvent;
use crate::product::agent::web_search::web_search_detail;
use crate::product::common::format_env_display::format_env_display;
use crate::product::mcp_types::EmbeddedResourceResource;
use crate::product::mcp_types::Resource;
use crate::product::mcp_types::ResourceLink;
use crate::product::mcp_types::ResourceTemplate;
use crate::product::otel::RuntimeMetricsSummary;
use crate::product::protocol::models::WebSearchAction;
use crate::product::protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
use crate::product::protocol::plan_tool::PlanItemArg;
use crate::product::protocol::plan_tool::StepStatus;
use crate::product::protocol::plan_tool::UpdatePlanArgs;
use crate::product::protocol::user_input::TextElement;
use crate::product::tui_app::buddy::model::Buddy;
use crate::product::tui_app::buddy::model::BuddyStats;
use crate::product::tui_app::buddy::model::rarity_stars;
use crate::product::tui_app::buddy::sprites;
use crate::product::tui_app::buddy::style::rarity_color;
use crate::product::tui_app::changelog::ChangelogEntry;
use crate::product::tui_app::changelog::ChangelogKind;
use crate::product::tui_app::changelog::format_changelog_path;
use crate::product::tui_app::diff_render::create_diff_summary;
use crate::product::tui_app::diff_render::display_path_for;
use crate::product::tui_app::diff_render::render_line_count_summary;
use crate::product::tui_app::exec_cell::CommandOutput;
use crate::product::tui_app::exec_cell::OutputLinesParams;
use crate::product::tui_app::exec_cell::TOOL_CALL_MAX_LINES;
use crate::product::tui_app::exec_cell::output_lines;
use crate::product::tui_app::exec_cell::spinner;
use crate::product::tui_app::exec_command::relativize_to_home;
use crate::product::tui_app::exec_command::strip_bash_lc_and_escape;
use crate::product::tui_app::line_truncation::truncate_line_to_width;
use crate::product::tui_app::live_wrap::take_prefix_by_width;
use crate::product::tui_app::markdown::append_markdown;
use crate::product::tui_app::render::line_utils::line_to_static;
use crate::product::tui_app::render::line_utils::prefix_lines;
use crate::product::tui_app::render::line_utils::push_owned_lines;
use crate::product::tui_app::render::renderable::Renderable;
use crate::product::tui_app::style::proposed_plan_style;
use crate::product::tui_app::style::user_message_style;
use crate::product::tui_app::text_formatting::format_and_truncate_tool_result;
use crate::product::tui_app::text_formatting::truncate_text;
use crate::product::tui_app::tooltips;
use crate::product::tui_app::ui_consts::LIVE_PREFIX_COLS;
use crate::product::tui_app::update_action::UpdateAction;
use crate::product::tui_app::version::CODEX_CLI_VERSION;
use crate::product::tui_app::wrapping::RtOptions;
use crate::product::tui_app::wrapping::word_wrap_line;
use crate::product::tui_app::wrapping::word_wrap_lines;
use base64::Engine;
use image::DynamicImage;
use image::ImageReader;
use ratatui::prelude::*;
use ratatui::style::Color;
use ratatui::style::Style;
use ratatui::style::Styled;
use ratatui::style::Stylize;
use ratatui::widgets::Block;
use ratatui::widgets::Clear;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Wrap;
use std::any::Any;
use std::collections::HashMap;
use std::io::Cursor;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Mutex;
use std::time::Duration;
use std::time::Instant;
use tracing::error;
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;

/// Represents an event to display in the conversation history. Returns its
/// `Vec<Line<'static>>` representation to make it easier to display in a
/// scrollable list.
pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any {
    fn display_lines(&self, width: u16) -> Vec<Line<'static>>;

    fn has_display_content(&self) -> bool {
        true
    }

    fn block_style(&self) -> Option<Style> {
        None
    }

    fn fill_line_backgrounds(&self) -> bool {
        false
    }

    fn desired_height(&self, width: u16) -> u16 {
        Paragraph::new(Text::from(self.display_lines(width)))
            .wrap(Wrap { trim: false })
            .line_count(width)
            .try_into()
            .unwrap_or(0)
    }

    fn transcript_lines(&self, width: u16) -> Vec<Line<'static>> {
        self.display_lines(width)
    }

    fn desired_transcript_height(&self, width: u16) -> u16 {
        let lines = self.transcript_lines(width);
        // Workaround for ratatui bug: if there's only one line and it's whitespace-only, ratatui gives 2 lines.
        if let [line] = &lines[..]
            && line
                .spans
                .iter()
                .all(|s| s.content.chars().all(char::is_whitespace))
        {
            return 1;
        }

        Paragraph::new(Text::from(lines))
            .wrap(Wrap { trim: false })
            .line_count(width)
            .try_into()
            .unwrap_or(0)
    }

    fn is_stream_continuation(&self) -> bool {
        false
    }

    fn force_transcript_separator_after(&self) -> bool {
        false
    }

    /// Returns a coarse "animation tick" when transcript output is time-dependent.
    ///
    /// The transcript overlay caches the rendered output of the in-flight active cell, so cells
    /// that include time-based UI (spinner, shimmer, etc.) should return a tick that changes over
    /// time to signal that the cached tail should be recomputed. Returning `None` means the
    /// transcript lines are stable, while returning `Some(tick)` during an in-flight animation
    /// allows the overlay to keep up with the main viewport.
    ///
    /// If a cell uses time-based visuals but always returns `None`, `Ctrl+T` can appear "frozen" on
    /// the first rendered frame even though the main viewport is animating.
    fn transcript_animation_tick(&self) -> Option<u64> {
        None
    }
}

impl Renderable for Box<dyn HistoryCell> {
    fn render(&self, area: Rect, buf: &mut Buffer) {
        let lines = self.display_lines(area.width);
        let paragraph = Paragraph::new(Text::from(lines.clone())).wrap(Wrap { trim: false });
        let y = if area.height == 0 {
            0
        } else {
            let overflow = paragraph
                .line_count(area.width)
                .saturating_sub(usize::from(area.height));
            u16::try_from(overflow).unwrap_or(u16::MAX)
        };
        Clear.render(area, buf);
        if let Some(style) = self.block_style() {
            Block::default().style(style).render(area, buf);
        }
        if self.fill_line_backgrounds() {
            render_line_backgrounds(&lines, area, buf, y);
        }
        paragraph.scroll((y, 0)).render(area, buf);
    }
    fn desired_height(&self, width: u16) -> u16 {
        HistoryCell::desired_height(self.as_ref(), width)
    }
}

pub(crate) fn render_line_backgrounds(
    lines: &[Line<'static>],
    area: Rect,
    buf: &mut Buffer,
    scroll_y: u16,
) {
    if area.is_empty() {
        return;
    }

    let mut visual_y = 0u16;
    for line in lines {
        let height = line_visual_height(line, area.width);
        if let Some(bg) = line.style.bg {
            let style = Style::default().bg(bg);
            for row in visual_y..visual_y.saturating_add(height) {
                let Some(target_y) = row.checked_sub(scroll_y) else {
                    continue;
                };
                if target_y >= area.height {
                    continue;
                }
                let y = area.y + target_y;
                for x in area.x..area.right() {
                    let current = buf[(x, y)].style();
                    buf[(x, y)].set_style(current.patch(style));
                }
            }
        }
        visual_y = visual_y.saturating_add(height);
    }
}

fn line_visual_height(line: &Line<'static>, width: u16) -> u16 {
    if width == 0 {
        return 0;
    }
    if line
        .spans
        .iter()
        .all(|span| span.content.chars().all(char::is_whitespace))
    {
        return 1;
    }
    Paragraph::new(Text::from(vec![line.clone()]))
        .wrap(Wrap { trim: false })
        .line_count(width)
        .try_into()
        .unwrap_or(u16::MAX)
}

impl dyn HistoryCell {
    pub(crate) fn as_any(&self) -> &dyn Any {
        self
    }

    pub(crate) fn as_any_mut(&mut self) -> &mut dyn Any {
        self
    }
}

#[derive(Debug)]
pub(crate) struct UserHistoryCell {
    pub message: String,
    pub text_elements: Vec<TextElement>,
    #[allow(dead_code)]
    pub local_image_paths: Vec<PathBuf>,
}

/// Build logical lines for a user message with styled text elements.
///
/// This preserves explicit newlines while interleaving element spans and skips
/// malformed byte ranges instead of panicking during history rendering.
fn build_user_message_lines_with_elements(
    message: &str,
    elements: &[TextElement],
    style: Style,
    element_style: Style,
) -> Vec<Line<'static>> {
    let mut elements = elements.to_vec();
    elements.sort_by_key(|e| e.byte_range.start);
    let mut offset = 0usize;
    let mut raw_lines: Vec<Line<'static>> = Vec::new();
    for line_text in message.split('\n') {
        let line_start = offset;
        let line_end = line_start + line_text.len();
        let mut spans: Vec<Span<'static>> = Vec::new();
        // Track how much of the line we've emitted to interleave plain and styled spans.
        let mut cursor = line_start;
        for elem in &elements {
            let start = elem.byte_range.start.max(line_start);
            let end = elem.byte_range.end.min(line_end);
            if start >= end {
                continue;
            }
            let rel_start = start - line_start;
            let rel_end = end - line_start;
            // Guard against malformed UTF-8 byte ranges from upstream data; skip
            // invalid elements rather than panicking while rendering history.
            if !line_text.is_char_boundary(rel_start) || !line_text.is_char_boundary(rel_end) {
                continue;
            }
            let rel_cursor = cursor - line_start;
            if cursor < start
                && line_text.is_char_boundary(rel_cursor)
                && let Some(segment) = line_text.get(rel_cursor..rel_start)
            {
                spans.push(Span::from(segment.to_string()));
            }
            if let Some(segment) = line_text.get(rel_start..rel_end) {
                spans.push(Span::styled(segment.to_string(), element_style));
                cursor = end;
            }
        }
        let rel_cursor = cursor - line_start;
        if cursor < line_end
            && line_text.is_char_boundary(rel_cursor)
            && let Some(segment) = line_text.get(rel_cursor..)
        {
            spans.push(Span::from(segment.to_string()));
        }
        let line = if spans.is_empty() {
            Line::from(line_text.to_string()).style(style)
        } else {
            Line::from(spans).style(style)
        };
        raw_lines.push(line);
        // Split on '\n' so any '\r' stays in the line; advancing by 1 accounts
        // for the separator byte.
        offset = line_end + 1;
    }

    raw_lines
}

impl HistoryCell for UserHistoryCell {
    fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
        let mut lines: Vec<Line<'static>> = Vec::new();

        let wrap_width = width
            .saturating_sub(
                LIVE_PREFIX_COLS + 1, /* keep a one-column right margin for wrapping */
            )
            .max(1);

        let style = user_message_style();
        let element_style = style.fg(Color::Cyan);

        let wrapped = if self.text_elements.is_empty() {
            word_wrap_lines(
                self.message.split('\n').map(|l| Line::from(l).style(style)),
                // Wrap algorithm matches textarea.rs.
                RtOptions::new(usize::from(wrap_width))
                    .wrap_algorithm(textwrap::WrapAlgorithm::FirstFit),
            )
        } else {
            let raw_lines = build_user_message_lines_with_elements(
                &self.message,
                &self.text_elements,
                style,
                element_style,
            );
            word_wrap_lines(
                raw_lines,
                RtOptions::new(usize::from(wrap_width))
                    .wrap_algorithm(textwrap::WrapAlgorithm::FirstFit),
            )
        };

        lines.push(Line::from("").style(style));
        lines.extend(prefix_lines(wrapped, "› ".bold().dim(), "  ".into()));
        lines.push(Line::from("").style(style));
        lines
    }

    fn block_style(&self) -> Option<Style> {
        Some(user_message_style())
    }

    fn force_transcript_separator_after(&self) -> bool {
        true
    }
}

#[derive(Debug)]
pub(crate) struct ReasoningSummaryCell {
    _header: String,
    content: String,
    transcript_only: bool,
    lines_cache: std::sync::Mutex<Option<ReasoningSummaryLinesCache>>,
}

#[derive(Debug, Clone)]
struct ReasoningSummaryLinesCache {
    width: u16,
    lines: Vec<Line<'static>>,
}

impl ReasoningSummaryCell {
    pub(crate) fn new(header: String, content: String, transcript_only: bool) -> Self {
        Self {
            _header: header,
            content,
            transcript_only,
            lines_cache: std::sync::Mutex::new(None),
        }
    }

    fn lines(&self, width: u16) -> Vec<Line<'static>> {
        if let Ok(cache) = self.lines_cache.lock()
            && let Some(cache) = cache.as_ref()
            && cache.width == width
        {
            return cache.lines.clone();
        }

        let mut lines: Vec<Line<'static>> = Vec::new();
        append_markdown(
            &self.content,
            Some((width as usize).saturating_sub(2)),
            &mut lines,
        );
        let summary_style = Style::default().dim().italic();
        let summary_lines = lines
            .into_iter()
            .map(|mut line| {
                line.spans = line
                    .spans
                    .into_iter()
                    .map(|span| span.patch_style(summary_style))
                    .collect();
                line
            })
            .collect::<Vec<_>>();

        let wrapped = word_wrap_lines(
            &summary_lines,
            RtOptions::new(width as usize)
                .initial_indent("• ".dim().into())
                .subsequent_indent("  ".into()),
        );
        if let Ok(mut cache) = self.lines_cache.lock() {
            *cache = Some(ReasoningSummaryLinesCache {
                width,
                lines: wrapped.clone(),
            });
        }
        wrapped
    }
}

impl HistoryCell for ReasoningSummaryCell {
    fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
        if self.transcript_only {
            Vec::new()
        } else {
            self.lines(width)
        }
    }

    fn desired_height(&self, width: u16) -> u16 {
        if self.transcript_only {
            0
        } else {
            self.lines(width).len() as u16
        }
    }

    fn has_display_content(&self) -> bool {
        !self.transcript_only
    }

    fn transcript_lines(&self, width: u16) -> Vec<Line<'static>> {
        self.lines(width)
    }

    fn desired_transcript_height(&self, width: u16) -> u16 {
        self.lines(width).len() as u16
    }
}

#[derive(Debug)]
pub(crate) struct AgentMessageCell {
    content: AgentMessageContent,
    is_first_line: bool,
    revision: u64,
    lines_cache: Mutex<Option<AgentMessageLinesCache>>,
}

#[derive(Debug)]
enum AgentMessageContent {
    RenderedLines(Vec<Line<'static>>),
    Markdown {
        source: String,
        visible_rendered_lines: Option<usize>,
    },
}

#[derive(Debug, Clone)]
struct AgentMessageLinesCache {
    width: u16,
    revision: u64,
    visible_rendered_lines: Option<usize>,
    lines: Vec<Line<'static>>,
}

impl AgentMessageCell {
    pub(crate) fn new(lines: Vec<Line<'static>>, is_first_line: bool) -> Self {
        Self {
            content: AgentMessageContent::RenderedLines(lines),
            is_first_line,
            revision: 0,
            lines_cache: Mutex::new(None),
        }
    }

    pub(crate) fn new_markdown(source: String, is_first_line: bool) -> Self {
        Self {
            content: AgentMessageContent::Markdown {
                source,
                visible_rendered_lines: None,
            },
            is_first_line,
            revision: 0,
            lines_cache: Mutex::new(None),
        }
    }

    pub(crate) fn new_streaming_markdown(is_first_line: bool) -> Self {
        Self {
            content: AgentMessageContent::Markdown {
                source: String::new(),
                visible_rendered_lines: Some(0),
            },
            is_first_line,
            revision: 0,
            lines_cache: Mutex::new(None),
        }
    }

    pub(crate) fn is_streaming_markdown(&self) -> bool {
        matches!(
            &self.content,
            AgentMessageContent::Markdown {
                visible_rendered_lines: Some(_),
                ..
            }
        )
    }

    pub(crate) fn set_markdown_stream_state(
        &mut self,
        source: String,
        visible_lines: usize,
    ) -> bool {
        let AgentMessageContent::Markdown {
            source: current_source,
            visible_rendered_lines: current_visible_lines,
        } = &mut self.content
        else {
            return false;
        };

        let changed = *current_source != source || *current_visible_lines != Some(visible_lines);
        if changed {
            *current_source = source;
            *current_visible_lines = Some(visible_lines);
            self.revision = self.revision.wrapping_add(1);
            self.clear_lines_cache();
        }
        changed
    }

    pub(crate) fn set_visible_rendered_lines(&mut self, visible_lines: usize) -> bool {
        let AgentMessageContent::Markdown {
            visible_rendered_lines,
            ..
        } = &mut self.content
        else {
            return false;
        };

        if *visible_rendered_lines == Some(visible_lines) {
            return false;
        }

        *visible_rendered_lines = Some(visible_lines);
        self.revision = self.revision.wrapping_add(1);
        self.clear_lines_cache();
        true
    }

    pub(crate) fn show_all_markdown(&mut self, source: String) -> bool {
        let AgentMessageContent::Markdown {
            source: current_source,
            visible_rendered_lines,
        } = &mut self.content
        else {
            return false;
        };

        let changed = *current_source != source || visible_rendered_lines.is_some();
        if changed {
            *current_source = source;
            *visible_rendered_lines = None;
            self.revision = self.revision.wrapping_add(1);
            self.clear_lines_cache();
        }
        changed
    }

    fn clear_lines_cache(&self) {
        if let Ok(mut cache) = self.lines_cache.lock() {
            *cache = None;
        }
    }

    fn markdown_lines(
        &self,
        source: &str,
        visible_rendered_lines: Option<usize>,
        width: u16,
    ) -> Vec<Line<'static>> {
        if let Ok(cache) = self.lines_cache.lock()
            && let Some(cache) = cache.as_ref()
            && cache.width == width
            && cache.revision == self.revision
            && cache.visible_rendered_lines == visible_rendered_lines
        {
            return cache.lines.clone();
        }

        let mut source = source.to_string();
        if !source.ends_with('\n') {
            source.push('\n');
        }

        let mut lines: Vec<Line<'static>> = Vec::new();
        append_markdown(
            &source,
            Some((width as usize).saturating_sub(2).max(1)),
            &mut lines,
        );
        if let Some(visible_rendered_lines) = visible_rendered_lines {
            lines.truncate(visible_rendered_lines);
        }
        let wrapped = self.wrap_agent_lines(&lines, width);

        if let Ok(mut cache) = self.lines_cache.lock() {
            *cache = Some(AgentMessageLinesCache {
                width,
                revision: self.revision,
                visible_rendered_lines,
                lines: wrapped.clone(),
            });
        }

        wrapped
    }

    fn wrap_agent_lines(&self, lines: &[Line<'static>], width: u16) -> Vec<Line<'static>> {
        word_wrap_lines(
            lines,
            RtOptions::new(width as usize)
                .initial_indent(if self.is_first_line {
                    "• ".dim().into()
                } else {
                    "  ".into()
                })
                .subsequent_indent("  ".into()),
        )
    }
}

impl HistoryCell for AgentMessageCell {
    fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
        match &self.content {
            AgentMessageContent::RenderedLines(lines) => self.wrap_agent_lines(lines, width),
            AgentMessageContent::Markdown {
                source,
                visible_rendered_lines,
            } => self.markdown_lines(source, *visible_rendered_lines, width),
        }
    }

    fn is_stream_continuation(&self) -> bool {
        !self.is_first_line
    }
}

#[derive(Debug)]
pub(crate) struct PlainHistoryCell {
    lines: Vec<Line<'static>>,
}

impl PlainHistoryCell {
    pub(crate) fn new(lines: Vec<Line<'static>>) -> Self {
        Self { lines }
    }
}

impl HistoryCell for PlainHistoryCell {
    fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
        self.lines.clone()
    }

    fn has_display_content(&self) -> bool {
        !self.lines.is_empty()
    }
}

#[cfg_attr(debug_assertions, allow(dead_code))]
#[derive(Debug)]
pub(crate) struct UpdateAvailableHistoryCell {
    latest_version: String,
    update_action: Option<UpdateAction>,
}

#[cfg_attr(debug_assertions, allow(dead_code))]
impl UpdateAvailableHistoryCell {
    pub(crate) fn new(latest_version: String, update_action: Option<UpdateAction>) -> Self {
        Self {
            latest_version,
            update_action,
        }
    }
}

impl HistoryCell for UpdateAvailableHistoryCell {
    fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
        use ratatui_macros::line;
        use ratatui_macros::text;
        let update_instruction = if let Some(update_action) = self.update_action {
            line!["Run ", update_action.command_str().cyan(), " to update."]
        } else {
            line![
                "See ",
                "https://github.com/openai/codex".cyan().underlined(),
                " for installation options."
            ]
        };

        let content = text![
            line![
                padded_emoji("✨").bold().cyan(),
                "Update available!".bold().cyan(),
                " ",
                format!("{CODEX_CLI_VERSION} -> {}", self.latest_version).bold(),
            ],
            update_instruction,
            "",
            "See full release notes:",
            "https://github.com/openai/codex/releases/latest"
                .cyan()
                .underlined(),
        ];

        let inner_width = content
            .width()
            .min(usize::from(width.saturating_sub(4)))
            .max(1);
        with_border_with_inner_width(content.lines, inner_width)
    }
}

#[derive(Debug)]
pub(crate) struct PrefixedWrappedHistoryCell {
    text: Text<'static>,
    initial_prefix: Line<'static>,
    subsequent_prefix: Line<'static>,
}

impl PrefixedWrappedHistoryCell {
    pub(crate) fn new(
        text: impl Into<Text<'static>>,
        initial_prefix: impl Into<Line<'static>>,
        subsequent_prefix: impl Into<Line<'static>>,
    ) -> Self {
        Self {
            text: text.into(),
            initial_prefix: initial_prefix.into(),
            subsequent_prefix: subsequent_prefix.into(),
        }
    }
}

impl HistoryCell for PrefixedWrappedHistoryCell {
    fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
        if width == 0 {
            return Vec::new();
        }
        let opts = RtOptions::new(width.max(1) as usize)
            .initial_indent(self.initial_prefix.clone())
            .subsequent_indent(self.subsequent_prefix.clone());
        let wrapped = word_wrap_lines(&self.text, opts);
        let mut out = Vec::new();
        push_owned_lines(&wrapped, &mut out);
        out
    }

    fn desired_height(&self, width: u16) -> u16 {
        self.display_lines(width).len() as u16
    }
}

#[derive(Debug)]
pub(crate) struct UnifiedExecInteractionCell {
    command_display: Option<String>,
    stdin: String,
}

impl UnifiedExecInteractionCell {
    pub(crate) fn new(command_display: Option<String>, stdin: String) -> Self {
        Self {
            command_display,
            stdin,
        }
    }
}

impl HistoryCell for UnifiedExecInteractionCell {
    fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
        if width == 0 {
            return Vec::new();
        }
        let wrap_width = width as usize;

        let mut header_spans = vec!["↳ ".dim(), "Interacted with background terminal".bold()];
        if let Some(command) = &self.command_display
            && !command.is_empty()
        {
            header_spans.push(" · ".dim());
            header_spans.push(command.clone().dim());
        }
        let header = Line::from(header_spans);

        let mut out: Vec<Line<'static>> = Vec::new();
        let header_wrapped = word_wrap_line(&header, RtOptions::new(wrap_width));
        push_owned_lines(&header_wrapped, &mut out);

        let input_lines: Vec<Line<'static>> = if self.stdin.is_empty() {
            vec![vec!["(waited)".dim()].into()]
        } else {
            self.stdin
                .lines()
                .map(|line| Line::from(line.to_string()))
                .collect()
        };

        let input_wrapped = word_wrap_lines(
            input_lines,
            RtOptions::new(wrap_width)
                .initial_indent(Line::from("  └ ".dim()))
                .subsequent_indent(Line::from("    ".dim())),
        );
        out.extend(input_wrapped);
        out
    }

    fn desired_height(&self, width: u16) -> u16 {
        self.display_lines(width).len() as u16
    }
}

pub(crate) fn new_unified_exec_interaction(
    command_display: Option<String>,
    stdin: String,
) -> UnifiedExecInteractionCell {
    UnifiedExecInteractionCell::new(command_display, stdin)
}

#[derive(Debug)]
struct UnifiedExecProcessesCell {
    processes: Vec<UnifiedExecProcessDetails>,
}

impl UnifiedExecProcessesCell {
    fn new(processes: Vec<UnifiedExecProcessDetails>) -> Self {
        Self { processes }
    }
}

#[derive(Debug, Clone)]
pub(crate) struct UnifiedExecProcessDetails {
    pub(crate) command_display: String,
    pub(crate) recent_chunks: Vec<String>,
}

impl HistoryCell for UnifiedExecProcessesCell {
    fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
        if width == 0 {
            return Vec::new();
        }

        let wrap_width = width as usize;
        let max_processes = 16usize;
        let mut out: Vec<Line<'static>> = Vec::new();
        out.push(vec!["Background terminals".bold()].into());
        out.push("".into());

        if self.processes.is_empty() {
            out.push("  • No background terminals running.".italic().into());
            return out;
        }

        let prefix = "  • ";
        let prefix_width = UnicodeWidthStr::width(prefix);
        let truncation_suffix = " [...]";
        let truncation_suffix_width = UnicodeWidthStr::width(truncation_suffix);
        let mut shown = 0usize;
        for process in &self.processes {
            if shown >= max_processes {
                break;
            }
            let command = &process.command_display;
            let (snippet, snippet_truncated) = {
                let (first_line, has_more_lines) = match command.split_once('\n') {
                    Some((first, _)) => (first, true),
                    None => (command.as_str(), false),
                };
                let max_graphemes = 80;
                let mut graphemes = first_line.grapheme_indices(true);
                if let Some((byte_index, _)) = graphemes.nth(max_graphemes) {
                    (first_line[..byte_index].to_string(), true)
                } else {
                    (first_line.to_string(), has_more_lines)
                }
            };
            if wrap_width <= prefix_width {
                out.push(Line::from(prefix.dim()));
                shown += 1;
                continue;
            }
            let budget = wrap_width.saturating_sub(prefix_width);
            let mut needs_suffix = snippet_truncated;
            if !needs_suffix {
                let (_, remainder, _) = take_prefix_by_width(&snippet, budget);
                if !remainder.is_empty() {
                    needs_suffix = true;
                }
            }
            if needs_suffix && budget > truncation_suffix_width {
                let available = budget.saturating_sub(truncation_suffix_width);
                let (truncated, _, _) = take_prefix_by_width(&snippet, available);
                out.push(vec![prefix.dim(), truncated.cyan(), truncation_suffix.dim()].into());
            } else {
                let (truncated, _, _) = take_prefix_by_width(&snippet, budget);
                out.push(vec![prefix.dim(), truncated.cyan()].into());
            }

            let chunk_prefix_first = "    ↳ ";
            let chunk_prefix_next = "      ";
            for (idx, chunk) in process.recent_chunks.iter().enumerate() {
                let chunk_prefix = if idx == 0 {
                    chunk_prefix_first
                } else {
                    chunk_prefix_next
                };
                let chunk_prefix_width = UnicodeWidthStr::width(chunk_prefix);
                if wrap_width <= chunk_prefix_width {
                    out.push(Line::from(chunk_prefix.dim()));
                    continue;
                }
                let budget = wrap_width.saturating_sub(chunk_prefix_width);
                let (truncated, remainder, _) = take_prefix_by_width(chunk, budget);
                if !remainder.is_empty() && budget > truncation_suffix_width {
                    let available = budget.saturating_sub(truncation_suffix_width);
                    let (shorter, _, _) = take_prefix_by_width(chunk, available);
                    out.push(
                        vec![chunk_prefix.dim(), shorter.dim(), truncation_suffix.dim()].into(),
                    );
                } else {
                    out.push(vec![chunk_prefix.dim(), truncated.dim()].into());
                }
            }
            shown += 1;
        }

        let remaining = self.processes.len().saturating_sub(shown);
        if remaining > 0 {
            let more_text = format!("... and {remaining} more running");
            if wrap_width <= prefix_width {
                out.push(Line::from(prefix.dim()));
            } else {
                let budget = wrap_width.saturating_sub(prefix_width);
                let (truncated, _, _) = take_prefix_by_width(&more_text, budget);
                out.push(vec![prefix.dim(), truncated.dim()].into());
            }
        }

        out
    }

    fn block_style(&self) -> Option<Style> {
        Some(Style::default())
    }

    fn desired_height(&self, width: u16) -> u16 {
        self.display_lines(width).len() as u16
    }
}

pub(crate) fn new_unified_exec_processes_output(
    processes: Vec<UnifiedExecProcessDetails>,
) -> CompositeHistoryCell {
    let command = PlainHistoryCell::new(vec!["/ps".magenta().into()]);
    let summary = UnifiedExecProcessesCell::new(processes);
    CompositeHistoryCell::new(vec![Box::new(command), Box::new(summary)])
}

fn truncate_exec_snippet(full_cmd: &str) -> String {
    let mut snippet = match full_cmd.split_once('\n') {
        Some((first, _)) => format!("{first} ..."),
        None => full_cmd.to_string(),
    };
    snippet = truncate_text(&snippet, 80);
    snippet
}

fn exec_snippet(command: &[String]) -> String {
    let full_cmd = strip_bash_lc_and_escape(command);
    truncate_exec_snippet(&full_cmd)
}

pub fn new_approval_decision_cell(
    command: Vec<String>,
    decision: crate::product::agent::protocol::ReviewDecision,
) -> Box<dyn HistoryCell> {
    use crate::product::agent::protocol::ReviewDecision::*;

    let (symbol, summary): (Span<'static>, Vec<Span<'static>>) = match decision {
        Approved => {
            let snippet = Span::from(exec_snippet(&command)).dim();
            (
                "✔ ".green(),
                vec![
                    "You ".into(),
                    "approved".bold(),
                    " codex to run ".into(),
                    snippet,
                    " this time".bold(),
                ],
            )
        }
        ApprovedExecpolicyAmendment {
            proposed_execpolicy_amendment,
        } => {
            let snippet = Span::from(exec_snippet(&proposed_execpolicy_amendment.command)).dim();
            (
                "✔ ".green(),
                vec![
                    "You ".into(),
                    "approved".bold(),
                    " codex to always run commands that start with ".into(),
                    snippet,
                ],
            )
        }
        ApprovedForSession => {
            let snippet = Span::from(exec_snippet(&command)).dim();
            (
                "✔ ".green(),
                vec![
                    "You ".into(),
                    "approved".bold(),
                    " codex to run ".into(),
                    snippet,
                    " every time this session".bold(),
                ],
            )
        }
        Denied => {
            let snippet = Span::from(exec_snippet(&command)).dim();
            (
                "✗ ".red(),
                vec![
                    "You ".into(),
                    "did not approve".bold(),
                    " codex to run ".into(),
                    snippet,
                ],
            )
        }
        Abort => {
            let snippet = Span::from(exec_snippet(&command)).dim();
            (
                "✗ ".red(),
                vec![
                    "You ".into(),
                    "canceled".bold(),
                    " the request to run ".into(),
                    snippet,
                ],
            )
        }
    };

    Box::new(PrefixedWrappedHistoryCell::new(
        Line::from(summary),
        symbol,
        "  ",
    ))
}

/// Cyan history cell line showing the current review status.
pub(crate) fn new_review_status_line(message: String) -> PlainHistoryCell {
    PlainHistoryCell {
        lines: vec![Line::from(message.cyan())],
    }
}

#[derive(Debug)]
pub(crate) struct PatchHistoryCell {
    changes: HashMap<PathBuf, FileChange>,
    cwd: PathBuf,
    lines_cache: Mutex<Option<PatchHistoryCellLinesCache>>,
}

#[derive(Debug)]
struct PatchHistoryCellLinesCache {
    width: u16,
    lines: Vec<Line<'static>>,
}

impl HistoryCell for PatchHistoryCell {
    fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
        if let Ok(cache) = self.lines_cache.lock()
            && let Some(cache) = cache.as_ref()
            && cache.width == width
        {
            return cache.lines.clone();
        }

        let lines = create_diff_summary(&self.changes, &self.cwd, width as usize);
        if let Ok(mut cache) = self.lines_cache.lock() {
            *cache = Some(PatchHistoryCellLinesCache {
                width,
                lines: lines.clone(),
            });
        }
        lines
    }

    fn block_style(&self) -> Option<Style> {
        Some(Style::default())
    }
}

#[derive(Debug)]
struct CompletedMcpToolCallWithImageOutput {
    _image: DynamicImage,
}
impl HistoryCell for CompletedMcpToolCallWithImageOutput {
    fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
        vec!["tool result (image output)".into()]
    }
}

pub(crate) const SESSION_HEADER_MAX_INNER_WIDTH: usize = 56; // Just an eyeballed value

pub(crate) fn card_inner_width(width: u16, max_inner_width: usize) -> Option<usize> {
    if width < 4 {
        return None;
    }
    let inner_width = std::cmp::min(width.saturating_sub(4) as usize, max_inner_width);
    Some(inner_width)
}

/// Render `lines` inside a border whose inner width is at least `inner_width`.
///
/// This is useful when callers have already clamped their content to a
/// specific width and want the border math centralized here instead of
/// duplicating padding logic in the TUI widgets themselves.
pub(crate) fn with_border_with_inner_width(
    lines: Vec<Line<'static>>,
    inner_width: usize,
) -> Vec<Line<'static>> {
    with_border_internal(lines, Some(inner_width))
}

fn with_border_internal(
    lines: Vec<Line<'static>>,
    forced_inner_width: Option<usize>,
) -> Vec<Line<'static>> {
    let max_line_width = lines
        .iter()
        .map(|line| {
            line.iter()
                .map(|span| UnicodeWidthStr::width(span.content.as_ref()))
                .sum::<usize>()
        })
        .max()
        .unwrap_or(0);
    let content_width = forced_inner_width
        .unwrap_or(max_line_width)
        .max(max_line_width);

    let mut out = Vec::with_capacity(lines.len() + 2);
    let border_inner_width = content_width + 2;
    out.push(vec![format!("╭{}╮", "─".repeat(border_inner_width)).dim()].into());

    for line in lines.into_iter() {
        let used_width: usize = line
            .iter()
            .map(|span| UnicodeWidthStr::width(span.content.as_ref()))
            .sum();
        let span_count = line.spans.len();
        let mut spans: Vec<Span<'static>> = Vec::with_capacity(span_count + 4);
        spans.push(Span::from("│ ").dim());
        spans.extend(line.into_iter());
        if used_width < content_width {
            spans.push(Span::from(" ".repeat(content_width - used_width)).dim());
        }
        spans.push(Span::from(" │").dim());
        out.push(Line::from(spans));
    }

    out.push(vec![format!("╰{}╯", "─".repeat(border_inner_width)).dim()].into());

    out
}

/// Return the emoji followed by a hair space (U+200A).
/// Using only the hair space avoids excessive padding after the emoji while
/// still providing a small visual gap across terminals.
pub(crate) fn padded_emoji(emoji: &str) -> String {
    format!("{emoji}\u{200A}")
}

#[derive(Debug)]
struct TooltipHistoryCell {
    tip: String,
}

impl TooltipHistoryCell {
    fn new(tip: String) -> Self {
        Self { tip }
    }
}

impl HistoryCell for TooltipHistoryCell {
    fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
        let indent = "  ";
        let indent_width = UnicodeWidthStr::width(indent);
        let wrap_width = usize::from(width.max(1))
            .saturating_sub(indent_width)
            .max(1);
        let mut lines: Vec<Line<'static>> = Vec::new();
        append_markdown(
            &format!("**Tip:** {}", self.tip),
            Some(wrap_width),
            &mut lines,
        );

        prefix_lines(lines, indent.into(), indent.into())
    }
}

#[derive(Debug)]
pub struct SessionInfoCell(CompositeHistoryCell);

impl HistoryCell for SessionInfoCell {
    fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
        self.0.display_lines(width)
    }

    fn desired_height(&self, width: u16) -> u16 {
        self.0.desired_height(width)
    }

    fn transcript_lines(&self, width: u16) -> Vec<Line<'static>> {
        self.0.transcript_lines(width)
    }
}

pub(crate) fn new_session_info(
    config: &Config,
    requested_model: &str,
    event: SessionConfiguredEvent,
    is_first_event: bool,
) -> SessionInfoCell {
    let SessionConfiguredEvent {
        model,
        reasoning_effort,
        ..
    } = event;
    // Header box rendered as history (so it appears at the very top)
    let header = SessionHeaderHistoryCell::new(
        model.clone(),
        reasoning_effort,
        config.cwd.clone(),
        CODEX_CLI_VERSION,
    );
    let mut parts: Vec<Box<dyn HistoryCell>> = vec![Box::new(header)];

    if is_first_event {
        // Help lines below the header (new copy and list)
        let help_lines: Vec<Line<'static>> = vec![
            "  To get started, describe a task or try one of these commands:"
                .dim()
                .into(),
            Line::from(""),
            Line::from(vec![
                "  ".into(),
                "/init".into(),
                " - create an AGENTS.md file with instructions for LHA".dim(),
            ]),
            Line::from(vec![
                "  ".into(),
                "/status".into(),
                " - show current session configuration".dim(),
            ]),
            Line::from(vec![
                "  ".into(),
                "/permissions".into(),
                " - choose what LHA is allowed to do".dim(),
            ]),
            Line::from(vec![
                "  ".into(),
                "/model".into(),
                " - choose what model and reasoning effort to use".dim(),
            ]),
            Line::from(vec![
                "  ".into(),
                "/review".into(),
                " - review any changes and find issues".dim(),
            ]),
        ];

        parts.push(Box::new(PlainHistoryCell { lines: help_lines }));
    } else {
        if config.show_tooltips
            && let Some(tooltips) = tooltips::random_tooltip().map(TooltipHistoryCell::new)
        {
            parts.push(Box::new(tooltips));
        }
        if requested_model != model {
            let lines = vec![
                "model changed:".magenta().bold().into(),
                format!("requested: {requested_model}").into(),
                format!("used: {model}").into(),
            ];
            parts.push(Box::new(PlainHistoryCell { lines }));
        }
    }

    SessionInfoCell(CompositeHistoryCell { parts })
}

pub(crate) fn new_user_prompt(
    message: String,
    text_elements: Vec<TextElement>,
    local_image_paths: Vec<PathBuf>,
) -> UserHistoryCell {
    UserHistoryCell {
        message,
        text_elements,
        local_image_paths,
    }
}

#[derive(Debug)]
pub(crate) struct SessionHeaderHistoryCell {
    version: &'static str,
    model: String,
    model_style: Style,
    reasoning_effort: Option<ReasoningEffortConfig>,
    directory: PathBuf,
}

impl SessionHeaderHistoryCell {
    pub(crate) fn new(
        model: String,
        reasoning_effort: Option<ReasoningEffortConfig>,
        directory: PathBuf,
        version: &'static str,
    ) -> Self {
        Self::new_with_style(
            model,
            Style::default(),
            reasoning_effort,
            directory,
            version,
        )
    }

    pub(crate) fn new_with_style(
        model: String,
        model_style: Style,
        reasoning_effort: Option<ReasoningEffortConfig>,
        directory: PathBuf,
        version: &'static str,
    ) -> Self {
        Self {
            version,
            model,
            model_style,
            reasoning_effort,
            directory,
        }
    }

    fn format_directory(&self, max_width: Option<usize>) -> String {
        Self::format_directory_inner(&self.directory, max_width)
    }

    fn format_directory_inner(directory: &Path, max_width: Option<usize>) -> String {
        let formatted = if let Some(rel) = relativize_to_home(directory) {
            if rel.as_os_str().is_empty() {
                "~".to_string()
            } else {
                format!("~{}{}", std::path::MAIN_SEPARATOR, rel.display())
            }
        } else {
            directory.display().to_string()
        };

        if let Some(max_width) = max_width {
            if max_width == 0 {
                return String::new();
            }
            if UnicodeWidthStr::width(formatted.as_str()) > max_width {
                return crate::product::tui_app::text_formatting::center_truncate_path(
                    &formatted, max_width,
                );
            }
        }

        formatted
    }

    fn reasoning_label(&self) -> Option<&'static str> {
        self.reasoning_effort.map(|effort| match effort {
            ReasoningEffortConfig::Minimal => "minimal",
            ReasoningEffortConfig::Low => "low",
            ReasoningEffortConfig::Medium => "medium",
            ReasoningEffortConfig::High => "high",
            ReasoningEffortConfig::XHigh => "xhigh",
            ReasoningEffortConfig::None => "none",
        })
    }
}

impl HistoryCell for SessionHeaderHistoryCell {
    fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
        let Some(inner_width) = card_inner_width(width, SESSION_HEADER_MAX_INNER_WIDTH) else {
            return Vec::new();
        };

        let make_row = |spans: Vec<Span<'static>>| Line::from(spans);

        // Title line rendered inside the box: ">_ Long-Horizon Agent (vX)"
        let title_line = make_row(vec![
            Span::from(">_ ").dim(),
            Span::from("Long-Horizon Agent").bold(),
            Span::from(" ").dim(),
            Span::from(format!("(v{})", self.version)).dim(),
        ]);
        let title_line = truncate_line_to_width(title_line, inner_width);

        const CHANGE_MODEL_HINT_COMMAND: &str = "/model";
        const CHANGE_MODEL_HINT_EXPLANATION: &str = " to change";
        const DIR_LABEL: &str = "directory:";
        let label_width = DIR_LABEL.len();

        let model_label = format!(
            "{model_label:<label_width$}",
            model_label = "model:",
            label_width = label_width
        );
        let reasoning_label = self.reasoning_label();
        let model_spans: Vec<Span<'static>> = {
            let mut spans = vec![
                Span::from(format!("{model_label} ")).dim(),
                Span::styled(self.model.clone(), self.model_style),
            ];
            if let Some(reasoning) = reasoning_label {
                spans.push(Span::from(" "));
                spans.push(Span::from(reasoning));
            }
            spans.push("   ".dim());
            spans.push(CHANGE_MODEL_HINT_COMMAND.cyan());
            spans.push(CHANGE_MODEL_HINT_EXPLANATION.dim());
            spans
        };

        let dir_label = format!("{DIR_LABEL:<label_width$}");
        let dir_prefix = format!("{dir_label} ");
        let dir_prefix_width = UnicodeWidthStr::width(dir_prefix.as_str());
        let dir_max_width = inner_width.saturating_sub(dir_prefix_width);
        let dir = self.format_directory(Some(dir_max_width));
        let dir_spans = vec![Span::from(dir_prefix).dim(), Span::from(dir)];

        let lines = vec![
            title_line,
            make_row(Vec::new()),
            truncate_line_to_width(make_row(model_spans), inner_width),
            truncate_line_to_width(make_row(dir_spans), inner_width),
        ];

        with_border_with_inner_width(lines, inner_width)
    }
}

#[derive(Debug)]
pub(crate) struct CompositeHistoryCell {
    parts: Vec<Box<dyn HistoryCell>>,
}

impl CompositeHistoryCell {
    pub(crate) fn new(parts: Vec<Box<dyn HistoryCell>>) -> Self {
        Self { parts }
    }
}

impl HistoryCell for CompositeHistoryCell {
    fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
        let mut out: Vec<Line<'static>> = Vec::new();
        let mut first = true;
        for part in &self.parts {
            let mut lines = part.display_lines(width);
            if !lines.is_empty() {
                if !first {
                    out.push(Line::from(""));
                }
                out.append(&mut lines);
                first = false;
            }
        }
        out
    }
}

#[derive(Debug)]
pub(crate) struct McpToolCallCell {
    call_id: String,
    invocation: McpInvocation,
    start_time: Instant,
    duration: Option<Duration>,
    result: Option<Result<crate::product::mcp_types::CallToolResult, String>>,
    animations_enabled: bool,
}

impl McpToolCallCell {
    pub(crate) fn new(
        call_id: String,
        invocation: McpInvocation,
        animations_enabled: bool,
    ) -> Self {
        Self {
            call_id,
            invocation,
            start_time: Instant::now(),
            duration: None,
            result: None,
            animations_enabled,
        }
    }

    pub(crate) fn call_id(&self) -> &str {
        &self.call_id
    }

    pub(crate) fn complete(
        &mut self,
        duration: Duration,
        result: Result<crate::product::mcp_types::CallToolResult, String>,
    ) -> Option<Box<dyn HistoryCell>> {
        let image_cell = try_new_completed_mcp_tool_call_with_image_output(&result)
            .map(|cell| Box::new(cell) as Box<dyn HistoryCell>);
        self.duration = Some(duration);
        self.result = Some(result);
        image_cell
    }

    fn success(&self) -> Option<bool> {
        match self.result.as_ref() {
            Some(Ok(result)) => Some(!result.is_error.unwrap_or(false)),
            Some(Err(_)) => Some(false),
            None => None,
        }
    }

    pub(crate) fn mark_failed(&mut self) {
        let elapsed = self.start_time.elapsed();
        self.duration = Some(elapsed);
        self.result = Some(Err("interrupted".to_string()));
    }

    fn render_content_block(
        block: &crate::product::mcp_types::ContentBlock,
        width: usize,
    ) -> String {
        match block {
            crate::product::mcp_types::ContentBlock::TextContent(text) => {
                format_and_truncate_tool_result(&text.text, TOOL_CALL_MAX_LINES, width)
            }
            crate::product::mcp_types::ContentBlock::ImageContent(_) => {
                "<image content>".to_string()
            }
            crate::product::mcp_types::ContentBlock::AudioContent(_) => {
                "<audio content>".to_string()
            }
            crate::product::mcp_types::ContentBlock::EmbeddedResource(resource) => {
                let uri = match &resource.resource {
                    EmbeddedResourceResource::TextResourceContents(text) => text.uri.clone(),
                    EmbeddedResourceResource::BlobResourceContents(blob) => blob.uri.clone(),
                };
                format!("embedded resource: {uri}")
            }
            crate::product::mcp_types::ContentBlock::ResourceLink(ResourceLink { uri, .. }) => {
                format!("link: {uri}")
            }
        }
    }
}

impl HistoryCell for McpToolCallCell {
    fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
        let mut lines: Vec<Line<'static>> = Vec::new();
        let status = self.success();
        let bullet = match status {
            Some(true) => "•".green().bold(),
            Some(false) => "•".red().bold(),
            None => spinner(Some(self.start_time), self.animations_enabled),
        };
        let header_text = if status.is_some() {
            "Called"
        } else {
            "Calling"
        };

        let invocation_line = line_to_static(&format_mcp_invocation(self.invocation.clone()));
        let mut compact_spans = vec![bullet.clone(), " ".into(), header_text.bold(), " ".into()];
        let mut compact_header = Line::from(compact_spans.clone());
        let reserved = compact_header.width();

        let inline_invocation =
            invocation_line.width() <= (width as usize).saturating_sub(reserved);

        if inline_invocation {
            compact_header.extend(invocation_line.spans.clone());
            lines.push(compact_header);
        } else {
            compact_spans.pop(); // drop trailing space for standalone header
            lines.push(Line::from(compact_spans));

            let opts = RtOptions::new((width as usize).saturating_sub(4))
                .initial_indent("".into())
                .subsequent_indent("    ".into());
            let wrapped = word_wrap_line(&invocation_line, opts);
            let body_lines: Vec<Line<'static>> = wrapped.iter().map(line_to_static).collect();
            lines.extend(prefix_lines(body_lines, "  └ ".dim(), "    ".into()));
        }

        let mut detail_lines: Vec<Line<'static>> = Vec::new();
        // Reserve four columns for the tree prefix ("  └ "/"    ") and ensure the wrapper still has at least one cell to work with.
        let detail_wrap_width = (width as usize).saturating_sub(4).max(1);

        if let Some(result) = &self.result {
            match result {
                Ok(crate::product::mcp_types::CallToolResult { content, .. }) => {
                    if !content.is_empty() {
                        for block in content {
                            let text = Self::render_content_block(block, detail_wrap_width);
                            for segment in text.split('\n') {
                                let line = Line::from(segment.to_string().dim());
                                let wrapped = word_wrap_line(
                                    &line,
                                    RtOptions::new(detail_wrap_width)
                                        .initial_indent("".into())
                                        .subsequent_indent("    ".into()),
                                );
                                detail_lines.extend(wrapped.iter().map(line_to_static));
                            }
                        }
                    }
                }
                Err(err) => {
                    let err_text = format_and_truncate_tool_result(
                        &format!("Error: {err}"),
                        TOOL_CALL_MAX_LINES,
                        width as usize,
                    );
                    let err_line = Line::from(err_text.dim());
                    let wrapped = word_wrap_line(
                        &err_line,
                        RtOptions::new(detail_wrap_width)
                            .initial_indent("".into())
                            .subsequent_indent("    ".into()),
                    );
                    detail_lines.extend(wrapped.iter().map(line_to_static));
                }
            }
        }

        if !detail_lines.is_empty() {
            let initial_prefix: Span<'static> = if inline_invocation {
                "  └ ".dim()
            } else {
                "    ".into()
            };
            lines.extend(prefix_lines(detail_lines, initial_prefix, "    ".into()));
        }

        lines
    }

    fn transcript_animation_tick(&self) -> Option<u64> {
        if !self.animations_enabled || self.result.is_some() {
            return None;
        }
        Some((self.start_time.elapsed().as_millis() / 50) as u64)
    }
}

pub(crate) fn new_active_mcp_tool_call(
    call_id: String,
    invocation: McpInvocation,
    animations_enabled: bool,
) -> McpToolCallCell {
    McpToolCallCell::new(call_id, invocation, animations_enabled)
}

fn web_search_header(completed: bool) -> &'static str {
    if completed {
        "Searched"
    } else {
        "Searching the web"
    }
}

#[derive(Debug)]
pub(crate) struct WebSearchCell {
    call_id: String,
    query: String,
    action: Option<WebSearchAction>,
    start_time: Instant,
    completed: bool,
    animations_enabled: bool,
}

impl WebSearchCell {
    pub(crate) fn new(
        call_id: String,
        query: String,
        action: Option<WebSearchAction>,
        animations_enabled: bool,
    ) -> Self {
        Self {
            call_id,
            query,
            action,
            start_time: Instant::now(),
            completed: false,
            animations_enabled,
        }
    }

    pub(crate) fn call_id(&self) -> &str {
        &self.call_id
    }

    pub(crate) fn update(&mut self, action: WebSearchAction, query: String) {
        self.action = Some(action);
        self.query = query;
    }

    pub(crate) fn complete(&mut self) {
        self.completed = true;
    }
}

impl HistoryCell for WebSearchCell {
    fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
        let bullet = if self.completed {
            "•".dim()
        } else {
            spinner(Some(self.start_time), self.animations_enabled)
        };
        let header = web_search_header(self.completed);
        let detail = web_search_detail(self.action.as_ref(), &self.query);
        let text: Text<'static> = if detail.is_empty() {
            Line::from(vec![header.bold()]).into()
        } else {
            Line::from(vec![header.bold(), " ".into(), detail.into()]).into()
        };
        PrefixedWrappedHistoryCell::new(text, vec![bullet, " ".into()], "  ").display_lines(width)
    }

    fn transcript_animation_tick(&self) -> Option<u64> {
        if !self.animations_enabled || self.completed {
            return None;
        }
        Some((self.start_time.elapsed().as_millis() / 50) as u64)
    }
}

pub(crate) fn new_active_web_search_call(
    call_id: String,
    query: String,
    animations_enabled: bool,
) -> WebSearchCell {
    WebSearchCell::new(call_id, query, None, animations_enabled)
}

pub(crate) fn new_web_search_call(
    call_id: String,
    query: String,
    action: WebSearchAction,
) -> WebSearchCell {
    let mut cell = WebSearchCell::new(call_id, query, Some(action), false);
    cell.complete();
    cell
}

/// Returns an additional history cell if an MCP tool result includes a decodable image.
///
/// This intentionally returns at most one cell: the first image in `CallToolResult.content` that
/// successfully base64-decodes and parses as an image. This is used as a lightweight “image output
/// exists” affordance separate from the main MCP tool call cell.
///
/// Manual testing tip:
/// - Run the rmcp stdio test server (`src/resources/rmcp-client/src/bin/test_stdio_server.rs`) and
///   register it as an MCP server via `lha mcp add`.
/// - Use its `image_scenario` tool with cases like `text_then_image`,
///   `invalid_base64_then_image`, or `invalid_image_bytes_then_image` to ensure this path triggers
///   even when the first block is not a valid image.
fn try_new_completed_mcp_tool_call_with_image_output(
    result: &Result<crate::product::mcp_types::CallToolResult, String>,
) -> Option<CompletedMcpToolCallWithImageOutput> {
    let image = result
        .as_ref()
        .ok()?
        .content
        .iter()
        .find_map(decode_mcp_image)?;

    Some(CompletedMcpToolCallWithImageOutput { _image: image })
}

/// Decodes an MCP `ImageContent` block into an in-memory image.
///
/// Returns `None` when the block is not an image, when base64 decoding fails, when the format
/// cannot be inferred, or when the image decoder rejects the bytes.
fn decode_mcp_image(block: &crate::product::mcp_types::ContentBlock) -> Option<DynamicImage> {
    let image = match block {
        crate::product::mcp_types::ContentBlock::ImageContent(image) => image,
        _ => return None,
    };
    let raw_data = base64::engine::general_purpose::STANDARD
        .decode(&image.data)
        .map_err(|e| {
            error!("Failed to decode image data: {e}");
            e
        })
        .ok()?;
    let reader = ImageReader::new(Cursor::new(raw_data))
        .with_guessed_format()
        .map_err(|e| {
            error!("Failed to guess image format: {e}");
            e
        })
        .ok()?;

    reader
        .decode()
        .map_err(|e| {
            error!("Image decoding failed: {e}");
            e
        })
        .ok()
}

#[allow(clippy::disallowed_methods)]
pub(crate) fn new_warning_event(message: String) -> PrefixedWrappedHistoryCell {
    PrefixedWrappedHistoryCell::new(message.yellow(), "⚠ ".yellow(), "  ")
}

#[derive(Debug)]
pub(crate) struct DeprecationNoticeCell {
    summary: String,
    details: Option<String>,
}

pub(crate) fn new_deprecation_notice(
    summary: String,
    details: Option<String>,
) -> DeprecationNoticeCell {
    DeprecationNoticeCell { summary, details }
}

impl HistoryCell for DeprecationNoticeCell {
    fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
        let mut lines: Vec<Line<'static>> = Vec::new();
        lines.push(vec!["⚠ ".red().bold(), self.summary.clone().red()].into());

        let wrap_width = width.saturating_sub(4).max(1) as usize;

        if let Some(details) = &self.details {
            let line = textwrap::wrap(details, wrap_width)
                .into_iter()
                .map(|s| s.to_string().dim().into())
                .collect::<Vec<_>>();
            lines.extend(line);
        }

        lines
    }
}

/// Render MCP tools grouped by connection using the fully-qualified tool names.
pub(crate) fn new_mcp_tools_output(
    config: &Config,
    tools: HashMap<String, crate::product::mcp_types::Tool>,
    resources: HashMap<String, Vec<Resource>>,
    resource_templates: HashMap<String, Vec<ResourceTemplate>>,
    auth_statuses: &HashMap<String, McpAuthStatus>,
) -> PlainHistoryCell {
    let mut lines: Vec<Line<'static>> = vec![
        "/mcp".magenta().into(),
        "".into(),
        vec!["🔌  ".into(), "MCP Tools".bold()].into(),
        "".into(),
    ];

    if tools.is_empty() {
        lines.push("  • No MCP tools available.".italic().into());
        lines.push("".into());
    }

    let mut servers: Vec<_> = config.mcp_servers.iter().collect();
    servers.sort_by(|(a, _), (b, _)| a.cmp(b));

    for (server, cfg) in servers {
        let prefix = format!("mcp__{server}__");
        let mut names: Vec<String> = tools
            .keys()
            .filter(|k| k.starts_with(&prefix))
            .map(|k| k[prefix.len()..].to_string())
            .collect();
        names.sort();

        let auth_status = auth_statuses
            .get(server.as_str())
            .copied()
            .unwrap_or(McpAuthStatus::Unsupported);
        let mut header: Vec<Span<'static>> = vec!["  • ".into(), server.clone().into()];
        if !cfg.enabled {
            header.push(" ".into());
            header.push("(disabled)".red());
            lines.push(header.into());
            if let Some(reason) = cfg.disabled_reason.as_ref().map(ToString::to_string) {
                lines.push(vec!["    • Reason: ".into(), reason.dim()].into());
            }
            lines.push(Line::from(""));
            continue;
        }
        lines.push(header.into());
        lines.push(vec!["    • Status: ".into(), "enabled".green()].into());
        lines.push(vec!["    • Auth: ".into(), auth_status.to_string().into()].into());

        match &cfg.transport {
            McpServerTransportConfig::Stdio {
                command,
                args,
                env,
                env_vars,
                cwd,
            } => {
                let args_suffix = if args.is_empty() {
                    String::new()
                } else {
                    format!(" {}", args.join(" "))
                };
                let cmd_display = format!("{command}{args_suffix}");
                lines.push(vec!["    • Command: ".into(), cmd_display.into()].into());

                if let Some(cwd) = cwd.as_ref() {
                    lines.push(vec!["    • Cwd: ".into(), cwd.display().to_string().into()].into());
                }

                let env_display = format_env_display(env.as_ref(), env_vars);
                if env_display != "-" {
                    lines.push(vec!["    • Env: ".into(), env_display.into()].into());
                }
            }
            McpServerTransportConfig::StreamableHttp {
                url,
                http_headers,
                env_http_headers,
                ..
            } => {
                lines.push(vec!["    • URL: ".into(), url.clone().into()].into());
                if let Some(headers) = http_headers.as_ref()
                    && !headers.is_empty()
                {
                    let mut pairs: Vec<_> = headers.iter().collect();
                    pairs.sort_by(|(a, _), (b, _)| a.cmp(b));
                    let display = pairs
                        .into_iter()
                        .map(|(name, _)| format!("{name}=*****"))
                        .collect::<Vec<_>>()
                        .join(", ");
                    lines.push(vec!["    • HTTP headers: ".into(), display.into()].into());
                }
                if let Some(headers) = env_http_headers.as_ref()
                    && !headers.is_empty()
                {
                    let mut pairs: Vec<_> = headers.iter().collect();
                    pairs.sort_by(|(a, _), (b, _)| a.cmp(b));
                    let display = pairs
                        .into_iter()
                        .map(|(name, var)| format!("{name}={var}"))
                        .collect::<Vec<_>>()
                        .join(", ");
                    lines.push(vec!["    • Env HTTP headers: ".into(), display.into()].into());
                }
            }
        }

        if names.is_empty() {
            lines.push("    • Tools: (none)".into());
        } else {
            lines.push(vec!["    • Tools: ".into(), names.join(", ").into()].into());
        }

        let server_resources: Vec<Resource> =
            resources.get(server.as_str()).cloned().unwrap_or_default();
        if server_resources.is_empty() {
            lines.push("    • Resources: (none)".into());
        } else {
            let mut spans: Vec<Span<'static>> = vec!["    • Resources: ".into()];

            for (idx, resource) in server_resources.iter().enumerate() {
                if idx > 0 {
                    spans.push(", ".into());
                }

                let label = resource.title.as_ref().unwrap_or(&resource.name);
                spans.push(label.clone().into());
                spans.push(" ".into());
                spans.push(format!("({})", resource.uri).dim());
            }

            lines.push(spans.into());
        }

        let server_templates: Vec<ResourceTemplate> = resource_templates
            .get(server.as_str())
            .cloned()
            .unwrap_or_default();
        if server_templates.is_empty() {
            lines.push("    • Resource templates: (none)".into());
        } else {
            let mut spans: Vec<Span<'static>> = vec!["    • Resource templates: ".into()];

            for (idx, template) in server_templates.iter().enumerate() {
                if idx > 0 {
                    spans.push(", ".into());
                }

                let label = template.title.as_ref().unwrap_or(&template.name);
                spans.push(label.clone().into());
                spans.push(" ".into());
                spans.push(format!("({})", template.uri_template).dim());
            }

            lines.push(spans.into());
        }

        lines.push(Line::from(""));
    }

    PlainHistoryCell { lines }
}
pub(crate) fn new_info_event(message: String, hint: Option<String>) -> PlainHistoryCell {
    let mut line = vec!["• ".dim(), message.into()];
    if let Some(hint) = hint {
        line.push(" ".into());
        line.push(hint.dark_gray());
    }
    let lines: Vec<Line<'static>> = vec![line.into()];
    PlainHistoryCell { lines }
}

#[derive(Debug)]
pub(crate) struct BuddyDetailsCell {
    buddy: Buddy,
    config: TuiBuddy,
}

pub(crate) fn new_buddy_details(buddy: Buddy, config: TuiBuddy) -> BuddyDetailsCell {
    BuddyDetailsCell { buddy, config }
}

impl HistoryCell for BuddyDetailsCell {
    fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
        let color = rarity_color(self.buddy.rarity);
        let inner_width = width.saturating_sub(4).clamp(1, 72);
        let mut content = Vec::new();

        content.push(Line::from(vec![
            "/buddy".bold().cyan(),
            " ".into(),
            identity_label(self.buddy.identity_kind).dim(),
            " buddy".dim(),
        ]));

        let sprite =
            sprites::render_sprite(self.buddy.species, self.buddy.eye, self.buddy.hat, false, 0);
        if inner_width >= 68 {
            let details = detail_lines(&self.buddy, &self.config);
            let rows = sprite.len().max(details.len());
            for index in 0..rows {
                let left = sprite.get(index).map_or("", String::as_str);
                let right = details
                    .get(index)
                    .cloned()
                    .unwrap_or_else(|| Line::from(""));
                let mut spans = vec![
                    Span::from(center_text_to_width(left, 14)).fg(color),
                    "  ".into(),
                ];
                spans.extend(right.spans);
                content.push(Line::from(spans));
            }
        } else {
            for line in sprite {
                content.push(Line::from(line).fg(color));
            }
            content.push(Line::from(""));
            content.extend(detail_lines(&self.buddy, &self.config));
        }

        content.extend(about_lines(&self.buddy, &self.config, inner_width));
        content.push(Line::from(""));
        content.extend(stat_lines(self.buddy.stats, inner_width));
        content.push(Line::from(""));
        content.push(Line::from(vec![
            "Commands: ".dim(),
            "/buddy pet".cyan(),
            " · ".dim(),
            "/buddy hide".cyan(),
            " · ".dim(),
            "/buddy talk on|off".cyan(),
        ]));

        with_border_with_inner_width(content, usize::from(inner_width))
    }
}

fn center_text_to_width(text: &str, width: usize) -> String {
    let text_width = UnicodeWidthStr::width(text);
    if text_width >= width {
        return text.to_string();
    }
    let left_pad = (width - text_width) / 2;
    let right_pad = width - text_width - left_pad;
    format!("{}{}{}", " ".repeat(left_pad), text, " ".repeat(right_pad))
}

fn detail_lines(buddy: &Buddy, config: &TuiBuddy) -> Vec<Line<'static>> {
    vec![
        Line::from(vec![
            buddy.name.clone().bold().fg(rarity_color(buddy.rarity)),
            " the ".dim(),
            buddy.species.to_string().into(),
        ]),
        Line::from(vec![
            "Rarity: ".dim(),
            rarity_stars(buddy.rarity).fg(rarity_color(buddy.rarity)),
            " ".into(),
            buddy.rarity.to_string().into(),
            shiny_suffix(buddy.rarity, buddy.shiny),
        ]),
        Line::from(vec![
            "Eye: ".dim(),
            buddy.eye.to_string().into(),
            "   Hat: ".dim(),
            buddy.hat.to_string().into(),
        ]),
        Line::from(vec![
            "Personality: ".dim(),
            buddy.personality.clone().italic(),
        ]),
        Line::from(vec![
            "Status: ".dim(),
            buddy_visibility_label(config).into(),
            " · ".dim(),
            buddy_talk_label(config).into(),
        ]),
    ]
}

fn buddy_visibility_label(config: &TuiBuddy) -> &'static str {
    if config.muted {
        "muted"
    } else if config.enabled {
        "shown"
    } else {
        "hidden"
    }
}

fn buddy_talk_label(config: &TuiBuddy) -> &'static str {
    if config.observer.enabled {
        "talk on"
    } else {
        "talk off"
    }
}

fn buddy_intro_text(buddy: &Buddy, config: &TuiBuddy) -> String {
    let mut intro = format!(
        "A small {} named {} sits beside your input box and may comment in short speech bubbles.",
        buddy.species, buddy.name
    );
    if !buddy.personality.trim().is_empty() {
        intro.push_str(&format!(
            " {} has a {} temperament.",
            buddy.name, buddy.personality
        ));
    }
    if !config.observer.enabled {
        intro.push_str(" Talk is currently off.");
    }
    intro
}

fn about_lines(buddy: &Buddy, config: &TuiBuddy, width: u16) -> Vec<Line<'static>> {
    let wrap_width = usize::from(width.saturating_sub(7).max(1));
    textwrap::wrap(&buddy_intro_text(buddy, config), wrap_width)
        .into_iter()
        .enumerate()
        .map(|(index, line)| {
            if index == 0 {
                Line::from(vec!["About: ".dim(), line.into_owned().into()])
            } else {
                Line::from(vec!["       ".dim(), line.into_owned().into()])
            }
        })
        .collect()
}

fn shiny_suffix(rarity: BuddyRarity, shiny: bool) -> Span<'static> {
    if shiny {
        Span::from(" shiny").fg(rarity_color(rarity)).bold()
    } else {
        Span::from("")
    }
}

fn stat_lines(stats: BuddyStats, width: u16) -> Vec<Line<'static>> {
    [
        ("DEBUGGING", stats.debugging),
        ("PATIENCE", stats.patience),
        ("CHAOS", stats.chaos),
        ("WISDOM", stats.wisdom),
        ("SNARK", stats.snark),
    ]
    .into_iter()
    .map(|(name, value)| stat_line(name, value, width))
    .collect()
}

fn stat_line(name: &'static str, value: u8, width: u16) -> Line<'static> {
    if width >= 46 {
        let filled = usize::from(value / 10);
        let bar = format!("{}{}", "█".repeat(filled), "░".repeat(10 - filled));
        Line::from(vec![
            format!("{name:<9}").dim(),
            " ".into(),
            bar.cyan(),
            " ".into(),
            format!("{value:>3}").into(),
        ])
    } else {
        Line::from(vec![
            format!("{name:<9}").dim(),
            " ".into(),
            format!("{value:>3}").into(),
        ])
    }
}

fn identity_label(
    identity_kind: crate::product::protocol::config_types::IdentityKind,
) -> &'static str {
    match identity_kind {
        crate::product::protocol::config_types::IdentityKind::Nobody => "nobody",
        crate::product::protocol::config_types::IdentityKind::Planner => "planner",
        crate::product::protocol::config_types::IdentityKind::Programmer => "programmer",
        crate::product::protocol::config_types::IdentityKind::Explorer => "explorer",
        crate::product::protocol::config_types::IdentityKind::Reviewer => "reviewer",
    }
}

pub(crate) fn new_changelog_output(
    entries: Vec<ChangelogEntry>,
    cwd: &Path,
    display_root: &Path,
) -> PlainHistoryCell {
    let mut lines: Vec<Line<'static>> = vec!["Changed files".bold().into()];

    for (kind, title, marker) in [
        (ChangelogKind::Added, "Added", "A"),
        (ChangelogKind::Modified, "Modified", "M"),
        (ChangelogKind::Deleted, "Deleted", "D"),
    ] {
        let grouped: Vec<&ChangelogEntry> =
            entries.iter().filter(|entry| entry.kind == kind).collect();
        if grouped.is_empty() {
            continue;
        }

        lines.push(Line::from(""));
        lines.push(format!("{title} ({})", grouped.len()).bold().into());

        for (index, entry) in grouped.iter().enumerate() {
            let marker = match kind {
                ChangelogKind::Added => marker.green(),
                ChangelogKind::Modified => marker.cyan(),
                ChangelogKind::Deleted => marker.red(),
            };
            let path = format_changelog_path(&entry.path, cwd, display_root);
            let prefix = if index == 0 { "  └ " } else { "    " };
            let mut spans = vec![prefix.into(), marker, " ".dim(), path.dim()];
            if let Some(line_stats) = entry.line_stats {
                spans.push(" ".into());
                spans.extend(render_line_count_summary(
                    line_stats.added,
                    line_stats.removed,
                ));
            }
            lines.push(spans.into());
        }
    }

    PlainHistoryCell { lines }
}

pub(crate) fn new_error_event(message: String) -> PlainHistoryCell {
    // Use a hair space (U+200A) to create a subtle, near-invisible separation
    // before the text. VS16 is intentionally omitted to keep spacing tighter
    // in terminals like Ghostty.
    let lines: Vec<Line<'static>> = vec![vec![format!("■ {message}").red()].into()];
    PlainHistoryCell { lines }
}

/// Render a user‑friendly plan update styled like a checkbox todo list.
pub(crate) fn new_plan_update(update: UpdatePlanArgs) -> PlanUpdateCell {
    let UpdatePlanArgs { explanation, plan } = update;
    PlanUpdateCell { explanation, plan }
}

pub(crate) fn new_proposed_plan(plan_markdown: String) -> ProposedPlanCell {
    ProposedPlanCell { plan_markdown }
}

#[allow(dead_code)]
pub(crate) fn new_proposed_plan_stream(
    lines: Vec<Line<'static>>,
    is_stream_continuation: bool,
) -> ProposedPlanStreamCell {
    ProposedPlanStreamCell {
        content: ProposedPlanStreamContent::RenderedLines(lines),
        is_stream_continuation,
        revision: 0,
        lines_cache: Mutex::new(None),
    }
}

pub(crate) fn is_proposed_plan_cell(cell: &dyn HistoryCell) -> bool {
    cell.as_any().is::<ProposedPlanCell>() || cell.as_any().is::<ProposedPlanStreamCell>()
}

pub(crate) fn style_proposed_plan_body_lines(lines: Vec<Line<'static>>) -> Vec<Line<'static>> {
    let plan_style = proposed_plan_style();
    lines
        .into_iter()
        .map(|line| line.style(plan_style))
        .collect()
}

pub(crate) fn proposed_plan_header_gap_lines() -> Vec<Line<'static>> {
    vec![
        Line::default(),
        Line::default().style(proposed_plan_style()),
    ]
}

pub(crate) fn proposed_plan_trailing_body_gap_line() -> Line<'static> {
    Line::default().style(proposed_plan_style())
}

pub(crate) fn prefix_proposed_plan_body_lines(lines: Vec<Line<'static>>) -> Vec<Line<'static>> {
    lines
        .into_iter()
        .map(|line| {
            if line
                .spans
                .iter()
                .all(|span| span.content.chars().all(char::is_whitespace))
            {
                Line::default().style(line.style)
            } else {
                let mut spans = Vec::with_capacity(line.spans.len() + 1);
                spans.push("  ".into());
                spans.extend(line.spans);
                Line::from(spans).style(line.style)
            }
        })
        .collect()
}

#[derive(Debug)]
pub(crate) struct ProposedPlanCell {
    plan_markdown: String,
}

#[derive(Debug)]
pub(crate) struct ProposedPlanStreamCell {
    content: ProposedPlanStreamContent,
    is_stream_continuation: bool,
    revision: u64,
    lines_cache: Mutex<Option<ProposedPlanStreamLinesCache>>,
}

#[derive(Debug)]
enum ProposedPlanStreamContent {
    #[allow(dead_code)]
    RenderedLines(Vec<Line<'static>>),
    Markdown {
        source: String,
        visible_rendered_lines: Option<usize>,
        include_trailing_gap: bool,
    },
}

#[derive(Debug, Clone)]
struct ProposedPlanStreamLinesCache {
    width: u16,
    revision: u64,
    visible_rendered_lines: Option<usize>,
    include_trailing_gap: bool,
    lines: Vec<Line<'static>>,
}

impl HistoryCell for ProposedPlanCell {
    fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
        let mut lines: Vec<Line<'static>> = Vec::new();
        lines.push(vec!["• ".dim(), "Proposed Plan".bold()].into());
        lines.extend(proposed_plan_header_gap_lines());

        let wrap_width = width.saturating_sub(4).max(1) as usize;
        let mut body: Vec<Line<'static>> = Vec::new();
        append_markdown(&self.plan_markdown, Some(wrap_width), &mut body);
        if body.is_empty() {
            body.push(Line::from("(empty)".dim().italic()));
        }

        let plan_lines = prefix_proposed_plan_body_lines(body);
        lines.extend(style_proposed_plan_body_lines(plan_lines));
        lines.push(proposed_plan_trailing_body_gap_line());
        lines
    }

    fn fill_line_backgrounds(&self) -> bool {
        true
    }

    fn force_transcript_separator_after(&self) -> bool {
        true
    }
}

impl ProposedPlanStreamCell {
    pub(crate) fn new_streaming_markdown() -> Self {
        Self {
            content: ProposedPlanStreamContent::Markdown {
                source: String::new(),
                visible_rendered_lines: Some(0),
                include_trailing_gap: false,
            },
            is_stream_continuation: false,
            revision: 0,
            lines_cache: Mutex::new(None),
        }
    }

    pub(crate) fn is_streaming_markdown(&self) -> bool {
        matches!(
            &self.content,
            ProposedPlanStreamContent::Markdown {
                visible_rendered_lines: Some(_),
                ..
            }
        )
    }

    pub(crate) fn set_markdown_stream_state(
        &mut self,
        source: String,
        visible_lines: usize,
    ) -> bool {
        let ProposedPlanStreamContent::Markdown {
            source: current_source,
            visible_rendered_lines: current_visible_lines,
            include_trailing_gap,
        } = &mut self.content
        else {
            return false;
        };

        let changed = *current_source != source
            || *current_visible_lines != Some(visible_lines)
            || *include_trailing_gap;
        if changed {
            *current_source = source;
            *current_visible_lines = Some(visible_lines);
            *include_trailing_gap = false;
            self.revision = self.revision.wrapping_add(1);
            self.clear_lines_cache();
        }
        changed
    }

    pub(crate) fn set_visible_rendered_lines(&mut self, visible_lines: usize) -> bool {
        let ProposedPlanStreamContent::Markdown {
            visible_rendered_lines,
            include_trailing_gap,
            ..
        } = &mut self.content
        else {
            return false;
        };

        let changed = *visible_rendered_lines != Some(visible_lines) || *include_trailing_gap;
        if !changed {
            return false;
        }

        *visible_rendered_lines = Some(visible_lines);
        *include_trailing_gap = false;
        self.revision = self.revision.wrapping_add(1);
        self.clear_lines_cache();
        true
    }

    pub(crate) fn show_all_markdown(&mut self, source: String) -> bool {
        let ProposedPlanStreamContent::Markdown {
            source: current_source,
            visible_rendered_lines,
            include_trailing_gap,
        } = &mut self.content
        else {
            return false;
        };

        let changed =
            *current_source != source || visible_rendered_lines.is_some() || !*include_trailing_gap;
        if changed {
            *current_source = source;
            *visible_rendered_lines = None;
            *include_trailing_gap = true;
            self.revision = self.revision.wrapping_add(1);
            self.clear_lines_cache();
        }
        changed
    }

    fn clear_lines_cache(&self) {
        if let Ok(mut cache) = self.lines_cache.lock() {
            *cache = None;
        }
    }

    fn markdown_lines(
        &self,
        source: &str,
        visible_rendered_lines: Option<usize>,
        include_trailing_gap: bool,
        width: u16,
    ) -> Vec<Line<'static>> {
        if let Ok(cache) = self.lines_cache.lock()
            && let Some(cache) = cache.as_ref()
            && cache.width == width
            && cache.revision == self.revision
            && cache.visible_rendered_lines == visible_rendered_lines
            && cache.include_trailing_gap == include_trailing_gap
        {
            return cache.lines.clone();
        }

        let mut source = source.to_string();
        if !source.ends_with('\n') {
            source.push('\n');
        }

        let mut lines: Vec<Line<'static>> = Vec::new();
        lines.push(vec!["• ".dim(), "Proposed Plan".bold()].into());
        lines.extend(proposed_plan_header_gap_lines());

        let wrap_width = width.saturating_sub(4).max(1) as usize;
        let mut body: Vec<Line<'static>> = Vec::new();
        append_markdown(&source, Some(wrap_width), &mut body);
        if let Some(visible_rendered_lines) = visible_rendered_lines {
            body.truncate(visible_rendered_lines);
        }

        let plan_lines = prefix_proposed_plan_body_lines(body);
        lines.extend(style_proposed_plan_body_lines(plan_lines));
        if include_trailing_gap {
            lines.push(proposed_plan_trailing_body_gap_line());
        }

        if let Ok(mut cache) = self.lines_cache.lock() {
            *cache = Some(ProposedPlanStreamLinesCache {
                width,
                revision: self.revision,
                visible_rendered_lines,
                include_trailing_gap,
                lines: lines.clone(),
            });
        }

        lines
    }
}

impl HistoryCell for ProposedPlanStreamCell {
    fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
        match &self.content {
            ProposedPlanStreamContent::RenderedLines(lines) => lines.clone(),
            ProposedPlanStreamContent::Markdown {
                source,
                visible_rendered_lines,
                include_trailing_gap,
            } => self.markdown_lines(
                source,
                *visible_rendered_lines,
                *include_trailing_gap,
                width,
            ),
        }
    }

    fn fill_line_backgrounds(&self) -> bool {
        true
    }

    fn is_stream_continuation(&self) -> bool {
        self.is_stream_continuation
    }

    fn force_transcript_separator_after(&self) -> bool {
        true
    }
}

#[derive(Debug)]
pub(crate) struct PlanUpdateCell {
    explanation: Option<String>,
    plan: Vec<PlanItemArg>,
}

impl HistoryCell for PlanUpdateCell {
    fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
        let render_note = |text: &str| -> Vec<Line<'static>> {
            let wrap_width = width.saturating_sub(4).max(1) as usize;
            textwrap::wrap(text, wrap_width)
                .into_iter()
                .map(|s| s.to_string().dim().italic().into())
                .collect()
        };

        let render_step = |status: &StepStatus, text: &str| -> Vec<Line<'static>> {
            let (box_str, step_style) = match status {
                StepStatus::Completed => ("✔ ", Style::default().crossed_out().dim()),
                StepStatus::InProgress => ("□ ", Style::default().cyan().bold()),
                StepStatus::Pending => ("□ ", Style::default().dim()),
            };
            let wrap_width = (width as usize)
                .saturating_sub(4)
                .saturating_sub(box_str.width())
                .max(1);
            let parts = textwrap::wrap(text, wrap_width);
            let step_text = parts
                .into_iter()
                .map(|s| s.to_string().set_style(step_style).into())
                .collect();
            prefix_lines(step_text, box_str.into(), "  ".into())
        };

        let mut lines: Vec<Line<'static>> = vec![Line::default()];
        lines.push(vec!["• ".dim(), "Updated Plan".bold()].into());

        let mut indented_lines = vec![];
        let note = self
            .explanation
            .as_ref()
            .map(|s| s.trim())
            .filter(|t| !t.is_empty());
        if let Some(expl) = note {
            indented_lines.extend(render_note(expl));
        };

        if self.plan.is_empty() {
            indented_lines.push(Line::from("(no steps provided)".dim().italic()));
        } else {
            for PlanItemArg { step, status } in self.plan.iter() {
                indented_lines.extend(render_step(status, step));
            }
        }
        lines.extend(prefix_lines(indented_lines, "  └ ".dim(), "    ".into()));
        lines.push(Line::default());

        lines
    }

    fn block_style(&self) -> Option<Style> {
        Some(proposed_plan_style())
    }

    fn force_transcript_separator_after(&self) -> bool {
        true
    }
}

/// Create a new `PendingPatch` cell that lists the file‑level summary of
/// a proposed patch. The summary lines should already be formatted (e.g.
/// "A path/to/file.rs").
pub(crate) fn new_patch_event(
    changes: HashMap<PathBuf, FileChange>,
    cwd: &Path,
) -> PatchHistoryCell {
    PatchHistoryCell {
        changes,
        cwd: cwd.to_path_buf(),
        lines_cache: Mutex::new(None),
    }
}

pub(crate) fn new_patch_apply_failure(stderr: String) -> PlainHistoryCell {
    let mut lines: Vec<Line<'static>> = Vec::new();

    // Failure title
    lines.push(Line::from("✘ Failed to apply patch".magenta().bold()));

    if !stderr.trim().is_empty() {
        let output = output_lines(
            Some(&CommandOutput {
                exit_code: 1,
                formatted_output: String::new(),
                aggregated_output: stderr,
            }),
            OutputLinesParams {
                line_limit: TOOL_CALL_MAX_LINES,
                only_err: true,
                include_angle_pipe: true,
                include_prefix: true,
            },
        );
        lines.extend(output.lines);
    }

    PlainHistoryCell { lines }
}

pub(crate) fn new_view_image_tool_call(path: PathBuf, cwd: &Path) -> PlainHistoryCell {
    let display_path = display_path_for(&path, cwd);

    let lines: Vec<Line<'static>> = vec![
        vec!["• ".dim(), "Viewed Image".bold()].into(),
        vec!["  └ ".dim(), display_path.dim()].into(),
    ];

    PlainHistoryCell { lines }
}

pub(crate) fn new_reasoning_summary_block(full_reasoning_buffer: String) -> Box<dyn HistoryCell> {
    new_reasoning_summary_block_with_visibility(full_reasoning_buffer, true)
}

pub(crate) fn new_reasoning_summary_block_transcript_only(
    full_reasoning_buffer: String,
) -> Box<dyn HistoryCell> {
    new_reasoning_summary_block_with_visibility(full_reasoning_buffer, false)
}

fn new_reasoning_summary_block_with_visibility(
    full_reasoning_buffer: String,
    visible_when_summary_present: bool,
) -> Box<dyn HistoryCell> {
    let full_reasoning_buffer = full_reasoning_buffer.trim();
    if let Some(open) = full_reasoning_buffer.find("**") {
        let after_open = &full_reasoning_buffer[(open + 2)..];
        if let Some(close) = after_open.find("**") {
            let after_close_idx = open + 2 + close + 2;
            // if we don't have anything beyond `after_close_idx`
            // then we don't have a summary to inject into history
            if after_close_idx < full_reasoning_buffer.len() {
                let header_buffer = full_reasoning_buffer[..after_close_idx].to_string();
                let summary_buffer = full_reasoning_buffer[after_close_idx..].to_string();
                return Box::new(ReasoningSummaryCell::new(
                    header_buffer,
                    summary_buffer,
                    !visible_when_summary_present,
                ));
            }
        }
    }
    Box::new(ReasoningSummaryCell::new(
        "".to_string(),
        full_reasoning_buffer.to_string(),
        true,
    ))
}

#[derive(Debug)]
/// A visual divider between turns, optionally showing how long the assistant "worked for".
///
/// This separator is only emitted for turns that performed concrete work (e.g., running commands,
/// applying patches, making MCP tool calls), so purely conversational turns do not show an empty
/// divider.
pub struct FinalMessageSeparator {
    elapsed_seconds: Option<u64>,
    runtime_metrics: Option<RuntimeMetricsSummary>,
}
impl FinalMessageSeparator {
    /// Creates a separator; `elapsed_seconds` typically comes from the status indicator timer.
    pub(crate) fn new(
        elapsed_seconds: Option<u64>,
        runtime_metrics: Option<RuntimeMetricsSummary>,
    ) -> Self {
        Self {
            elapsed_seconds,
            runtime_metrics,
        }
    }
}
impl HistoryCell for FinalMessageSeparator {
    fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
        let mut label_parts = Vec::new();
        if let Some(elapsed_seconds) = self
            .elapsed_seconds
            .map(super::status_indicator_widget::fmt_elapsed_compact)
        {
            label_parts.push(format!("Worked for {elapsed_seconds}"));
        }
        if let Some(metrics_label) = self.runtime_metrics.and_then(runtime_metrics_label) {
            label_parts.push(metrics_label);
        }

        if label_parts.is_empty() {
            return vec![Line::from_iter(["─".repeat(width as usize).dim()])];
        }

        let label = format!("─ {} ─", label_parts.join(" • "));
        let (label, _suffix, label_width) = take_prefix_by_width(&label, width as usize);
        vec![
            Line::from_iter([
                label,
                "─".repeat((width as usize).saturating_sub(label_width)),
            ])
            .dim(),
        ]
    }
}

fn runtime_metrics_label(summary: RuntimeMetricsSummary) -> Option<String> {
    let mut parts = Vec::new();
    if summary.tool_calls.count > 0 {
        let duration = format_duration_ms(summary.tool_calls.duration_ms);
        let calls = pluralize(summary.tool_calls.count, "call", "calls");
        parts.push(format!(
            "Local tools: {} {calls} ({duration})",
            summary.tool_calls.count
        ));
    }
    if summary.api_calls.count > 0 {
        let duration = format_duration_ms(summary.api_calls.duration_ms);
        let calls = pluralize(summary.api_calls.count, "call", "calls");
        parts.push(format!(
            "Inference: {} {calls} ({duration})",
            summary.api_calls.count
        ));
    }
    if summary.websocket_calls.count > 0 {
        let duration = format_duration_ms(summary.websocket_calls.duration_ms);
        parts.push(format!(
            "WebSocket: {} events send ({duration})",
            summary.websocket_calls.count
        ));
    }
    if summary.streaming_events.count > 0 {
        let duration = format_duration_ms(summary.streaming_events.duration_ms);
        let stream_label = pluralize(summary.streaming_events.count, "Stream", "Streams");
        let events = pluralize(summary.streaming_events.count, "event", "events");
        parts.push(format!(
            "{stream_label}: {} {events} ({duration})",
            summary.streaming_events.count
        ));
    }
    if summary.websocket_events.count > 0 {
        let duration = format_duration_ms(summary.websocket_events.duration_ms);
        parts.push(format!(
            "{} events received ({duration})",
            summary.websocket_events.count
        ));
    }
    if parts.is_empty() {
        None
    } else {
        Some(parts.join(" • "))
    }
}

fn format_duration_ms(duration_ms: u64) -> String {
    if duration_ms >= 1_000 {
        let seconds = duration_ms as f64 / 1_000.0;
        format!("{seconds:.1}s")
    } else {
        format!("{duration_ms}ms")
    }
}

fn pluralize(count: u64, singular: &'static str, plural: &'static str) -> &'static str {
    if count == 1 { singular } else { plural }
}

fn format_mcp_invocation<'a>(invocation: McpInvocation) -> Line<'a> {
    let args_str = invocation
        .arguments
        .as_ref()
        .map(|v: &serde_json::Value| {
            // Use compact form to keep things short but readable.
            serde_json::to_string(v).unwrap_or_else(|_| v.to_string())
        })
        .unwrap_or_default();

    let invocation_spans = vec![
        invocation.server.clone().cyan(),
        ".".into(),
        invocation.tool.cyan(),
        "(".into(),
        args_str.dim(),
        ")".into(),
    ];
    invocation_spans.into()
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::product::agent::config::Config;
    use crate::product::agent::config::ConfigBuilder;
    use crate::product::agent::config::types::McpServerConfig;
    use crate::product::agent::config::types::McpServerTransportConfig;
    use crate::product::agent::protocol::McpAuthStatus;
    use crate::product::otel::RuntimeMetricTotals;
    use crate::product::otel::RuntimeMetricsSummary;
    use crate::product::protocol::models::WebSearchAction;
    use crate::product::protocol::parse_command::ParsedCommand;
    use crate::product::tui_app::exec_cell::CommandOutput;
    use crate::product::tui_app::exec_cell::ExecCall;
    use crate::product::tui_app::exec_cell::ExecCell;
    use dirs::home_dir;
    use pretty_assertions::assert_eq;
    use serde_json::json;
    use std::collections::HashMap;

    use crate::product::agent::protocol::ExecCommandSource;
    use crate::product::mcp_types::CallToolResult;
    use crate::product::mcp_types::ContentBlock;
    use crate::product::mcp_types::ImageContent;
    use crate::product::mcp_types::TextContent;
    use crate::product::mcp_types::Tool;
    use crate::product::mcp_types::ToolInputSchema;

    const SMALL_PNG_BASE64: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==";
    async fn test_config() -> Config {
        let lha_home = std::env::temp_dir();
        ConfigBuilder::default()
            .lha_home(lha_home.clone())
            .build()
            .await
            .expect("config")
    }

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

    fn compact_text(text: &str) -> String {
        text.chars().filter(|c| !c.is_whitespace()).collect()
    }

    fn render_transcript(cell: &dyn HistoryCell) -> Vec<String> {
        render_lines(&cell.transcript_lines(u16::MAX))
    }

    fn assert_cell_background_fills_text_row(cell: Box<dyn HistoryCell>, width: u16, text: &str) {
        let Some(bg) = proposed_plan_style().bg else {
            return;
        };
        let height = cell.desired_height(width);
        let mut buf = Buffer::empty(Rect::new(0, 0, width, height));
        cell.render(buf.area, &mut buf);

        let row = (0..height)
            .find(|y| {
                (0..width)
                    .map(|x| buf[(x, *y)].symbol())
                    .collect::<String>()
                    .contains(text)
            })
            .expect("expected rendered proposed plan row");

        for x in 0..width {
            assert_eq!(
                buf[(x, row)].style().bg,
                Some(bg),
                "expected proposed plan background at x={x}, y={row}"
            );
        }
    }

    #[test]
    fn changelog_output_groups_entries_and_formats_paths() {
        let home = home_dir().expect("home directory");
        let cwd = Path::new("/repo/project");
        let repo_root = Path::new("/repo/project");
        let lines = new_changelog_output(
            vec![
                ChangelogEntry {
                    kind: ChangelogKind::Added,
                    path: PathBuf::from("/repo/project/src/lib.rs"),
                    line_stats: Some(crate::product::tui_app::changelog::LineStats {
                        added: 10,
                        removed: 0,
                    }),
                },
                ChangelogEntry {
                    kind: ChangelogKind::Added,
                    path: PathBuf::from("/repo/project/src/main.rs"),
                    line_stats: None,
                },
                ChangelogEntry {
                    kind: ChangelogKind::Modified,
                    path: home.join("notes/ideas.md"),
                    line_stats: Some(crate::product::tui_app::changelog::LineStats {
                        added: 4,
                        removed: 2,
                    }),
                },
                ChangelogEntry {
                    kind: ChangelogKind::Modified,
                    path: home.join("notes/todo.md"),
                    line_stats: None,
                },
                ChangelogEntry {
                    kind: ChangelogKind::Deleted,
                    path: PathBuf::from("/opt/shared/legacy.txt"),
                    line_stats: Some(crate::product::tui_app::changelog::LineStats {
                        added: 0,
                        removed: 7,
                    }),
                },
                ChangelogEntry {
                    kind: ChangelogKind::Deleted,
                    path: PathBuf::from("/opt/shared/old.txt"),
                    line_stats: None,
                },
            ],
            cwd,
            repo_root,
        )
        .display_lines(80);

        assert_eq!(
            render_lines(&lines),
            vec![
                "Changed files".to_string(),
                "".to_string(),
                "Added (2)".to_string(),
                "  └ A ./src/lib.rs (+10 -0)".to_string(),
                "    A ./src/main.rs".to_string(),
                "".to_string(),
                "Modified (2)".to_string(),
                "  └ M ~/notes/ideas.md (+4 -2)".to_string(),
                "    M ~/notes/todo.md".to_string(),
                "".to_string(),
                "Deleted (2)".to_string(),
                "  └ D /opt/shared/legacy.txt (+0 -7)".to_string(),
                "    D /opt/shared/old.txt".to_string(),
            ]
        );
    }

    #[test]
    fn changelog_output_uses_repo_relative_paths_outside_cwd() {
        let cwd = Path::new("/repo/project/app");
        let repo_root = Path::new("/repo/project");
        let lines = new_changelog_output(
            vec![ChangelogEntry {
                kind: ChangelogKind::Modified,
                path: PathBuf::from("/repo/project/shared/lib.rs"),
                line_stats: None,
            }],
            cwd,
            repo_root,
        )
        .display_lines(80);

        assert_eq!(
            render_lines(&lines),
            vec![
                "Changed files".to_string(),
                "".to_string(),
                "Modified (1)".to_string(),
                "  └ M ../shared/lib.rs".to_string(),
            ]
        );
    }

    fn image_block(data: &str) -> ContentBlock {
        ContentBlock::ImageContent(ImageContent {
            annotations: None,
            data: data.to_string(),
            mime_type: "image/png".into(),
            r#type: "image".into(),
        })
    }

    #[test]
    fn unified_exec_interaction_cell_renders_input() {
        let cell =
            new_unified_exec_interaction(Some("echo hello".to_string()), "ls\npwd".to_string());
        let lines = render_transcript(&cell);
        assert_eq!(
            lines,
            vec![
                "↳ Interacted with background terminal · echo hello",
                "  └ ls",
                "    pwd",
            ],
        );
    }

    #[test]
    fn unified_exec_interaction_cell_renders_wait() {
        let cell = new_unified_exec_interaction(None, String::new());
        let lines = render_transcript(&cell);
        assert_eq!(
            lines,
            vec!["↳ Interacted with background terminal", "  └ (waited)"],
        );
    }

    #[test]
    fn final_message_separator_includes_runtime_metrics() {
        let summary = RuntimeMetricsSummary {
            tool_calls: RuntimeMetricTotals {
                count: 3,
                duration_ms: 2_450,
            },
            api_calls: RuntimeMetricTotals {
                count: 2,
                duration_ms: 1_200,
            },
            streaming_events: RuntimeMetricTotals {
                count: 6,
                duration_ms: 900,
            },
            websocket_calls: RuntimeMetricTotals {
                count: 1,
                duration_ms: 700,
            },
            websocket_events: RuntimeMetricTotals {
                count: 4,
                duration_ms: 1_200,
            },
        };
        let cell = FinalMessageSeparator::new(Some(12), Some(summary));
        let rendered = render_lines(&cell.display_lines(200));

        assert_eq!(rendered.len(), 1);
        assert!(rendered[0].contains("Worked for 12s"));
        assert!(rendered[0].contains("Local tools: 3 calls (2.5s)"));
        assert!(rendered[0].contains("Inference: 2 calls (1.2s)"));
        assert!(rendered[0].contains("WebSocket: 1 events send (700ms)"));
        assert!(rendered[0].contains("Streams: 6 events (900ms)"));
        assert!(rendered[0].contains("4 events received (1.2s)"));
    }

    #[test]
    fn ps_output_empty_snapshot() {
        let cell = new_unified_exec_processes_output(Vec::new());
        let rendered = render_lines(&cell.display_lines(60)).join("\n");
        insta::assert_snapshot!(rendered);
    }

    #[test]
    fn ps_output_multiline_snapshot() {
        let cell = new_unified_exec_processes_output(vec![
            UnifiedExecProcessDetails {
                command_display: "echo hello\nand then some extra text".to_string(),
                recent_chunks: vec!["hello".to_string(), "done".to_string()],
            },
            UnifiedExecProcessDetails {
                command_display: "rg \"foo\" src".to_string(),
                recent_chunks: vec!["src/main.rs:12:foo".to_string()],
            },
        ]);
        let rendered = render_lines(&cell.display_lines(40)).join("\n");
        insta::assert_snapshot!(rendered);
    }

    #[test]
    fn ps_output_long_command_snapshot() {
        let cell = new_unified_exec_processes_output(vec![UnifiedExecProcessDetails {
            command_display: String::from(
                "rg \"foo\" src --glob '**/*.rs' --max-count 1000 --no-ignore --hidden --follow --glob '!target/**'",
            ),
            recent_chunks: vec!["searching...".to_string()],
        }]);
        let rendered = render_lines(&cell.display_lines(36)).join("\n");
        insta::assert_snapshot!(rendered);
    }

    #[test]
    fn ps_output_many_sessions_snapshot() {
        let cell = new_unified_exec_processes_output(
            (0..20)
                .map(|idx| UnifiedExecProcessDetails {
                    command_display: format!("command {idx}"),
                    recent_chunks: Vec::new(),
                })
                .collect(),
        );
        let rendered = render_lines(&cell.display_lines(32)).join("\n");
        insta::assert_snapshot!(rendered);
    }

    #[test]
    fn ps_output_chunk_leading_whitespace_snapshot() {
        let cell = new_unified_exec_processes_output(vec![UnifiedExecProcessDetails {
            command_display: "just fix".to_string(),
            recent_chunks: vec![
                "  indented first".to_string(),
                "    more indented".to_string(),
            ],
        }]);
        let rendered = render_lines(&cell.display_lines(60)).join("\n");
        insta::assert_snapshot!(rendered);
    }

    #[tokio::test]
    async fn mcp_tools_output_masks_sensitive_values() {
        let mut config = test_config().await;
        let mut env = HashMap::new();
        env.insert("TOKEN".to_string(), "secret".to_string());
        let stdio_config = McpServerConfig {
            transport: McpServerTransportConfig::Stdio {
                command: "docs-server".to_string(),
                args: vec![],
                env: Some(env),
                env_vars: vec!["APP_TOKEN".to_string()],
                cwd: None,
            },
            enabled: true,
            disabled_reason: None,
            startup_timeout_sec: None,
            tool_timeout_sec: None,
            enabled_tools: None,
            disabled_tools: None,
            scopes: None,
        };
        let mut servers = config.mcp_servers.get().clone();
        servers.insert("docs".to_string(), stdio_config);

        let mut headers = HashMap::new();
        headers.insert("Authorization".to_string(), "Bearer secret".to_string());
        let mut env_headers = HashMap::new();
        env_headers.insert("X-API-Key".to_string(), "API_KEY_ENV".to_string());
        let http_config = McpServerConfig {
            transport: McpServerTransportConfig::StreamableHttp {
                url: "https://example.com/mcp".to_string(),
                bearer_token_env_var: Some("MCP_TOKEN".to_string()),
                http_headers: Some(headers),
                env_http_headers: Some(env_headers),
            },
            enabled: true,
            disabled_reason: None,
            startup_timeout_sec: None,
            tool_timeout_sec: None,
            enabled_tools: None,
            disabled_tools: None,
            scopes: None,
        };
        servers.insert("http".to_string(), http_config);
        config
            .mcp_servers
            .set(servers)
            .expect("test mcp servers should accept any configuration");

        let mut tools: HashMap<String, Tool> = HashMap::new();
        tools.insert(
            "mcp__docs__list".to_string(),
            Tool {
                annotations: None,
                description: None,
                input_schema: ToolInputSchema {
                    properties: None,
                    required: None,
                    r#type: "object".to_string(),
                },
                name: "list".to_string(),
                output_schema: None,
                title: None,
            },
        );
        tools.insert(
            "mcp__http__ping".to_string(),
            Tool {
                annotations: None,
                description: None,
                input_schema: ToolInputSchema {
                    properties: None,
                    required: None,
                    r#type: "object".to_string(),
                },
                name: "ping".to_string(),
                output_schema: None,
                title: None,
            },
        );

        let auth_statuses: HashMap<String, McpAuthStatus> = HashMap::new();
        let cell = new_mcp_tools_output(
            &config,
            tools,
            HashMap::new(),
            HashMap::new(),
            &auth_statuses,
        );
        let rendered = render_lines(&cell.display_lines(120)).join("\n");

        insta::assert_snapshot!(rendered);
    }

    #[test]
    fn empty_agent_message_cell_transcript() {
        let cell = AgentMessageCell::new(vec![Line::default()], false);
        assert_eq!(cell.transcript_lines(80), vec![Line::default()]);
        assert_eq!(cell.desired_transcript_height(80), 1);
    }

    #[test]
    fn markdown_agent_message_cell_reflows_from_narrow_to_wide() {
        let cell = AgentMessageCell::new_markdown(
            "This assistant answer should flow back into one line when the terminal becomes wide again."
                .to_string(),
            true,
        );

        let narrow = render_lines(&cell.display_lines(32));
        let wide = render_lines(&cell.display_lines(100));

        assert!(narrow.len() > wide.len());
        assert_eq!(
            wide,
            vec![
                "• This assistant answer should flow back into one line when the terminal becomes wide again."
            ]
        );
    }

    #[test]
    fn markdown_agent_message_cell_preserves_list_continuation_indent() {
        let cell = AgentMessageCell::new_markdown(
            "- first second third fourth fifth sixth seventh eighth ninth tenth".to_string(),
            true,
        );

        let rendered = render_lines(&cell.display_lines(32));

        assert!(rendered[0].starts_with("• - first"));
        assert!(
            rendered.iter().skip(1).any(|line| line.starts_with("    ")),
            "expected continuation lines to keep list indentation: {rendered:?}"
        );
    }

    #[test]
    fn markdown_agent_message_cell_preserves_cjk_ascii_order() {
        let source = "不是说本地开发或合 main 绝对不能有 Git 依赖；我说的是：如果这个分支的目标是让 `lha-llm` / `lha-core` / `lha-cli` 进入 crates.io 发布准备状态，就不能让发布后的依赖图依赖 Git 源。\n\n- 如果这个分支合到 `main` 后要被认为是 crates.io 发布就绪，那么现在还不满足发布条件。";
        let cell = AgentMessageCell::new_markdown(source.to_string(), true);

        for width in [32, 40, 48, 56, 72, 100] {
            let rendered = render_lines(&cell.display_lines(width));
            let compact = compact_text(&rendered.join(""));

            assert!(
                compact.contains(&compact_text("Git 依赖；我说的是")),
                "width {width} reordered Git dependency text: {rendered:?}"
            );
            assert!(
                compact.contains(&compact_text("被认为是 crates.io 发布就绪")),
                "width {width} reordered crates.io readiness text: {rendered:?}"
            );
            assert!(
                !compact.contains("依；赖"),
                "width {width} rendered the known CJK punctuation corruption: {rendered:?}"
            );
            assert!(
                !compact.contains("crates是.io"),
                "width {width} rendered the known crates.io corruption: {rendered:?}"
            );
            assert!(
                !compact.contains("crates.io是"),
                "width {width} moved the Chinese predicate after crates.io: {rendered:?}"
            );
        }

        let wide = render_lines(&cell.display_lines(100)).join("\n");
        assert!(wide.contains("Git 依赖；我说的是"));
        assert!(wide.contains("被认为是 crates.io 发布就绪"));
    }

    #[test]
    fn prefixed_wrapped_history_cell_indents_wrapped_lines() {
        let summary = Line::from(vec![
            "You ".into(),
            "approved".bold(),
            " codex to run ".into(),
            "echo something really long to ensure wrapping happens".dim(),
            " this time".bold(),
        ]);
        let cell = PrefixedWrappedHistoryCell::new(summary, "✔ ".green(), "  ");
        let rendered = render_lines(&cell.display_lines(24));
        assert_eq!(
            rendered,
            vec![
                "✔ You approved codex to".to_string(),
                "  run echo something".to_string(),
                "  really long to ensure".to_string(),
                "  wrapping happens this".to_string(),
                "  time".to_string(),
            ]
        );
    }

    #[test]
    fn web_search_history_cell_snapshot() {
        let query =
            "example search query with several generic words to exercise wrapping".to_string();
        let cell = new_web_search_call(
            "call-1".to_string(),
            query.clone(),
            WebSearchAction::Search {
                query: Some(query),
                queries: None,
            },
        );
        let rendered = render_lines(&cell.display_lines(64)).join("\n");

        insta::assert_snapshot!(rendered);
    }

    #[test]
    fn web_search_history_cell_wraps_with_indented_continuation() {
        let query =
            "example search query with several generic words to exercise wrapping".to_string();
        let cell = new_web_search_call(
            "call-1".to_string(),
            query.clone(),
            WebSearchAction::Search {
                query: Some(query),
                queries: None,
            },
        );
        let rendered = render_lines(&cell.display_lines(64));

        assert_eq!(
            rendered,
            vec![
                "• Searched example search query with several generic words to".to_string(),
                "  exercise wrapping".to_string(),
            ]
        );
    }

    #[test]
    fn web_search_history_cell_short_query_does_not_wrap() {
        let query = "short query".to_string();
        let cell = new_web_search_call(
            "call-1".to_string(),
            query.clone(),
            WebSearchAction::Search {
                query: Some(query),
                queries: None,
            },
        );
        let rendered = render_lines(&cell.display_lines(64));

        assert_eq!(rendered, vec!["• Searched short query".to_string()]);
    }

    #[test]
    fn web_search_history_cell_transcript_snapshot() {
        let query =
            "example search query with several generic words to exercise wrapping".to_string();
        let cell = new_web_search_call(
            "call-1".to_string(),
            query.clone(),
            WebSearchAction::Search {
                query: Some(query),
                queries: None,
            },
        );
        let rendered = render_lines(&cell.transcript_lines(64)).join("\n");

        insta::assert_snapshot!(rendered);
    }

    #[test]
    fn active_web_search_exposes_animation_tick() {
        let cell = new_active_web_search_call("call-1".to_string(), "rust tui".to_string(), true);

        assert!(cell.transcript_animation_tick().is_some());
    }

    #[test]
    fn web_search_without_animations_has_no_animation_tick() {
        let cell = new_active_web_search_call("call-1".to_string(), "rust tui".to_string(), false);

        assert_eq!(cell.transcript_animation_tick(), None);
    }

    #[test]
    fn completed_web_search_has_no_animation_tick() {
        let cell = new_web_search_call(
            "call-1".to_string(),
            "rust tui".to_string(),
            WebSearchAction::Search {
                query: Some("rust tui".to_string()),
                queries: None,
            },
        );

        assert_eq!(cell.transcript_animation_tick(), None);
    }

    #[test]
    fn active_mcp_tool_call_snapshot() {
        let invocation = McpInvocation {
            server: "search".into(),
            tool: "find_docs".into(),
            arguments: Some(json!({
                "query": "ratatui styling",
                "limit": 3,
            })),
        };

        let cell = new_active_mcp_tool_call("call-1".into(), invocation, true);
        let rendered = render_lines(&cell.display_lines(80)).join("\n");

        insta::assert_snapshot!(rendered);
    }

    #[test]
    fn completed_mcp_tool_call_success_snapshot() {
        let invocation = McpInvocation {
            server: "search".into(),
            tool: "find_docs".into(),
            arguments: Some(json!({
                "query": "ratatui styling",
                "limit": 3,
            })),
        };

        let result = CallToolResult {
            content: vec![ContentBlock::TextContent(TextContent {
                annotations: None,
                text: "Found styling guidance in styles.md".into(),
                r#type: "text".into(),
            })],
            is_error: None,
            structured_content: None,
        };

        let mut cell = new_active_mcp_tool_call("call-2".into(), invocation, true);
        assert!(
            cell.complete(Duration::from_millis(1420), Ok(result))
                .is_none()
        );

        let rendered = render_lines(&cell.display_lines(80)).join("\n");

        insta::assert_snapshot!(rendered);
    }

    #[test]
    fn completed_mcp_tool_call_image_after_text_returns_extra_cell() {
        let invocation = McpInvocation {
            server: "image".into(),
            tool: "generate".into(),
            arguments: Some(json!({
                "prompt": "tiny image",
            })),
        };

        let result = CallToolResult {
            content: vec![
                ContentBlock::TextContent(TextContent {
                    annotations: None,
                    text: "Here is the image:".into(),
                    r#type: "text".into(),
                }),
                image_block(SMALL_PNG_BASE64),
            ],
            is_error: None,
            structured_content: None,
        };

        let mut cell = new_active_mcp_tool_call("call-image".into(), invocation, true);
        let extra_cell = cell
            .complete(Duration::from_millis(25), Ok(result))
            .expect("expected image cell");

        let rendered = render_lines(&extra_cell.display_lines(80));
        assert_eq!(rendered, vec!["tool result (image output)"]);
    }

    #[test]
    fn completed_mcp_tool_call_skips_invalid_image_blocks() {
        let invocation = McpInvocation {
            server: "image".into(),
            tool: "generate".into(),
            arguments: Some(json!({
                "prompt": "tiny image",
            })),
        };

        let result = CallToolResult {
            content: vec![image_block("not-base64"), image_block(SMALL_PNG_BASE64)],
            is_error: None,
            structured_content: None,
        };

        let mut cell = new_active_mcp_tool_call("call-image-2".into(), invocation, true);
        let extra_cell = cell
            .complete(Duration::from_millis(25), Ok(result))
            .expect("expected image cell");

        let rendered = render_lines(&extra_cell.display_lines(80));
        assert_eq!(rendered, vec!["tool result (image output)"]);
    }

    #[test]
    fn completed_mcp_tool_call_error_snapshot() {
        let invocation = McpInvocation {
            server: "search".into(),
            tool: "find_docs".into(),
            arguments: Some(json!({
                "query": "ratatui styling",
                "limit": 3,
            })),
        };

        let mut cell = new_active_mcp_tool_call("call-3".into(), invocation, true);
        assert!(
            cell.complete(Duration::from_secs(2), Err("network timeout".into()))
                .is_none()
        );

        let rendered = render_lines(&cell.display_lines(80)).join("\n");

        insta::assert_snapshot!(rendered);
    }

    #[test]
    fn completed_mcp_tool_call_multiple_outputs_snapshot() {
        let invocation = McpInvocation {
            server: "search".into(),
            tool: "find_docs".into(),
            arguments: Some(json!({
                "query": "ratatui styling",
                "limit": 3,
            })),
        };

        let result = CallToolResult {
            content: vec![
                ContentBlock::TextContent(TextContent {
                    annotations: None,
                    text: "Found styling guidance in styles.md and additional notes in CONTRIBUTING.md.".into(),
                    r#type: "text".into(),
                }),
                ContentBlock::ResourceLink(ResourceLink {
                    annotations: None,
                    description: Some("Link to styles documentation".into()),
                    mime_type: None,
                    name: "styles.md".into(),
                    size: None,
                    title: Some("Styles".into()),
                    r#type: "resource_link".into(),
                    uri: "file:///docs/styles.md".into(),
                }),
            ],
            is_error: None,
            structured_content: None,
        };

        let mut cell = new_active_mcp_tool_call("call-4".into(), invocation, true);
        assert!(
            cell.complete(Duration::from_millis(640), Ok(result))
                .is_none()
        );

        let rendered = render_lines(&cell.display_lines(48)).join("\n");

        insta::assert_snapshot!(rendered);
    }

    #[test]
    fn completed_mcp_tool_call_wrapped_outputs_snapshot() {
        let invocation = McpInvocation {
            server: "metrics".into(),
            tool: "get_nearby_metric".into(),
            arguments: Some(json!({
                "query": "very_long_query_that_needs_wrapping_to_display_properly_in_the_history",
                "limit": 1,
            })),
        };

        let result = CallToolResult {
            content: vec![ContentBlock::TextContent(TextContent {
                annotations: None,
                text: "Line one of the response, which is quite long and needs wrapping.\nLine two continues the response with more detail.".into(),
                r#type: "text".into(),
            })],
            is_error: None,
            structured_content: None,
        };

        let mut cell = new_active_mcp_tool_call("call-5".into(), invocation, true);
        assert!(
            cell.complete(Duration::from_millis(1280), Ok(result))
                .is_none()
        );

        let rendered = render_lines(&cell.display_lines(40)).join("\n");

        insta::assert_snapshot!(rendered);
    }

    #[test]
    fn completed_mcp_tool_call_multiple_outputs_inline_snapshot() {
        let invocation = McpInvocation {
            server: "metrics".into(),
            tool: "summary".into(),
            arguments: Some(json!({
                "metric": "trace.latency",
                "window": "15m",
            })),
        };

        let result = CallToolResult {
            content: vec![
                ContentBlock::TextContent(TextContent {
                    annotations: None,
                    text: "Latency summary: p50=120ms, p95=480ms.".into(),
                    r#type: "text".into(),
                }),
                ContentBlock::TextContent(TextContent {
                    annotations: None,
                    text: "No anomalies detected.".into(),
                    r#type: "text".into(),
                }),
            ],
            is_error: None,
            structured_content: None,
        };

        let mut cell = new_active_mcp_tool_call("call-6".into(), invocation, true);
        assert!(
            cell.complete(Duration::from_millis(320), Ok(result))
                .is_none()
        );

        let rendered = render_lines(&cell.display_lines(120)).join("\n");

        insta::assert_snapshot!(rendered);
    }

    #[test]
    fn session_header_includes_reasoning_level_when_present() {
        let cell = SessionHeaderHistoryCell::new(
            "gpt-4o".to_string(),
            Some(ReasoningEffortConfig::High),
            std::env::temp_dir(),
            "test",
        );

        let lines = render_lines(&cell.display_lines(80));
        let model_line = lines
            .into_iter()
            .find(|line| line.contains("model:"))
            .expect("model line");

        assert!(model_line.contains("gpt-4o high"));
        assert!(model_line.contains("/model to change"));
    }

    #[test]
    fn session_header_clamps_title_to_available_width() {
        let cell = SessionHeaderHistoryCell::new(
            "gpt-4o".to_string(),
            Some(ReasoningEffortConfig::High),
            std::env::temp_dir(),
            "test",
        );

        let width = 24;
        let lines = cell.display_lines(width);

        assert!(!lines.is_empty());
        assert!(
            lines.iter().all(|line| {
                line.spans
                    .iter()
                    .map(|span| UnicodeWidthStr::width(span.content.as_ref()))
                    .sum::<usize>()
                    <= usize::from(width)
            }),
            "session header should not exceed requested width: {lines:?}"
        );
    }

    #[test]
    fn session_header_directory_center_truncates() {
        let mut dir = home_dir().expect("home directory");
        for part in ["hello", "the", "fox", "is", "very", "fast"] {
            dir.push(part);
        }

        let formatted = SessionHeaderHistoryCell::format_directory_inner(&dir, Some(24));
        let sep = std::path::MAIN_SEPARATOR;
        let expected = format!("~{sep}hello{sep}the{sep}…{sep}very{sep}fast");
        assert_eq!(formatted, expected);
    }

    #[test]
    fn session_header_directory_front_truncates_long_segment() {
        let mut dir = home_dir().expect("home directory");
        dir.push("supercalifragilisticexpialidocious");

        let formatted = SessionHeaderHistoryCell::format_directory_inner(&dir, Some(18));
        let sep = std::path::MAIN_SEPARATOR;
        let expected = format!("~{sep}…cexpialidocious");
        assert_eq!(formatted, expected);
    }

    #[test]
    fn coalesces_sequential_reads_within_one_call() {
        // Build one exec cell with a Search followed by two Reads
        let call_id = "c1".to_string();
        let mut cell = ExecCell::new(
            ExecCall {
                call_id: call_id.clone(),
                command: vec!["bash".into(), "-lc".into(), "echo".into()],
                parsed: vec![
                    ParsedCommand::Search {
                        query: Some("shimmer_spans".into()),
                        path: None,
                        cmd: "rg shimmer_spans".into(),
                    },
                    ParsedCommand::Read {
                        name: "shimmer.rs".into(),
                        cmd: "cat shimmer.rs".into(),
                        path: "shimmer.rs".into(),
                    },
                    ParsedCommand::Read {
                        name: "status_indicator_widget.rs".into(),
                        cmd: "cat status_indicator_widget.rs".into(),
                        path: "status_indicator_widget.rs".into(),
                    },
                ],
                output: None,
                completed: false,
                source: ExecCommandSource::Agent,
                start_time: Some(Instant::now()),
                duration: None,
                interaction_input: None,
            },
            true,
        );
        // Mark call complete so markers are ✓
        cell.complete_call(&call_id, CommandOutput::default(), Duration::from_millis(1));

        let lines = cell.display_lines(80);
        let rendered = render_lines(&lines).join("\n");
        insta::assert_snapshot!(rendered);
        let transcript = render_transcript(&cell).join("\n");
        assert_eq!(transcript, rendered);
    }

    #[test]
    fn coalesces_reads_across_multiple_calls() {
        let mut cell = ExecCell::new(
            ExecCall {
                call_id: "c1".to_string(),
                command: vec!["bash".into(), "-lc".into(), "echo".into()],
                parsed: vec![ParsedCommand::Search {
                    query: Some("shimmer_spans".into()),
                    path: None,
                    cmd: "rg shimmer_spans".into(),
                }],
                output: None,
                completed: false,
                source: ExecCommandSource::Agent,
                start_time: Some(Instant::now()),
                duration: None,
                interaction_input: None,
            },
            true,
        );
        // Call 1: Search only
        cell.complete_call("c1", CommandOutput::default(), Duration::from_millis(1));
        // Call 2: Read A
        cell = cell
            .with_added_call(
                "c2".into(),
                vec!["bash".into(), "-lc".into(), "echo".into()],
                vec![ParsedCommand::Read {
                    name: "shimmer.rs".into(),
                    cmd: "cat shimmer.rs".into(),
                    path: "shimmer.rs".into(),
                }],
                ExecCommandSource::Agent,
                None,
            )
            .unwrap();
        cell.complete_call("c2", CommandOutput::default(), Duration::from_millis(1));
        // Call 3: Read B
        cell = cell
            .with_added_call(
                "c3".into(),
                vec!["bash".into(), "-lc".into(), "echo".into()],
                vec![ParsedCommand::Read {
                    name: "status_indicator_widget.rs".into(),
                    cmd: "cat status_indicator_widget.rs".into(),
                    path: "status_indicator_widget.rs".into(),
                }],
                ExecCommandSource::Agent,
                None,
            )
            .unwrap();
        cell.complete_call("c3", CommandOutput::default(), Duration::from_millis(1));

        let lines = cell.display_lines(80);
        let rendered = render_lines(&lines).join("\n");
        insta::assert_snapshot!(rendered);
        let transcript = render_transcript(&cell).join("\n");
        assert_eq!(transcript, rendered);
    }

    #[test]
    fn coalesced_reads_dedupe_names() {
        let mut cell = ExecCell::new(
            ExecCall {
                call_id: "c1".to_string(),
                command: vec!["bash".into(), "-lc".into(), "echo".into()],
                parsed: vec![
                    ParsedCommand::Read {
                        name: "auth.rs".into(),
                        cmd: "cat auth.rs".into(),
                        path: "auth.rs".into(),
                    },
                    ParsedCommand::Read {
                        name: "auth.rs".into(),
                        cmd: "cat auth.rs".into(),
                        path: "auth.rs".into(),
                    },
                    ParsedCommand::Read {
                        name: "shimmer.rs".into(),
                        cmd: "cat shimmer.rs".into(),
                        path: "shimmer.rs".into(),
                    },
                ],
                output: None,
                completed: false,
                source: ExecCommandSource::Agent,
                start_time: Some(Instant::now()),
                duration: None,
                interaction_input: None,
            },
            true,
        );
        cell.complete_call("c1", CommandOutput::default(), Duration::from_millis(1));
        let lines = cell.display_lines(80);
        let rendered = render_lines(&lines).join("\n");
        insta::assert_snapshot!(rendered);
        let transcript = render_transcript(&cell).join("\n");
        assert_eq!(transcript, rendered);
    }

    #[test]
    fn multiline_command_wraps_with_extra_indent_on_subsequent_lines() {
        // Create a completed exec cell with a multiline command
        let cmd = "set -o pipefail\ncargo test --all-features --quiet".to_string();
        let call_id = "c1".to_string();
        let mut cell = ExecCell::new(
            ExecCall {
                call_id: call_id.clone(),
                command: vec!["bash".into(), "-lc".into(), cmd],
                parsed: Vec::new(),
                output: None,
                completed: false,
                source: ExecCommandSource::Agent,
                start_time: Some(Instant::now()),
                duration: None,
                interaction_input: None,
            },
            true,
        );
        // Mark call complete so it renders as "Ran"
        cell.complete_call(&call_id, CommandOutput::default(), Duration::from_millis(1));

        // Small width to force wrapping on both lines
        let width: u16 = 28;
        let lines = cell.display_lines(width);
        let rendered = render_lines(&lines).join("\n");
        insta::assert_snapshot!(rendered);
    }

    #[test]
    fn single_line_command_compact_when_fits() {
        let call_id = "c1".to_string();
        let mut cell = ExecCell::new(
            ExecCall {
                call_id: call_id.clone(),
                command: vec!["echo".into(), "ok".into()],
                parsed: Vec::new(),
                output: None,
                completed: false,
                source: ExecCommandSource::Agent,
                start_time: Some(Instant::now()),
                duration: None,
                interaction_input: None,
            },
            true,
        );
        cell.complete_call(&call_id, CommandOutput::default(), Duration::from_millis(1));
        // Wide enough that it fits inline
        let lines = cell.display_lines(80);
        let rendered = render_lines(&lines).join("\n");
        insta::assert_snapshot!(rendered);
    }

    #[test]
    fn single_line_command_wraps_with_four_space_continuation() {
        let call_id = "c1".to_string();
        let long = "a_very_long_token_without_spaces_to_force_wrapping".to_string();
        let mut cell = ExecCell::new(
            ExecCall {
                call_id: call_id.clone(),
                command: vec!["bash".into(), "-lc".into(), long],
                parsed: Vec::new(),
                output: None,
                completed: false,
                source: ExecCommandSource::Agent,
                start_time: Some(Instant::now()),
                duration: None,
                interaction_input: None,
            },
            true,
        );
        cell.complete_call(&call_id, CommandOutput::default(), Duration::from_millis(1));
        let lines = cell.display_lines(24);
        let rendered = render_lines(&lines).join("\n");
        insta::assert_snapshot!(rendered);
    }

    #[test]
    fn multiline_command_without_wrap_uses_branch_then_eight_spaces() {
        let call_id = "c1".to_string();
        let cmd = "echo one\necho two".to_string();
        let mut cell = ExecCell::new(
            ExecCall {
                call_id: call_id.clone(),
                command: vec!["bash".into(), "-lc".into(), cmd],
                parsed: Vec::new(),
                output: None,
                completed: false,
                source: ExecCommandSource::Agent,
                start_time: Some(Instant::now()),
                duration: None,
                interaction_input: None,
            },
            true,
        );
        cell.complete_call(&call_id, CommandOutput::default(), Duration::from_millis(1));
        let lines = cell.display_lines(80);
        let rendered = render_lines(&lines).join("\n");
        insta::assert_snapshot!(rendered);
    }

    #[test]
    fn multiline_command_both_lines_wrap_with_correct_prefixes() {
        let call_id = "c1".to_string();
        let cmd = "first_token_is_long_enough_to_wrap\nsecond_token_is_also_long_enough_to_wrap"
            .to_string();
        let mut cell = ExecCell::new(
            ExecCall {
                call_id: call_id.clone(),
                command: vec!["bash".into(), "-lc".into(), cmd],
                parsed: Vec::new(),
                output: None,
                completed: false,
                source: ExecCommandSource::Agent,
                start_time: Some(Instant::now()),
                duration: None,
                interaction_input: None,
            },
            true,
        );
        cell.complete_call(&call_id, CommandOutput::default(), Duration::from_millis(1));
        let lines = cell.display_lines(28);
        let rendered = render_lines(&lines).join("\n");
        insta::assert_snapshot!(rendered);
    }

    #[test]
    fn stderr_tail_more_than_five_lines_snapshot() {
        // Build an exec cell with a non-zero exit and 10 lines on stderr to exercise
        // the head/tail rendering and gutter prefixes.
        let call_id = "c_err".to_string();
        let mut cell = ExecCell::new(
            ExecCall {
                call_id: call_id.clone(),
                command: vec!["bash".into(), "-lc".into(), "seq 1 10 1>&2 && false".into()],
                parsed: Vec::new(),
                output: None,
                completed: false,
                source: ExecCommandSource::Agent,
                start_time: Some(Instant::now()),
                duration: None,
                interaction_input: None,
            },
            true,
        );
        let stderr: String = (1..=10)
            .map(|n| n.to_string())
            .collect::<Vec<_>>()
            .join("\n");
        cell.complete_call(
            &call_id,
            CommandOutput {
                exit_code: 1,
                formatted_output: String::new(),
                aggregated_output: stderr,
            },
            Duration::from_millis(1),
        );

        let rendered = cell
            .display_lines(80)
            .iter()
            .map(|l| {
                l.spans
                    .iter()
                    .map(|s| s.content.as_ref())
                    .collect::<String>()
            })
            .collect::<Vec<_>>()
            .join("\n");
        insta::assert_snapshot!(rendered);
    }

    #[test]
    fn ran_cell_multiline_with_stderr_snapshot() {
        // Build an exec cell that completes (so it renders as "Ran") with a
        // command long enough that it must render on its own line under the
        // header, and include a couple of stderr lines to verify the output
        // block prefixes and wrapping.
        let call_id = "c_wrap_err".to_string();
        let long_cmd =
            "echo this_is_a_very_long_single_token_that_will_wrap_across_the_available_width";
        let mut cell = ExecCell::new(
            ExecCall {
                call_id: call_id.clone(),
                command: vec!["bash".into(), "-lc".into(), long_cmd.to_string()],
                parsed: Vec::new(),
                output: None,
                completed: false,
                source: ExecCommandSource::Agent,
                start_time: Some(Instant::now()),
                duration: None,
                interaction_input: None,
            },
            true,
        );

        let stderr = "error: first line on stderr\nerror: second line on stderr".to_string();
        cell.complete_call(
            &call_id,
            CommandOutput {
                exit_code: 1,
                formatted_output: String::new(),
                aggregated_output: stderr,
            },
            Duration::from_millis(5),
        );

        // Narrow width to force the command to render under the header line.
        let width: u16 = 28;
        let rendered = cell
            .display_lines(width)
            .iter()
            .map(|l| {
                l.spans
                    .iter()
                    .map(|s| s.content.as_ref())
                    .collect::<String>()
            })
            .collect::<Vec<_>>()
            .join("\n");
        insta::assert_snapshot!(rendered);
    }
    #[test]
    fn user_history_cell_wraps_and_prefixes_each_line_snapshot() {
        let msg = "one two three four five six seven";
        let cell = UserHistoryCell {
            message: msg.to_string(),
            text_elements: Vec::new(),
            local_image_paths: Vec::new(),
        };

        // Small width to force wrapping more clearly. Effective wrap width is width-2 due to the ▌ prefix and trailing space.
        let width: u16 = 12;
        let lines = cell.display_lines(width);
        let rendered = render_lines(&lines).join("\n");

        insta::assert_snapshot!(rendered);
    }

    #[test]
    fn plan_update_with_note_and_wrapping_snapshot() {
        // Long explanation forces wrapping; include long step text to verify step wrapping and alignment.
        let update = UpdatePlanArgs {
            explanation: Some(
                "I’ll update Grafana call error handling by adding retries and clearer messages when the backend is unreachable."
                    .to_string(),
            ),
            plan: vec![
                PlanItemArg {
                    step: "Investigate existing error paths and logging around HTTP timeouts".into(),
                    status: StepStatus::Completed,
                },
                PlanItemArg {
                    step: "Harden Grafana client error handling with retry/backoff and user‑friendly messages".into(),
                    status: StepStatus::InProgress,
                },
                PlanItemArg {
                    step: "Add tests for transient failure scenarios and surfacing to the UI".into(),
                    status: StepStatus::Pending,
                },
            ],
        };

        let cell = new_plan_update(update);
        // Narrow width to force wrapping for both the note and steps
        let lines = cell.display_lines(32);
        let rendered = render_lines(&lines).join("\n");
        insta::assert_snapshot!(rendered);
    }

    #[test]
    fn plan_update_without_note_snapshot() {
        let update = UpdatePlanArgs {
            explanation: None,
            plan: vec![
                PlanItemArg {
                    step: "Define error taxonomy".into(),
                    status: StepStatus::InProgress,
                },
                PlanItemArg {
                    step: "Implement mapping to user messages".into(),
                    status: StepStatus::Pending,
                },
            ],
        };

        let cell = new_plan_update(update);
        let lines = cell.display_lines(40);
        let rendered = render_lines(&lines).join("\n");
        insta::assert_snapshot!(rendered);
    }

    #[test]
    fn plan_update_display_lines_include_vertical_padding() {
        let cell = new_plan_update(UpdatePlanArgs {
            explanation: None,
            plan: vec![PlanItemArg {
                step: "Define error taxonomy".into(),
                status: StepStatus::InProgress,
            }],
        });
        let lines = render_lines(&cell.display_lines(40));

        assert_eq!(
            lines,
            vec![
                "".to_string(),
                "• Updated Plan".to_string(),
                "  └ □ Define error taxonomy".to_string(),
                "".to_string(),
            ]
        );
    }

    #[test]
    fn plan_update_padding_rows_have_block_background() {
        let Some(plan_bg) = proposed_plan_style().bg else {
            return;
        };
        let cell: Box<dyn HistoryCell> = Box::new(new_plan_update(UpdatePlanArgs {
            explanation: None,
            plan: vec![PlanItemArg {
                step: "Define error taxonomy".into(),
                status: StepStatus::InProgress,
            }],
        }));
        let width = 40;
        let height = cell.desired_height(width);
        let mut buf = Buffer::empty(Rect::new(0, 0, width, height));
        cell.render(buf.area, &mut buf);

        for y in [0, height - 1] {
            for x in 0..width {
                assert_eq!(
                    buf[(x, y)].style().bg,
                    Some(plan_bg),
                    "expected plan update padding background at x={x}, y={y}"
                );
            }
        }

        let rendered = (0..height)
            .map(|y| (0..width).map(|x| buf[(x, y)].symbol()).collect::<String>())
            .collect::<Vec<_>>()
            .join("\n");
        assert!(
            rendered.contains("Updated Plan"),
            "expected rendered plan update header: {rendered:?}"
        );
    }

    #[test]
    fn proposed_plan_background_fills_rendered_rows() {
        assert_cell_background_fills_text_row(
            Box::new(new_proposed_plan("short plan line".to_string())),
            40,
            "short plan line",
        );
    }

    #[test]
    fn proposed_plan_header_has_no_background() {
        let Some(plan_bg) = proposed_plan_style().bg else {
            return;
        };
        let cell: Box<dyn HistoryCell> = Box::new(new_proposed_plan("short plan line".to_string()));
        let width = 40;
        let height = cell.desired_height(width);
        let mut buf = Buffer::empty(Rect::new(0, 0, width, height));
        cell.render(buf.area, &mut buf);

        let row = (0..height)
            .find(|y| {
                (0..width)
                    .map(|x| buf[(x, *y)].symbol())
                    .collect::<String>()
                    .contains("Proposed Plan")
            })
            .expect("expected proposed plan header row");

        for x in 0..width {
            assert_ne!(
                buf[(x, row)].style().bg,
                Some(plan_bg),
                "did not expect proposed plan background at x={x}, y={row}"
            );
        }
    }

    #[test]
    fn proposed_plan_gap_backgrounds_match_layout() {
        let Some(plan_bg) = proposed_plan_style().bg else {
            return;
        };
        let cell: Box<dyn HistoryCell> = Box::new(new_proposed_plan("short plan line".to_string()));
        let width = 40;
        let height = cell.desired_height(width);
        let mut buf = Buffer::empty(Rect::new(0, 0, width, height));
        cell.render(buf.area, &mut buf);

        for x in 0..width {
            assert_ne!(
                buf[(x, 1)].style().bg,
                Some(plan_bg),
                "did not expect proposed plan background on the first header gap at x={x}"
            );
            assert_eq!(
                buf[(x, 2)].style().bg,
                Some(plan_bg),
                "expected proposed plan background on the second header gap at x={x}"
            );
            assert_eq!(
                buf[(x, height - 1)].style().bg,
                Some(plan_bg),
                "expected proposed plan background on the trailing gap at x={x}"
            );
        }
    }

    #[test]
    fn proposed_plan_has_no_outer_padding() {
        let cell = new_proposed_plan("# Title\n\n## Summary\n\nText".to_string());
        let rendered = render_lines(&cell.display_lines(80));
        assert_eq!(
            rendered,
            vec![
                "• Proposed Plan".to_string(),
                "".to_string(),
                "".to_string(),
                "  # Title".to_string(),
                "".to_string(),
                "  ## Summary".to_string(),
                "".to_string(),
                "  Text".to_string(),
                "".to_string(),
            ]
        );
    }

    #[test]
    fn proposed_plan_stream_background_fills_rendered_rows() {
        let cell = new_proposed_plan_stream(
            vec![
                vec!["• ".dim(), "Proposed Plan".bold()].into(),
                Line::from("  short plan line").style(proposed_plan_style()),
            ],
            false,
        );
        assert_cell_background_fills_text_row(Box::new(cell), 40, "short plan line");
    }

    #[test]
    fn proposed_plan_stream_background_fills_blank_body_rows() {
        let Some(plan_bg) = proposed_plan_style().bg else {
            return;
        };
        let cell: Box<dyn HistoryCell> = Box::new(new_proposed_plan_stream(
            vec![
                vec!["• ".dim(), "Proposed Plan".bold()].into(),
                Line::from("  # Title").style(proposed_plan_style()),
                Line::default().style(proposed_plan_style()),
                Line::from("  body").style(proposed_plan_style()),
            ],
            false,
        ));
        let width = 40;
        let height = cell.desired_height(width);
        let mut buf = Buffer::empty(Rect::new(0, 0, width, height));
        cell.render(buf.area, &mut buf);

        let title_row = (0..height)
            .find(|y| {
                (0..width)
                    .map(|x| buf[(x, *y)].symbol())
                    .collect::<String>()
                    .contains("# Title")
            })
            .expect("expected rendered proposed plan title row");
        let blank_row = title_row + 1;

        for x in 0..width {
            assert_eq!(
                buf[(x, blank_row)].symbol(),
                " ",
                "expected blank proposed plan row at x={x}, y={blank_row}"
            );
            assert_eq!(
                buf[(x, blank_row)].style().bg,
                Some(plan_bg),
                "expected proposed plan background at x={x}, y={blank_row}"
            );
        }
    }

    #[test]
    fn proposed_plan_stream_reflows_background_after_resize() {
        let lines = vec![Line::from("  short plan line").style(proposed_plan_style())];
        assert_cell_background_fills_text_row(
            Box::new(new_proposed_plan_stream(lines.clone(), false)),
            20,
            "short plan line",
        );
        assert_cell_background_fills_text_row(
            Box::new(new_proposed_plan_stream(lines, false)),
            60,
            "short plan line",
        );
    }

    #[test]
    fn proposed_plan_stream_markdown_reflows_from_narrow_to_wide() {
        let source = "This proposed plan should flow back into one line when the terminal becomes wide again.";
        let mut cell = ProposedPlanStreamCell::new_streaming_markdown();
        assert!(cell.show_all_markdown(source.to_string()));

        let narrow = render_lines(&cell.display_lines(32));
        let wide = render_lines(&cell.display_lines(120));

        assert!(
            narrow.len() > wide.len(),
            "expected narrow render to wrap more than wide render; narrow={narrow:?}, wide={wide:?}"
        );
        assert_eq!(
            wide,
            vec![
                "• Proposed Plan".to_string(),
                String::new(),
                String::new(),
                format!("  {source}"),
                String::new(),
            ]
        );
    }

    #[test]
    fn proposed_plan_stream_markdown_background_fills_after_resize() {
        let mut narrow = ProposedPlanStreamCell::new_streaming_markdown();
        assert!(narrow.show_all_markdown("short plan line".to_string()));
        assert_cell_background_fills_text_row(Box::new(narrow), 20, "short plan line");

        let mut wide = ProposedPlanStreamCell::new_streaming_markdown();
        assert!(wide.show_all_markdown("short plan line".to_string()));
        assert_cell_background_fills_text_row(Box::new(wide), 60, "short plan line");
    }

    #[test]
    fn reasoning_summary_block() {
        let cell = new_reasoning_summary_block(
            "**High level reasoning**\n\nDetailed reasoning goes here.".to_string(),
        );

        let rendered_display = render_lines(&cell.display_lines(80));
        assert_eq!(rendered_display, vec!["• Detailed reasoning goes here."]);

        let rendered_transcript = render_transcript(cell.as_ref());
        assert_eq!(rendered_transcript, vec!["• Detailed reasoning goes here."]);
    }

    #[test]
    fn reasoning_summary_block_returns_reasoning_cell_when_feature_disabled() {
        let cell = new_reasoning_summary_block("Detailed reasoning goes here.".to_string());

        let rendered = render_transcript(cell.as_ref());
        assert_eq!(rendered, vec!["• Detailed reasoning goes here."]);
    }

    #[tokio::test]
    async fn reasoning_summary_block_respects_config_overrides() {
        let mut config = test_config().await;
        config.model = Some("gpt-3.5-turbo".to_string());
        config.model_supports_reasoning_summaries = Some(true);
        let cell = new_reasoning_summary_block(
            "**High level reasoning**\n\nDetailed reasoning goes here.".to_string(),
        );

        let rendered_display = render_lines(&cell.display_lines(80));
        assert_eq!(rendered_display, vec!["• Detailed reasoning goes here."]);
    }

    #[test]
    fn reasoning_summary_block_falls_back_when_header_is_missing() {
        let cell =
            new_reasoning_summary_block("**High level reasoning without closing".to_string());

        let rendered = render_transcript(cell.as_ref());
        assert_eq!(rendered, vec!["• **High level reasoning without closing"]);
    }

    #[test]
    fn reasoning_summary_block_falls_back_when_summary_is_missing() {
        let cell =
            new_reasoning_summary_block("**High level reasoning without closing**".to_string());

        let rendered = render_transcript(cell.as_ref());
        assert_eq!(rendered, vec!["• High level reasoning without closing"]);

        let cell = new_reasoning_summary_block(
            "**High level reasoning without closing**\n\n  ".to_string(),
        );

        let rendered = render_transcript(cell.as_ref());
        assert_eq!(rendered, vec!["• High level reasoning without closing"]);
    }

    #[test]
    fn reasoning_summary_block_splits_header_and_summary_when_present() {
        let cell = new_reasoning_summary_block(
            "**High level plan**\n\nWe should fix the bug next.".to_string(),
        );

        let rendered_display = render_lines(&cell.display_lines(80));
        assert_eq!(rendered_display, vec!["• We should fix the bug next."]);

        let rendered_transcript = render_transcript(cell.as_ref());
        assert_eq!(rendered_transcript, vec!["• We should fix the bug next."]);
    }

    #[test]
    fn reasoning_summary_block_transcript_only_hides_display_lines() {
        let cell = new_reasoning_summary_block_transcript_only(
            "**Late reasoning**\n\nThis should stay hidden.".to_string(),
        );

        let rendered_display = render_lines(&cell.display_lines(80));
        assert_eq!(rendered_display, Vec::<String>::new());

        let rendered_transcript = render_transcript(cell.as_ref());
        assert_eq!(rendered_transcript, vec!["• This should stay hidden."]);
    }

    #[test]
    fn deprecation_notice_renders_summary_with_details() {
        let cell = new_deprecation_notice(
            "Feature flag `foo`".to_string(),
            Some("Use flag `bar` instead.".to_string()),
        );
        let lines = cell.display_lines(80);
        let rendered = render_lines(&lines);
        assert_eq!(
            rendered,
            vec![
                "⚠ Feature flag `foo`".to_string(),
                "Use flag `bar` instead.".to_string(),
            ]
        );
    }

    #[test]
    fn buddy_details_cell_contains_generated_details() {
        let buddy = Buddy {
            name: "Patch".to_string(),
            species: crate::product::agent::config::types::BuddySpecies::Duck,
            eye: crate::product::agent::config::types::BuddyEye::Dot,
            hat: crate::product::agent::config::types::BuddyHat::None,
            rarity: BuddyRarity::Rare,
            shiny: false,
            personality: "quiet optimizer".to_string(),
            stats: BuddyStats {
                debugging: 72,
                patience: 31,
                chaos: 48,
                wisdom: 66,
                snark: 14,
            },
            identity_kind: crate::product::protocol::config_types::IdentityKind::Planner,
        };

        let lines = new_buddy_details(buddy, visible_buddy_config()).display_lines(80);
        let rendered = lines
            .iter()
            .flat_map(|line| line.spans.iter())
            .map(|span| span.content.as_ref())
            .collect::<String>();

        assert!(rendered.contains("/buddy"));
        assert!(rendered.contains("planner buddy"));
        assert!(rendered.contains("Patch"));
        assert!(rendered.contains("quiet optimizer"));
        assert!(rendered.contains("Status: shown · talk on"));
        assert!(rendered.contains("About:"));
        assert!(rendered.contains("DEBUGGING"));
    }

    #[test]
    fn buddy_details_cell_uses_compact_width_on_wide_terminal() {
        let buddy = Buddy {
            name: "Patch".to_string(),
            species: crate::product::agent::config::types::BuddySpecies::Duck,
            eye: crate::product::agent::config::types::BuddyEye::Dot,
            hat: crate::product::agent::config::types::BuddyHat::None,
            rarity: BuddyRarity::Rare,
            shiny: false,
            personality: "quiet optimizer".to_string(),
            stats: BuddyStats {
                debugging: 72,
                patience: 31,
                chaos: 48,
                wisdom: 66,
                snark: 14,
            },
            identity_kind: crate::product::protocol::config_types::IdentityKind::Planner,
        };

        let lines = new_buddy_details(buddy, visible_buddy_config()).display_lines(120);
        let max_width = lines
            .iter()
            .map(|line| {
                line.spans
                    .iter()
                    .map(|span| UnicodeWidthStr::width(span.content.as_ref()))
                    .sum::<usize>()
            })
            .max()
            .expect("details lines");

        assert!(max_width < 90, "details card should stay compact");
    }

    #[test]
    fn buddy_details_cell_centers_owl_sprite_in_detail_column() {
        let buddy = Buddy {
            name: "Kilo".to_string(),
            species: crate::product::agent::config::types::BuddySpecies::Owl,
            eye: crate::product::agent::config::types::BuddyEye::Degree,
            hat: crate::product::agent::config::types::BuddyHat::None,
            rarity: BuddyRarity::Rare,
            shiny: false,
            personality: "careful watcher".to_string(),
            stats: BuddyStats {
                debugging: 72,
                patience: 31,
                chaos: 48,
                wisdom: 66,
                snark: 14,
            },
            identity_kind: crate::product::protocol::config_types::IdentityKind::Programmer,
        };

        let rendered =
            render_lines(&new_buddy_details(buddy, visible_buddy_config()).display_lines(120));

        assert!(
            rendered
                .iter()
                .any(|line| line.contains("    /\\  /\\    ")),
            "expected owl ears centered in the 14-column sprite detail area: {rendered:?}",
        );
        assert!(
            rendered.iter().any(|line| line.contains("   ((°)(°))   ")),
            "expected owl face centered in the 14-column sprite detail area: {rendered:?}",
        );
    }

    fn visible_buddy_config() -> TuiBuddy {
        TuiBuddy {
            enabled: true,
            muted: false,
            observer: crate::product::agent::config::types::BuddyObserverConfig {
                enabled: true,
                ..Default::default()
            },
            ..Default::default()
        }
    }
}
