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};
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);
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()
}
};
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
}