use inkjet::{constants::HIGHLIGHT_NAMES, tree_sitter_highlight::HighlightEvent};
use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd};
use ratatui::{
    Frame,
    layout::{Alignment, Constraint, Direction, Layout, Position, Rect},
    style::{Color, Modifier, Style},
    text::{Line, Span},
    widgets::{Block, Borders, Clear, Padding, Paragraph, Wrap},
};
use std::{borrow::Cow, sync::OnceLock};
use unicode_width::UnicodeWidthStr;

use crate::{
    app::{
        ChatMessageRenderCacheEntry, ChatMessageRenderCacheKey, DiffOverlay,
        DiffOverlayRenderCache, DiffRenderer, InfoOverlay, ProfileOverlay, SessionsOverlay,
        TuiApplication, is_turn_failure_summary,
    },
    core::{Attachment, ChatMessage},
    guardrails::ApprovalRequest,
    ui::input::InputState,
};

const BG: Color = Color::Rgb(8, 9, 10);
const FG: Color = Color::Rgb(220, 220, 220);
const DIM: Color = Color::Rgb(105, 105, 112);
const GREEN: Color = Color::Rgb(88, 220, 120);
const CYAN: Color = Color::Rgb(80, 190, 220);
const AMBER: Color = Color::Rgb(220, 170, 65);
const RED: Color = Color::Rgb(230, 86, 86);
const BORDER: Color = Color::Rgb(62, 66, 76);
const PANEL: Color = Color::Rgb(16, 17, 20);
const ACTIVITY_LABEL_WIDTH: usize = 18;

const FALLBACK_SPINNER_VERBS: &[&str] = &[
    "Architecting",
    "Brewing",
    "Calculating",
    "Channeling",
    "Cogitating",
    "Computing",
    "Conjuring",
    "Crafting",
    "Crunching",
    "Deciphering",
    "Forging",
    "Hyperspacing",
    "Ideating",
    "Inferring",
    "Noodling",
    "Orchestrating",
    "Percolating",
    "Pondering",
    "Processing",
    "Reasoning",
    "Synthesizing",
    "Thinking",
    "Tinkering",
    "Wrangling",
];

pub fn draw(f: &mut Frame<'_>, app: &mut TuiApplication) {
    let area = f.area();
    f.render_widget(Clear, area);
    let pending_approvals = pending_approvals(app);
    let pending = pending_approvals.first().cloned();
    let activity_height = activity_strip_height(app, pending.as_ref());
    let input_height = input_height(&app.input, area.width).min(10);
    let chunks = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Length(2),
            Constraint::Min(6),
            Constraint::Length(activity_height),
            Constraint::Length(input_height),
            Constraint::Length(1),
        ])
        .split(area);

    draw_header(f, app, chunks[0]);
    draw_chat(f, app, chunks[1]);
    if activity_height > 0 {
        draw_activity_strip(f, app, pending.as_ref(), chunks[2]);
    }
    draw_input(f, app, chunks[3]);
    draw_status(f, app, chunks[4]);
    draw_suggestions(f, app, chunks[3], area);
    if app.help_overlay_open {
        draw_help_overlay(f, app, centered_rect(92, 24, area));
    }
    if app.diff_overlay.is_some() {
        let area = centered_rect(120, 34, area);
        // Temporarily take the overlay so we can update its render cache while
        // still borrowing the rest of app for scroll state.
        if let Some(mut diff) = app.diff_overlay.take() {
            draw_diff_overlay(f, app, &mut diff, area);
            app.diff_overlay = Some(diff);
        }
    }
    if let Some(info) = app.info_overlay.as_ref() {
        draw_info_overlay(f, app, info, centered_rect(110, 30, area));
    }
    if let Some(sessions) = app.sessions_overlay.as_ref() {
        draw_sessions_overlay(f, sessions, centered_rect(116, 30, area));
    }
    if app.search_open {
        draw_search_overlay(f, app, search_rect(area));
    }
    if let Some(profile) = app.profile_overlay.as_ref() {
        draw_profile_overlay(f, profile, centered_rect(104, 28, area));
    }
    if !pending_approvals.is_empty() {
        draw_approval_modal(
            f,
            &pending_approvals,
            app.approval_selected_index,
            centered_rect(88, 14, area),
        );
    }
    set_input_cursor(f, app, chunks[3]);
}

fn activity_strip_height(app: &TuiApplication, pending: Option<&ApprovalRequest>) -> u16 {
    let has_activity = pending.is_some()
        || app.session.status == "streaming"
        || !app.session.activity.trim().is_empty();
    let has_context = !app.session.pending_attachments.is_empty();
    match (has_activity, has_context) {
        (true, true) => 4,
        (true, false) | (false, true) => 3,
        (false, false) => 0,
    }
}

fn pending_approvals(app: &TuiApplication) -> Vec<ApprovalRequest> {
    app.tool_executor
        .guardrails
        .approvals
        .pending()
        .into_values()
        .collect()
}

fn draw_header(f: &mut Frame<'_>, app: &TuiApplication, area: Rect) {
    let title = Span::styled(
        " Vegvisir ",
        Style::default().fg(FG).bg(BG).add_modifier(Modifier::BOLD),
    );
    let auto = if app.autonomous_mode_enabled {
        " auto=on"
    } else {
        ""
    };
    let meta = format!(
        " provider={} model={}{} workspace={} ",
        app.session.current_provider, app.session.current_model, auto, app.session.cwd
    );
    let line = Line::from(vec![title, Span::styled(meta, Style::default().fg(DIM))]);
    let paragraph = Paragraph::new(vec![line, Line::from("")])
        .style(Style::default().fg(FG).bg(BG))
        .block(Block::default().borders(Borders::BOTTOM).border_style(DIM));
    f.render_widget(paragraph, area);
}

fn draw_chat(f: &mut Frame<'_>, app: &mut TuiApplication, area: Rect) {
    let block = Block::default()
        .borders(Borders::BOTTOM)
        .border_style(Style::default().fg(BORDER));
    let inner = block.inner(area);
    f.render_widget(block, area);

    let tool_log_height = tool_log_panel_height(app, inner.height);
    let chat_height = inner.height.saturating_sub(tool_log_height);
    let (chat_area, tool_log_area) = if tool_log_height > 0 {
        let chunks = Layout::default()
            .direction(Direction::Vertical)
            .constraints([
                Constraint::Length(chat_height.max(1)),
                Constraint::Length(tool_log_height),
            ])
            .split(inner);
        (chunks[0], Some(chunks[1]))
    } else {
        (inner, None)
    };

    draw_chat_transcript(f, app, chat_area);
    if let Some(area) = tool_log_area {
        draw_tool_log_panel(f, app, area);
    }
}

fn draw_chat_transcript(f: &mut Frame<'_>, app: &mut TuiApplication, area: Rect) {
    let content_width = area.width as usize;
    let lines = visual_chat_lines(app, content_width);

    let visible_height = area.height.max(1) as usize;
    let max_offset = lines.len().saturating_sub(visible_height);
    let offset = app.chat_scroll_offset.min(max_offset);
    let end = lines.len().saturating_sub(offset);
    let start = end.saturating_sub(visible_height);
    app.chat_area_x = area.x;
    app.chat_area_y = area.y;
    app.chat_area_width = area.width;
    app.chat_area_height = area.height;
    app.chat_render_scroll = start;

    let mut visible = lines[start..end].to_vec();
    // Selection/copy only needs the currently rendered viewport. Keeping only
    // visible plain-text rows avoids rebuilding and retaining a plain-text copy
    // of the entire transcript on every redraw.
    app.chat_rendered_lines = visible.iter().map(line_to_plain_text).collect();
    apply_chat_drag_highlight(app, &mut visible, start);
    apply_scroll_indicators(&mut visible, offset, max_offset, area.width as usize);
    let paragraph = Paragraph::new(visible).style(Style::default().fg(FG).bg(BG));
    // Chat lines are pre-wrapped before viewport slicing. Do not enable
    // Ratatui wrapping here: a second wrapping pass can consume extra
    // terminal rows after scroll math has already selected exactly
    // `area.height` visual rows, clipping the newest/bottom lines.
    f.render_widget(paragraph, area);
}

fn tool_log_panel_height(app: &TuiApplication, chat_surface_height: u16) -> u16 {
    if chat_surface_height < 8 || !has_tool_log_messages(app) {
        return 0;
    }
    let max_height = ((chat_surface_height as f32) * 0.15).floor() as u16;
    max_height.clamp(1, chat_surface_height.saturating_sub(4))
}

fn has_tool_log_messages(app: &TuiApplication) -> bool {
    app.session.messages.iter().any(is_tool_log_message)
}

fn is_tool_log_message(message: &ChatMessage) -> bool {
    if message.role != "system" {
        return false;
    }
    matches!(
        classify_system_message(&message.content),
        SystemMessageKind::Tool | SystemMessageKind::Note | SystemMessageKind::Error
    )
}

#[cfg(test)]
fn chat_transcript_messages(app: &TuiApplication) -> impl Iterator<Item = &ChatMessage> {
    app.session
        .messages
        .iter()
        .filter(|message| !is_tool_log_message(message))
}

fn draw_tool_log_panel(f: &mut Frame<'_>, app: &TuiApplication, area: Rect) {
    if area.height == 0 {
        return;
    }
    let block = Block::default()
        .borders(Borders::TOP)
        .border_style(Style::default().fg(BORDER))
        .title(Span::styled(
            " tool / note log ",
            Style::default().fg(DIM).bg(BG),
        ));
    let inner = block.inner(area);
    f.render_widget(block, area);
    if inner.height == 0 {
        return;
    }
    let lines = tool_log_lines(app, inner.width as usize, inner.height as usize);
    let paragraph = Paragraph::new(lines).style(Style::default().fg(FG).bg(BG));
    f.render_widget(paragraph, inner);
}

fn tool_log_lines(app: &TuiApplication, width: usize, max_rows: usize) -> Vec<Line<'static>> {
    let mut rows = app
        .session
        .messages
        .iter()
        .filter(|message| is_tool_log_message(message))
        .rev()
        .take(max_rows.max(1))
        .map(|message| tool_log_message_line(message, width))
        .collect::<Vec<_>>();
    rows.reverse();
    rows
}

fn tool_log_message_line(message: &ChatMessage, width: usize) -> Line<'static> {
    let kind = classify_system_message(&message.content);
    let lifecycle_style = tool_lifecycle_style(&message.content);
    let (marker, style) = match kind {
        SystemMessageKind::Error => ("!", Style::default().fg(RED).add_modifier(Modifier::BOLD)),
        SystemMessageKind::Approval => {
            ("?", Style::default().fg(AMBER).add_modifier(Modifier::BOLD))
        }
        SystemMessageKind::Tool => (
            "›",
            lifecycle_style
                .unwrap_or_else(|| Style::default().fg(CYAN))
                .add_modifier(Modifier::BOLD),
        ),
        SystemMessageKind::Note => ("·", Style::default().fg(AMBER).add_modifier(Modifier::BOLD)),
    };
    compact_system_message_line(
        marker,
        message_label("system", Some(kind), "you").as_ref(),
        &message.created_at.format("%H:%M:%S").to_string(),
        &message.content,
        style,
        lifecycle_style.unwrap_or_else(|| kind.content_style()),
        message_matches_search(message, ""),
        width,
    )
}

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

fn apply_chat_drag_highlight(
    app: &TuiApplication,
    visible: &mut [Line<'static>],
    visible_start: usize,
) {
    let (Some(anchor), Some(current)) = (app.drag_anchor, app.drag_current) else {
        return;
    };
    let Some((start_line, start_col, end_line, end_col)) = chat_drag_bounds(app, anchor, current)
    else {
        return;
    };
    let highlight = Style::default()
        .fg(BG)
        .bg(CYAN)
        .add_modifier(Modifier::BOLD);
    for (row, line) in visible.iter_mut().enumerate() {
        let absolute_line = visible_start + row;
        if absolute_line < start_line || absolute_line > end_line {
            continue;
        }
        let from = if absolute_line == start_line {
            start_col
        } else {
            0
        };
        let to = if absolute_line == end_line {
            end_col
        } else {
            usize::MAX
        };
        *line = highlight_line_columns(line, from, to, highlight);
    }
}

fn chat_drag_bounds(
    app: &TuiApplication,
    anchor: (u16, u16),
    current: (u16, u16),
) -> Option<(usize, usize, usize, usize)> {
    let point = |(col, row): (u16, u16)| -> Option<(usize, usize)> {
        let row_in_chat = row.checked_sub(app.chat_area_y)? as usize;
        if row_in_chat >= app.chat_area_height as usize {
            return None;
        }
        let col_in_chat = col.saturating_sub(app.chat_area_x) as usize;
        Some((app.chat_render_scroll + row_in_chat, col_in_chat))
    };
    let a = point(anchor)?;
    let b = point(current)?;
    if a <= b {
        Some((a.0, a.1, b.0, b.1))
    } else {
        Some((b.0, b.1, a.0, a.1))
    }
}

fn highlight_line_columns(
    line: &Line<'static>,
    start_col: usize,
    end_col: usize,
    highlight: Style,
) -> Line<'static> {
    use unicode_width::UnicodeWidthChar;
    if end_col <= start_col {
        return line.clone();
    }
    let mut spans = Vec::new();
    let mut col = 0usize;
    for span in &line.spans {
        let mut normal = String::new();
        let mut selected = String::new();
        let flush = |spans: &mut Vec<Span<'static>>,
                     normal: &mut String,
                     selected: &mut String,
                     style: Style| {
            if !normal.is_empty() {
                spans.push(Span::styled(std::mem::take(normal), style));
            }
            if !selected.is_empty() {
                spans.push(Span::styled(std::mem::take(selected), highlight));
            }
        };
        for ch in span.content.as_ref().chars() {
            let width = UnicodeWidthChar::width(ch).unwrap_or(0).max(1);
            let next = col + width;
            let is_selected = next > start_col && col < end_col;
            if is_selected {
                if !normal.is_empty() {
                    spans.push(Span::styled(std::mem::take(&mut normal), span.style));
                }
                selected.push(ch);
            } else {
                if !selected.is_empty() {
                    spans.push(Span::styled(std::mem::take(&mut selected), highlight));
                }
                normal.push(ch);
            }
            col = next;
        }
        flush(&mut spans, &mut normal, &mut selected, span.style);
    }
    Line::from(spans)
}

fn visual_chat_lines(app: &mut TuiApplication, width: usize) -> Vec<Line<'static>> {
    cached_chat_lines(app, width)
}

fn wrap_line_for_viewport(line: Line<'static>, width: usize) -> Vec<Line<'static>> {
    if line.spans.is_empty() {
        return vec![line];
    }
    wrap_spans(line.spans, width.max(1), "")
}

fn cached_chat_lines(app: &mut TuiApplication, width: usize) -> Vec<Line<'static>> {
    if app.session.messages.is_empty() {
        app.chat_render_cache.message_lines.clear();
        let mut lines = vec![Line::from(Span::styled(
            "Ready. Type a request, paste context, or use /help.",
            Style::default().fg(DIM),
        ))];
        append_streaming_activity_lines(app, &mut lines);
        return lines;
    }

    if app.chat_render_cache.width != width
        || app.chat_render_cache.search_query != app.search_query
    {
        app.chat_render_cache.width = width;
        app.chat_render_cache.search_query = app.search_query.clone();
        app.chat_render_cache.message_lines.clear();
    }
    if app.chat_render_cache.message_lines.len() != app.session.messages.len() {
        app.chat_render_cache
            .message_lines
            .resize_with(app.session.messages.len(), || None);
    }

    let mut lines = Vec::new();
    let mut rendered_messages = 0usize;
    let mut last_transcript_role = None::<&str>;
    let mut last_transcript_content_empty = true;

    for index in 0..app.session.messages.len() {
        let message = &app.session.messages[index];
        if is_tool_log_message(message) {
            app.chat_render_cache.message_lines[index] = None;
            continue;
        }

        if rendered_messages > 0 {
            lines.push(Line::from(""));
        }
        rendered_messages += 1;
        last_transcript_role = Some(message.role.as_str());
        last_transcript_content_empty = message.content.is_empty();

        let user_label = user_message_label(app);
        let key = chat_message_render_cache_key(message, &user_label);
        let needs_render = app.chat_render_cache.message_lines[index]
            .as_ref()
            .is_none_or(|entry| entry.key != key);
        if needs_render {
            let rendered = message_lines(message, width, &app.search_query, &user_label)
                .into_iter()
                .flat_map(|line| wrap_line_for_viewport(line, width))
                .collect();
            app.chat_render_cache.message_lines[index] = Some(ChatMessageRenderCacheEntry {
                key,
                lines: rendered,
            });
        }
        if let Some(entry) = app.chat_render_cache.message_lines[index].as_ref() {
            lines.extend(entry.lines.iter().cloned());
        }
    }

    if last_transcript_role == Some("assistant") && !last_transcript_content_empty {
        // Keep one rendered spacer row after completed assistant output so the
        // final line breathes above the chat/input border. This is a
        // presentation-only pad; stored transcript content is unchanged.
        lines.push(Line::from(""));
    }
    append_streaming_activity_lines(app, &mut lines);
    lines
}

fn chat_message_render_cache_key(
    message: &ChatMessage,
    user_label: &str,
) -> ChatMessageRenderCacheKey {
    ChatMessageRenderCacheKey {
        role: message.role.clone(),
        role_label_hash: if message.role == "user" {
            stable_hash(user_label)
        } else {
            0
        },
        created_at_millis: message.created_at.timestamp_millis(),
        content_len: message.content.len(),
        content_hash: stable_hash(&message.content),
        thinking_trace_visible: thinking_trace_visible(message),
    }
}

fn thinking_trace_visible(_message: &ChatMessage) -> bool {
    false
}

fn append_streaming_activity_lines(app: &TuiApplication, lines: &mut Vec<Line<'static>>) {
    if app.session.status == "streaming" && !app.session.activity.trim().is_empty() {
        lines.push(Line::from(""));
        lines.push(Line::from(vec![
            Span::styled("● ", Style::default().fg(CYAN)),
            Span::styled(app.session.activity.clone(), Style::default().fg(DIM)),
        ]));
    }
}

#[cfg(test)]
fn chat_lines(app: &TuiApplication, width: usize) -> Vec<Line<'static>> {
    let mut lines = Vec::new();
    if app.session.messages.is_empty() {
        lines.push(Line::from(Span::styled(
            "Ready. Type a request, paste context, or use /help.",
            Style::default().fg(DIM),
        )));
    } else {
        for (index, message) in chat_transcript_messages(app).enumerate() {
            if index > 0 {
                lines.push(Line::from(""));
            }
            let user_label = user_message_label(app);
            lines.extend(message_lines(
                message,
                width,
                &app.search_query,
                &user_label,
            ));
        }
        if app
            .session
            .messages
            .iter()
            .rev()
            .find(|message| !is_tool_log_message(message))
            .is_some_and(|message| message.role == "assistant" && !message.content.is_empty())
        {
            // Keep one rendered spacer row after completed assistant output so the
            // final line breathes above the chat/input border. This is a
            // presentation-only pad; stored transcript content is unchanged.
            lines.push(Line::from(""));
        }
    }
    append_streaming_activity_lines(app, &mut lines);
    lines
}

