travelagent 1.10.3

Agent-first TUI code review tool
use ratatui::{
    Frame,
    layout::{Alignment, Constraint, Flex, Layout, Rect},
    style::Style,
    text::{Line, Span},
    widgets::{Block, Borders, Clear, Paragraph},
};

use crate::app::{App, ai_summary::AiSummaryStaleness};
use crate::ui::{markdown, styles};

/// Render the AI summary popup panel centered over the given `area`. Callers
/// should gate on `app.ai.show_panel` before invoking this.
pub fn render(frame: &mut Frame, area: Rect, app: &mut App) {
    let popup_area = centered_rect(70, 70, area);

    frame.render_widget(Clear, popup_area);

    let title = match app.ai.updated_at {
        Some(ts) => format!(" AI Summary — updated {} ", ts.format("%Y-%m-%d %H:%M UTC")),
        None => " AI Summary ".to_string(),
    };
    let (popup_style, border_style) = {
        let theme = &app.theme;
        (
            styles::popup_style(theme),
            styles::border_style(theme, true),
        )
    };
    let block = Block::default()
        .title(title)
        .borders(Borders::ALL)
        .style(popup_style)
        .border_style(border_style);

    let inner = block.inner(popup_area);
    frame.render_widget(block, popup_area);

    // Reserve the last row for the footer hint; everything else is content.
    let content_height = inner.height.saturating_sub(1);
    let content_area = Rect {
        x: inner.x,
        y: inner.y,
        width: inner.width,
        height: content_height,
    };
    let footer_area = Rect {
        x: inner.x,
        y: inner.y + content_height,
        width: inner.width,
        height: inner.height.saturating_sub(content_height),
    };

    let has_content = app.ai.summary.is_some();
    if has_content {
        render_content(frame, content_area, app);
    } else {
        render_empty_state(frame, content_area, app);
    }

    if footer_area.height > 0 {
        let (dim, popup) = {
            let theme = &app.theme;
            (styles::dim_style(theme), styles::popup_style(theme))
        };
        let footer = Paragraph::new(Line::from(Span::styled(
            "Ctrl+A or Esc to close · j/k to scroll",
            dim,
        )))
        .alignment(Alignment::Center)
        .style(popup);
        frame.render_widget(footer, footer_area);
    }
}

fn render_empty_state(frame: &mut Frame, area: Rect, app: &App) {
    let theme = &app.theme;
    let lines = vec![
        Line::from(""),
        Line::from(Span::styled(
            "No AI summary yet.",
            Style::default().fg(theme.fg_primary),
        ))
        .alignment(Alignment::Center),
        Line::from(""),
        Line::from(Span::styled(
            "Ask your agent to call `trv_set_ai_summary(markdown)` via MCP.",
            styles::dim_style(theme),
        ))
        .alignment(Alignment::Center),
    ];
    let paragraph = Paragraph::new(lines)
        .alignment(Alignment::Center)
        .style(styles::popup_style(theme));
    frame.render_widget(paragraph, area);
}

fn render_content(frame: &mut Frame, area: Rect, app: &mut App) {
    let body_width = (area.width as usize).max(1);
    let staleness = app.ai_summary_staleness();

    let mut lines: Vec<Line<'static>> = {
        let theme = &app.theme;
        let content = app.ai.summary.as_deref().unwrap_or("");
        if app.markdown_rendering_enabled {
            markdown::render_markdown(content, theme, body_width)
        } else {
            content
                .lines()
                .map(|l| Line::from(Span::raw(l.to_string())))
                .collect()
        }
    };

    // Prepend a single warning row when the diff has advanced past what the
    // summary was written for. Uses the theme's warning style so it pops.
    if let AiSummaryStaleness::Stale { stored_short } = &staleness {
        let warning_style = Style::default()
            .fg(app.theme.message_warning_fg)
            .bg(app.theme.message_warning_bg);
        let warning = Line::from(Span::styled(
            format!("⚠ Summary is stale: written for {stored_short}, diff has changed."),
            warning_style,
        ));
        lines.insert(0, warning);
    }

    let total_lines = lines.len();
    let viewport_height = area.height as usize;
    let max_scroll = total_lines.saturating_sub(viewport_height);
    if app.ai.scroll > max_scroll {
        app.ai.scroll = max_scroll;
    }

    let popup = styles::popup_style(&app.theme);
    let paragraph = Paragraph::new(lines)
        .scroll((app.ai.scroll as u16, 0))
        .style(popup);
    frame.render_widget(paragraph, area);
}

fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
    let vertical = Layout::vertical([Constraint::Percentage(percent_y)]).flex(Flex::Center);
    let horizontal = Layout::horizontal([Constraint::Percentage(percent_x)]).flex(Flex::Center);
    let [area] = vertical.areas(area);
    let [area] = horizontal.areas(area);
    area
}