feat(view): Tambah parameter dim dan pewarnaan baris diff di markdown renderer

Tambahkan helper apply_dim dan diff_line_style, ubah signature
render_markdown untuk menerima flag dim, serta deteksi fence bahasa
diff sehingga baris +/-/@@ tetap berwarna meskipun pesan sedang
dirender dim (tampilan tool-output).
This commit is contained in:
asepharyana
2026-07-15 06:32:57 +07:00
parent 2bb8e6f255
commit 683715cd7a
+114 -16
View File
@@ -19,21 +19,54 @@ use ratatui::style::{Modifier, Style};
use ratatui::text::Span; use ratatui::text::Span;
use super::theme::Theme; use super::theme::Theme;
/// Apply the "tool output" dim/italic style, or pass `style` through
/// unchanged, depending on `dim`.
fn apply_dim(style: Style, dim: bool) -> Style {
if dim {
Style::default().fg(Theme::TEXT_DIM).add_modifier(Modifier::ITALIC)
} else {
style
}
}
/// Classify a single line inside a ` ```diff ` fenced block by its unified-diff
/// prefix, returning the color it should always render with (even when the
/// surrounding tool output is dimmed) — or `None` for context lines and the
/// `+++`/`---` file-header lines, which use the normal code-block color.
fn diff_line_style(line: &str) -> Option<Style> {
if line.starts_with("@@") {
Some(Style::default().fg(Theme::INFO).bg(Theme::CODE_BG))
} else if line.starts_with('+') && !line.starts_with("+++") {
Some(Style::default().fg(Theme::SUCCESS).bg(Theme::CODE_BG))
} else if line.starts_with('-') && !line.starts_with("---") {
Some(Style::default().fg(Theme::ERROR).bg(Theme::CODE_BG))
} else {
None
}
}
/// Render a markdown string into styled terminal spans, word-wrapped to `width`. /// Render a markdown string into styled terminal spans, word-wrapped to `width`.
/// ///
/// Flow: `pulldown_cmark` parses `text` into an event stream → each /// Flow: `pulldown_cmark` parses `text` into an event stream → each
/// Start/End/Text/Code/Break event is translated into styled `Span`s → /// Start/End/Text/Code/Break event is translated into styled `Span`s →
/// if `width > 0`, a second pass wraps long lines. /// if `width > 0`, a second pass wraps long lines.
/// ///
/// `dim`: when `true`, every span falls back to `Theme::TEXT_DIM` + italic
/// (the "tool output" look) *except* lines inside a ` ```diff ` fenced
/// block, which always keep their +/-/@@ diff color regardless of `dim` —
/// this is what lets diff output stay colored inside otherwise-dimmed
/// `Role::Tool` chat messages.
///
/// Return: a flat vec of styled spans; `chat::split_spans_into_lines` /// Return: a flat vec of styled spans; `chat::split_spans_into_lines`
/// turns it back into `Line`s for the Paragraph widget. /// turns it back into `Line`s for the Paragraph widget.
#[allow(clippy::too_many_lines)] #[allow(clippy::too_many_lines)]
pub fn render_markdown(text: &str, width: u16) -> Vec<Span<'static>> { pub fn render_markdown(text: &str, width: u16, dim: bool) -> Vec<Span<'static>> {
let mut spans = Vec::new(); let mut spans = Vec::new();
let mut options = pulldown_cmark::Options::empty(); let mut options = pulldown_cmark::Options::empty();
options.insert(pulldown_cmark::Options::ENABLE_TABLES); options.insert(pulldown_cmark::Options::ENABLE_TABLES);
let parser = pulldown_cmark::Parser::new_ext(text, options); let parser = pulldown_cmark::Parser::new_ext(text, options);
let mut in_code_block = false; let mut in_code_block = false;
let mut in_diff_block = false;
let mut in_heading = false; let mut in_heading = false;
let mut heading_level = 0; let mut heading_level = 0;
@@ -46,8 +79,12 @@ pub fn render_markdown(text: &str, width: u16) -> Vec<Span<'static>> {
match event { match event {
pulldown_cmark::Event::Start(tag) => { pulldown_cmark::Event::Start(tag) => {
match tag { match tag {
pulldown_cmark::Tag::CodeBlock(_) => { pulldown_cmark::Tag::CodeBlock(kind) => {
in_code_block = true; in_code_block = true;
in_diff_block = matches!(
&kind,
pulldown_cmark::CodeBlockKind::Fenced(lang) if lang.as_ref() == "diff"
);
// Code block top bar // Code block top bar
spans.push(Span::styled( spans.push(Span::styled(
"\n", "\n",
@@ -55,7 +92,7 @@ pub fn render_markdown(text: &str, width: u16) -> Vec<Span<'static>> {
)); ));
spans.push(Span::styled( spans.push(Span::styled(
" ┌─ code ", " ┌─ code ",
Style::default().fg(Theme::TEXT_MUTED).bg(Theme::CODE_BG), apply_dim(Style::default().fg(Theme::TEXT_MUTED).bg(Theme::CODE_BG), dim),
)); ));
spans.push(Span::styled( spans.push(Span::styled(
"\n", "\n",
@@ -76,25 +113,25 @@ pub fn render_markdown(text: &str, width: u16) -> Vec<Span<'static>> {
// List item bullet // List item bullet
spans.push(Span::styled( spans.push(Span::styled(
"", "",
Style::default().fg(Theme::PRIMARY), apply_dim(Style::default().fg(Theme::PRIMARY), dim),
)); ));
} }
pulldown_cmark::Tag::Link { dest_url, .. } => { pulldown_cmark::Tag::Link { dest_url, .. } => {
spans.push(Span::styled( spans.push(Span::styled(
"[", "[",
Style::default().fg(Theme::INFO), apply_dim(Style::default().fg(Theme::INFO), dim),
)); ));
// We push the URL as a tooltip-like suffix // We push the URL as a tooltip-like suffix
// After the link text ends, we'll add the URL // After the link text ends, we'll add the URL
spans.push(Span::styled( spans.push(Span::styled(
format!("]({dest_url})"), format!("]({dest_url})"),
Style::default().fg(Theme::TEXT_MUTED).add_modifier(Modifier::ITALIC), apply_dim(Style::default().fg(Theme::TEXT_MUTED).add_modifier(Modifier::ITALIC), dim),
)); ));
} }
pulldown_cmark::Tag::BlockQuote(_) => { pulldown_cmark::Tag::BlockQuote(_) => {
spans.push(Span::styled( spans.push(Span::styled(
"", "",
Style::default().fg(Theme::BLOCKQUOTE_BAR), apply_dim(Style::default().fg(Theme::BLOCKQUOTE_BAR), dim),
)); ));
} }
pulldown_cmark::Tag::Table(_) => { pulldown_cmark::Tag::Table(_) => {
@@ -114,10 +151,11 @@ pub fn render_markdown(text: &str, width: u16) -> Vec<Span<'static>> {
match tag { match tag {
pulldown_cmark::TagEnd::CodeBlock => { pulldown_cmark::TagEnd::CodeBlock => {
in_code_block = false; in_code_block = false;
in_diff_block = false;
// Code block bottom bar // Code block bottom bar
spans.push(Span::styled( spans.push(Span::styled(
"\n └─\n", "\n └─\n",
Style::default().fg(Theme::TEXT_MUTED).bg(Theme::CODE_BG), apply_dim(Style::default().fg(Theme::TEXT_MUTED).bg(Theme::CODE_BG), dim),
)); ));
} }
pulldown_cmark::TagEnd::Heading(_) => { pulldown_cmark::TagEnd::Heading(_) => {
@@ -182,7 +220,7 @@ pub fn render_markdown(text: &str, width: u16) -> Vec<Span<'static>> {
let max_height = cell_lines.iter().map(|cl| cl.len()).max().unwrap_or(1); let max_height = cell_lines.iter().map(|cl| cl.len()).max().unwrap_or(1);
for y in 0..max_height { for y in 0..max_height {
spans.push(Span::styled(" | ", Style::default().fg(Theme::BORDER))); spans.push(Span::styled(" | ", apply_dim(Style::default().fg(Theme::BORDER), dim)));
for (i, cl) in cell_lines.iter().enumerate() { for (i, cl) in cell_lines.iter().enumerate() {
let line_spans = if y < cl.len() { &cl[y] } else { [].as_slice() }; let line_spans = if y < cl.len() { &cl[y] } else { [].as_slice() };
let mut line_width = 0; let mut line_width = 0;
@@ -192,15 +230,15 @@ pub fn render_markdown(text: &str, width: u16) -> Vec<Span<'static>> {
} }
let pad = col_widths[i].saturating_sub(line_width); let pad = col_widths[i].saturating_sub(line_width);
spans.push(Span::raw(" ".repeat(pad))); spans.push(Span::raw(" ".repeat(pad)));
spans.push(Span::styled(" | ", Style::default().fg(Theme::BORDER))); spans.push(Span::styled(" | ", apply_dim(Style::default().fg(Theme::BORDER), dim)));
} }
spans.push(Span::raw("\n")); spans.push(Span::raw("\n"));
} }
if r == 0 { if r == 0 {
spans.push(Span::styled(" |", Style::default().fg(Theme::BORDER))); spans.push(Span::styled(" |", apply_dim(Style::default().fg(Theme::BORDER), dim)));
for w in &col_widths { for w in &col_widths {
spans.push(Span::styled(format!("{}-|", "-".repeat(*w + 2)), Style::default().fg(Theme::BORDER))); spans.push(Span::styled(format!("{}-|", "-".repeat(*w + 2)), apply_dim(Style::default().fg(Theme::BORDER), dim)));
} }
spans.push(Span::raw("\n")); spans.push(Span::raw("\n"));
} }
@@ -213,11 +251,25 @@ pub fn render_markdown(text: &str, width: u16) -> Vec<Span<'static>> {
pulldown_cmark::Event::Text(text) => { pulldown_cmark::Event::Text(text) => {
let s = text.to_string(); let s = text.to_string();
if in_code_block { if in_code_block {
if in_diff_block {
for (i, line) in s.split('\n').enumerate() {
if i > 0 {
spans.push(Span::raw("\n"));
}
if line.is_empty() {
continue;
}
let style = diff_line_style(line)
.unwrap_or_else(|| Style::default().fg(Theme::ACCENT_TEAL).bg(Theme::CODE_BG));
spans.push(Span::styled(format!(" {line}"), style));
}
} else {
let indented = format!(" {}", s.replace('\n', "\n ")); let indented = format!(" {}", s.replace('\n', "\n "));
spans.push(Span::styled( spans.push(Span::styled(
indented, indented,
Style::default().fg(Theme::ACCENT_TEAL).bg(Theme::CODE_BG), apply_dim(Style::default().fg(Theme::ACCENT_TEAL).bg(Theme::CODE_BG), dim),
)); ));
}
} else if in_heading { } else if in_heading {
let color = match heading_level { let color = match heading_level {
1 => Theme::PRIMARY, 1 => Theme::PRIMARY,
@@ -227,21 +279,24 @@ pub fn render_markdown(text: &str, width: u16) -> Vec<Span<'static>> {
}; };
spans.push(Span::styled( spans.push(Span::styled(
s, s,
Style::default().fg(color).add_modifier(Modifier::BOLD), apply_dim(Style::default().fg(color).add_modifier(Modifier::BOLD), dim),
)); ));
} else if in_table_cell { } else if in_table_cell {
current_cell.push(Span::raw(s)); current_cell.push(Span::styled(s, apply_dim(Style::default(), dim)));
} else { } else {
spans.push(Span::raw(s)); spans.push(Span::styled(s, apply_dim(Style::default(), dim)));
} }
} }
pulldown_cmark::Event::Code(text) => { pulldown_cmark::Event::Code(text) => {
let span = Span::styled( let span = Span::styled(
format!(" {text} "), format!(" {text} "),
apply_dim(
Style::default() Style::default()
.fg(Theme::ACCENT_TEAL) .fg(Theme::ACCENT_TEAL)
.bg(Theme::CODE_BAR) .bg(Theme::CODE_BAR)
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
dim,
),
); );
if in_table_cell { if in_table_cell {
current_cell.push(span); current_cell.push(span);
@@ -377,3 +432,46 @@ fn wrap_spans_to_lines(spans: &[Span<'static>], target_width: usize) -> Vec<Vec<
} }
lines lines
} }
#[cfg(test)]
mod tests {
use super::*;
fn span_text(spans: &[Span<'static>]) -> String {
spans.iter().map(|s| s.content.as_ref()).collect()
}
#[test]
fn dim_false_plain_text_has_no_color() {
let spans = render_markdown("hello world", 0, false);
assert_eq!(span_text(&spans), "hello world");
assert_eq!(spans[0].style, Style::default());
}
#[test]
fn dim_true_plain_text_is_dim_italic() {
let spans = render_markdown("hello", 0, true);
let expected = Style::default().fg(Theme::TEXT_DIM).add_modifier(Modifier::ITALIC);
assert_eq!(spans[0].style, expected);
}
#[test]
fn dim_true_diff_lines_keep_their_own_color() {
let md = "```diff\n@@ -1,2 +1,2 @@\n-old line\n+new line\n context line\n```";
let spans = render_markdown(md, 0, true);
let plus_span = spans.iter().find(|s| s.content.contains("+new line")).expect("plus span present");
assert_eq!(plus_span.style.fg, Some(Theme::SUCCESS));
let minus_span = spans.iter().find(|s| s.content.contains("-old line")).expect("minus span present");
assert_eq!(minus_span.style.fg, Some(Theme::ERROR));
let hunk_span = spans.iter().find(|s| s.content.contains("@@")).expect("hunk header span present");
assert_eq!(hunk_span.style.fg, Some(Theme::INFO));
}
#[test]
fn dim_true_non_diff_code_block_is_dimmed() {
let md = "```rust\nfn main() {}\n```";
let spans = render_markdown(md, 0, true);
let code_span = spans.iter().find(|s| s.content.contains("fn main")).expect("code span present");
assert_eq!(code_span.style, Style::default().fg(Theme::TEXT_DIM).add_modifier(Modifier::ITALIC));
}
}