fn apply_scroll_indicators(
    visible: &mut [Line<'static>],
    offset: usize,
    max_offset: usize,
    width: usize,
) {
    if visible.is_empty() || max_offset == 0 {
        return;
    }
    if offset < max_offset {
        visible[0] = Line::from(Span::styled(
            truncate("↑ older messages above", width),
            Style::default().fg(DIM),
        ));
    }
    if offset > 0
        && let Some(last) = visible.last_mut()
    {
        *last = Line::from(Span::styled(
            truncate("↓ newer messages below - press End to follow", width),
            Style::default().fg(AMBER),
        ));
    }
}

fn draw_activity_strip(
    f: &mut Frame<'_>,
    app: &TuiApplication,
    pending: Option<&ApprovalRequest>,
    area: Rect,
) {
    let mut lines = Vec::new();
    if let Some(line) = activity_line(app, pending, area.width as usize) {
        lines.push(line);
    }
    if let Some(line) = attachment_line(app, area.width as usize) {
        lines.push(line);
    }
    let paragraph = Paragraph::new(lines)
        .style(Style::default().fg(FG).bg(BG))
        .block(
            Block::default()
                .borders(Borders::TOP | Borders::BOTTOM)
                .border_style(Style::default().fg(BORDER)),
        );
    f.render_widget(paragraph, area);
}

fn activity_line(
    app: &TuiApplication,
    pending: Option<&ApprovalRequest>,
    width: usize,
) -> Option<Line<'static>> {
    let (indicator, label, detail, color) = if let Some(approval) = pending {
        (
            "● ".to_string(),
            "waiting approval".to_string(),
            format!(
                "{} wants {}. Press 1 approve once, 2 allow session, 3 deny.",
                approval.risk_label, approval.tool_name
            ),
            AMBER,
        )
    } else if app.session.status == "streaming" {
        (
            streaming_spinner_dot(app.session.activity_tick),
            animated_spinner_verb(app),
            if app.session.activity.trim().is_empty() {
                "model response in progress".to_string()
            } else {
                app.session.activity.clone()
            },
            CYAN,
        )
    } else if !app.session.activity.trim().is_empty() {
        (
            "● ".to_string(),
            "activity".to_string(),
            app.session.activity.clone(),
            DIM,
        )
    } else {
        return None;
    };

    let label = fixed_width(&label, ACTIVITY_LABEL_WIDTH);
    let reserved_width = indicator.width() + ACTIVITY_LABEL_WIDTH + 1;
    let detail_budget = width.saturating_sub(reserved_width);
    Some(Line::from(vec![
        Span::styled(
            indicator,
            Style::default().fg(color).add_modifier(Modifier::BOLD),
        ),
        Span::styled(
            label,
            Style::default().fg(color).add_modifier(Modifier::BOLD),
        ),
        Span::styled(" ", Style::default().fg(DIM)),
        Span::styled(truncate(&detail, detail_budget), Style::default().fg(FG)),
    ]))
}

fn fixed_width(text: &str, width: usize) -> String {
    let mut out = truncate(text, width);
    let current = out.width();
    if current < width {
        out.push_str(&" ".repeat(width - current));
    }
    out
}

fn streaming_spinner_dot(tick: u64) -> String {
    let frames = ["⠋ ", "⠙ ", "⠹ ", "⠸ ", "⠼ ", "⠴ ", "⠦ ", "⠧ ", "⠇ ", "⠏ "];
    frames[(tick as usize / 2) % frames.len()].to_string()
}

fn animated_spinner_verb(app: &TuiApplication) -> String {
    let verb = selected_spinner_verb(app);
    let suffix_frames = ["", ".", "..", "...", "..", "."];
    let suffix = suffix_frames[(app.session.activity_tick as usize / 6) % suffix_frames.len()];
    let shimmer = match (app.session.activity_tick / 4) % 4 {
        0 => "",
        1 => " ✦",
        2 => "",
        _ => " ✧",
    };
    format!("{verb}{suffix}{shimmer}")
}

fn selected_spinner_verb(app: &TuiApplication) -> String {
    let verbs = spinner_verbs();
    if verbs.is_empty() {
        return "Thinking".to_string();
    }
    let seed = if app.session.spinner_verb_seed == 0 {
        stable_hash(&app.session.session_id)
    } else {
        app.session.spinner_verb_seed
    };
    verbs[(seed as usize) % verbs.len()].clone()
}

fn spinner_verbs() -> &'static Vec<String> {
    static VERBS: OnceLock<Vec<String>> = OnceLock::new();
    VERBS.get_or_init(|| {
        let from_file = std::fs::read_to_string("spinner_verbs.md")
            .ok()
            .map(|content| parse_spinner_verbs(&content))
            .unwrap_or_default();
        if from_file.is_empty() {
            FALLBACK_SPINNER_VERBS
                .iter()
                .map(|verb| (*verb).to_string())
                .collect()
        } else {
            from_file
        }
    })
}

fn parse_spinner_verbs(content: &str) -> Vec<String> {
    content
        .lines()
        .map(str::trim)
        .filter(|line| !line.is_empty() && !line.starts_with('#'))
        .map(str::to_string)
        .collect()
}

fn stable_hash(value: &str) -> u64 {
    let mut hash = 0xcbf29ce484222325_u64;
    for byte in value.as_bytes() {
        hash ^= u64::from(*byte);
        hash = hash.wrapping_mul(0x100000001b3);
    }
    hash
}

fn attachment_line(app: &TuiApplication, width: usize) -> Option<Line<'static>> {
    if app.session.pending_attachments.is_empty() {
        return None;
    }
    let summary = pending_attachment_summary(&app.session.pending_attachments, width);
    Some(Line::from(vec![
        Span::styled("◇ ", Style::default().fg(CYAN)),
        Span::styled(
            "context",
            Style::default().fg(CYAN).add_modifier(Modifier::BOLD),
        ),
        Span::styled(" ", Style::default().fg(DIM)),
        Span::styled(summary, Style::default().fg(FG)),
    ]))
}

fn pending_attachment_summary(attachments: &[Attachment], width: usize) -> String {
    let labels = attachments
        .iter()
        .take(4)
        .map(attachment_label)
        .collect::<Vec<_>>();
    let more = attachments.len().saturating_sub(labels.len());
    let mut summary = format!(
        "{} pending attachment{}: {}",
        attachments.len(),
        if attachments.len() == 1 { "" } else { "s" },
        labels.join(", ")
    );
    if more > 0 {
        summary.push_str(&format!(", +{more} more"));
    }
    truncate(&summary, width.saturating_sub(14))
}

fn attachment_label(attachment: &Attachment) -> String {
    let name = attachment.name.as_deref().unwrap_or_else(|| {
        std::path::Path::new(&attachment.path)
            .file_name()
            .and_then(|name| name.to_str())
            .unwrap_or(&attachment.path)
    });
    match attachment.size_bytes {
        Some(bytes) => format!("{} {} ({})", attachment.kind, name, format_bytes(bytes)),
        None => format!("{} {}", attachment.kind, name),
    }
}

fn format_bytes(bytes: u64) -> String {
    const KB: u64 = 1024;
    const MB: u64 = 1024 * KB;
    if bytes >= MB {
        format!("{:.1} MB", bytes as f64 / MB as f64)
    } else if bytes >= KB {
        format!("{:.1} KB", bytes as f64 / KB as f64)
    } else {
        format!("{bytes} B")
    }
}

#[cfg(test)]
fn work_log_lines(app: &TuiApplication, width: usize) -> Vec<Line<'static>> {
    let mut lines = Vec::new();
    if app.pending_send.is_some() {
        lines.push(work_log_line(
            "running",
            "model response in progress",
            CYAN,
            width,
        ));
    }
    if !app.session.activity.trim().is_empty() {
        lines.push(work_log_line(
            "activity",
            &app.session.activity,
            CYAN,
            width,
        ));
    }
    let pending = app.tool_executor.guardrails.approvals.pending();
    for approval in pending.values().take(3) {
        lines.push(work_log_line(
            "approval",
            &format!("{} {}", approval.tool_name, approval.risk_label),
            AMBER,
            width,
        ));
    }
    for message in app.session.messages.iter().rev().take(8).rev() {
        match message.role.as_str() {
            "system" => {
                let label = if message.content.to_ascii_lowercase().contains("error") {
                    "error"
                } else {
                    "note"
                };
                let color = if label == "error" { RED } else { AMBER };
                lines.push(work_log_line(label, &message.content, color, width));
            }
            "user" => lines.push(work_log_line("user", &message.content, CYAN, width)),
            "assistant" => {
                if message.content.trim().is_empty() {
                    lines.push(work_log_line("agent", "stream opened", GREEN, width));
                }
            }
            _ => {}
        }
    }
    if lines.is_empty() {
        lines.push(Line::from(Span::styled(
            "No recent work log entries.",
            Style::default().fg(DIM),
        )));
    }
    lines
}

#[cfg(test)]
fn work_log_line(label: &str, detail: &str, color: Color, width: usize) -> Line<'static> {
    let detail_width = width.saturating_sub(label.len() + 5).max(8);
    Line::from(vec![
        Span::styled("• ", Style::default().fg(color)),
        Span::styled(
            format!("{label:<8}"),
            Style::default().fg(color).add_modifier(Modifier::BOLD),
        ),
        Span::styled(
            truncate(&detail.replace('\n', " "), detail_width),
            Style::default().fg(DIM),
        ),
    ])
}

fn message_lines(
    message: &ChatMessage,
    width: usize,
    search_query: &str,
    user_label: &str,
) -> Vec<Line<'static>> {
    let system_kind = if message.role == "system" {
        Some(classify_system_message(&message.content))
    } else {
        None
    };
    let (marker, style) = match (message.role.as_str(), system_kind) {
        ("user", _) => ("❯", Style::default().fg(CYAN).add_modifier(Modifier::BOLD)),
        ("assistant", _) => ("●", Style::default().fg(GREEN).add_modifier(Modifier::BOLD)),
        ("system", Some(SystemMessageKind::Error)) => {
            ("!", Style::default().fg(RED).add_modifier(Modifier::BOLD))
        }
        ("system", Some(SystemMessageKind::Approval)) => {
            ("?", Style::default().fg(AMBER).add_modifier(Modifier::BOLD))
        }
        ("system", Some(SystemMessageKind::Tool)) => {
            ("›", Style::default().fg(CYAN).add_modifier(Modifier::BOLD))
        }
        ("system", _) => ("·", Style::default().fg(AMBER).add_modifier(Modifier::BOLD)),
        _ => ("·", Style::default().fg(DIM)),
    };
    let content_style = match message.role.as_str() {
        "system" => system_kind
            .map(SystemMessageKind::content_style)
            .unwrap_or_else(|| Style::default().fg(AMBER)),
        _ => Style::default().fg(FG),
    };
    let role_label = message_label(message.role.as_str(), system_kind, user_label);
    let timestamp = message.created_at.format("%H:%M:%S").to_string();
    let is_match = message_matches_search(message, search_query);

    if message.role == "system"
        && matches!(
            system_kind,
            Some(SystemMessageKind::Tool | SystemMessageKind::Note)
        )
    {
        return vec![compact_system_message_line(
            marker,
            role_label.as_ref(),
            &timestamp,
            &message.content,
            style,
            content_style,
            is_match,
            width,
        )];
    }

    let mut header_spans = vec![
        Span::styled(format!("{marker} "), style),
        Span::styled(role_label.to_string(), style),
        Span::styled(" ", Style::default().fg(DIM)),
        Span::styled(timestamp, Style::default().fg(DIM)),
    ];
    if is_match {
        header_spans.push(Span::styled("  match", Style::default().fg(AMBER)));
    }
    let header = Line::from(header_spans);
    let display_content = visible_chat_message_content(message);
    let rendered = render_markdown(
        &display_content,
        width.saturating_sub(2).max(10),
        content_style,
    );
    let mut out = vec![header];
    for line in rendered {
        let mut spans = vec![Span::raw("  ".to_string())];
        spans.extend(line.spans);
        out.push(Line::from(spans));
    }
    out
}

pub fn next_thinking_trace_expiry_at(
    _app: &TuiApplication,
) -> Option<chrono::DateTime<chrono::Utc>> {
    None
}

fn visible_chat_message_content(message: &ChatMessage) -> String {
    if message.role != "assistant" || !contains_thinking_trace(&message.content) {
        return message.content.clone();
    }
    strip_thinking_trace_sections(&message.content)
}

fn contains_thinking_trace(content: &str) -> bool {
    content
        .lines()
        .any(|line| line.trim().eq_ignore_ascii_case("**Thinking trace**"))
}

fn strip_thinking_trace_sections(content: &str) -> String {
    // Thinking traces are hidden from the chat viewport. Some older persisted
    // messages may contain a `**Thinking trace**` heading without a matching
    // `**Answer**` heading; keep those messages visible rather than stripping
    // everything to blank. Only strip when there is an explicit answer boundary.
    let has_answer_boundary = content
        .lines()
        .any(|line| line.trim().eq_ignore_ascii_case("**Answer**"));
    if !has_answer_boundary {
        return content.to_string();
    }

    let mut out = Vec::new();
    let mut skipping_trace = false;
    let mut pending_blank = false;

    for line in content.lines() {
        let trimmed = line.trim();
        if trimmed.eq_ignore_ascii_case("**Thinking trace**") {
            skipping_trace = true;
            pending_blank = false;
            continue;
        }
        if skipping_trace {
            if trimmed.eq_ignore_ascii_case("**Answer**") {
                skipping_trace = false;
                pending_blank = false;
                continue;
            }
            continue;
        }
        if trimmed.is_empty() {
            pending_blank = !out.is_empty();
            continue;
        }
        if pending_blank && !out.is_empty() {
            out.push(String::new());
        }
        pending_blank = false;
        out.push(line.to_string());
    }

    let stripped = out.join("\n");
    if stripped.trim().is_empty() {
        content.to_string()
    } else {
        stripped
    }
}

#[allow(clippy::too_many_arguments)]
fn compact_system_message_line(
    marker: &str,
    role_label: &str,
    timestamp: &str,
    content: &str,
    marker_style: Style,
    content_style: Style,
    is_match: bool,
    width: usize,
) -> Line<'static> {
    let prefix_width = marker.width() + 1 + role_label.width() + 2 + timestamp.width() + 2;
    let match_width = if is_match { " match".width() } else { 0 };
    let detail_width = width.saturating_sub(prefix_width + match_width).max(12);
    let detail = summarize_system_inline(content, detail_width);
    let mut spans = vec![
        Span::styled(format!("{marker} "), marker_style),
        Span::styled(role_label.to_string(), marker_style),
        Span::styled(" ", Style::default().fg(DIM)),
        Span::styled(timestamp.to_string(), Style::default().fg(DIM)),
        Span::styled(" ", Style::default().fg(DIM)),
        Span::styled(detail, content_style),
    ];
    if is_match {
        spans.push(Span::styled(" match", Style::default().fg(AMBER)));
    }
    Line::from(spans)
}

fn summarize_system_inline(content: &str, width: usize) -> String {
    let normalized = content
        .lines()
        .map(str::trim)
        .filter(|line| !line.is_empty())
        .collect::<Vec<_>>()
        .join("  ");
    truncate(&normalized, width)
}

fn message_matches_search(message: &ChatMessage, search_query: &str) -> bool {
    let query = search_query.trim().to_ascii_lowercase();
    if query.is_empty() {
        return false;
    }
    message.role.to_ascii_lowercase().contains(&query)
        || message.content.to_ascii_lowercase().contains(&query)
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum SystemMessageKind {
    Note,
    Error,
    Approval,
    Tool,
}

impl SystemMessageKind {
    fn content_style(self) -> Style {
        match self {
            Self::Error => Style::default().fg(RED),
            Self::Approval => Style::default().fg(AMBER),
            Self::Tool => Style::default().fg(CYAN),
            Self::Note => Style::default().fg(AMBER),
        }
    }
}

fn user_message_label(app: &TuiApplication) -> String {
    app.user_profile
        .display_name()
        .map(str::trim)
        .filter(|name| !name.is_empty())
        .unwrap_or("you")
        .to_string()
}

fn message_label<'a>(
    role: &'a str,
    system_kind: Option<SystemMessageKind>,
    user_label: &'a str,
) -> Cow<'a, str> {
    match role {
        "user" if user_label.trim().is_empty() => Cow::Borrowed("you"),
        "user" => Cow::Borrowed(user_label.trim()),
        "assistant" => Cow::Borrowed("agent"),
        "system" => Cow::Borrowed(match system_kind.unwrap_or(SystemMessageKind::Note) {
            SystemMessageKind::Error => "error",
            SystemMessageKind::Approval => "approval",
            SystemMessageKind::Tool => "tool",
            SystemMessageKind::Note => "note",
        }),
        other => Cow::Borrowed(other),
    }
}

fn classify_system_message(content: &str) -> SystemMessageKind {
    let trimmed = content.trim_start();
    let lower = trimmed.to_ascii_lowercase();

    // Tool lifecycle messages should be classified from their explicit status
    // prefix, not from arbitrary words inside captured stdout/stderr. A
    // successful command can legitimately print test names, stderr text, or
    // strings containing "error" while still ending with `- ok:`.
    if lower.starts_with("running tool:")
        || lower.starts_with("tool started:")
        || lower.starts_with("tool call")
        || lower.starts_with("tool finished:")
    {
        if lower.starts_with("tool finished:") {
            let status = lower.split_once(" - ").map(|(_, rest)| rest.trim_start());
            if matches!(status, Some(rest) if rest.starts_with("ok:") || rest == "ok") {
                return SystemMessageKind::Tool;
            }
            if matches!(status, Some(rest) if rest.starts_with("error:") || rest.starts_with("failed:") || rest.starts_with("err:"))
            {
                return SystemMessageKind::Error;
            }
        }
        return SystemMessageKind::Tool;
    }

    if lower.starts_with("tool failed:")
        || lower.starts_with("tool error:")
        || lower.starts_with("error:")
        || lower.starts_with("command failed:")
        || is_turn_failure_summary(trimmed)
        || lower.starts_with("turn ended after an error")
        || lower.starts_with("i hit an error before")
    {
        return SystemMessageKind::Error;
    }

    if lower.contains("approval") || lower.contains("approve") || lower.contains("deny") {
        return SystemMessageKind::Approval;
    }

    SystemMessageKind::Note
}

