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:
+122
-24
@@ -19,21 +19,54 @@ use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::Span;
|
||||
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`.
|
||||
///
|
||||
/// Flow: `pulldown_cmark` parses `text` into an event stream → each
|
||||
/// Start/End/Text/Code/Break event is translated into styled `Span`s →
|
||||
/// 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`
|
||||
/// turns it back into `Line`s for the Paragraph widget.
|
||||
#[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 options = pulldown_cmark::Options::empty();
|
||||
options.insert(pulldown_cmark::Options::ENABLE_TABLES);
|
||||
let parser = pulldown_cmark::Parser::new_ext(text, options);
|
||||
let mut in_code_block = false;
|
||||
let mut in_diff_block = false;
|
||||
let mut in_heading = false;
|
||||
let mut heading_level = 0;
|
||||
|
||||
@@ -46,8 +79,12 @@ pub fn render_markdown(text: &str, width: u16) -> Vec<Span<'static>> {
|
||||
match event {
|
||||
pulldown_cmark::Event::Start(tag) => {
|
||||
match tag {
|
||||
pulldown_cmark::Tag::CodeBlock(_) => {
|
||||
pulldown_cmark::Tag::CodeBlock(kind) => {
|
||||
in_code_block = true;
|
||||
in_diff_block = matches!(
|
||||
&kind,
|
||||
pulldown_cmark::CodeBlockKind::Fenced(lang) if lang.as_ref() == "diff"
|
||||
);
|
||||
// Code block top bar
|
||||
spans.push(Span::styled(
|
||||
"\n",
|
||||
@@ -55,7 +92,7 @@ pub fn render_markdown(text: &str, width: u16) -> Vec<Span<'static>> {
|
||||
));
|
||||
spans.push(Span::styled(
|
||||
" ┌─ 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(
|
||||
"\n",
|
||||
@@ -76,25 +113,25 @@ pub fn render_markdown(text: &str, width: u16) -> Vec<Span<'static>> {
|
||||
// List item bullet
|
||||
spans.push(Span::styled(
|
||||
"• ",
|
||||
Style::default().fg(Theme::PRIMARY),
|
||||
apply_dim(Style::default().fg(Theme::PRIMARY), dim),
|
||||
));
|
||||
}
|
||||
pulldown_cmark::Tag::Link { dest_url, .. } => {
|
||||
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
|
||||
// After the link text ends, we'll add the URL
|
||||
spans.push(Span::styled(
|
||||
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(_) => {
|
||||
spans.push(Span::styled(
|
||||
"▎",
|
||||
Style::default().fg(Theme::BLOCKQUOTE_BAR),
|
||||
apply_dim(Style::default().fg(Theme::BLOCKQUOTE_BAR), dim),
|
||||
));
|
||||
}
|
||||
pulldown_cmark::Tag::Table(_) => {
|
||||
@@ -114,10 +151,11 @@ pub fn render_markdown(text: &str, width: u16) -> Vec<Span<'static>> {
|
||||
match tag {
|
||||
pulldown_cmark::TagEnd::CodeBlock => {
|
||||
in_code_block = false;
|
||||
in_diff_block = false;
|
||||
// Code block bottom bar
|
||||
spans.push(Span::styled(
|
||||
"\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(_) => {
|
||||
@@ -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);
|
||||
|
||||
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() {
|
||||
let line_spans = if y < cl.len() { &cl[y] } else { [].as_slice() };
|
||||
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);
|
||||
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"));
|
||||
}
|
||||
|
||||
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 {
|
||||
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"));
|
||||
}
|
||||
@@ -213,11 +251,25 @@ pub fn render_markdown(text: &str, width: u16) -> Vec<Span<'static>> {
|
||||
pulldown_cmark::Event::Text(text) => {
|
||||
let s = text.to_string();
|
||||
if in_code_block {
|
||||
let indented = format!(" {}", s.replace('\n', "\n "));
|
||||
spans.push(Span::styled(
|
||||
indented,
|
||||
Style::default().fg(Theme::ACCENT_TEAL).bg(Theme::CODE_BG),
|
||||
));
|
||||
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 "));
|
||||
spans.push(Span::styled(
|
||||
indented,
|
||||
apply_dim(Style::default().fg(Theme::ACCENT_TEAL).bg(Theme::CODE_BG), dim),
|
||||
));
|
||||
}
|
||||
} else if in_heading {
|
||||
let color = match heading_level {
|
||||
1 => Theme::PRIMARY,
|
||||
@@ -227,21 +279,24 @@ pub fn render_markdown(text: &str, width: u16) -> Vec<Span<'static>> {
|
||||
};
|
||||
spans.push(Span::styled(
|
||||
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 {
|
||||
current_cell.push(Span::raw(s));
|
||||
current_cell.push(Span::styled(s, apply_dim(Style::default(), dim)));
|
||||
} else {
|
||||
spans.push(Span::raw(s));
|
||||
spans.push(Span::styled(s, apply_dim(Style::default(), dim)));
|
||||
}
|
||||
}
|
||||
pulldown_cmark::Event::Code(text) => {
|
||||
let span = Span::styled(
|
||||
format!(" {text} "),
|
||||
Style::default()
|
||||
.fg(Theme::ACCENT_TEAL)
|
||||
.bg(Theme::CODE_BAR)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
apply_dim(
|
||||
Style::default()
|
||||
.fg(Theme::ACCENT_TEAL)
|
||||
.bg(Theme::CODE_BAR)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
dim,
|
||||
),
|
||||
);
|
||||
if in_table_cell {
|
||||
current_cell.push(span);
|
||||
@@ -377,3 +432,46 @@ fn wrap_spans_to_lines(spans: &[Span<'static>], target_width: usize) -> Vec<Vec<
|
||||
}
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user