use ag_forge::RequestedReview;
use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph};
use crate::ui::state::help_action;
use crate::ui::{Page, layout, markdown, style};
pub struct ReviewDetailPage<'a> {
markdown_render_cache: &'a markdown::MarkdownRenderCache,
review: &'a RequestedReview,
scroll_offset: u16,
}
impl<'a> ReviewDetailPage<'a> {
pub fn new(
review: &'a RequestedReview,
markdown_render_cache: &'a markdown::MarkdownRenderCache,
scroll_offset: u16,
) -> Self {
Self {
markdown_render_cache,
review,
scroll_offset,
}
}
}
impl Page for ReviewDetailPage<'_> {
fn render(&mut self, f: &mut Frame, area: Rect) {
let areas = layout::tab_page_areas(area);
let content_width = detail_content_width(area);
let paragraph = Paragraph::new(detail_lines(
self.review,
self.markdown_render_cache,
content_width,
))
.block(review_detail_block())
.style(Style::default().fg(style::palette::text()))
.scroll((self.scroll_offset, 0));
f.render_widget(paragraph, areas.main_area);
let help = Paragraph::new(review_detail_footer_line());
f.render_widget(help, areas.footer_area);
}
}
pub(crate) fn review_detail_max_scroll_offset(
review: &RequestedReview,
area: Rect,
markdown_render_cache: &markdown::MarkdownRenderCache,
) -> u16 {
let viewport_height = detail_view_height(area);
if viewport_height == 0 {
return 0;
}
let rendered_line_count =
detail_lines(review, markdown_render_cache, detail_content_width(area)).len();
u16::try_from(rendered_line_count.saturating_sub(usize::from(viewport_height)))
.unwrap_or(u16::MAX)
}
fn detail_lines(
review: &RequestedReview,
markdown_render_cache: &markdown::MarkdownRenderCache,
width: usize,
) -> Vec<Line<'static>> {
let description = review
.body
.as_deref()
.map(str::trim)
.filter(|body| !body.is_empty())
.unwrap_or("No description provided.");
let description = review_description_markdown(description);
let mut lines = vec![
section_label("Title"),
Line::from(review.title.clone()),
Line::from(""),
section_label("Description"),
];
lines.extend(
markdown_render_cache
.render(&description, width)
.iter()
.cloned(),
);
lines
}
fn review_description_markdown(description: &str) -> String {
let mut rendered = String::new();
let mut index = 0;
while index < description.len() {
if let Some(tag) = parse_html_tag(description, index) {
append_html_tag_replacement(&mut rendered, &tag);
index = tag.end_index;
continue;
}
if let Some((decoded, consumed)) = decode_html_entity(&description[index..]) {
rendered.push(decoded);
index += consumed;
continue;
}
let Some(character) = description[index..].chars().next() else {
break;
};
rendered.push(character);
index += character.len_utf8();
}
compact_blank_lines(&rendered)
}
struct HtmlTag<'a> {
end_index: usize,
is_closing: bool,
name: &'a str,
}
fn parse_html_tag(description: &str, index: usize) -> Option<HtmlTag<'_>> {
let suffix = description.get(index..)?;
if !suffix.starts_with('<') {
return None;
}
let close_offset = suffix.find('>')?;
let raw_tag = suffix[1..close_offset].trim();
let (is_closing, tag_content) = raw_tag
.strip_prefix('/')
.map_or((false, raw_tag), |content| (true, content.trim_start()));
let name_end = tag_content
.char_indices()
.take_while(|(_, character)| character.is_ascii_alphanumeric())
.map(|(offset, character)| offset + character.len_utf8())
.last()?;
let name = &tag_content[..name_end];
if !name
.chars()
.next()
.is_some_and(|character| character.is_ascii_alphabetic())
{
return None;
}
Some(HtmlTag {
end_index: index + close_offset + 1,
is_closing,
name,
})
}
fn append_html_tag_replacement(output: &mut String, tag: &HtmlTag<'_>) {
match (tag.name.to_ascii_lowercase().as_str(), tag.is_closing) {
("h1", false) => append_line_prefix(output, "# "),
("h2", false) => append_line_prefix(output, "## "),
("h3" | "summary", false) => append_line_prefix(output, "### "),
("h4", false) => append_line_prefix(output, "#### "),
("li", false) => append_line_prefix(output, "- "),
("blockquote", false) => append_line_prefix(output, "> "),
("code", _) => output.push('`'),
("strong" | "b", _) => output.push_str("**"),
("em" | "i", _) => output.push('*'),
(
"br" | "p" | "details" | "summary" | "blockquote" | "h1" | "h2" | "h3" | "h4" | "li",
true,
)
| ("br" | "p" | "ul" | "ol" | "details", false) => append_line_break(output),
_ => {}
}
}
fn append_line_prefix(output: &mut String, prefix: &str) {
if !output.is_empty() && !output.ends_with('\n') {
output.push('\n');
}
output.push_str(prefix);
}
fn append_line_break(output: &mut String) {
if !output.ends_with('\n') {
output.push('\n');
}
}
fn decode_html_entity(input: &str) -> Option<(char, usize)> {
if !input.starts_with('&') {
return None;
}
let semicolon_index = input.find(';')?;
let entity = &input[1..semicolon_index];
let decoded = match entity {
"amp" => '&',
"lt" => '<',
"gt" => '>',
"quot" => '"',
"apos" | "#39" => '\'',
"nbsp" => ' ',
_ => decode_numeric_html_entity(entity)?,
};
Some((decoded, semicolon_index + 1))
}
fn decode_numeric_html_entity(entity: &str) -> Option<char> {
let codepoint = if let Some(hexadecimal) = entity
.strip_prefix("#x")
.or_else(|| entity.strip_prefix("#X"))
{
u32::from_str_radix(hexadecimal, 16).ok()?
} else {
let decimal = entity.strip_prefix('#')?;
decimal.parse::<u32>().ok()?
};
char::from_u32(codepoint)
}
fn compact_blank_lines(markdown: &str) -> String {
let mut compacted = Vec::new();
let mut previous_blank = false;
for line in markdown.lines().map(str::trim_end) {
let is_blank = line.trim().is_empty();
if is_blank && previous_blank {
continue;
}
compacted.push(line);
previous_blank = is_blank;
}
compacted.join("\n").trim().to_string()
}
fn detail_content_width(area: Rect) -> usize {
usize::from(detail_content_area(area).width)
}
fn detail_view_height(area: Rect) -> u16 {
detail_content_area(area).height
}
fn detail_content_area(area: Rect) -> Rect {
let areas = layout::tab_page_areas(area);
review_detail_block().inner(areas.main_area)
}
fn section_label(label: &'static str) -> Line<'static> {
Line::from(Span::styled(
label,
Style::default()
.fg(style::palette::text_muted())
.add_modifier(Modifier::BOLD),
))
}
fn review_detail_footer_line() -> Line<'static> {
help_action::footer_line(&[
help_action::HelpAction::new("back", "q", "Back"),
help_action::HelpAction::new("scroll", "j/k", "Scroll"),
help_action::HelpAction::new("page", "Ctrl+d/u", "Page"),
help_action::HelpAction::new("top/bottom", "g/G", "Top/bottom"),
])
}
fn review_detail_block() -> Block<'static> {
Block::default()
.borders(Borders::ALL)
.title("Review Request")
.border_style(style::border_style())
}
#[cfg(test)]
mod tests {
use ag_forge::{ForgeKind, RequestedReviewAudience};
use ratatui::backend::TestBackend;
use super::*;
use crate::domain::theme::ColorTheme;
#[test]
fn test_render_detail_shows_title_and_description() {
let _theme_scope = style::scoped_active_theme(ColorTheme::Current);
let backend = TestBackend::new(80, 10);
let mut terminal = ratatui::Terminal::new(backend).expect("failed to create terminal");
let review = requested_review(Some("Implements the detail page."));
terminal
.draw(|frame| {
ReviewDetailPage::new(&review, &markdown::MarkdownRenderCache::default(), 0)
.render(frame, frame.area());
})
.expect("failed to draw");
let text = buffer_text(terminal.backend().buffer());
assert!(text.contains("Review Request"));
assert!(text.contains("Title"));
assert!(text.contains("Add review detail page"));
assert!(text.contains("Description"));
assert!(text.contains("Implements the detail page."));
assert!(text.contains("q: back"));
}
#[test]
fn test_render_detail_shows_missing_description_fallback() {
let _theme_scope = style::scoped_active_theme(ColorTheme::Current);
let backend = TestBackend::new(80, 10);
let mut terminal = ratatui::Terminal::new(backend).expect("failed to create terminal");
let review = requested_review(None);
terminal
.draw(|frame| {
ReviewDetailPage::new(&review, &markdown::MarkdownRenderCache::default(), 0)
.render(frame, frame.area());
})
.expect("failed to draw");
let text = buffer_text(terminal.backend().buffer());
assert!(text.contains("No description provided."));
}
#[test]
fn test_render_detail_renders_markdown_description() {
let _theme_scope = style::scoped_active_theme(ColorTheme::Current);
let backend = TestBackend::new(80, 10);
let mut terminal = ratatui::Terminal::new(backend).expect("failed to create terminal");
let review = requested_review(Some("## Details\n- **Parser** uses `fast` mode."));
terminal
.draw(|frame| {
ReviewDetailPage::new(&review, &markdown::MarkdownRenderCache::default(), 0)
.render(frame, frame.area());
})
.expect("failed to draw");
let text = buffer_text(terminal.backend().buffer());
assert!(text.contains("Details"));
assert!(text.contains("- Parser uses fast mode."));
assert!(!text.contains("## Details"));
assert!(!text.contains("**Parser**"));
assert!(!text.contains("`fast`"));
}
#[test]
fn test_render_detail_normalizes_common_html_description() {
let _theme_scope = style::scoped_active_theme(ColorTheme::Current);
let backend = TestBackend::new(100, 14);
let mut terminal = ratatui::Terminal::new(backend).expect("failed to create terminal");
let review = requested_review(Some(
"<details>\n<summary>Release notes</summary>\n<h2>v1.0.0</h2>\n<ul>\n<li>Fix <code>parser</code> by <a href=\"https://example.com\">alice</a></li>\n</ul>\n</details>",
));
terminal
.draw(|frame| {
ReviewDetailPage::new(&review, &markdown::MarkdownRenderCache::default(), 0)
.render(frame, frame.area());
})
.expect("failed to draw");
let text = buffer_text(terminal.backend().buffer());
assert!(text.contains("Release notes"));
assert!(text.contains("v1.0.0"));
assert!(text.contains("- Fix parser by alice"));
assert!(!text.contains("<summary>"));
assert!(!text.contains("<li>"));
assert!(!text.contains("<code>"));
}
#[test]
fn test_render_detail_applies_scroll_offset() {
let _theme_scope = style::scoped_active_theme(ColorTheme::Current);
let backend = TestBackend::new(80, 8);
let mut terminal = ratatui::Terminal::new(backend).expect("failed to create terminal");
let review = requested_review(Some(
"line 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7",
));
terminal
.draw(|frame| {
ReviewDetailPage::new(&review, &markdown::MarkdownRenderCache::default(), 5)
.render(frame, frame.area());
})
.expect("failed to draw");
let text = buffer_text(terminal.backend().buffer());
assert!(!text.contains("Add review detail page"));
assert!(text.contains("line 2"));
assert!(text.contains("line 3"));
}
#[test]
fn test_review_detail_max_scroll_offset_accounts_for_rendered_markdown() {
let review = requested_review(Some("line 1\nline 2\nline 3\nline 4\nline 5\nline 6"));
let markdown_render_cache = markdown::MarkdownRenderCache::default();
let max_scroll_offset = review_detail_max_scroll_offset(
&review,
Rect::new(0, 0, 80, 8),
&markdown_render_cache,
);
assert_eq!(max_scroll_offset, 6);
}
#[test]
fn test_review_description_markdown_preserves_literal_angle_brackets() {
let description = "Keep 2 < 3 and 5 > 4 visible.";
let rendered = review_description_markdown(description);
assert_eq!(rendered, "Keep 2 < 3 and 5 > 4 visible.");
}
fn requested_review(body: Option<&str>) -> RequestedReview {
RequestedReview {
audience: RequestedReviewAudience::Personal,
body: body.map(str::to_string),
display_id: "#42".to_string(),
forge_kind: ForgeKind::GitHub,
repository: "agentty-xyz/agentty".to_string(),
status_summary: None,
title: "Add review detail page".to_string(),
updated_at: Some("2026-04-27T21:30:00Z".to_string()),
web_url: "https://example.com/42".to_string(),
}
}
fn buffer_text(buffer: &ratatui::buffer::Buffer) -> String {
buffer
.content()
.iter()
.map(ratatui::buffer::Cell::symbol)
.collect::<Vec<_>>()
.join("")
}
}