fn render_markdown_only(text: &str, width: usize, base_style: Style) -> Vec<Line<'static>> {
    let mut options = Options::empty();
    options.insert(Options::ENABLE_TABLES);
    let parser = Parser::new_ext(text, options);
    let mut out = Vec::new();
    let mut current: Vec<Span<'static>> = Vec::new();
    let mut strong = false;
    let mut emphasis = false;
    let mut list_depth = 0usize;
    let mut heading_level = 0usize;
    let mut in_code = false;
    let mut code_language = String::new();
    let mut code = String::new();
    let mut table = TableRenderState::default();

    for event in parser {
        match event {
            Event::Start(tag) => match tag {
                Tag::Heading { level, .. } => {
                    heading_level = level as usize;
                }
                Tag::Strong => strong = true,
                Tag::Emphasis => emphasis = true,
                Tag::List(_) => list_depth += 1,
                Tag::Item => {
                    flush_current(&mut out, &mut current, width, "");
                    current.push(Span::styled(
                        format!("{}- ", "  ".repeat(list_depth.saturating_sub(1))),
                        Style::default().fg(CYAN),
                    ));
                }
                Tag::CodeBlock(kind) => {
                    flush_current(&mut out, &mut current, width, "");
                    in_code = true;
                    code_language = match kind {
                        CodeBlockKind::Fenced(language) => language.to_string(),
                        CodeBlockKind::Indented => String::new(),
                    };
                    code.clear();
                }
                Tag::BlockQuote(_) => {
                    flush_current(&mut out, &mut current, width, "");
                    current.push(Span::styled("> ", Style::default().fg(DIM)));
                }
                Tag::Table(_) => {
                    flush_current(&mut out, &mut current, width, "");
                    table = TableRenderState {
                        in_table: true,
                        ..Default::default()
                    };
                }
                Tag::TableHead => table.in_header = true,
                Tag::TableRow => table.current_row.clear(),
                Tag::TableCell => table.current_cell.clear(),
                _ => {}
            },
            Event::End(tag) => match tag {
                TagEnd::Heading(_) => {
                    let prefix = match heading_level {
                        1 => "# ",
                        2 => "## ",
                        3 => "### ",
                        _ => "",
                    };
                    if !prefix.is_empty() {
                        current.insert(0, Span::styled(prefix, Style::default().fg(CYAN)));
                    }
                    for span in &mut current {
                        span.style = span.style.add_modifier(Modifier::BOLD);
                    }
                    flush_current(&mut out, &mut current, width, "");
                    out.push(Line::from(""));
                    heading_level = 0;
                }
                TagEnd::Strong => strong = false,
                TagEnd::Emphasis => emphasis = false,
                TagEnd::Paragraph | TagEnd::Item => {
                    flush_current(&mut out, &mut current, width, "")
                }
                TagEnd::List(_) => {
                    list_depth = list_depth.saturating_sub(1);
                    if list_depth == 0 {
                        out.push(Line::from(""));
                    }
                }
                TagEnd::CodeBlock => {
                    render_code_block(&mut out, &code_language, &code, width);
                    in_code = false;
                    code_language.clear();
                    code.clear();
                }
                TagEnd::BlockQuote(_) => {
                    flush_current(&mut out, &mut current, width, "");
                    out.push(Line::from(""));
                }
                TagEnd::TableCell => table
                    .current_row
                    .push(std::mem::take(&mut table.current_cell)),
                TagEnd::TableHead => {
                    table.headers = std::mem::take(&mut table.current_row);
                    table.in_header = false;
                }
                TagEnd::TableRow => {
                    if table.in_header {
                        table.headers = std::mem::take(&mut table.current_row);
                    } else if !table.current_row.is_empty() {
                        table.rows.push(std::mem::take(&mut table.current_row));
                    }
                }
                TagEnd::Table => {
                    render_table(&mut out, &table, width);
                    table = TableRenderState::default();
                    out.push(Line::from(""));
                }
                _ => {}
            },
            Event::Text(value) => {
                let value = value.to_string();
                if in_code {
                    code.push_str(&value);
                } else if table.in_table {
                    table.current_cell.push_str(&value);
                } else {
                    let mut style = base_style;
                    if strong {
                        style = style.add_modifier(Modifier::BOLD);
                    }
                    if emphasis {
                        style = style.add_modifier(Modifier::ITALIC);
                    }
                    current.push(Span::styled(value, style));
                }
            }
            Event::Code(value) => current.push(Span::styled(
                value.to_string(),
                Style::default().fg(CYAN).bg(PANEL),
            )),
            Event::SoftBreak | Event::HardBreak => flush_current(&mut out, &mut current, width, ""),
            Event::Rule => out.push(Line::from(Span::styled(
                "─".repeat(width),
                Style::default().fg(BORDER),
            ))),
            _ => {}
        }
    }
    flush_current(&mut out, &mut current, width, "");
    if out.is_empty() {
        out.push(Line::from(""));
    }
    while out.last().is_some_and(|line| {
        line.spans.is_empty() || line.spans.iter().all(|span| span.content.is_empty())
    }) {
        out.pop();
    }
    if out.is_empty() {
        out.push(Line::from(""));
    }
    out
}

fn render_markdown(text: &str, width: usize, base_style: Style) -> Vec<Line<'static>> {
    render_chat_content(text, width, base_style)
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ChatContentSegmentKind {
    Markdown,
    Code,
    Diff,
}

#[derive(Clone, Debug, PartialEq, Eq)]
struct ChatContentSegment {
    kind: ChatContentSegmentKind,
    language: String,
    content: String,
}

fn render_chat_content(text: &str, width: usize, base_style: Style) -> Vec<Line<'static>> {
    let segments = segment_chat_content(text);
    if segments.is_empty() {
        return vec![Line::from("")];
    }

    let mut out = Vec::new();
    for (index, segment) in segments.iter().enumerate() {
        if index > 0 && !out.last().is_some_and(is_blank_line) {
            out.push(Line::from(""));
        }
        match segment.kind {
            ChatContentSegmentKind::Markdown => {
                out.extend(render_markdown_only(&segment.content, width, base_style));
            }
            ChatContentSegmentKind::Code | ChatContentSegmentKind::Diff => {
                render_code_block(&mut out, &segment.language, &segment.content, width);
            }
        }
    }
    while out.last().is_some_and(is_blank_line) {
        out.pop();
    }
    if out.is_empty() {
        out.push(Line::from(""));
    }
    out
}

fn is_blank_line(line: &Line<'static>) -> bool {
    line.spans.is_empty()
        || line
            .spans
            .iter()
            .all(|span| span.content.as_ref().trim().is_empty())
}

fn segment_chat_content(text: &str) -> Vec<ChatContentSegment> {
    let mut segments = Vec::new();
    let mut markdown = Vec::<String>::new();
    let mut code = Vec::<String>::new();
    let mut code_kind = ChatContentSegmentKind::Code;
    let mut code_language = String::from("text");
    let mut in_fence = false;
    let mut raw_diff_mode = false;

    let flush_markdown = |segments: &mut Vec<ChatContentSegment>, markdown: &mut Vec<String>| {
        if markdown.iter().any(|line| !line.trim().is_empty()) {
            segments.push(ChatContentSegment {
                kind: ChatContentSegmentKind::Markdown,
                language: String::new(),
                content: markdown.join("\n"),
            });
        }
        markdown.clear();
    };
    let flush_code = |segments: &mut Vec<ChatContentSegment>,
                      code: &mut Vec<String>,
                      kind: ChatContentSegmentKind,
                      language: &str| {
        if code.iter().any(|line| !line.trim().is_empty()) {
            segments.push(ChatContentSegment {
                kind,
                language: language.to_string(),
                content: trim_blank_edge_lines(code).join("\n"),
            });
        }
        code.clear();
    };

    let mut pending_code = Vec::<String>::new();
    let mut pending_language = String::from("text");

    let flush_pending_code_as_markdown = |markdown: &mut Vec<String>, pending: &mut Vec<String>| {
        markdown.append(pending);
    };
    let flush_pending_code_as_code = |segments: &mut Vec<ChatContentSegment>,
                                      markdown: &mut Vec<String>,
                                      pending: &mut Vec<String>,
                                      language: &str| {
        flush_markdown(segments, markdown);
        if pending.iter().any(|line| !line.trim().is_empty()) {
            segments.push(ChatContentSegment {
                kind: ChatContentSegmentKind::Code,
                language: language.to_string(),
                content: trim_blank_edge_lines(pending).join("\n"),
            });
        }
        pending.clear();
    };

    for line in text.lines() {
        let trimmed = line.trim_start();
        if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
            // Existing fenced blocks are valid Markdown. Keep them in the
            // Markdown segment so pulldown-cmark preserves the declared language.
            if !pending_code.is_empty() {
                if pending_code_should_render_as_code(&pending_code) {
                    flush_pending_code_as_code(
                        &mut segments,
                        &mut markdown,
                        &mut pending_code,
                        &pending_language,
                    );
                } else {
                    flush_pending_code_as_markdown(&mut markdown, &mut pending_code);
                }
            }
            markdown.push(line.to_string());
            in_fence = !in_fence;
            continue;
        }
        if in_fence {
            markdown.push(line.to_string());
            continue;
        }

        if raw_diff_mode {
            if line.trim().is_empty() {
                flush_code(&mut segments, &mut code, code_kind, &code_language);
                raw_diff_mode = false;
                markdown.push(line.to_string());
            } else {
                code.push(line.to_string());
            }
            continue;
        }

        if is_raw_diff_start(line) {
            if !pending_code.is_empty() {
                if pending_code_should_render_as_code(&pending_code) {
                    flush_pending_code_as_code(
                        &mut segments,
                        &mut markdown,
                        &mut pending_code,
                        &pending_language,
                    );
                } else {
                    flush_pending_code_as_markdown(&mut markdown, &mut pending_code);
                }
            }
            flush_markdown(&mut segments, &mut markdown);
            raw_diff_mode = true;
            code_kind = ChatContentSegmentKind::Diff;
            code_language = String::from("diff");
            code.push(line.to_string());
            continue;
        }

        if is_unfenced_code_line(line) {
            if pending_code.is_empty() || pending_language == "text" {
                pending_language = detect_code_language(line);
            }
            pending_code.push(line.to_string());
            continue;
        }

        if !pending_code.is_empty() {
            if line.trim().is_empty() {
                pending_code.push(line.to_string());
                continue;
            }
            if pending_code_should_render_as_code(&pending_code) {
                flush_pending_code_as_code(
                    &mut segments,
                    &mut markdown,
                    &mut pending_code,
                    &pending_language,
                );
            } else {
                flush_pending_code_as_markdown(&mut markdown, &mut pending_code);
            }
        }
        markdown.push(line.to_string());
    }

    if raw_diff_mode {
        flush_code(&mut segments, &mut code, code_kind, &code_language);
    }
    if !pending_code.is_empty() {
        if pending_code_should_render_as_code(&pending_code) {
            flush_pending_code_as_code(
                &mut segments,
                &mut markdown,
                &mut pending_code,
                &pending_language,
            );
        } else {
            flush_pending_code_as_markdown(&mut markdown, &mut pending_code);
        }
    }
    flush_markdown(&mut segments, &mut markdown);
    segments
}

fn trim_blank_edge_lines(lines: &[String]) -> Vec<String> {
    let start = lines
        .iter()
        .position(|line| !line.trim().is_empty())
        .unwrap_or(lines.len());
    let end = lines
        .iter()
        .rposition(|line| !line.trim().is_empty())
        .map(|index| index + 1)
        .unwrap_or(start);
    lines[start..end].to_vec()
}

fn pending_code_should_render_as_code(lines: &[String]) -> bool {
    let significant = lines
        .iter()
        .filter(|line| !line.trim().is_empty())
        .collect::<Vec<_>>();
    if significant.len() >= 2 {
        return true;
    }
    significant
        .first()
        .is_some_and(|line| is_strong_single_line_code(line))
}

fn is_raw_diff_start(line: &str) -> bool {
    let trimmed = line.trim_start();
    trimmed.starts_with("diff --git ")
        || trimmed.starts_with("@@ ")
        || trimmed.starts_with("--- ")
        || trimmed.starts_with("+++ ")
        || trimmed.starts_with("index ")
}

fn is_unfenced_code_line(line: &str) -> bool {
    let trimmed = line.trim_start();
    if trimmed.is_empty() {
        return false;
    }
    if line.starts_with("    ") || line.starts_with('\t') {
        return true;
    }
    if trimmed.starts_with("//")
        || trimmed.starts_with("/*")
        || trimmed.starts_with("*/")
        || trimmed.starts_with('#') && !trimmed.starts_with("# ")
    {
        return true;
    }
    if trimmed.starts_with('{') || trimmed.starts_with('}') || trimmed.starts_with(']') {
        return true;
    }
    let lower = trimmed.to_ascii_lowercase();
    let code_prefixes = [
        "fn ",
        "pub ",
        "use ",
        "impl ",
        "struct ",
        "enum ",
        "trait ",
        "let ",
        "const ",
        "static ",
        "mod ",
        "type ",
        "async ",
        "await ",
        "match ",
        "return ",
        "class ",
        "def ",
        "import ",
        "from ",
        "function ",
        "export ",
        "interface ",
        "package ",
        "public ",
        "private ",
        "protected ",
        "namespace ",
        "using ",
        "#include",
        "if (",
        "for (",
        "while (",
        "switch (",
        "select ",
        "insert ",
        "update ",
        "delete ",
        "create ",
        "alter ",
        "drop ",
        "contract ",
        "prompt ",
    ];
    if code_prefixes.iter().any(|prefix| lower.starts_with(prefix)) {
        return true;
    }
    let has_code_punctuation = trimmed.ends_with(';')
        || trimmed.ends_with('{')
        || trimmed.ends_with('}')
        || trimmed.contains("=>")
        || trimmed.contains("::")
        || trimmed.contains(" := ")
        || trimmed.contains(" = ")
        || trimmed.contains(" == ")
        || trimmed.contains(" != ")
        || trimmed.contains(" && ")
        || trimmed.contains(" || ");
    has_code_punctuation && !looks_like_plain_sentence(trimmed)
}

fn is_strong_single_line_code(line: &str) -> bool {
    let trimmed = line.trim_start();
    trimmed.starts_with("diff --git ")
        || trimmed.starts_with("@@ ")
        || trimmed.ends_with(';')
        || trimmed.ends_with('{')
        || trimmed.ends_with('}')
        || trimmed.contains("=>")
        || trimmed.contains("::")
        || trimmed.contains(" := ")
        || trimmed.starts_with("fn ")
        || trimmed.starts_with("def ")
        || trimmed.starts_with("class ")
        || trimmed.starts_with("function ")
}

fn looks_like_plain_sentence(trimmed: &str) -> bool {
    trimmed.contains(' ')
        && trimmed.ends_with('.')
        && !trimmed.contains(';')
        && !trimmed.contains('{')
        && !trimmed.contains('}')
}

fn detect_code_language(line: &str) -> String {
    let trimmed = line.trim_start();
    let lower = trimmed.to_ascii_lowercase();
    if lower.starts_with("fn ")
        || lower.starts_with("pub ")
        || lower.starts_with("impl ")
        || lower.starts_with("struct ")
        || lower.starts_with("enum ")
        || lower.starts_with("use ")
        || lower.starts_with("let ")
    {
        "rust".to_string()
    } else if lower.starts_with("def ")
        || lower.starts_with("from ")
        || lower.starts_with("import ")
    {
        "python".to_string()
    } else if lower.starts_with("function ")
        || lower.starts_with("const ")
        || lower.starts_with("export ")
        || lower.starts_with("interface ")
    {
        "typescript".to_string()
    } else if lower.starts_with("select ")
        || lower.starts_with("insert ")
        || lower.starts_with("update ")
        || lower.starts_with("delete ")
    {
        "sql".to_string()
    } else if lower.starts_with("contract ") || lower.starts_with("prompt ") {
        "usrl".to_string()
    } else {
        "text".to_string()
    }
}

#[derive(Default)]
struct TableRenderState {
    in_table: bool,
    in_header: bool,
    headers: Vec<String>,
    rows: Vec<Vec<String>>,
    current_row: Vec<String>,
    current_cell: String,
}

fn flush_current(
    out: &mut Vec<Line<'static>>,
    current: &mut Vec<Span<'static>>,
    width: usize,
    continuation_prefix: &str,
) {
    if current.is_empty() {
        return;
    }
    out.extend(wrap_spans(
        std::mem::take(current),
        width,
        continuation_prefix,
    ));
}

fn wrap_spans(
    spans: Vec<Span<'static>>,
    width: usize,
    continuation_prefix: &str,
) -> Vec<Line<'static>> {
    let width = width.max(1);
    let mut rows: Vec<Line<'static>> = Vec::new();
    let mut current: Vec<Span<'static>> = Vec::new();
    let mut current_width = 0usize;
    let continuation_width = continuation_prefix.width();
    for span in spans {
        let style = span.style;
        let safe_content = sanitize_display_text(span.content.as_ref());
        for piece in split_preserving_spaces(&safe_content) {
            let piece_width = piece.width();
            if current_width > 0 && current_width + piece_width > width {
                rows.push(Line::from(std::mem::take(&mut current)));
                current_width = 0;
                if !continuation_prefix.is_empty() {
                    current.push(Span::raw(continuation_prefix.to_string()));
                    current_width = continuation_width;
                }
            }
            if piece_width > width && current_width == 0 {
                for chunk in wrap_preserve(&piece, width) {
                    rows.push(Line::from(Span::styled(chunk, style)));
                }
                continue;
            }
            current.push(Span::styled(piece, style));
            current_width += piece_width;
        }
    }
    if !current.is_empty() {
        rows.push(Line::from(current));
    }
    rows
}

fn split_preserving_spaces(text: &str) -> Vec<String> {
    let mut pieces = Vec::new();
    let mut current = String::new();
    let mut was_space = None::<bool>;
    for ch in text.chars() {
        let is_space = ch.is_whitespace();
        if let Some(previous) = was_space
            && previous != is_space
            && !current.is_empty()
        {
            pieces.push(std::mem::take(&mut current));
        }
        current.push(ch);
        was_space = Some(is_space);
    }
    if !current.is_empty() {
        pieces.push(current);
    }
    pieces
}

fn render_code_block(out: &mut Vec<Line<'static>>, language: &str, code: &str, width: usize) {
    let language = normalize_code_language(language);
    let title = if language.is_empty() {
        " code ".to_string()
    } else {
        format!(" {language} ")
    };
    out.push(Line::from(vec![
        Span::styled("╭─", Style::default().fg(BORDER)),
        Span::styled(
            title,
            Style::default()
                .fg(CYAN)
                .bg(PANEL)
                .add_modifier(Modifier::BOLD),
        ),
        Span::styled(
            "─".repeat(width.saturating_sub(language.len() + 4)),
            Style::default().fg(BORDER),
        ),
    ]));

    let highlighted_lines = highlight_code_block_with_inkjet(&language, code)
        .unwrap_or_else(|| fallback_highlight_code_block(&language, code));
    let body_width = width.saturating_sub(7).max(1);
    for (index, spans) in highlighted_lines.into_iter().enumerate() {
        let gutter = format!("{:>4} │ ", index + 1);
        let wrapped = wrap_spans(spans, body_width, "");
        if wrapped.is_empty() {
            out.push(Line::from(vec![Span::styled(
                gutter,
                Style::default().fg(DIM).bg(PANEL),
            )]));
            continue;
        }
        for (wrap_index, line) in wrapped.into_iter().enumerate() {
            let prefix = if wrap_index == 0 {
                gutter.clone()
            } else {
                "     │ ".to_string()
            };
            let mut rendered = vec![Span::styled(prefix, Style::default().fg(DIM).bg(PANEL))];
            rendered.extend(line.spans);
            out.push(Line::from(rendered));
        }
    }
    out.push(Line::from(Span::styled(
        "╰────",
        Style::default().fg(BORDER),
    )));
    out.push(Line::from(""));
}

fn normalize_code_language(language: &str) -> String {
    let language = language.trim().trim_start_matches('.').to_ascii_lowercase();
    match language.as_str() {
        "patch" => "diff".to_string(),
        "c++" => "cpp".to_string(),
        "c#" => "csharp".to_string(),
        "shell" => "bash".to_string(),
        "yml" => "yaml".to_string(),
        "rs" => "rust".to_string(),
        "py" => "python".to_string(),
        "js" => "javascript".to_string(),
        "jsx" => "jsx".to_string(),
        "ts" => "typescript".to_string(),
        "tsx" => "tsx".to_string(),
        "cs" => "csharp".to_string(),
        other => other.to_string(),
    }
}

fn highlight_code_block_with_inkjet(language: &str, code: &str) -> Option<Vec<Vec<Span<'static>>>> {
    if code.is_empty() {
        return Some(vec![Vec::new()]);
    }
    let inkjet_language = inkjet::Language::from_token(if language.is_empty() {
        "plaintext"
    } else {
        language
    })?;
    let mut highlighter = inkjet::Highlighter::new();
    let events = highlighter.highlight_raw(inkjet_language, &code).ok()?;
    let mut style_stack = vec![Style::default().fg(Color::Rgb(185, 190, 200)).bg(PANEL)];
    let mut lines = vec![Vec::<Span<'static>>::new()];

    for event in events {
        match event.ok()? {
            HighlightEvent::Source { start, end } => {
                push_inkjet_source(&mut lines, &code[start..end], *style_stack.last().unwrap());
            }
            HighlightEvent::HighlightStart(highlight) => {
                style_stack.push(inkjet_scope_style(highlight.0));
            }
            HighlightEvent::HighlightEnd => {
                if style_stack.len() > 1 {
                    style_stack.pop();
                }
            }
        }
    }

    if code.ends_with('\n') {
        while lines.last().is_some_and(|line| line.is_empty()) && lines.len() > 1 {
            lines.pop();
        }
    }
    Some(lines)
}

fn push_inkjet_source(lines: &mut Vec<Vec<Span<'static>>>, source: &str, style: Style) {
    for (index, segment) in source.split('\n').enumerate() {
        if index > 0 {
            lines.push(Vec::new());
        }
        if !segment.is_empty() {
            lines
                .last_mut()
                .expect("inkjet line accumulator is never empty")
                .push(Span::styled(segment.to_string(), style));
        }
    }
}

fn inkjet_scope_style(highlight_index: usize) -> Style {
    let scope = HIGHLIGHT_NAMES
        .get(highlight_index)
        .copied()
        .unwrap_or_default();
    let mut style = Style::default().bg(PANEL);
    if scope.starts_with("comment") {
        style = style.fg(DIM).add_modifier(Modifier::ITALIC);
    } else if scope.starts_with("string") || scope == "escape" {
        style = style.fg(GREEN);
    } else if scope.starts_with("constant.numeric") || scope.starts_with("constant.builtin") {
        style = style.fg(AMBER);
    } else if scope.starts_with("constant") || scope == "special" {
        style = style.fg(Color::Rgb(210, 150, 230));
    } else if scope.starts_with("keyword") || scope == "operator" {
        style = style.fg(CYAN).add_modifier(Modifier::BOLD);
    } else if scope.starts_with("function") || scope == "constructor" {
        style = style.fg(Color::Rgb(120, 200, 255));
    } else if scope.starts_with("type") || scope == "namespace" {
        style = style.fg(Color::Rgb(125, 220, 190));
    } else if scope.starts_with("variable.parameter") || scope.starts_with("label") {
        style = style.fg(Color::Rgb(235, 190, 120));
    } else if scope.starts_with("tag") {
        style = style.fg(Color::Rgb(255, 135, 170));
    } else if scope.starts_with("punctuation") {
        style = style.fg(Color::Rgb(150, 155, 165));
    } else if scope == "diff.plus" {
        style = style.fg(GREEN);
    } else if scope == "diff.minus" {
        style = style.fg(RED);
    } else if scope.starts_with("diff.delta") || scope == "diff" {
        style = style.fg(CYAN).add_modifier(Modifier::BOLD);
    } else if scope.starts_with("markup.heading") {
        style = style.fg(CYAN).add_modifier(Modifier::BOLD);
    } else if scope.starts_with("markup") {
        style = style.fg(AMBER);
    } else {
        style = style.fg(Color::Rgb(185, 190, 200));
    }
    style
}

fn fallback_highlight_code_block(language: &str, code: &str) -> Vec<Vec<Span<'static>>> {
    code.lines()
        .map(|raw| {
            let line_style = if matches!(language, "diff" | "patch") {
                diff_line_style(raw)
            } else {
                code_line_style(language, raw)
            };
            highlight_code_piece(language, raw, line_style)
        })
        .collect::<Vec<_>>()
}

fn diff_line_style(line: &str) -> Style {
    if line.starts_with('+') && !line.starts_with("+++") {
        Style::default().fg(GREEN)
    } else if line.starts_with('-') && !line.starts_with("---") {
        Style::default().fg(RED)
    } else if line.starts_with("@@") {
        Style::default().fg(CYAN).add_modifier(Modifier::BOLD)
    } else {
        Style::default().fg(Color::Rgb(185, 190, 200))
    }
}

fn code_line_style(language: &str, line: &str) -> Style {
    let trimmed = line.trim_start();
    if matches!(
        language,
        "rust"
            | "rs"
            | "python"
            | "py"
            | "typescript"
            | "ts"
            | "javascript"
            | "js"
            | "java"
            | "csharp"
            | "cs"
            | "cpp"
            | "c++"
            | "c"
    ) {
        if trimmed.starts_with("//") || trimmed.starts_with('#') {
            return Style::default().fg(DIM).add_modifier(Modifier::ITALIC);
        }
        if trimmed.starts_with("fn ")
            || trimmed.starts_with("def ")
            || trimmed.starts_with("class ")
            || trimmed.starts_with("function ")
            || trimmed.starts_with("public ")
            || trimmed.starts_with("private ")
        {
            return Style::default().fg(CYAN);
        }
        if trimmed.starts_with("use ")
            || trimmed.starts_with("import ")
            || trimmed.starts_with("from ")
            || trimmed.starts_with("#include")
            || trimmed.starts_with("using ")
        {
            return Style::default().fg(AMBER);
        }
    }
    Style::default().fg(Color::Rgb(185, 190, 200))
}

fn highlight_code_piece(language: &str, piece: &str, fallback: Style) -> Vec<Span<'static>> {
    let fallback = fallback.bg(PANEL);
    if matches!(language, "diff" | "patch") {
        return vec![Span::styled(piece.to_string(), fallback)];
    }
    if matches!(language, "json" | "jsonc") {
        return highlight_json_piece(piece, fallback);
    }
    if is_rust_language(language) {
        return highlight_rust_piece(piece, fallback);
    }
    if !is_code_language(language) {
        return vec![Span::styled(piece.to_string(), fallback)];
    }
    let trimmed = piece.trim_start();
    if trimmed.starts_with("//") || trimmed.starts_with('#') {
        return vec![Span::styled(
            piece.to_string(),
            Style::default()
                .fg(DIM)
                .bg(PANEL)
                .add_modifier(Modifier::ITALIC),
        )];
    }
    highlight_language_piece(piece, fallback)
}

fn is_rust_language(language: &str) -> bool {
    matches!(language, "rust" | "rs")
}

fn is_code_language(language: &str) -> bool {
    matches!(
        language,
        "rust"
            | "rs"
            | "python"
            | "py"
            | "typescript"
            | "ts"
            | "tsx"
            | "javascript"
            | "js"
            | "jsx"
            | "java"
            | "csharp"
            | "cs"
            | "cpp"
            | "c++"
            | "c"
            | "go"
            | "html"
            | "css"
            | "toml"
            | "yaml"
            | "yml"
            | "bash"
            | "sh"
            | "shell"
    )
}

fn highlight_rust_piece(piece: &str, fallback: Style) -> Vec<Span<'static>> {
    // Keep the TUI input path responsive: this function runs while chat is
    // rendered, including redraws caused by ordinary typing. Tree-sitter was
    // previously invoked per wrapped Rust line here, which made every keystroke
    // reparse visible chat code. Use the lightweight lexer in the hot path; a
    // cached/full-block Tree-sitter highlighter can be reintroduced off this
    // path later.
    highlight_language_piece(piece, fallback)
}

fn highlight_language_piece(piece: &str, fallback: Style) -> Vec<Span<'static>> {
    let mut spans = Vec::new();
    let mut chars = piece.char_indices().peekable();
    while let Some((start, ch)) = chars.next() {
        if ch == '"' || ch == '\'' || ch == '`' {
            let quote = ch;
            let mut end = start + ch.len_utf8();
            let mut escaped = false;
            for (idx, next) in chars.by_ref() {
                end = idx + next.len_utf8();
                if escaped {
                    escaped = false;
                } else if next == '\\' {
                    escaped = true;
                } else if next == quote {
                    break;
                }
            }
            spans.push(Span::styled(
                piece[start..end].to_string(),
                Style::default().fg(GREEN).bg(PANEL),
            ));
        } else if ch.is_ascii_digit() {
            let mut end = start + ch.len_utf8();
            while let Some((idx, next)) = chars.peek().copied() {
                if next.is_ascii_digit() || next == '.' || next == '_' {
                    chars.next();
                    end = idx + next.len_utf8();
                } else {
                    break;
                }
            }
            spans.push(Span::styled(
                piece[start..end].to_string(),
                Style::default().fg(AMBER).bg(PANEL),
            ));
        } else if is_ident_start(ch) {
            let mut end = start + ch.len_utf8();
            while let Some((idx, next)) = chars.peek().copied() {
                if is_ident_continue(next) {
                    chars.next();
                    end = idx + next.len_utf8();
                } else {
                    break;
                }
            }
            let token = &piece[start..end];
            let style = if is_keyword(token) {
                Style::default()
                    .fg(CYAN)
                    .bg(PANEL)
                    .add_modifier(Modifier::BOLD)
            } else if is_literal(token) {
                Style::default().fg(AMBER).bg(PANEL)
            } else {
                fallback
            };
            spans.push(Span::styled(token.to_string(), style));
        } else {
            spans.push(Span::styled(ch.to_string(), fallback));
        }
    }
    spans
}

fn highlight_json_piece(piece: &str, fallback: Style) -> Vec<Span<'static>> {
    let mut spans = Vec::new();
    let mut chars = piece.char_indices().peekable();
    while let Some((start, ch)) = chars.next() {
        if ch == '"' {
            let mut end = start + 1;
            let mut escaped = false;
            for (idx, next) in chars.by_ref() {
                end = idx + next.len_utf8();
                if escaped {
                    escaped = false;
                } else if next == '\\' {
                    escaped = true;
                } else if next == '"' {
                    break;
                }
            }
            let rest = piece[end..].trim_start();
            let style = if rest.starts_with(':') {
                Style::default().fg(CYAN).bg(PANEL)
            } else {
                Style::default().fg(GREEN).bg(PANEL)
            };
            spans.push(Span::styled(piece[start..end].to_string(), style));
        } else if ch.is_ascii_digit() || ch == '-' {
            let mut end = start + ch.len_utf8();
            while let Some((idx, next)) = chars.peek().copied() {
                if next.is_ascii_digit() || matches!(next, '.' | 'e' | 'E' | '+' | '-') {
                    chars.next();
                    end = idx + next.len_utf8();
                } else {
                    break;
                }
            }
            spans.push(Span::styled(
                piece[start..end].to_string(),
                Style::default().fg(AMBER).bg(PANEL),
            ));
        } else if is_ident_start(ch) {
            let mut end = start + ch.len_utf8();
            while let Some((idx, next)) = chars.peek().copied() {
                if is_ident_continue(next) {
                    chars.next();
                    end = idx + next.len_utf8();
                } else {
                    break;
                }
            }
            let token = &piece[start..end];
            let style = if matches!(token, "true" | "false" | "null") {
                Style::default().fg(AMBER).bg(PANEL)
            } else {
                fallback
            };
            spans.push(Span::styled(token.to_string(), style));
        } else {
            spans.push(Span::styled(ch.to_string(), fallback));
        }
    }
    spans
}

fn is_ident_start(ch: char) -> bool {
    ch == '_' || ch.is_ascii_alphabetic()
}

fn is_ident_continue(ch: char) -> bool {
    ch == '_' || ch.is_ascii_alphanumeric()
}

fn is_keyword(token: &str) -> bool {
    matches!(
        token,
        "as" | "async"
            | "await"
            | "break"
            | "case"
            | "class"
            | "const"
            | "continue"
            | "def"
            | "else"
            | "enum"
            | "export"
            | "extends"
            | "false"
            | "fn"
            | "for"
            | "from"
            | "function"
            | "if"
            | "impl"
            | "import"
            | "in"
            | "interface"
            | "let"
            | "loop"
            | "match"
            | "mod"
            | "mut"
            | "new"
            | "private"
            | "pub"
            | "public"
            | "return"
            | "self"
            | "static"
            | "struct"
            | "super"
            | "switch"
            | "this"
            | "trait"
            | "true"
            | "type"
            | "use"
            | "var"
            | "while"
    )
}

fn is_literal(token: &str) -> bool {
    matches!(
        token,
        "true" | "false" | "null" | "None" | "Some" | "Ok" | "Err"
    )
}

fn render_table(out: &mut Vec<Line<'static>>, table: &TableRenderState, width: usize) {
    if table.headers.is_empty() && table.rows.is_empty() {
        return;
    }
    let columns = table
        .headers
        .len()
        .max(table.rows.iter().map(Vec::len).max().unwrap_or(0));
    if columns == 0 {
        return;
    }
    let max_col_width = (width / columns).saturating_sub(3).clamp(8, 28);
    let header = (0..columns)
        .map(|index| {
            truncate(
                table.headers.get(index).map(String::as_str).unwrap_or(""),
                max_col_width,
            )
        })
        .collect::<Vec<_>>();
    out.push(table_line(
        &header,
        max_col_width,
        Style::default().fg(CYAN).add_modifier(Modifier::BOLD),
    ));
    out.push(Line::from(Span::styled(
        "─".repeat(width.min(columns * (max_col_width + 3))),
        Style::default().fg(BORDER),
    )));
    for row in &table.rows {
        let cells = (0..columns)
            .map(|index| {
                truncate(
                    row.get(index).map(String::as_str).unwrap_or(""),
                    max_col_width,
                )
            })
            .collect::<Vec<_>>();
        out.push(table_line(&cells, max_col_width, Style::default().fg(FG)));
    }
}

fn table_line(cells: &[String], width: usize, style: Style) -> Line<'static> {
    let mut spans = Vec::new();
    for (index, cell) in cells.iter().enumerate() {
        if index > 0 {
            spans.push(Span::styled(" │ ", Style::default().fg(BORDER)));
        }
        spans.push(Span::styled(format!("{cell:<width$}"), style));
    }
    Line::from(spans)
}

fn draw_input(f: &mut Frame<'_>, app: &TuiApplication, area: Rect) {
    let focused = app.session.status != "streaming";
    let title = if app.input.buffer.starts_with('/') {
        " command "
    } else {
        " message "
    };
    let border_style = if focused { CYAN } else { BORDER };
    let max_rows = area.height.saturating_sub(2).max(1) as usize;
    let lines = input_lines(&app.input, area.width.saturating_sub(4) as usize, max_rows);
    let paragraph = Paragraph::new(lines)
        .style(Style::default().fg(FG).bg(PANEL))
        .block(
            Block::default()
                .title(title)
                .borders(Borders::ALL)
                .border_style(Style::default().fg(border_style))
                .padding(Padding::horizontal(1)),
        )
        .wrap(Wrap { trim: false });
    f.render_widget(paragraph, area);
}

fn draw_suggestions(f: &mut Frame<'_>, app: &TuiApplication, input_area: Rect, screen: Rect) {
    if !app.input.buffer.starts_with('/') || app.input.suggestions.is_empty() {
        return;
    }
    if app.command_palette_open {
        draw_command_palette(f, app, screen);
        return;
    }
    let count = app.input.suggestions.len().min(8) as u16;
    let width = input_area.width.clamp(30, 92);
    let height = count + 2;
    let x = input_area.x;
    let y = input_area.y.saturating_sub(height);
    let area = Rect {
        x,
        y,
        width,
        height: height.min(input_area.y.saturating_add(input_area.height)),
    };
    f.render_widget(Clear, area);
    let lines = app
        .input
        .suggestions
        .iter()
        .take(count as usize)
        .enumerate()
        .map(|(index, suggestion)| {
            let selected = index == app.input.selected_suggestion;
            let style = if selected {
                Style::default().fg(Color::Black).bg(CYAN)
            } else {
                Style::default().fg(FG).bg(PANEL)
            };
            let value = suggestion
                .replacement
                .as_deref()
                .unwrap_or(&suggestion.value);
            Line::from(vec![
                Span::styled(format!(" {:<22}", truncate(value, 22)), style),
                Span::styled(
                    truncate(&suggestion.description, width.saturating_sub(26) as usize),
                    style.fg(if selected { Color::Black } else { DIM }),
                ),
            ])
        })
        .collect::<Vec<_>>();
    let paragraph = Paragraph::new(lines).block(
        Block::default()
            .title(" commands ")
            .borders(Borders::ALL)
            .border_style(Style::default().fg(BORDER)),
    );
    f.render_widget(paragraph, area);
}

fn draw_help_overlay(f: &mut Frame<'_>, app: &TuiApplication, area: Rect) {
    f.render_widget(Clear, area);
    let lines = help_overlay_lines(
        app,
        area.width.saturating_sub(4) as usize,
        area.height as usize,
    );
    let paragraph = Paragraph::new(lines)
        .style(Style::default().fg(FG).bg(PANEL))
        .block(
            Block::default()
                .title(" help ")
                .borders(Borders::ALL)
                .border_style(Style::default().fg(CYAN))
                .padding(Padding::horizontal(1)),
        )
        .wrap(Wrap { trim: false });
    f.render_widget(paragraph, area);
}

fn help_overlay_lines(app: &TuiApplication, width: usize, height: usize) -> Vec<Line<'static>> {
    let mut lines = vec![
        Line::from(vec![
            Span::styled(
                "Vegvisir controls",
                Style::default().fg(FG).add_modifier(Modifier::BOLD),
            ),
            Span::raw("   "),
            Span::styled("Esc or ? closes", Style::default().fg(DIM)),
        ]),
        Line::from(""),
        Line::from(vec![
            Span::styled("Enter", Style::default().fg(CYAN)),
            Span::styled(" send/run   ", Style::default().fg(DIM)),
            Span::styled("Shift+Enter", Style::default().fg(CYAN)),
            Span::styled(" newline   ", Style::default().fg(DIM)),
            Span::styled("Ctrl+P", Style::default().fg(CYAN)),
            Span::styled(" commands", Style::default().fg(DIM)),
        ]),
        Line::from(vec![
            Span::styled("PgUp/PgDn", Style::default().fg(CYAN)),
            Span::styled(" scroll chat   ", Style::default().fg(DIM)),
            Span::styled("End", Style::default().fg(CYAN)),
            Span::styled(" follow live output   ", Style::default().fg(DIM)),
            Span::styled("Ctrl+F", Style::default().fg(CYAN)),
            Span::styled(" search   ", Style::default().fg(DIM)),
            Span::styled("Ctrl+C", Style::default().fg(CYAN)),
            Span::styled(" quit", Style::default().fg(DIM)),
        ]),
        Line::from(vec![
            Span::styled("1", Style::default().fg(GREEN)),
            Span::styled(" approve once   ", Style::default().fg(DIM)),
            Span::styled("2", Style::default().fg(CYAN)),
            Span::styled(" allow session   ", Style::default().fg(DIM)),
            Span::styled("3", Style::default().fg(RED)),
            Span::styled(" deny pending tool", Style::default().fg(DIM)),
        ]),
        Line::from(""),
        Line::from(Span::styled(
            "Common commands",
            Style::default().fg(FG).add_modifier(Modifier::BOLD),
        )),
    ];
    let common = [
        ("/models", "list or refresh models for the active provider"),
        ("/provider", "switch provider or inspect current provider"),
        ("/workspace", "switch project workspace and session context"),
        ("/approvals", "inspect or resolve pending tool approvals"),
        ("/work", "open recent work, command, and tool activity"),
        ("/context", "inspect current context and memory use"),
        ("/tools", "show or change available tool permissions"),
        (
            "/auto",
            "toggle autonomous working mode for unattended workflows",
        ),
    ];
    for (name, description) in common {
        push_help_command_line(&mut lines, name, description, width);
    }
    if lines.len() < height.saturating_sub(1) {
        lines.push(Line::from(""));
        lines.push(Line::from(Span::styled(
            "Command inventory",
            Style::default().fg(FG).add_modifier(Modifier::BOLD),
        )));
    }
    let remaining = height.saturating_sub(lines.len() + 1);
    for command in app.commands.all().into_iter().take(remaining) {
        push_help_command_line(&mut lines, &command.name, &command.description, width);
    }
    lines
}

fn push_help_command_line(
    lines: &mut Vec<Line<'static>>,
    name: &str,
    description: &str,
    width: usize,
) {
    let name_width = 18usize;
    let desc_width = width.saturating_sub(name_width + 3).max(8);
    lines.push(Line::from(vec![
        Span::styled(
            format!("{:<name_width$}", truncate(name, name_width)),
            Style::default().fg(CYAN),
        ),
        Span::styled(" ", Style::default().fg(DIM)),
        Span::styled(truncate(description, desc_width), Style::default().fg(DIM)),
    ]));
}

fn draw_diff_overlay(f: &mut Frame<'_>, app: &TuiApplication, diff: &mut DiffOverlay, area: Rect) {
    f.render_widget(Clear, area);
    let visible_height = area.height.saturating_sub(6).max(1) as usize;
    let content_width = area.width.saturating_sub(4) as usize;
    let all_lines = cached_diff_overlay_lines(diff, content_width);
    let max_offset = all_lines.len().saturating_sub(visible_height);
    let offset = app.diff_scroll_offset.min(max_offset);
    let end = all_lines.len().saturating_sub(offset);
    let start = end.saturating_sub(visible_height);
    let mut visible = all_lines[start..end].to_vec();
    apply_scroll_indicators(&mut visible, offset, max_offset, content_width);

    let summary = format!(
        " {} file{}  +{} -{}  {}   PgUp/PgDn scroll   End follow   Esc close ",
        diff.files_changed,
        if diff.files_changed == 1 { "" } else { "s" },
        diff.added_lines,
        diff.removed_lines,
        diff.rendered_by.label()
    );
    let paragraph = Paragraph::new(visible)
        .style(Style::default().fg(FG).bg(PANEL))
        .block(
            Block::default()
                .title(format!(" review: {} ", diff.title))
                .title_bottom(summary)
                .borders(Borders::ALL)
                .border_style(Style::default().fg(CYAN))
                .padding(Padding::horizontal(1)),
        )
        .wrap(Wrap { trim: false });
    f.render_widget(paragraph, area);
}

fn cached_diff_overlay_lines(diff: &mut DiffOverlay, width: usize) -> &[Line<'static>] {
    let cache_matches = diff
        .rendered_lines_cache
        .as_ref()
        .is_some_and(|cache| cache.width == width);
    if !cache_matches {
        let lines = diff_overlay_lines(diff, width);
        diff.rendered_lines_cache = Some(DiffOverlayRenderCache { width, lines });
    }
    diff.rendered_lines_cache
        .as_ref()
        .map(|cache| cache.lines.as_slice())
        .unwrap_or(&[])
}

fn diff_overlay_lines(diff: &DiffOverlay, width: usize) -> Vec<Line<'static>> {
    if diff.rendered_by != DiffRenderer::Unified {
        return external_diff_overlay_lines(diff, width);
    }
    let mut lines = Vec::new();
    let mut current_file: Option<String> = None;
    for raw in diff.diff.lines() {
        if let Some(file) = parse_diff_file(raw) {
            current_file = Some(file.clone());
            lines.push(Line::from(""));
            lines.push(Line::from(vec![
                Span::styled("file ", Style::default().fg(DIM)),
                Span::styled(file, Style::default().fg(FG).add_modifier(Modifier::BOLD)),
            ]));
            continue;
        }
        if raw.starts_with("@@") {
            lines.push(Line::from(Span::styled(
                truncate(raw, width),
                Style::default().fg(CYAN).add_modifier(Modifier::BOLD),
            )));
            continue;
        }
        if raw.starts_with("+++ ") || raw.starts_with("--- ") {
            continue;
        }
        if raw.starts_with('+') {
            push_wrapped_diff_line(&mut lines, raw, width, "+", GREEN);
        } else if raw.starts_with('-') {
            push_wrapped_diff_line(&mut lines, raw, width, "-", RED);
        } else if raw.starts_with("diff --git ") {
            if current_file.is_none() {
                lines.push(Line::from(Span::styled(
                    truncate(raw, width),
                    Style::default().fg(DIM),
                )));
            }
        } else {
            push_wrapped_diff_line(&mut lines, raw, width, " ", Color::Rgb(185, 190, 200));
        }
    }
    if lines.is_empty() {
        lines.push(Line::from(Span::styled(
            "No diff content.",
            Style::default().fg(DIM),
        )));
    }
    lines
}

fn external_diff_overlay_lines(diff: &DiffOverlay, width: usize) -> Vec<Line<'static>> {
    let mut lines = Vec::new();
    for raw in diff.diff.lines() {
        let style = if raw.starts_with('+') {
            Style::default().fg(GREEN)
        } else if raw.starts_with('-') {
            Style::default().fg(RED)
        } else if raw.contains("---") || raw.contains("+++") || raw.contains("changed") {
            Style::default().fg(CYAN).add_modifier(Modifier::BOLD)
        } else {
            Style::default().fg(Color::Rgb(185, 190, 200))
        };
        for chunk in wrap_preserve(raw, width.max(1)) {
            lines.push(Line::from(Span::styled(chunk, style)));
        }
    }
    if lines.is_empty() {
        lines.push(Line::from(Span::styled(
            "No diff content.",
            Style::default().fg(DIM),
        )));
    }
    lines
}

fn parse_diff_file(line: &str) -> Option<String> {
    let rest = line.strip_prefix("diff --git ")?;
    let mut parts = rest.split_whitespace();
    let _left = parts.next()?;
    let right = parts.next()?;
    Some(right.trim_start_matches("b/").to_string())
}

fn push_wrapped_diff_line(
    lines: &mut Vec<Line<'static>>,
    raw: &str,
    width: usize,
    marker: &str,
    color: Color,
) {
    let text = raw.strip_prefix(marker).unwrap_or(raw);
    let body_width = width.saturating_sub(4).max(1);
    for (index, chunk) in wrap_preserve(text, body_width).into_iter().enumerate() {
        let prefix = if index == 0 {
            format!("{marker} ")
        } else {
            "  ".to_string()
        };
        lines.push(Line::from(vec![
            Span::styled(
                prefix,
                Style::default().fg(color).add_modifier(Modifier::BOLD),
            ),
            Span::styled(chunk, Style::default().fg(color)),
        ]));
    }
}

fn draw_info_overlay(f: &mut Frame<'_>, app: &TuiApplication, info: &InfoOverlay, area: Rect) {
    f.render_widget(Clear, area);
    let visible_height = area.height.saturating_sub(4).max(1) as usize;
    let content_width = area.width.saturating_sub(4) as usize;
    let all_lines = info_overlay_lines(info, content_width);
    let max_offset = all_lines.len().saturating_sub(visible_height);
    let offset = app.info_scroll_offset.min(max_offset);
    let end = all_lines.len().saturating_sub(offset);
    let start = end.saturating_sub(visible_height);
    let mut visible = all_lines[start..end].to_vec();
    apply_scroll_indicators(&mut visible, offset, max_offset, content_width);
    let paragraph = Paragraph::new(visible)
        .style(Style::default().fg(FG).bg(PANEL))
        .block(
            Block::default()
                .title(format!(" inspect: {} ", info.title))
                .title_bottom(" PgUp/PgDn scroll   End follow   Esc close ")
                .borders(Borders::ALL)
                .border_style(Style::default().fg(CYAN))
                .padding(Padding::horizontal(1)),
        )
        .wrap(Wrap { trim: false });
    f.render_widget(paragraph, area);
}

fn tool_lifecycle_style(content: &str) -> Option<Style> {
    let lower = content.trim_start().to_ascii_lowercase();
    if lower.starts_with("running tool:")
        || lower.starts_with("tool started:")
        || lower.starts_with("tool call")
    {
        return Some(Style::default().fg(CYAN));
    }
    if lower.starts_with("tool finished:") {
        let status = lower.split_once(" - ").map(|(_, rest)| rest.trim_start());
        if matches!(status, Some(rest) if rest.starts_with("ok:") || rest == "ok") {
            return Some(Style::default().fg(GREEN));
        }
        if matches!(status, Some(rest) if rest.starts_with("error:") || rest.starts_with("failed:") || rest.starts_with("err:"))
        {
            return Some(Style::default().fg(RED));
        }
        return Some(Style::default().fg(AMBER));
    }
    if lower.starts_with("tool failed:") || lower.starts_with("tool error:") {
        return Some(Style::default().fg(RED));
    }
    None
}

fn trace_event_line_style(raw: &str) -> Option<Style> {
    // `/trace` lines are rendered as plain command output in the info overlay:
    // `<timestamp> <event_name> <json payload>`.  A successful `tool_end`
    // payload includes `"error":null`, so classify by event/status before the
    // generic substring-based error rule below.
    if raw.contains(" tool_start ") {
        return Some(Style::default().fg(CYAN));
    }
    if raw.contains(" tool_end ") {
        let compact = raw.replace(char::is_whitespace, "");
        let ok_true = compact.contains("\"ok\":true");
        let ok_false = compact.contains("\"ok\":false");
        let null_error = compact.contains("\"error\":null");
        if ok_true && null_error {
            return Some(Style::default().fg(GREEN));
        }
        if ok_false || (compact.contains("\"error\":") && !null_error) {
            return Some(Style::default().fg(RED));
        }
        return Some(Style::default().fg(AMBER));
    }
    None
}

fn info_overlay_lines(info: &InfoOverlay, width: usize) -> Vec<Line<'static>> {
    if info.title.trim().eq_ignore_ascii_case("trace") {
        return trace_info_overlay_lines(info, width);
    }
    render_chat_content(&info.body, width, Style::default().fg(FG))
}

fn trace_info_overlay_lines(info: &InfoOverlay, width: usize) -> Vec<Line<'static>> {
    let mut lines = Vec::new();
    for raw in info.body.lines() {
        if raw.trim().is_empty() {
            lines.push(Line::from(""));
            continue;
        }
        if raw.starts_with('#') {
            lines.push(Line::from(Span::styled(
                truncate(raw, width),
                Style::default().fg(CYAN).add_modifier(Modifier::BOLD),
            )));
            continue;
        }
        if raw.starts_with("```") {
            lines.push(Line::from(Span::styled(
                truncate(raw, width),
                Style::default().fg(DIM),
            )));
            continue;
        }
        let style = if let Some(style) = trace_event_line_style(raw) {
            style
        } else if raw.to_ascii_lowercase().contains("error") {
            Style::default().fg(RED)
        } else if raw.starts_with('/') || raw.contains(" = ") || raw.contains(':') {
            Style::default().fg(CYAN)
        } else {
            Style::default().fg(FG)
        };
        for piece in wrap_preserve(raw, width) {
            lines.push(Line::from(Span::styled(piece, style)));
        }
    }
    if lines.is_empty() {
        lines.push(Line::from(Span::styled(
            "No content.",
            Style::default().fg(DIM),
        )));
    }
    lines
}

fn draw_sessions_overlay(f: &mut Frame<'_>, overlay: &SessionsOverlay, area: Rect) {
    f.render_widget(Clear, area);
    let content_width = area.width.saturating_sub(4) as usize;
    let visible_capacity = area.height.saturating_sub(7) as usize;
    let lines = sessions_overlay_lines(overlay, content_width, visible_capacity);
    let paragraph = Paragraph::new(lines)
        .style(Style::default().fg(FG).bg(PANEL))
        .block(
            Block::default()
                .title(" sessions: current workspace ")
                .title_bottom(" ↑/↓ select   Enter load   Esc close ")
                .borders(Borders::ALL)
                .border_style(Style::default().fg(CYAN))
                .padding(Padding::horizontal(1)),
        );
    f.render_widget(paragraph, area);
}

fn sessions_overlay_lines(
    overlay: &SessionsOverlay,
    width: usize,
    visible_capacity: usize,
) -> Vec<Line<'static>> {
    let mut lines = Vec::new();
    lines.push(Line::from(vec![
        Span::styled(
            "/sessions",
            Style::default().fg(CYAN).add_modifier(Modifier::BOLD),
        ),
        Span::styled("  ", Style::default().fg(DIM)),
        Span::styled(
            truncate(&overlay.workspace, width.saturating_sub(12)),
            Style::default().fg(DIM),
        ),
    ]));
    lines.push(Line::from(Span::styled(
        truncate(&overlay.status, width),
        Style::default().fg(AMBER),
    )));
    lines.push(Line::from(""));

    if overlay.entries.is_empty() {
        lines.push(Line::from(Span::styled(
            "No saved sessions for this workspace.",
            Style::default().fg(DIM),
        )));
        return lines;
    }

    let selected = overlay
        .selected
        .min(overlay.entries.len().saturating_sub(1));
    let visible_capacity = visible_capacity.max(1);
    let start = selected.saturating_sub(visible_capacity.saturating_sub(1));
    let end = (start + visible_capacity).min(overlay.entries.len());
    if start > 0 {
        lines.push(Line::from(Span::styled(
            format!("↑ {} older session(s)", start),
            Style::default().fg(DIM),
        )));
    }
    for (index, entry) in overlay.entries[start..end].iter().enumerate() {
        let absolute = start + index;
        let is_selected = absolute == selected;
        let current = if entry.current { " current" } else { "" };
        let raw = format!(
            "{}  {}  messages={}{}  {}",
            entry.session_id,
            entry.created_at.format("%Y-%m-%d %H:%M:%S"),
            entry.message_count,
            current,
            entry.title
        );
        let prefix = if is_selected { "> " } else { "  " };
        let style = if is_selected {
            Style::default()
                .fg(BG)
                .bg(CYAN)
                .add_modifier(Modifier::BOLD)
        } else if entry.current {
            Style::default().fg(GREEN)
        } else {
            Style::default().fg(FG)
        };
        lines.push(Line::from(vec![
            Span::styled(prefix, style),
            Span::styled(truncate(&raw, width.saturating_sub(2)), style),
        ]));
    }
    if end < overlay.entries.len() {
        lines.push(Line::from(Span::styled(
            format!(
                "↓ {} newer/remaining session(s)",
                overlay.entries.len() - end
            ),
            Style::default().fg(DIM),
        )));
    }
    lines
}

fn draw_profile_overlay(f: &mut Frame<'_>, overlay: &ProfileOverlay, area: Rect) {
    f.render_widget(Clear, area);
    let content_width = area.width.saturating_sub(4) as usize;
    let mut lines = Vec::new();
    lines.push(Line::from(vec![
        Span::styled(
            "User profile enrollment",
            Style::default().fg(CYAN).add_modifier(Modifier::BOLD),
        ),
        Span::styled("  /profile set", Style::default().fg(DIM)),
    ]));
    lines.push(Line::from(Span::styled(
        truncate(&overlay.status, content_width),
        Style::default().fg(AMBER),
    )));
    lines.push(Line::from(""));

    let label_width = overlay
        .fields
        .iter()
        .map(|field| field.label.width())
        .max()
        .unwrap_or(14)
        .min(24);
    let value_width = content_width.saturating_sub(label_width + 6).max(12);
    let visible_capacity = area.height.saturating_sub(8) as usize;
    let selected = overlay.selected.min(overlay.fields.len().saturating_sub(1));
    let start = selected.saturating_sub(visible_capacity.saturating_sub(1));
    let end = (start + visible_capacity).min(overlay.fields.len());

    for (index, field) in overlay.fields.iter().enumerate().take(end).skip(start) {
        let selected_row = index == selected;
        let marker = if selected_row { "▶" } else { " " };
        let marker_style = if selected_row {
            Style::default().fg(GREEN).add_modifier(Modifier::BOLD)
        } else {
            Style::default().fg(DIM)
        };
        let label_style = if selected_row {
            Style::default().fg(FG).add_modifier(Modifier::BOLD)
        } else {
            Style::default().fg(CYAN)
        };
        let value_style = if selected_row {
            Style::default().fg(GREEN)
        } else if field.value.trim().is_empty() {
            Style::default().fg(DIM)
        } else {
            Style::default().fg(FG)
        };
        let value = if field.value.trim().is_empty() {
            "<blank>".to_string()
        } else {
            truncate(&field.value, value_width)
        };
        lines.push(Line::from(vec![
            Span::styled(format!("{marker} "), marker_style),
            Span::styled(format!("{:<label_width$} ", field.label), label_style),
            Span::styled(value, value_style),
        ]));
        if selected_row {
            lines.push(Line::from(vec![
                Span::styled("  ", Style::default().fg(DIM)),
                Span::styled(
                    truncate(&field.hint, content_width.saturating_sub(2)),
                    Style::default().fg(DIM),
                ),
            ]));
        }
    }

    if overlay.fields.is_empty() {
        lines.push(Line::from(Span::styled(
            "No profile fields available.",
            Style::default().fg(DIM),
        )));
    }

    let paragraph = Paragraph::new(lines)
        .style(Style::default().fg(FG).bg(PANEL))
        .block(
            Block::default()
                .title(" profile form ")
                .title_bottom(" Enter save   Esc cancel   Tab/↓ next   Shift+Tab/↑ previous   Del clear field ")
                .borders(Borders::ALL)
                .border_style(Style::default().fg(CYAN))
                .padding(Padding::horizontal(1)),
        )
        .wrap(Wrap { trim: false });
    f.render_widget(paragraph, area);
}

fn draw_search_overlay(f: &mut Frame<'_>, app: &TuiApplication, area: Rect) {
    f.render_widget(Clear, area);
    let matches = app.search_matches();
    let count = matches.len();
    let current = if count == 0 {
        "0/0".to_string()
    } else {
        format!("{}/{}", app.search_match_index.min(count - 1) + 1, count)
    };
    let query = if app.search_query.is_empty() {
        "type to search".to_string()
    } else {
        app.search_query.clone()
    };
    let lines = vec![Line::from(vec![
        Span::styled(
            "Search ",
            Style::default().fg(CYAN).add_modifier(Modifier::BOLD),
        ),
        Span::styled(
            truncate(&query, area.width.saturating_sub(28) as usize),
            Style::default().fg(FG),
        ),
        Span::styled(" ", Style::default().fg(DIM)),
        Span::styled(current, Style::default().fg(AMBER)),
        Span::styled(
            "  Enter/↓ next  ↑ previous  Esc close",
            Style::default().fg(DIM),
        ),
    ])];
    let paragraph = Paragraph::new(lines)
        .style(Style::default().fg(FG).bg(PANEL))
        .block(
            Block::default()
                .title(" search ")
                .borders(Borders::ALL)
                .border_style(Style::default().fg(CYAN))
                .padding(Padding::horizontal(1)),
        );
    f.render_widget(paragraph, area);
}

fn draw_command_palette(f: &mut Frame<'_>, app: &TuiApplication, screen: Rect) {
    let count = app.input.suggestions.len().min(12);
    let area = command_palette_rect(screen, count);
    f.render_widget(Clear, area);
    let lines = command_palette_lines(app, area.width.saturating_sub(4) as usize, count);
    let paragraph = Paragraph::new(lines)
        .style(Style::default().fg(FG).bg(PANEL))
        .block(
            Block::default()
                .title(" command palette ")
                .borders(Borders::ALL)
                .border_style(Style::default().fg(CYAN))
                .padding(Padding::horizontal(1)),
        );
    f.render_widget(paragraph, area);
}

fn command_palette_rect(screen: Rect, visible_items: usize) -> Rect {
    let width = screen.width.clamp(34, 92);
    let height = (visible_items as u16 + 4).min(screen.height.saturating_sub(4).max(8));
    Rect {
        x: screen.x + screen.width.saturating_sub(width) / 2,
        y: screen.y + screen.height.saturating_sub(height) / 3,
        width,
        height,
    }
}

fn command_palette_lines(
    app: &TuiApplication,
    width: usize,
    visible_items: usize,
) -> Vec<Line<'static>> {
    let mut lines = Vec::new();
    lines.push(Line::from(vec![
        Span::styled("Type to filter", Style::default().fg(DIM)),
        Span::raw("   "),
        Span::styled("Enter", Style::default().fg(CYAN)),
        Span::styled(" run  ", Style::default().fg(DIM)),
        Span::styled("Esc", Style::default().fg(CYAN)),
        Span::styled(" close", Style::default().fg(DIM)),
    ]));
    lines.push(Line::from(""));

    let value_width = (width / 3).clamp(16, 30);
    let desc_width = width.saturating_sub(value_width + 3).max(8);
    let total = app.input.suggestions.len();
    let selected_index = app.input.selected_suggestion.min(total.saturating_sub(1));
    let start = if total <= visible_items {
        0
    } else {
        selected_index
            .saturating_sub(visible_items / 2)
            .min(total - visible_items)
    };
    for (index, suggestion) in app
        .input
        .suggestions
        .iter()
        .enumerate()
        .skip(start)
        .take(visible_items)
    {
        let selected = index == app.input.selected_suggestion;
        let row_style = if selected {
            Style::default().fg(Color::Black).bg(CYAN)
        } else {
            Style::default().fg(FG).bg(PANEL)
        };
        let dim_style = if selected {
            Style::default().fg(Color::Black).bg(CYAN)
        } else {
            Style::default().fg(DIM).bg(PANEL)
        };
        let value = suggestion
            .replacement
            .as_deref()
            .unwrap_or(&suggestion.value);
        lines.push(Line::from(vec![
            Span::styled(if selected { ">" } else { " " }, row_style),
            Span::styled(" ", row_style),
            Span::styled(
                format!("{:<value_width$}", truncate(value, value_width)),
                row_style.add_modifier(if selected {
                    Modifier::BOLD
                } else {
                    Modifier::empty()
                }),
            ),
            Span::styled(" ", row_style),
            Span::styled(truncate(&suggestion.description, desc_width), dim_style),
        ]));
    }
    if total > visible_items {
        lines.push(Line::from(""));
        lines.push(Line::from(Span::styled(
            format!(
                "showing {}-{} of {} items",
                start + 1,
                (start + visible_items).min(total),
                total
            ),
            Style::default().fg(DIM),
        )));
    }
    lines
}

fn input_lines(input: &InputState, width: usize, max_rows: usize) -> Vec<Line<'static>> {
    let width = width.max(1);
    let max_rows = max_rows.max(1);
    if input.buffer.is_empty() {
        return vec![Line::from(Span::styled(
            "Ask anything, @tag files/folders, use skills, or / for commands",
            Style::default().fg(DIM),
        ))];
    }
    if should_collapse_paste(input, width) {
        let marker = format!("[Pasted {} characters]", input.paste_char_count);
        let mut lines = vec![Line::from(Span::styled(
            truncate(&marker, width),
            Style::default().fg(AMBER).add_modifier(Modifier::BOLD),
        ))];
        let preview_width = width.saturating_sub(2).max(1);
        let preview = paste_tail_preview(&input.buffer, preview_width, max_rows.saturating_sub(1));
        for line in preview {
            lines.push(Line::from(vec![
                Span::styled(" ", Style::default().fg(DIM)),
                Span::styled(line, Style::default().fg(DIM)),
            ]));
        }
        return tail_lines(lines, max_rows);
    }
    let mut lines = Vec::new();
    for raw in input.buffer.split('\n') {
        let wrapped = wrap_preserve(raw, width);
        if wrapped.is_empty() {
            lines.push(Line::from(""));
        } else {
            for piece in wrapped {
                lines.push(Line::from(Span::styled(piece, Style::default().fg(FG))));
            }
        }
    }
    tail_lines(lines, max_rows)
}

fn draw_status(f: &mut Frame<'_>, app: &TuiApplication, area: Rect) {
    let pending = app.tool_executor.guardrails.approvals.pending_len();
    let approval = if pending > 0 {
        format!(" approvals:{pending}")
    } else {
        String::new()
    };
    let controls = if pending > 0 {
        "Enter approve | S allow session | D deny | /approvals show"
    } else if app.search_open {
        "search: type query | Enter/Down next | Up previous | Esc close"
    } else if app.input.buffer.starts_with('/') {
        "Enter run command | Tab complete | Esc close"
    } else if app.session.status == "streaming" {
        "Ctrl+C cancel/quit | PgUp read history | End follow | F12 native select"
    } else if app.mouse_capture_enabled {
        "Enter send | drag copy chat | wheel scroll | F12 native select | Ctrl+C quit"
    } else {
        "Mouse capture OFF: terminal selection active | F12 restore mouse | Ctrl+C quit"
    };
    let text = format!(
        " {} | session {} | ctx {}/{} | tools {} | skills {}{} | {} ",
        app.session.status,
        app.session.session_id,
        app.session.tokens_used,
        app.session.context_limit,
        app.session.enabled_tools.len(),
        app.session.enabled_skills.len(),
        approval,
        controls
    );
    let paragraph = Paragraph::new(Line::from(Span::styled(
        truncate(&text, area.width as usize),
        Style::default().fg(CYAN).bg(BG),
    )));
    f.render_widget(paragraph, area);
}

fn draw_approval_modal(
    f: &mut Frame<'_>,
    approvals: &[ApprovalRequest],
    selected_index: usize,
    area: Rect,
) {
    let selected_index = selected_index.min(approvals.len().saturating_sub(1));
    let approval = &approvals[selected_index];
    f.render_widget(Clear, area);
    let mut lines = vec![
        Line::from(vec![
            Span::styled(
                "? ",
                Style::default().fg(AMBER).add_modifier(Modifier::BOLD),
            ),
            Span::styled(
                "Approval Required",
                Style::default().fg(FG).add_modifier(Modifier::BOLD),
            ),
            Span::styled(
                format!("  {} pending", approvals.len()),
                Style::default().fg(DIM),
            ),
        ]),
        Line::from(""),
    ];
    lines.extend(approval_queue_lines(
        approvals,
        selected_index,
        area.width.saturating_sub(4) as usize,
        4,
    ));
    lines.extend([
        Line::from(""),
        Line::from(vec![
            Span::styled("Tool: ", Style::default().fg(DIM)),
            Span::styled(approval.tool_name.clone(), Style::default().fg(CYAN)),
            Span::styled("  Risk: ", Style::default().fg(DIM)),
            Span::styled(approval.risk_label.clone(), Style::default().fg(AMBER)),
        ]),
    ]);
    lines.extend(approval_arg_preview(
        approval,
        area.width.saturating_sub(4) as usize,
        area.height.saturating_sub(11) as usize,
    ));
    lines.extend([
        Line::from(""),
        Line::from(vec![
            Span::styled("[Enter/A] Approve once", Style::default().fg(GREEN)),
            Span::raw("   "),
            Span::styled(
                if approval.risk_label == "command-allow" {
                    "[S] Allow command this session"
                } else {
                    "[S] Allow session"
                },
                Style::default().fg(CYAN),
            ),
            Span::raw("   "),
            Span::styled("[D] Deny", Style::default().fg(RED)),
            Span::raw("   "),
            Span::styled("[1/2/3] also work   [↑/↓] Select", Style::default().fg(DIM)),
        ]),
    ]);
    let paragraph = Paragraph::new(lines)
        .alignment(Alignment::Left)
        .style(Style::default().fg(FG).bg(PANEL))
        .block(
            Block::default()
                .title(" pending tool authorization ")
                .borders(Borders::ALL)
                .border_style(Style::default().fg(AMBER))
                .padding(Padding::horizontal(1)),
        )
        .wrap(Wrap { trim: false });
    f.render_widget(paragraph, area);
}

fn approval_queue_lines(
    approvals: &[ApprovalRequest],
    selected_index: usize,
    width: usize,
    max_rows: usize,
) -> Vec<Line<'static>> {
    let start = if approvals.len() <= max_rows {
        0
    } else {
        selected_index
            .saturating_sub(max_rows / 2)
            .min(approvals.len() - max_rows)
    };
    approvals
        .iter()
        .enumerate()
        .skip(start)
        .take(max_rows)
        .map(|(index, approval)| {
            let selected = index == selected_index;
            let style = if selected {
                Style::default().fg(Color::Black).bg(AMBER)
            } else {
                Style::default().fg(FG).bg(PANEL)
            };
            let muted = if selected {
                Style::default().fg(Color::Black).bg(AMBER)
            } else {
                Style::default().fg(DIM).bg(PANEL)
            };
            let text = format!(
                "{} {} {}",
                approval.risk_label, approval.tool_name, approval.id
            );
            Line::from(vec![
                Span::styled(if selected { "> " } else { "  " }, style),
                Span::styled(format!("{}/{} ", index + 1, approvals.len()), muted),
                Span::styled(truncate(&text, width.saturating_sub(8)), style),
            ])
        })
        .collect()
}

fn approval_arg_preview(
    approval: &ApprovalRequest,
    width: usize,
    max_lines: usize,
) -> Vec<Line<'static>> {
    let raw = serde_json::to_string_pretty(&approval.args).unwrap_or_else(|_| "{}".to_string());
    let mut lines = Vec::new();
    for line in raw.lines().take(max_lines.max(1)) {
        lines.push(Line::from(Span::styled(
            truncate(line, width),
            Style::default().fg(DIM),
        )));
    }
    if raw.lines().count() > max_lines.max(1) {
        lines.push(Line::from(Span::styled(
            truncate(
                "... more args hidden; use /approvals show for full details",
                width,
            ),
            Style::default().fg(AMBER),
        )));
    }
    lines
}

fn set_input_cursor(f: &mut Frame<'_>, app: &TuiApplication, area: Rect) {
    let width = area.width.saturating_sub(4).max(1) as usize;
    if should_collapse_paste(&app.input, width) {
        let marker = format!("[Pasted {} characters]", app.input.paste_char_count);
        let x = area.x + 2 + truncate(&marker, width).width().min(width) as u16;
        let y = area.y + 1;
        f.set_cursor_position(Position::new(x, y));
        return;
    }
    let max_content_rows = area.height.saturating_sub(2).max(1) as usize;
    let (line, col) = app
        .input
        .visible_visual_cursor_position(width, max_content_rows);
    let row = line.min(max_content_rows.saturating_sub(1)) as u16;
    let x = area.x + 2 + (col.min(width) as u16);
    let y = area.y + 1 + row;
    f.set_cursor_position(Position::new(x, y));
}

fn input_height(input: &InputState, terminal_width: u16) -> u16 {
    let width = terminal_width.saturating_sub(4).max(1) as usize;
    if should_collapse_paste(input, width) {
        return 4;
    }
    let rows = input.visual_line_count(width).clamp(1, 8) as u16;
    rows + 2
}

fn should_collapse_paste(input: &InputState, width: usize) -> bool {
    input.paste_char_count > width.max(80)
}

fn paste_tail_preview(text: &str, width: usize, max_rows: usize) -> Vec<String> {
    if max_rows == 0 {
        return Vec::new();
    }
    let mut pieces = Vec::new();
    for raw in text.split('\n') {
        let wrapped = wrap_preserve(raw, width);
        if wrapped.is_empty() {
            pieces.push(String::new());
        } else {
            pieces.extend(wrapped);
        }
    }
    tail_strings(pieces, max_rows)
}

fn tail_lines(mut lines: Vec<Line<'static>>, max_rows: usize) -> Vec<Line<'static>> {
    if lines.len() > max_rows {
        lines.drain(0..lines.len() - max_rows);
    }
    lines
}

fn tail_strings(mut lines: Vec<String>, max_rows: usize) -> Vec<String> {
    if lines.len() > max_rows {
        lines.drain(0..lines.len() - max_rows);
    }
    lines
}

fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
    let width = width.min(area.width.saturating_sub(4)).max(20);
    let height = height.min(area.height.saturating_sub(4)).max(6);
    Rect {
        x: area.x + area.width.saturating_sub(width) / 2,
        y: area.y + area.height.saturating_sub(height) / 2,
        width,
        height,
    }
}

fn search_rect(area: Rect) -> Rect {
    let width = area.width.clamp(32, 92);
    Rect {
        x: area.x + area.width.saturating_sub(width) / 2,
        y: area
            .y
            .saturating_add(3)
            .min(area.bottom().saturating_sub(4)),
        width,
        height: 3,
    }
}

fn wrap_preserve(text: &str, width: usize) -> Vec<String> {
    let width = width.max(1);
    let safe_text = sanitize_display_text(text);
    if safe_text.is_empty() {
        return vec![String::new()];
    }
    let mut lines = Vec::new();
    let mut current = String::new();
    let mut current_width = 0;
    for ch in safe_text.chars() {
        let ch_width = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
        if current_width + ch_width > width && !current.is_empty() {
            lines.push(std::mem::take(&mut current));
            current_width = 0;
        }
        current.push(ch);
        current_width += ch_width;
    }
    if !current.is_empty() {
        lines.push(current);
    }
    lines
}

fn sanitize_display_text(text: &str) -> String {
    let mut out = String::with_capacity(text.len());
    for ch in text.chars() {
        match ch {
            '\t' => out.push_str("    "),
            '\r' => out.push('␍'),
            '\u{1b}' => out.push('␛'),
            ch if ch.is_control() => out.push('�'),
            ch => out.push(ch),
        }
    }
    out
}

fn truncate(text: &str, width: usize) -> String {
    let safe_text = sanitize_display_text(text);
    if safe_text.width() <= width {
        return safe_text;
    }
    let mut out = String::new();
    let mut used = 0;
    for ch in safe_text.chars() {
        let ch_width = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
        if used + ch_width >= width.saturating_sub(1) {
            break;
        }
        out.push(ch);
        used += ch_width;
    }
    out.push('…');
    out
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn ratatui_markdown_renderer_handles_code_diff_and_tables() {
        let markdown = [
            "## Result",
            "",
            "| file | status |",
            "| --- | --- |",
            "| src/lib.rs | changed |",
            "",
            "```diff",
            "-old",
            "+new",
            "```",
        ]
        .join("\n");

        let lines = render_markdown(&markdown, 80, Style::default().fg(FG));
        let rendered = lines
            .iter()
            .flat_map(|line| line.spans.iter().map(|span| span.content.as_ref()))
            .collect::<Vec<_>>()
            .join("\n");

        assert!(rendered.contains("Result"));
        assert!(rendered.contains("file"));
        assert!(rendered.contains("src/lib.rs"));
        assert!(rendered.contains("diff"));
        assert!(rendered.contains("-old"));
        assert!(rendered.contains("+new"));
    }

    #[test]
    fn ratatui_code_blocks_apply_token_level_highlighting() {
        let markdown = [
            "```rust",
            "fn main() {",
            r#"    let value = "hello";"#,
            "}",
            "```",
        ]
        .join("\n");

        let lines = render_markdown(&markdown, 80, Style::default().fg(FG));
        let fn_span = lines
            .iter()
            .flat_map(|line| line.spans.iter())
            .find(|span| span.content.as_ref() == "fn")
            .expect("keyword span");
        let string_span = lines
            .iter()
            .flat_map(|line| line.spans.iter())
            .find(|span| span.content.as_ref() == r#""hello""#)
            .expect("string span");

        assert_eq!(fn_span.style.fg, Some(CYAN));
        assert_eq!(string_span.style.fg, Some(GREEN));
    }

    #[test]
    fn ratatui_chat_renderer_preserves_unfenced_assistant_code() {
        let content = [
            "Here is the implementation:",
            "",
            "pub struct RenderedCode {",
            "    pub path: String,",
            "}",
            "",
            "And it should remain visible in chat.",
        ]
        .join("\n");

        let lines = render_markdown(&content, 80, Style::default().fg(FG));
        let rendered = lines
            .iter()
            .flat_map(|line| line.spans.iter().map(|span| span.content.as_ref()))
            .collect::<String>();

        assert!(rendered.contains("Here is the implementation"));
        assert!(rendered.contains("rust"));
        assert!(rendered.contains("pub struct RenderedCode"));
        assert!(rendered.contains("pub path: String"));
        assert!(rendered.contains("And it should remain visible"));
    }

    #[test]
    fn ratatui_chat_renderer_preserves_raw_unfenced_diffs() {
        let content = [
            "Patch preview:",
            "diff --git a/src/lib.rs b/src/lib.rs",
            "--- a/src/lib.rs",
            "+++ b/src/lib.rs",
            "@@ -1,2 +1,2 @@",
            "-old();",
            "+new();",
        ]
        .join("\n");

        let lines = render_markdown(&content, 80, Style::default().fg(FG));
        let rendered = lines
            .iter()
            .flat_map(|line| line.spans.iter().map(|span| span.content.as_ref()))
            .collect::<String>();

        assert!(rendered.contains("Patch preview"));
        assert!(rendered.contains("diff"));
        assert!(rendered.contains("diff --git a/src/lib.rs b/src/lib.rs"));
        assert!(rendered.contains("-old();"));
        assert!(rendered.contains("+new();"));
    }

    #[test]
    fn ratatui_chat_transcript_keeps_assistant_code_but_excludes_tool_log() -> anyhow::Result<()> {
        let tmp = tempfile::tempdir()?;
        let mut app =
            crate::app::TuiApplication::with_data_root(tmp.path(), tmp.path().join("home"))?;
        app.session.messages.push(ChatMessage {
            role: "assistant".to_string(),
            content: "Generated code:\nfn visible_in_chat() {}".to_string(),
            attachments: Vec::new(),
            created_at: chrono::Utc::now(),
        });
        app.session.messages.push(ChatMessage {
            role: "system".to_string(),
            content: "Tool finished: run_command - ok: cargo check passed".to_string(),
            attachments: Vec::new(),
            created_at: chrono::Utc::now(),
        });

        let chat = chat_lines(&app, 100)
            .iter()
            .flat_map(|line| line.spans.iter().map(|span| span.content.as_ref()))
            .collect::<String>();
        let tool_log = tool_log_lines(&app, 100, 5)
            .iter()
            .flat_map(|line| line.spans.iter().map(|span| span.content.as_ref()))
            .collect::<String>();

        assert!(chat.contains("Generated code"));
        assert!(chat.contains("fn visible_in_chat()"));
        assert!(!chat.contains("Tool finished: run_command"));
        assert!(tool_log.contains("Tool finished: run_command"));
        Ok(())
    }

    #[test]
    fn ratatui_user_messages_use_profile_display_label() -> anyhow::Result<()> {
        let tmp = tempfile::tempdir()?;
        let mut app =
            crate::app::TuiApplication::with_data_root(tmp.path(), tmp.path().join("home"))?;
        app.user_profile.identity.display_name = "Display Only".to_string();
        app.user_profile.identity.address_as = "Malice".to_string();
        app.session.messages.push(ChatMessage {
            role: "user".to_string(),
            content: "please inspect the renderer".to_string(),
            attachments: Vec::new(),
            created_at: chrono::Utc::now(),
        });

        let first = cached_chat_lines(&mut app, 80);
        let first_text = first
            .iter()
            .flat_map(|line| line.spans.iter().map(|span| span.content.as_ref()))
            .collect::<String>();
        let first_key = app.chat_render_cache.message_lines[0]
            .as_ref()
            .expect("user message should be cached")
            .key
            .clone();

        assert!(first_text.contains("Malice"));
        assert!(!first_text.contains("you"));

        app.user_profile.identity.address_as = "".to_string();
        app.user_profile.identity.display_name = "Ada".to_string();
        let second = cached_chat_lines(&mut app, 80);
        let second_text = second
            .iter()
            .flat_map(|line| line.spans.iter().map(|span| span.content.as_ref()))
            .collect::<String>();
        let second_key = &app.chat_render_cache.message_lines[0]
            .as_ref()
            .expect("user message should be recached after profile label change")
            .key;

        assert!(second_text.contains("Ada"));
        assert!(!second_text.contains("Malice"));
        assert_ne!(&first_key, second_key);
        Ok(())
    }

    #[test]
    fn ratatui_chat_render_cache_reuses_unchanged_message_lines() -> anyhow::Result<()> {
        let tmp = tempfile::tempdir()?;
        let mut app =
            crate::app::TuiApplication::with_data_root(tmp.path(), tmp.path().join("home"))?;
        app.session.messages.push(ChatMessage {
            role: "user".to_string(),
            content: "please inspect the renderer".to_string(),
            attachments: Vec::new(),
            created_at: chrono::Utc::now(),
        });
        app.session.messages.push(ChatMessage {
            role: "assistant".to_string(),
            content: "initial response".to_string(),
            attachments: Vec::new(),
            created_at: chrono::Utc::now(),
        });

        let first_render = cached_chat_lines(&mut app, 80);
        let first_user_entry = app.chat_render_cache.message_lines[0]
            .as_ref()
            .expect("user message should be cached")
            .clone();
        let first_assistant_key = app.chat_render_cache.message_lines[1]
            .as_ref()
            .expect("assistant message should be cached")
            .key
            .clone();

        app.session.messages[1]
            .content
            .push_str(" with more streamed text");
        let second_render = cached_chat_lines(&mut app, 80);
        let second_user_entry = app.chat_render_cache.message_lines[0]
            .as_ref()
            .expect("user message should remain cached");
        let second_assistant_key = &app.chat_render_cache.message_lines[1]
            .as_ref()
            .expect("assistant message should be cached again")
            .key;

        assert_eq!(first_user_entry, *second_user_entry);
        assert_ne!(&first_assistant_key, second_assistant_key);
        assert!(second_render.len() >= first_render.len());
        Ok(())
    }

    #[test]
    fn ratatui_thinking_trace_is_hidden_immediately() {
        let message = ChatMessage {
            role: "assistant".to_string(),
            content: "**Thinking trace**\n\nworking through it\n\n**Answer**\n\nDone.".to_string(),
            attachments: Vec::new(),
            created_at: chrono::Utc::now(),
        };
        let rendered = message_lines(&message, 100, "", "you")
            .iter()
            .flat_map(|line| line.spans.iter().map(|span| span.content.as_ref()))
            .collect::<String>();

        assert!(!rendered.contains("Thinking trace"));
        assert!(!rendered.contains("working through it"));
        assert!(!rendered.contains("Answer"));
        assert!(rendered.contains("Done."));
        assert!(message.content.contains("working through it"));
    }

    #[test]
    fn ratatui_thinking_trace_without_answer_does_not_disappear() {
        let message = ChatMessage {
            role: "assistant".to_string(),
            content: "**Thinking trace**\n\nI am still useful visible text.".to_string(),
            attachments: Vec::new(),
            created_at: chrono::Utc::now(),
        };

        let rendered = message_lines(&message, 100, "", "you")
            .iter()
            .flat_map(|line| line.spans.iter().map(|span| span.content.as_ref()))
            .collect::<String>();

        assert!(rendered.contains("Thinking trace"));
        assert!(rendered.contains("I am still useful visible text."));
    }

    #[test]
    fn ratatui_does_not_schedule_thinking_trace_expiry_redraw() -> anyhow::Result<()> {
        let tmp = tempfile::tempdir()?;
        let mut app =
            crate::app::TuiApplication::with_data_root(tmp.path(), tmp.path().join("home"))?;
        app.session.messages.push(ChatMessage {
            role: "assistant".to_string(),
            content: "**Thinking trace**\n\nstill hidden\n\n**Answer**\n\nDone.".to_string(),
            attachments: Vec::new(),
            created_at: chrono::Utc::now(),
        });

        assert!(next_thinking_trace_expiry_at(&app).is_none());
        Ok(())
    }

    #[test]
    fn ratatui_multiple_thinking_traces_are_hidden_from_chat_only() {
        let message = ChatMessage {
            role: "assistant".to_string(),
            content: [
                "**Thinking trace**",
                "",
                "first transient thought",
                "",
                "**Answer**",
                "",
                "First visible answer.",
                "",
                "**Thinking trace**",
                "",
                "second transient thought",
                "",
                "**Answer**",
                "",
                "Second visible answer.",
            ]
            .join("\n"),
            attachments: Vec::new(),
            created_at: chrono::Utc::now(),
        };

        let rendered = message_lines(&message, 100, "", "you")
            .iter()
            .flat_map(|line| line.spans.iter().map(|span| span.content.as_ref()))
            .collect::<String>();

        assert!(!rendered.contains("first transient thought"));
        assert!(!rendered.contains("second transient thought"));
        assert!(rendered.contains("First visible answer."));
        assert!(rendered.contains("Second visible answer."));
        assert!(message.content.contains("first transient thought"));
        assert!(message.content.contains("second transient thought"));
    }

    #[test]
    fn ratatui_tool_and_note_messages_render_compactly() {
        let message = ChatMessage {
            role: "system".to_string(),
            content: "Tool finished: read_file - ok: read 20 bytes\n\nfull detail that should be summarized".to_string(),
            attachments: Vec::new(),
            created_at: chrono::Utc::now(),
        };
        let lines = message_lines(&message, 72, "", "you");
        let rendered = lines
            .iter()
            .flat_map(|line| line.spans.iter().map(|span| span.content.as_ref()))
            .collect::<String>();

        assert_eq!(lines.len(), 1);
        assert!(rendered.contains("tool"));
        assert!(rendered.contains("Tool finished: read_file"));
    }

    #[test]
    fn ratatui_input_collapses_large_paste_marker() {
        let mut input = InputState::default();
        input.append_text(&"x".repeat(240), true);

        let lines = input_lines(&input, 40, 4);
        let rendered = lines
            .iter()
            .flat_map(|line| line.spans.iter().map(|span| span.content.as_ref()))
            .collect::<Vec<_>>()
            .join("\n");

        assert!(rendered.contains("[Pasted 240 characters]"));
        assert!(!rendered.contains(&"x".repeat(80)));
        assert!(lines.len() <= 4);
    }

    #[test]
    fn ratatui_input_preserves_multiline_spacing() {
        let mut input = InputState::default();
        input.append_text(
            "hello   world
second line
",
            false,
        );

        let lines = input_lines(&input, 40, 8);
        let rendered = lines
            .iter()
            .map(|line| {
                line.spans
                    .iter()
                    .map(|span| span.content.as_ref())
                    .collect::<String>()
            })
            .collect::<Vec<_>>();

        assert_eq!(rendered, vec!["hello   world", "second line", ""]);
    }

    #[test]
    fn ratatui_input_cursor_matches_tail_clipped_rendered_row() {
        let mut input = InputState::default();
        input.append_text(
            "one
two
three
four",
            false,
        );

        let rendered = input_lines(&input, 20, 3);
        let rendered_text = rendered
            .iter()
            .map(|line| {
                line.spans
                    .iter()
                    .map(|span| span.content.as_ref())
                    .collect::<String>()
            })
            .collect::<Vec<_>>();
        let (row, col) = input.visible_visual_cursor_position(20, 3);

        assert_eq!(rendered_text, vec!["two", "three", "four"]);
        assert_eq!((row, col), (2, 4));
    }

    #[test]
    fn ratatui_wraps_unformatted_paste_without_overflow() {
        let width = 24;
        let wrapped = wrap_preserve(&"a".repeat(97), width);

        assert!(wrapped.len() > 1);
        assert!(wrapped.iter().all(|line| line.width() <= width));

        let markdown = render_markdown(&"b".repeat(97), width, Style::default().fg(FG));
        for line in markdown {
            for span in line.spans {
                assert!(span.content.width() <= width + 2, "{}", span.content);
            }
        }
    }

    #[test]
    fn ratatui_completed_assistant_message_has_one_trailing_spacer_line() -> anyhow::Result<()> {
        let tmp = tempfile::tempdir()?;
        let mut app =
            crate::app::TuiApplication::with_data_root(tmp.path(), tmp.path().join("home"))?;
        app.session.messages.push(ChatMessage {
            role: "assistant".to_string(),
            content: "final visible line".to_string(),
            attachments: Vec::new(),
            created_at: chrono::Utc::now(),
        });

        let lines = chat_lines(&app, 80);
        assert!(lines.len() >= 2);
        assert!(lines.last().is_some_and(|line| line.spans.is_empty()));
        Ok(())
    }

    #[test]
    fn ratatui_sanitizes_control_characters_before_rendering_chat() -> anyhow::Result<()> {
        let tmp = tempfile::tempdir()?;
        let mut app =
            crate::app::TuiApplication::with_data_root(tmp.path(), tmp.path().join("home"))?;
        app.session.messages.push(ChatMessage {
            role: "assistant".to_string(),
            content: "before\rafter\x1b[31m red\x07 tail".to_string(),
            attachments: Vec::new(),
            created_at: chrono::Utc::now(),
        });

        let mut terminal =
            ratatui::Terminal::new(ratatui::backend::TestBackend::new(90, 24)).unwrap();
        terminal.draw(|f| draw(f, &mut app)).unwrap();
        let rendered = terminal
            .backend()
            .buffer()
            .content()
            .iter()
            .map(|cell| cell.symbol())
            .collect::<Vec<_>>()
            .join("");

        assert!(rendered.contains("before"));
        assert!(rendered.contains("after"));
        assert!(rendered.contains("red"));
        assert!(rendered.contains("tail"));
        assert!(!rendered.contains('\r'));
        assert!(!rendered.contains('\u{1b}'));
        assert!(!rendered.contains('\u{7}'));
        Ok(())
    }

    #[test]
    fn ratatui_tool_log_uses_trace_lifecycle_palette() -> anyhow::Result<()> {
        let tmp = tempfile::tempdir()?;
        let mut app =
            crate::app::TuiApplication::with_data_root(tmp.path(), tmp.path().join("home"))?;
        for content in [
            "Running tool: subagents_list {\"status\":\"failed\"}",
            "Tool finished: subagents_list - ok: {\"error\":null,\"ok\":true}",
            "Tool finished: subagents_show - error: subagent not found",
            "Workspace set to /tmp/project",
        ] {
            app.session.messages.push(ChatMessage {
                role: "system".to_string(),
                content: content.to_string(),
                attachments: Vec::new(),
                created_at: chrono::Utc::now(),
            });
        }

        let lines = tool_log_lines(&app, 160, 8);
        let line_style = |index: usize| lines[index].spans[0].style.fg;
        let content_style = |index: usize| lines[index].spans[5].style.fg;

        assert_eq!(line_style(0), Some(CYAN));
        assert_eq!(content_style(0), Some(CYAN));
        assert_eq!(line_style(1), Some(GREEN));
        assert_eq!(content_style(1), Some(GREEN));
        assert_eq!(line_style(2), Some(RED));
        assert_eq!(content_style(2), Some(RED));
        assert_eq!(line_style(3), Some(AMBER));
        assert_eq!(content_style(3), Some(AMBER));
        Ok(())
    }

    #[test]
    fn ratatui_tool_log_is_bounded_to_lower_fifteen_percent_of_chat_surface() {
        let tmp = tempfile::tempdir().unwrap();
        let mut app =
            crate::app::TuiApplication::with_data_root(tmp.path(), tmp.path().join("home"))
                .unwrap();
        app.session.messages.push(ChatMessage {
            role: "assistant".to_string(),
            content: "agent output should remain visible".to_string(),
            attachments: Vec::new(),
            created_at: chrono::Utc::now(),
        });
        for i in 0..30 {
            app.session.messages.push(ChatMessage {
                role: "system".to_string(),
                content: format!("Running tool: run_command number {i}"),
                attachments: Vec::new(),
                created_at: chrono::Utc::now(),
            });
        }

        let mut terminal =
            ratatui::Terminal::new(ratatui::backend::TestBackend::new(120, 40)).unwrap();
        terminal.draw(|f| draw(f, &mut app)).unwrap();
        let buffer = terminal.backend().buffer();
        let rows = (0..buffer.area.height)
            .map(|y| {
                (0..buffer.area.width)
                    .map(|x| buffer[(x, y)].symbol())
                    .collect::<String>()
            })
            .collect::<Vec<_>>();
        let rendered = rows.join("\n");

        assert!(rendered.contains("agent output should remain visible"));
        assert!(rendered.contains("tool / note log"));
        let tool_rows = rows
            .iter()
            .filter(|row| row.contains("Running tool: run_command"))
            .count();
        let chat_surface_height =
            buffer.area.height - 2 - input_height(&app.input, buffer.area.width).min(10) - 1;
        let max_tool_rows = ((chat_surface_height as f32) * 0.15).floor() as usize;
        assert!(
            tool_rows <= max_tool_rows,
            "tool rows {tool_rows} should be capped at lower 15 percent {max_tool_rows}:\n{rendered}"
        );
    }

    #[test]
    fn ratatui_chat_area_ends_above_message_input_box() {
        let tmp = tempfile::tempdir().unwrap();
        let mut app =
            crate::app::TuiApplication::with_data_root(tmp.path(), tmp.path().join("home"))
                .unwrap();
        app.session.messages.push(ChatMessage {
            role: "assistant".to_string(),
            content: "final visible line above input".to_string(),
            attachments: Vec::new(),
            created_at: chrono::Utc::now(),
        });

        let mut terminal =
            ratatui::Terminal::new(ratatui::backend::TestBackend::new(90, 22)).unwrap();
        terminal.draw(|f| draw(f, &mut app)).unwrap();
        let buffer = terminal.backend().buffer();
        let mut final_line_y = None;
        let mut input_top_y = None;
        for y in 0..buffer.area.height {
            let row = (0..buffer.area.width)
                .map(|x| buffer[(x, y)].symbol())
                .collect::<String>();
            if row.contains("final visible line above input") {
                final_line_y = Some(y);
            }
            if row.contains(" message ") {
                input_top_y = Some(y);
            }
        }

        let final_line_y = final_line_y.expect("assistant content should render");
        let input_top_y = input_top_y.expect("input box title should render");
        assert!(
            final_line_y < input_top_y,
            "assistant line at y={final_line_y} should end above input top y={input_top_y}"
        );
    }

    #[test]
    fn ratatui_bottom_viewport_uses_visual_rows_so_summary_is_visible() {
        let tmp = tempfile::tempdir().unwrap();
        let mut app =
            crate::app::TuiApplication::with_data_root(tmp.path(), tmp.path().join("home"))
                .unwrap();
        let long_line = "x".repeat(420);
        app.session.messages.push(ChatMessage {
            role: "assistant".to_string(),
            content: format!("Here is a long line that wraps heavily:\n\n{long_line}\n\nSummary:\n- final summary visible"),
            attachments: Vec::new(),
            created_at: chrono::Utc::now(),
        });

        let mut terminal =
            ratatui::Terminal::new(ratatui::backend::TestBackend::new(72, 18)).unwrap();
        terminal.draw(|f| draw(f, &mut app)).unwrap();
        let buffer = terminal.backend().buffer();
        let rendered = (0..buffer.area.height)
            .map(|y| {
                (0..buffer.area.width)
                    .map(|x| buffer[(x, y)].symbol())
                    .collect::<String>()
            })
            .collect::<Vec<_>>()
            .join("\n");

        assert!(
            rendered.contains("Summary:"),
            "bottom-follow view should include the response summary, not stop mid wrapped content:\n{rendered}"
        );
        assert!(rendered.contains("final summary visible"));
    }

    #[test]
    fn ratatui_layout_keeps_chat_body_full_width_for_native_selection() {
        let tmp = tempfile::tempdir().unwrap();
        let mut app =
            crate::app::TuiApplication::with_data_root(tmp.path(), tmp.path().join("home"))
                .unwrap();
        app.session.messages.push(ChatMessage {
            role: "assistant".to_string(),
            content: "copyable chat body".to_string(),
            attachments: Vec::new(),
            created_at: chrono::Utc::now(),
        });

        let mut terminal =
            ratatui::Terminal::new(ratatui::backend::TestBackend::new(120, 32)).unwrap();
        terminal.draw(|f| draw(f, &mut app)).unwrap();
        let rendered = terminal
            .backend()
            .buffer()
            .content()
            .iter()
            .map(|cell| cell.symbol())
            .collect::<Vec<_>>()
            .join("");

        assert!(rendered.contains("copyable chat body"));
        assert!(!rendered.contains("state ready"));
        assert!(!rendered.contains("work log"));
    }

    #[test]
    fn ratatui_activity_strip_tracks_running_or_approval_state() -> anyhow::Result<()> {
        let tmp = tempfile::tempdir()?;
        let mut app =
            crate::app::TuiApplication::with_data_root(tmp.path(), tmp.path().join("home"))?;
        assert_eq!(activity_strip_height(&app, None), 0);

        app.session.status = "streaming".to_string();
        app.session.spinner_verb_seed = 0;
        app.session.session_id = "activity-strip-test".to_string();
        assert_eq!(activity_strip_height(&app, None), 3);
        let running_line = activity_line(&app, None, 80).expect("streaming activity line");
        let running_text = running_line
            .spans
            .iter()
            .map(|span| span.content.as_ref())
            .collect::<String>();
        assert!(!running_text.contains("running"));
        assert!(running_text.contains("model response in progress"));
        assert!(
            spinner_verbs()
                .iter()
                .any(|verb| running_text.contains(verb.trim()))
        );

        app.session.status = "ready".to_string();
        let approval = ApprovalRequest {
            id: "apr_test".to_string(),
            reason: "Risky tool requires human approval: write_file".to_string(),
            tool_name: "write_file".to_string(),
            args: serde_json::Map::new(),
            risk_label: "write".to_string(),
        };
        assert_eq!(activity_strip_height(&app, Some(&approval)), 3);
        Ok(())
    }

    #[test]
    fn ratatui_spinner_dot_and_verb_animate() -> anyhow::Result<()> {
        let tmp = tempfile::tempdir()?;
        let mut app =
            crate::app::TuiApplication::with_data_root(tmp.path(), tmp.path().join("home"))?;
        app.session.status = "streaming".to_string();
        app.session.session_id = "spinner-animation-test".to_string();
        app.session.spinner_verb_seed = 42;

        app.session.activity_tick = 0;
        let first = activity_line(&app, None, 100).expect("first line");
        let first_text = first
            .spans
            .iter()
            .map(|span| span.content.as_ref())
            .collect::<String>();

        app.session.activity_tick = 12;
        let second = activity_line(&app, None, 100).expect("second line");
        let second_text = second
            .spans
            .iter()
            .map(|span| span.content.as_ref())
            .collect::<String>();

        assert_ne!(first_text, second_text);
        assert!(first_text.starts_with('⠋'));
        assert!(second_text.starts_with('⠦'));
        Ok(())
    }

    #[test]
    fn ratatui_streaming_activity_detail_stays_anchored_while_spinner_animates()
    -> anyhow::Result<()> {
        let tmp = tempfile::tempdir()?;
        let mut app =
            crate::app::TuiApplication::with_data_root(tmp.path(), tmp.path().join("home"))?;
        app.session.status = "streaming".to_string();
        app.session.activity = "finished tool run_command".to_string();
        app.session.session_id = "spinner-anchor-test".to_string();
        app.session.spinner_verb_seed = 42;

        app.session.activity_tick = 0;
        let first = activity_line(&app, None, 100).expect("first line");
        let first_text = first
            .spans
            .iter()
            .map(|span| span.content.as_ref())
            .collect::<String>();

        app.session.activity_tick = 12;
        let second = activity_line(&app, None, 100).expect("second line");
        let second_text = second
            .spans
            .iter()
            .map(|span| span.content.as_ref())
            .collect::<String>();

        assert_ne!(first_text, second_text);
        fn visual_column_of(haystack: &str, needle: &str) -> Option<usize> {
            let idx = haystack.find(needle)?;
            Some(haystack[..idx].width())
        }

        let first_detail_col = visual_column_of(&first_text, "finished tool run_command");
        let second_detail_col = visual_column_of(&second_text, "finished tool run_command");
        assert_eq!(first_detail_col, second_detail_col);
        assert_eq!(first_detail_col, Some(21));
        Ok(())
    }

    #[test]
    fn ratatui_activity_strip_surfaces_pending_attachments() -> anyhow::Result<()> {
        let tmp = tempfile::tempdir()?;
        let mut app =
            crate::app::TuiApplication::with_data_root(tmp.path(), tmp.path().join("home"))?;
        app.session.pending_attachments.push(Attachment {
            path: "/tmp/example.rs".to_string(),
            kind: "file".to_string(),
            mime_type: None,
            name: None,
            size_bytes: Some(2048),
        });

        assert_eq!(activity_strip_height(&app, None), 3);
        let summary = pending_attachment_summary(&app.session.pending_attachments, 80);
        assert!(summary.contains("1 pending attachment"));
        assert!(summary.contains("example.rs"));
        assert!(summary.contains("2.0 KB"));

        app.session.status = "streaming".to_string();
        assert_eq!(activity_strip_height(&app, None), 4);
        Ok(())
    }

    #[test]
    fn ratatui_command_palette_centers_full_command_list() -> anyhow::Result<()> {
        let tmp = tempfile::tempdir()?;
        let mut app =
            crate::app::TuiApplication::with_data_root(tmp.path(), tmp.path().join("home"))?;
        app.command_palette_open = true;
        app.input.set_buffer("/");
        app.input.update_suggestions(vec![
            crate::ui::input::Suggestion::new(
                "/workspace",
                "switch workspace and project session",
                Some("/workspace".to_string()),
            ),
            crate::ui::input::Suggestion::new(
                "/models",
                "list or refresh provider models",
                Some("/models".to_string()),
            ),
        ]);

        let area = command_palette_rect(Rect::new(0, 0, 120, 40), app.input.suggestions.len());
        assert!(area.x > 0);
        assert!(area.y > 0);
        assert!(area.width <= 92);

        let lines = command_palette_lines(&app, 80, 12);
        let rendered = lines
            .iter()
            .flat_map(|line| line.spans.iter().map(|span| span.content.as_ref()))
            .collect::<String>();
        assert!(rendered.contains("Type to filter"));
        assert!(rendered.contains("/workspace"));
        assert!(rendered.contains("provider models"));
        Ok(())
    }

    #[test]
    fn ratatui_command_palette_window_follows_selected_row() -> anyhow::Result<()> {
        let tmp = tempfile::tempdir()?;
        let mut app =
            crate::app::TuiApplication::with_data_root(tmp.path(), tmp.path().join("home"))?;
        app.command_palette_open = true;
        app.input.set_buffer("/");
        let suggestions = (0..20)
            .map(|index| {
                crate::ui::input::Suggestion::new(
                    format!("/cmd-{index:02}"),
                    format!("Command {index}"),
                    Some(format!("/cmd-{index:02}")),
                )
            })
            .collect::<Vec<_>>();
        app.input.update_suggestions(suggestions);
        app.input.selected_suggestion = 17;

        let lines = command_palette_lines(&app, 80, 8);
        let rendered = lines
            .iter()
            .flat_map(|line| line.spans.iter().map(|span| span.content.as_ref()))
            .collect::<String>();
        assert!(rendered.contains("/cmd-17"));
        assert!(!rendered.contains("/cmd-00"));
        assert!(rendered.contains("showing"));
        Ok(())
    }

    #[test]
    fn ratatui_help_overlay_lists_controls_without_chat_pollution() -> anyhow::Result<()> {
        let tmp = tempfile::tempdir()?;
        let app = crate::app::TuiApplication::with_data_root(tmp.path(), tmp.path().join("home"))?;
        let lines = help_overlay_lines(&app, 88, 22);
        let rendered = lines
            .iter()
            .flat_map(|line| line.spans.iter().map(|span| span.content.as_ref()))
            .collect::<String>();
        assert!(rendered.contains("Vegvisir controls"));
        assert!(rendered.contains("Ctrl+P"));
        assert!(rendered.contains("/models"));
        assert!(rendered.contains("Esc or ? closes"));
        Ok(())
    }

    #[test]
    fn ratatui_diff_overlay_summarizes_patch_for_review() {
        let overlay = DiffOverlay {
            title: "Git diff".to_string(),
            rendered_by: DiffRenderer::Unified,
            diff: [
                "diff --git a/src/lib.rs b/src/lib.rs",
                "@@ -1,2 +1,3 @@",
                "-old",
                "+new",
                "+more",
            ]
            .join("\n"),
            files_changed: 1,
            added_lines: 2,
            removed_lines: 1,
            rendered_lines_cache: None,
        };
        let lines = diff_overlay_lines(&overlay, 80);
        let rendered = lines
            .iter()
            .flat_map(|line| line.spans.iter().map(|span| span.content.as_ref()))
            .collect::<String>();
        assert!(rendered.contains("src/lib.rs"));
        assert!(rendered.contains("@@ -1,2 +1,3 @@"));
        assert!(rendered.contains("old"));
        assert!(rendered.contains("new"));
    }

    #[test]
    fn ratatui_sessions_overlay_highlights_selected_session() {
        let overlay = SessionsOverlay {
            workspace: "/tmp/workspace".to_string(),
            entries: vec![
                crate::app::SessionsOverlayEntry {
                    session_id: "aaa111".to_string(),
                    title: "first".to_string(),
                    created_at: chrono::DateTime::parse_from_rfc3339("2026-06-04T01:00:00Z")
                        .unwrap()
                        .with_timezone(&chrono::Utc),
                    message_count: 2,
                    current: true,
                },
                crate::app::SessionsOverlayEntry {
                    session_id: "bbb222".to_string(),
                    title: "second".to_string(),
                    created_at: chrono::DateTime::parse_from_rfc3339("2026-06-04T02:00:00Z")
                        .unwrap()
                        .with_timezone(&chrono::Utc),
                    message_count: 5,
                    current: false,
                },
            ],
            selected: 1,
            status: "Up/Down select, Enter load, Esc close.".to_string(),
        };

        let lines = sessions_overlay_lines(&overlay, 100, 10);
        let rendered = lines
            .iter()
            .flat_map(|line| line.spans.iter().map(|span| span.content.as_ref()))
            .collect::<String>();

        assert!(rendered.contains("/sessions"));
        assert!(rendered.contains("aaa111"));
        assert!(rendered.contains("current"));
        assert!(rendered.contains("bbb222"));
        assert!(rendered.contains("messages=5"));
        let selected_line = lines
            .iter()
            .find(|line| {
                line.spans
                    .iter()
                    .any(|span| span.content.contains("bbb222"))
            })
            .expect("selected session row");
        assert_eq!(selected_line.spans[0].content.as_ref(), "> ");
        assert_eq!(selected_line.spans[0].style.bg, Some(CYAN));
    }

    #[test]
    fn ratatui_info_overlay_renders_command_output() {
        let overlay = InfoOverlay {
            title: "models".to_string(),
            body: "Models for provider openai\n/model gpt-5.5\ncontext: 400000".to_string(),
        };
        let lines = info_overlay_lines(&overlay, 80);
        let rendered = lines
            .iter()
            .flat_map(|line| line.spans.iter().map(|span| span.content.as_ref()))
            .collect::<String>();
        assert!(rendered.contains("Models for provider openai"));
        assert!(rendered.contains("/model gpt-5.5"));
        assert!(rendered.contains("context: 400000"));
    }

    #[test]
    fn ratatui_info_overlay_renders_markdown_code_and_diffs() {
        let overlay = InfoOverlay {
            title: "agent".to_string(),
            body: [
                "# Agent: Formatter",
                "",
                "| Field | Value |",
                "| --- | --- |",
                "| model | `gpt-5.4-mini` |",
                "",
                "```diff",
                "diff --git a/src/lib.rs b/src/lib.rs",
                "--- a/src/lib.rs",
                "+++ b/src/lib.rs",
                "@@ -1 +1 @@",
                "-old",
                "+new",
                "```",
            ]
            .join("\n"),
        };
        let lines = info_overlay_lines(&overlay, 100);
        let rendered = lines
            .iter()
            .flat_map(|line| line.spans.iter().map(|span| span.content.as_ref()))
            .collect::<String>();
        assert!(rendered.contains("Agent: Formatter"));
        assert!(rendered.contains("gpt-5.4-mini"));
        assert!(rendered.contains("diff"));
        assert!(rendered.contains("+new"));
    }

    #[test]
    fn ratatui_info_overlay_colors_trace_tool_lifecycle_by_status() {
        let overlay = InfoOverlay {
            title: "trace".to_string(),
            body: [
                r#"2026-06-04T01:28:17Z tool_start {"args":{"status":"failed"},"tool":"subagents_list"}"#,
                r#"2026-06-04T01:28:17Z tool_end {"error":null,"ok":true,"tool":"subagents_list"}"#,
                r#"2026-06-04T01:28:18Z tool_end {"error":"boom","ok":false,"tool":"subagents_list"}"#,
            ]
            .join("\n"),
        };
        let lines = info_overlay_lines(&overlay, 160);

        assert_eq!(lines[0].spans[0].style.fg, Some(CYAN));
        assert_eq!(lines[1].spans[0].style.fg, Some(GREEN));
        assert_eq!(lines[2].spans[0].style.fg, Some(RED));
    }

    #[test]
    fn ratatui_help_overlay_lists_work_activity_command() -> anyhow::Result<()> {
        let tmp = tempfile::tempdir()?;
        let app = crate::app::TuiApplication::with_data_root(tmp.path(), tmp.path().join("home"))?;
        let rendered = help_overlay_lines(&app, 88, 24)
            .iter()
            .flat_map(|line| line.spans.iter().map(|span| span.content.as_ref()))
            .collect::<String>();
        assert!(rendered.contains("/work"));
        assert!(rendered.contains("recent work"));
        Ok(())
    }

    #[test]
    fn ratatui_classifies_system_messages_for_scanability() {
        assert_eq!(
            classify_system_message("Error: model exceeded Vegvisir tool-call round limit."),
            SystemMessageKind::Error
        );
        assert_eq!(
            classify_system_message(
                "Risky tool requires human approval: write_file; approval_id=apr_123"
            ),
            SystemMessageKind::Approval
        );
        assert_eq!(
            classify_system_message("Tool call read_file completed with exit code 0"),
            SystemMessageKind::Tool
        );
        assert_eq!(
            classify_system_message(
                "Tool finished: run_command - ok: running 1 test test_that_mentions_error ... ok"
            ),
            SystemMessageKind::Tool
        );
        assert_eq!(
            classify_system_message("Tool finished: run_command - error: command failed"),
            SystemMessageKind::Error
        );
        assert_eq!(
            classify_system_message(
                "Turn failed before the model produced a normal final summary."
            ),
            SystemMessageKind::Error
        );
        assert_eq!(
            classify_system_message("Workspace set to /tmp/project"),
            SystemMessageKind::Note
        );

        let message = ChatMessage {
            role: "system".to_string(),
            content: "Error: provider failed".to_string(),
            attachments: Vec::new(),
            created_at: chrono::Utc::now(),
        };
        let rendered = message_lines(&message, 80, "", "you")
            .iter()
            .flat_map(|line| line.spans.iter().map(|span| span.content.as_ref()))
            .collect::<String>();
        assert!(rendered.contains("error"));
        assert!(!rendered.contains("note"));
    }

    #[test]
    fn ratatui_scroll_indicators_mark_history_position() {
        let mut visible = vec![Line::from("one"), Line::from("two"), Line::from("three")];
        apply_scroll_indicators(&mut visible, 2, 5, 80);
        assert!(
            visible[0]
                .spans
                .iter()
                .any(|span| span.content.contains("older messages"))
        );
        assert!(
            visible[2]
                .spans
                .iter()
                .any(|span| span.content.contains("newer messages"))
        );
    }

    #[test]
    fn ratatui_work_log_summarizes_recent_system_events() -> anyhow::Result<()> {
        let tmp = tempfile::tempdir()?;
        let mut app =
            crate::app::TuiApplication::with_data_root(tmp.path(), tmp.path().join("home"))?;
        app.session.messages.push(ChatMessage {
            role: "system".to_string(),
            content: "Error: something failed".to_string(),
            attachments: Vec::new(),
            created_at: chrono::Utc::now(),
        });
        let lines = work_log_lines(&app, 80);
        let rendered = lines
            .iter()
            .flat_map(|line| line.spans.iter().map(|span| span.content.as_ref()))
            .collect::<Vec<_>>()
            .join(" ");
        assert!(rendered.contains("error"));
        assert!(rendered.contains("something failed"));
        Ok(())
    }

    #[test]
    fn ratatui_approval_preview_truncates_large_args() {
        let mut args = serde_json::Map::new();
        args.insert("path".to_string(), serde_json::json!("/tmp/example"));
        args.insert(
            "content".to_string(),
            serde_json::json!("line1\nline2\nline3"),
        );
        let approval = ApprovalRequest {
            id: "apr_test".to_string(),
            reason: "Risky tool requires human approval: write_file".to_string(),
            tool_name: "write_file".to_string(),
            args,
            risk_label: "write".to_string(),
        };
        let preview = approval_arg_preview(&approval, 80, 2);
        let rendered = preview
            .iter()
            .flat_map(|line| line.spans.iter().map(|span| span.content.as_ref()))
            .collect::<Vec<_>>()
            .join("\n");
        assert!(rendered.contains("path") || rendered.contains("content"));
        assert!(rendered.contains("more args hidden"));
    }

    #[test]
    fn ratatui_approval_queue_highlights_selected_request() {
        let approvals = vec![
            ApprovalRequest {
                id: "apr_a".to_string(),
                reason: "first".to_string(),
                tool_name: "write_file".to_string(),
                args: serde_json::Map::new(),
                risk_label: "write".to_string(),
            },
            ApprovalRequest {
                id: "apr_b".to_string(),
                reason: "second".to_string(),
                tool_name: "run_command".to_string(),
                args: serde_json::Map::new(),
                risk_label: "command".to_string(),
            },
        ];
        let lines = approval_queue_lines(&approvals, 1, 80, 4);
        let rendered = lines
            .iter()
            .flat_map(|line| line.spans.iter().map(|span| span.content.as_ref()))
            .collect::<String>();
        assert!(rendered.contains("1/2"));
        assert!(rendered.contains("2/2"));
        assert!(rendered.contains("apr_b"));
        assert!(
            lines[1]
                .spans
                .first()
                .is_some_and(|span| span.content.as_ref() == "> ")
        );
    }

    #[test]
    fn ratatui_rust_code_blocks_apply_token_level_highlighting() {
        let markdown = "```rust\nfn main() { let value = 42; println!(\"{value}\"); }\n```";
        let lines = render_markdown(markdown, 100, Style::default().fg(FG));
        let spans = lines
            .iter()
            .flat_map(|line| line.spans.iter())
            .collect::<Vec<_>>();
        assert!(
            spans
                .iter()
                .any(|span| span.content.as_ref() == "fn" && span.style.fg == Some(CYAN))
        );
        assert!(
            spans
                .iter()
                .any(|span| span.content.as_ref().contains("42") && span.style.fg == Some(AMBER))
        );
        assert!(
            spans
                .iter()
                .any(|span| span.content.as_ref().contains("\"{value}\"")
                    && span.style.fg == Some(GREEN))
        );
    }

    #[test]
    fn ratatui_inkjet_highlights_typescript_code_blocks() {
        let markdown =
            "```typescript\nexport function greet(name: string) { return \"hello \" + name; }\n```";
        let lines = render_markdown(markdown, 100, Style::default().fg(FG));
        let spans = lines
            .iter()
            .flat_map(|line| line.spans.iter())
            .collect::<Vec<_>>();

        assert!(
            spans
                .iter()
                .any(|span| span.content.as_ref() == "export" && span.style.fg == Some(CYAN))
        );
        assert!(spans.iter().any(|span| span.content.as_ref() == "name"
            && span.style.fg == Some(Color::Rgb(235, 190, 120))));
        assert!(spans.iter().any(|span| span.content.as_ref() == "string"
            && span.style.fg == Some(Color::Rgb(125, 220, 190))));
    }

    #[test]
    fn ratatui_external_diff_overlay_renders_text_output() {
        let overlay = DiffOverlay {
            title: "Git diff (difftastic)".to_string(),
            rendered_by: DiffRenderer::Difftastic,
            diff: "src/lib.rs --- Rust\n1 + changed".to_string(),
            files_changed: 0,
            added_lines: 0,
            removed_lines: 0,
            rendered_lines_cache: None,
        };
        let lines = diff_overlay_lines(&overlay, 80);
        let rendered = lines
            .iter()
            .flat_map(|line| line.spans.iter().map(|span| span.content.as_ref()))
            .collect::<String>();
        assert!(rendered.contains("src/lib.rs"));
        assert!(rendered.contains("changed"));
    }
}
