Skip to main content

rpi_tui/
markdown.rs

1//! Markdown component for rendering markdown text.
2//!
3//! Provides ANSI-aware markdown rendering that wraps to the available width
4//! without panicking on multibyte (CJK/emoji/box-drawing) characters or
5//! leaking escape sequences. Reuses the shared utilities in [`crate::utils`].
6
7use std::any::Any;
8use std::sync::Mutex;
9
10use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
11
12use super::component::Component;
13use super::mermaid::Mermaid;
14use crate::ansi::{bold, italic, underline};
15use crate::theme::theme;
16use crate::utils::{
17    apply_background_to_line, truncate_to_width, visible_width, wrap_text_with_ansi,
18};
19
20/// Markdown rendering options.
21#[derive(Debug, Clone)]
22pub struct MarkdownOptions {
23    /// Indentation for code blocks.
24    pub code_block_indent: usize,
25    /// Maximum width for wrapping (None = use the render width passed in).
26    pub max_width: Option<usize>,
27    /// Base SGR sequence applied to every rendered line (e.g. the dim italic
28    /// style used for thinking blocks). Empty means no base style. The sequence
29    /// is re-applied after every nested SGR reset so inner spans (inline code,
30    /// links, headings) do not permanently drop the enclosing style.
31    pub base_style: String,
32}
33
34impl Default for MarkdownOptions {
35    fn default() -> Self {
36        Self {
37            code_block_indent: 2,
38            max_width: None,
39            base_style: String::new(),
40        }
41    }
42}
43
44/// Markdown - A component that renders markdown text.
45pub struct Markdown {
46    content: Mutex<String>,
47    options: MarkdownOptions,
48    padding_x: usize,
49    padding_y: usize,
50    /// Render cache keyed by (content, width). Markdown parsing + inline
51    /// pulldown-cmark work is the dominant per-frame cost of a long transcript;
52    /// a finalized message is re-rendered on every frame the transcript is
53    /// repainted, so caching the parsed lines is what keeps that O(1) per
54    /// unchanged message. Mirrors upstream `Markdown.cachedText/cachedLines`.
55    cache: Mutex<Option<MarkdownCache>>,
56}
57
58#[derive(Clone)]
59struct MarkdownCache {
60    content: String,
61    width: usize,
62    theme_revision: u64,
63    lines: Vec<String>,
64}
65
66impl Markdown {
67    /// Create a new markdown component.
68    pub fn new(content: impl Into<String>, padding_x: usize, padding_y: usize) -> Self {
69        Self {
70            content: Mutex::new(content.into()),
71            options: MarkdownOptions::default(),
72            padding_x,
73            padding_y,
74            cache: Mutex::new(None),
75        }
76    }
77
78    /// Create with options.
79    pub fn with_options(
80        content: impl Into<String>,
81        options: MarkdownOptions,
82        padding_x: usize,
83        padding_y: usize,
84    ) -> Self {
85        Self {
86            content: Mutex::new(content.into()),
87            options,
88            padding_x,
89            padding_y,
90            cache: Mutex::new(None),
91        }
92    }
93
94    /// Apply a base SGR sequence (for example the dim italic ``\x1b[3m\x1b[38;5;244m``
95    /// style) to every rendered line. The base style is re-opened after each
96    /// nested SGR reset so inner spans like inline code or links do not drop it
97    /// for the remainder of the line.
98    pub fn with_base_style(mut self, base_style: impl Into<String>) -> Self {
99        self.options.base_style = base_style.into();
100        self.clear_cache();
101        self
102    }
103
104    /// Wrap one rendered line with the configured base style, re-opening the
105    /// base after every nested `SGR 0` reset. Returns the line unchanged when
106    /// no base style is configured.
107    fn apply_base_style(&self, line: &str) -> String {
108        let base = self.options.base_style.as_str();
109        if base.is_empty() {
110            return line.to_string();
111        }
112        let reset = "\x1b[0m";
113        let mut out = String::with_capacity(line.len() + base.len() * 4 + reset.len());
114        out.push_str(base);
115        let mut rest = line;
116        while let Some(idx) = rest.find(reset) {
117            out.push_str(&rest[..idx + reset.len()]);
118            out.push_str(base);
119            rest = &rest[idx + reset.len()..];
120        }
121        out.push_str(rest);
122        // Terminate the line so the base style cannot bleed into the next row
123        // (rendered lines are written back-to-back with no implicit reset).
124        out.push_str(reset);
125        out
126    }
127
128    /// Set the content.
129    pub fn set_content(&self, content: impl Into<String>) {
130        if let Ok(mut c) = self.content.lock() {
131            *c = content.into();
132        }
133        self.clear_cache();
134    }
135
136    /// Drop the cached render output.
137    fn clear_cache(&self) {
138        if let Ok(mut cache) = self.cache.lock() {
139            *cache = None;
140        }
141    }
142
143    /// Get the content.
144    pub fn get_content(&self) -> String {
145        self.content.lock().map(|c| c.clone()).unwrap_or_default()
146    }
147
148    /// The left-pad prefix applied to every rendered line.
149    fn pad(&self) -> String {
150        " ".repeat(self.padding_x)
151    }
152
153    /// Available content width accounting for horizontal padding.
154    fn content_width(&self, width: usize) -> usize {
155        width.saturating_sub(self.padding_x * 2)
156    }
157
158    /// Render markdown to lines.
159    fn render_markdown(&self, width: usize) -> Vec<String> {
160        let content = self.content.lock().map(|c| c.clone()).unwrap_or_default();
161        let colors = theme().colors;
162        let mut lines: Vec<String> = Vec::new();
163        let indent = " ".repeat(self.options.code_block_indent);
164        let pad = self.pad();
165        let cwidth = self.content_width(width);
166
167        // Add top padding
168        for _ in 0..self.padding_y {
169            lines.push(String::new());
170        }
171
172        // Pre-scan for tables: collect consecutive table-ish lines.
173        let raw_lines: Vec<&str> = content.lines().collect();
174        let mut i = 0;
175        // Markdown allows a list item's paragraph to continue on an indented
176        // line. Keep the visual text offset so continuation lines don't jump
177        // back to the left edge of the transcript.
178        let mut list_continuation_indent: Option<usize> = None;
179
180        // Code-fence state. Fenced blocks are rendered as a compact terminal
181        // panel rather than echoing Markdown's literal ``` markers.
182        let mut in_code_block = false;
183        let mut code_fence_char = '`';
184        let mut code_language = String::new();
185        // Syntax highlighter for the current fenced block (highlight.js
186        // analogue; see `syntax_highlight`). Carries block-comment state
187        // across lines. `None` outside a code block.
188        let mut code_highlighter: Option<crate::syntax_highlight::Highlighter> = None;
189        let mut mermaid_source: Vec<String> = Vec::new();
190        while i < raw_lines.len() {
191            let line = raw_lines[i];
192
193            // Streamed partial closing fences (pi #5825): while the assistant
194            // streams the final ```` ``` ````, the tail arrives one char at a
195            // time (``, ```` ``` ````…). Rendering those would flicker the block
196            // (content shrinks, then the real fence closes) — and a lone ``
197            // or `````` would be mis-rendered as ordinary text. Trim any line
198            // that is a partial fence run (1-2 fence chars, whole line).
199            if is_partial_fence(line) {
200                i += 1;
201                continue;
202            }
203
204            // ---- Code fences ----
205            let fence = fence_info(line);
206            if let Some((fence_char, lang)) = fence {
207                list_continuation_indent = None;
208                // Inside a block, only the matching fence closes it. This keeps
209                // backticks embedded in a ~~~ block from collapsing the panel.
210                if !in_code_block {
211                    in_code_block = true;
212                    code_fence_char = fence_char;
213                    code_language = lang.to_ascii_lowercase();
214                    code_highlighter =
215                        Some(crate::syntax_highlight::Highlighter::new(&code_language));
216                    mermaid_source.clear();
217                    // Fence language names (for example `text`, `rust`, or
218                    // `bash`) are metadata, not code content. Keep ordinary
219                    // code blocks content-only; Mermaid renders its own
220                    // diagram label when the matching fence closes.
221                } else if fence_char == code_fence_char {
222                    if code_language == "mermaid" {
223                        let component = Mermaid::new(mermaid_source.join("\n"));
224                        let mermaid_lines = component.render(cwidth);
225                        lines.extend(mermaid_lines.into_iter().map(|line| format!("{pad}{line}")));
226                    }
227                    in_code_block = false;
228                    code_language.clear();
229                    code_highlighter = None;
230                    mermaid_source.clear();
231                    // The closing fence is structural only; do not draw a
232                    // bottom border around the code surface.
233                } else {
234                    // A non-matching fence is code content.
235                    push_code_line(
236                        line,
237                        &pad,
238                        &indent,
239                        cwidth,
240                        &mut lines,
241                        code_highlighter.as_mut(),
242                    );
243                }
244                i += 1;
245                continue;
246            }
247
248            if in_code_block {
249                list_continuation_indent = None;
250                if code_language == "mermaid" {
251                    mermaid_source.push(line.to_string());
252                    i += 1;
253                    continue;
254                }
255                // Code is never reflowed: wrapping destroys indentation and
256                // makes copied snippets invalid. Tabs are normalized and long
257                // physical lines are clipped with an ellipsis.
258                push_code_line(
259                    line,
260                    &pad,
261                    &indent,
262                    cwidth,
263                    &mut lines,
264                    code_highlighter.as_mut(),
265                );
266                i += 1;
267                continue;
268            }
269
270            // ---- Tables ----
271            if is_table_separator(raw_lines.get(i + 1)) && is_table_row(line) {
272                list_continuation_indent = None;
273                // Collect the table block.
274                let mut table_lines: Vec<&str> = Vec::new();
275                table_lines.push(line);
276                i += 1;
277                // skip separator
278                table_lines.push(raw_lines[i]);
279                i += 1;
280                while i < raw_lines.len() && is_table_row(raw_lines[i]) {
281                    table_lines.push(raw_lines[i]);
282                    i += 1;
283                }
284                render_table(&table_lines, &pad, cwidth, &mut lines, |cell| {
285                    self.render_inline(cell)
286                });
287                continue;
288            }
289
290            // ---- Headers ----
291            // Headings are rendered with the mdHeading color and bold text;
292            // h1/h2 are additionally underlined. Markdown markers are hidden.
293            if let Some(rest) = strip_header(line, "######") {
294                list_continuation_indent = None;
295                let h = bold(&colors.md_heading.fg(&self.render_inline(rest)));
296                push_wrapped(&pad, &h, cwidth, &mut lines);
297            } else if let Some(rest) = strip_header(line, "#####") {
298                list_continuation_indent = None;
299                let h = bold(&colors.md_heading.fg(&self.render_inline(rest)));
300                push_wrapped(&pad, &h, cwidth, &mut lines);
301            } else if let Some(rest) = strip_header(line, "####") {
302                list_continuation_indent = None;
303                let h = bold(&colors.md_heading.fg(&self.render_inline(rest)));
304                push_wrapped(&pad, &h, cwidth, &mut lines);
305            } else if let Some(rest) = strip_header(line, "###") {
306                list_continuation_indent = None;
307                let h = bold(&colors.md_heading.fg(&self.render_inline(rest)));
308                push_wrapped(&pad, &h, cwidth, &mut lines);
309            } else if let Some(rest) = strip_header(line, "##") {
310                list_continuation_indent = None;
311                let h = colors
312                    .md_heading
313                    .fg(&bold(&underline(&self.render_inline(rest))));
314                push_wrapped(&pad, &h, cwidth, &mut lines);
315            } else if let Some(rest) = strip_header(line, "#") {
316                list_continuation_indent = None;
317                let h = colors
318                    .md_heading
319                    .fg(&bold(&underline(&self.render_inline(rest))));
320                push_wrapped(&pad, &h, cwidth, &mut lines);
321            } else if let Some(item) = parse_list_item(line) {
322                let marker = match item.marker {
323                    ListMarker::Bullet => colors.md_list_bullet.fg("•"),
324                    ListMarker::Ordered(marker) => colors.md_list_bullet.fg(&marker),
325                    ListMarker::Task(marker) => colors.md_list_bullet.fg(&marker.to_string()),
326                };
327                let prefix = format!("{}{} ", "  ".repeat(item.depth + 1), marker);
328                list_continuation_indent = Some(visible_width(&prefix));
329                push_wrapped_with_prefix(
330                    &pad,
331                    &prefix,
332                    &self.render_inline(item.body),
333                    cwidth,
334                    &mut lines,
335                );
336            } else if let Some((depth, body)) = parse_blockquote(line) {
337                list_continuation_indent = None;
338                let border = colors.md_quote_border.fg("│");
339                let prefix = format!("  {}", format!("{border} ").repeat(depth));
340                let body = colors.md_quote.fg(&italic(&self.render_inline(body)));
341                push_wrapped_with_prefix(&pad, &prefix, &body, cwidth, &mut lines);
342            } else if line.trim().starts_with("---") || line.trim().starts_with("***") {
343                list_continuation_indent = None;
344                // Horizontal rule (capped like the TS reference), mdHr colored.
345                let rule_w = width.min(80).saturating_sub(self.padding_x * 2);
346                lines.push(format!(
347                    "{pad}{}",
348                    colors.md_hr.fg(&"─".repeat(rule_w.max(1)))
349                ));
350            } else if line.trim().is_empty() {
351                list_continuation_indent = None;
352                // Empty line — emit a (padded) blank so spacing is preserved.
353                lines.push(String::new());
354            } else if let Some(indent) =
355                list_continuation_indent.filter(|_| line.starts_with(' ') || line.starts_with('\t'))
356            {
357                push_wrapped_with_prefix(
358                    &pad,
359                    &" ".repeat(indent),
360                    &self.render_inline(line.trim()),
361                    cwidth,
362                    &mut lines,
363                );
364            } else {
365                list_continuation_indent = None;
366                // Regular text with inline formatting.
367                push_wrapped(&pad, &self.render_inline(line), cwidth, &mut lines);
368            }
369
370            i += 1;
371        }
372
373        // Add bottom padding
374        for _ in 0..self.padding_y {
375            lines.push(String::new());
376        }
377
378        if lines.is_empty() {
379            lines.push(String::new());
380        }
381
382        // Apply the configured base style (e.g. the dim italic thinking style)
383        // per rendered line, re-opening it after every nested reset.
384        if !self.options.base_style.is_empty() {
385            for line in lines.iter_mut() {
386                *line = self.apply_base_style(line);
387            }
388        }
389
390        lines
391    }
392
393    /// Render inline CommonMark/GFM with terminal-specific ANSI styling.
394    fn render_inline(&self, text: &str) -> String {
395        let colors = theme().colors;
396        let mut options = Options::empty();
397        options.insert(Options::ENABLE_STRIKETHROUGH);
398
399        // pulldown-cmark parses complete documents and has no inline-only
400        // entry point. A plain prefix keeps block-looking text in inline
401        // context; it is discarded from the first text event below.
402        let source = format!("x {text}");
403        let mut discard_prefix = true;
404        let mut frames = vec![InlineFrame::root()];
405
406        for event in Parser::new_ext(&source, options) {
407            match event {
408                Event::Start(Tag::Strong) => frames.push(InlineFrame::new(InlineKind::Bold)),
409                Event::Start(Tag::Emphasis) => frames.push(InlineFrame::new(InlineKind::Italic)),
410                Event::Start(Tag::Strikethrough) => {
411                    frames.push(InlineFrame::new(InlineKind::Strike));
412                }
413                Event::Start(Tag::Link { dest_url, .. }) => {
414                    frames.push(InlineFrame::new(InlineKind::Link(dest_url.into_string())))
415                }
416                Event::Start(Tag::Image { dest_url, .. }) => {
417                    frames.push(InlineFrame::new(InlineKind::Image(dest_url.into_string())))
418                }
419                Event::End(
420                    TagEnd::Strong
421                    | TagEnd::Emphasis
422                    | TagEnd::Strikethrough
423                    | TagEnd::Link
424                    | TagEnd::Image,
425                ) => close_inline_frame(&mut frames, &colors),
426                Event::Text(value) => {
427                    let mut value = value.as_ref();
428                    if discard_prefix {
429                        value = value.strip_prefix("x ").unwrap_or(value);
430                        discard_prefix = false;
431                    }
432                    frames
433                        .last_mut()
434                        .expect("root inline frame")
435                        .text
436                        .push_str(value);
437                }
438                Event::Code(code) => {
439                    let chip = format!(" {} ", colors.md_code.fg(code.trim()));
440                    frames
441                        .last_mut()
442                        .expect("root inline frame")
443                        .text
444                        .push_str(&colors.md_code_bg.bg(&chip));
445                }
446                Event::InlineMath(value) => frames
447                    .last_mut()
448                    .expect("root inline frame")
449                    .text
450                    .push_str(&format!("${value}$")),
451                Event::DisplayMath(value) => frames
452                    .last_mut()
453                    .expect("root inline frame")
454                    .text
455                    .push_str(&format!("$${value}$$")),
456                Event::Html(value) | Event::InlineHtml(value) => frames
457                    .last_mut()
458                    .expect("root inline frame")
459                    .text
460                    .push_str(&value),
461                Event::FootnoteReference(label) => frames
462                    .last_mut()
463                    .expect("root inline frame")
464                    .text
465                    .push_str(&format!("[^{label}]")),
466                Event::SoftBreak => frames.last_mut().expect("root inline frame").text.push(' '),
467                Event::HardBreak => frames
468                    .last_mut()
469                    .expect("root inline frame")
470                    .text
471                    .push('\n'),
472                Event::TaskListMarker(checked) => frames
473                    .last_mut()
474                    .expect("root inline frame")
475                    .text
476                    .push(if checked { '☒' } else { '☐' }),
477                Event::Rule | Event::Start(_) | Event::End(_) => {}
478            }
479        }
480
481        while frames.len() > 1 {
482            close_inline_frame(&mut frames, &colors);
483        }
484        frames.pop().expect("root inline frame").text
485    }
486}
487
488#[derive(Debug)]
489enum InlineKind {
490    Root,
491    Bold,
492    Italic,
493    Strike,
494    Link(String),
495    Image(String),
496}
497
498#[derive(Debug)]
499struct InlineFrame {
500    kind: InlineKind,
501    text: String,
502}
503
504impl InlineFrame {
505    fn root() -> Self {
506        Self::new(InlineKind::Root)
507    }
508
509    fn new(kind: InlineKind) -> Self {
510        Self {
511            kind,
512            text: String::new(),
513        }
514    }
515}
516
517fn close_inline_frame(frames: &mut Vec<InlineFrame>, colors: &crate::theme::ThemeColors) {
518    let Some(frame) = (frames.len() > 1).then(|| frames.pop()).flatten() else {
519        return;
520    };
521    let rendered = match frame.kind {
522        InlineKind::Root => frame.text,
523        InlineKind::Bold => bold(&frame.text),
524        InlineKind::Italic => italic(&frame.text),
525        InlineKind::Strike => format!("\x1b[9m{}\x1b[29m", frame.text),
526        InlineKind::Link(url) => {
527            let label = colors.md_link.fg(&underline(&frame.text));
528            if crate::ansi::strip_ansi(&frame.text) == url {
529                label
530            } else {
531                format!("{label}{}", colors.md_link_url.fg(&format!(" ({url})")))
532            }
533        }
534        InlineKind::Image(url) => {
535            let alt = if frame.text.is_empty() {
536                "image".to_string()
537            } else {
538                frame.text
539            };
540            format!("{alt}{}", colors.md_link_url.fg(&format!(" ({url})")))
541        }
542    };
543    frames
544        .last_mut()
545        .expect("root inline frame")
546        .text
547        .push_str(&rendered);
548}
549
550enum ListMarker {
551    Bullet,
552    Ordered(String),
553    Task(char),
554}
555
556struct ListItem<'a> {
557    depth: usize,
558    marker: ListMarker,
559    body: &'a str,
560}
561
562/// Parse a Markdown list item, including indented/nested lists. The parser is
563/// deliberately strict about the marker's following whitespace so ordinary
564/// numeric text without a list marker is not rendered as a list.
565fn parse_list_item(line: &str) -> Option<ListItem<'_>> {
566    let leading = line.len() - line.trim_start_matches([' ', '\t']).len();
567    let content = &line[leading..];
568    let depth = line[..leading]
569        .chars()
570        .fold(0usize, |width, ch| width + if ch == '\t' { 4 } else { 1 })
571        / 2;
572
573    let (marker, body) = if let Some(rest) = content
574        .strip_prefix("- ")
575        .or_else(|| content.strip_prefix("* "))
576        .or_else(|| content.strip_prefix("+ "))
577    {
578        if let Some(task_body) = rest.strip_prefix("[ ]") {
579            (ListMarker::Task('☐'), task_body.trim_start())
580        } else if let Some(task_body) = rest
581            .strip_prefix("[x]")
582            .or_else(|| rest.strip_prefix("[X]"))
583        {
584            (ListMarker::Task('☒'), task_body.trim_start())
585        } else {
586            (ListMarker::Bullet, rest)
587        }
588    } else {
589        let marker_end = content
590            .bytes()
591            .take_while(|byte| byte.is_ascii_digit())
592            .count();
593        if marker_end == 0 {
594            return None;
595        }
596        let marker_char = content.as_bytes().get(marker_end)?;
597        if !matches!(marker_char, b'.' | b')')
598            || !content
599                .as_bytes()
600                .get(marker_end + 1)
601                .is_some_and(u8::is_ascii_whitespace)
602        {
603            return None;
604        }
605        (
606            ListMarker::Ordered(content[..marker_end + 1].to_string()),
607            content[marker_end + 2..].trim_start(),
608        )
609    };
610
611    Some(ListItem {
612        depth,
613        marker,
614        body,
615    })
616}
617
618/// Parse one or more quote markers, accepting `>quote`, `> quote`, and nested
619/// `> > quote` forms used by CommonMark renderers.
620fn parse_blockquote(line: &str) -> Option<(usize, &str)> {
621    let mut body = line.trim_start_matches([' ', '\t']);
622    let mut depth = 0;
623    while let Some(rest) = body.strip_prefix('>') {
624        depth += 1;
625        body = rest.strip_prefix(' ').unwrap_or(rest);
626    }
627    (depth > 0).then_some((depth, body))
628}
629
630/// If `line` is a ```` ``` ```` (or ```` ~~~ ````) fence, return the language
631/// label (possibly empty). Returns `None` for non-fence lines.
632fn fence_info(line: &str) -> Option<(char, String)> {
633    let trimmed = line.trim_start();
634    if trimmed.starts_with("```") {
635        Some(('`', trimmed[3..].trim().to_string()))
636    } else if trimmed.starts_with("~~~") {
637        Some(('~', trimmed[3..].trim().to_string()))
638    } else {
639        None
640    }
641}
642
643/// Render one physical code line on a subtle surface, without an outer frame.
644fn push_code_line(
645    line: &str,
646    pad: &str,
647    indent: &str,
648    cwidth: usize,
649    out: &mut Vec<String>,
650    highlighter: Option<&mut crate::syntax_highlight::Highlighter>,
651) {
652    let colors = theme().colors;
653    // `cwidth` excludes Markdown padding. The code surface starts after the
654    // configured indent and fills the remaining width, including blank rows.
655    let panel_width = cwidth.saturating_sub(visible_width(indent)).max(1);
656    let body_width = panel_width.saturating_sub(2).max(1);
657    let expanded = line.replace('\t', "    ");
658    let clipped = truncate_to_width(&expanded, body_width, "…");
659    // Syntax-highlight the clipped plain text, then apply the panel background.
660    let styled = match highlighter {
661        Some(h) => h.highlight(&clipped, &colors),
662        None => colors.md_code_block.fg(&clipped),
663    };
664    let body = format!(" {styled}");
665    let body =
666        apply_background_to_line(&body, panel_width, |text| colors.md_code_block_bg.bg(text));
667    out.push(format!("{pad}{indent}{body}"));
668}
669
670/// True when the whole line is a run of 1-2 fence chars (``/````/`~`/`~~`)
671/// — a streamed, not-yet-complete closing fence. pi trims these (issue
672/// #5825) so code blocks don't flicker/shrink while the final fence char
673/// streams in; rendering them as text would also misrender the block tail.
674fn is_partial_fence(line: &str) -> bool {
675    let t = line.trim();
676    if t.is_empty() || t.len() > 2 {
677        return false;
678    }
679    t.chars().all(|c| c == '`' || c == '~')
680}
681
682/// Strip a leading `prefix` of `#`s followed by a space from `line`.
683fn strip_header<'a>(line: &'a str, hashes: &str) -> Option<&'a str> {
684    if line.starts_with(hashes) {
685        let rest = &line[hashes.len()..];
686        if let Some(stripped) = rest.strip_prefix(' ') {
687            return Some(stripped);
688        }
689    }
690    None
691}
692
693/// True if the line looks like a markdown table row (`| … | … |`).
694fn is_table_row(line: &str) -> bool {
695    let t = line.trim();
696    t.starts_with('|') && t.ends_with('|') && t.len() > 1
697}
698
699/// True if the line is a table separator (`|---|---|`, `:--:` allowed).
700fn is_table_separator(maybe: Option<&&str>) -> bool {
701    match maybe {
702        Some(line) => {
703            let t = line.trim();
704            if !t.contains('|') {
705                return false;
706            }
707            let cell = |s: &str| {
708                s.chars()
709                    .all(|c| c == '-' || c == ':' || c == ' ' || c == '\t')
710                    && s.contains('-')
711            };
712            let inner = t.trim_matches('|');
713            inner.split('|').all(cell)
714        }
715        None => false,
716    }
717}
718
719/// Parse a table into rendered lines with box-drawing borders.
720fn render_table<F>(rows: &[&str], pad: &str, cwidth: usize, out: &mut Vec<String>, render_inline: F)
721where
722    F: Fn(&str) -> String,
723{
724    // Split each row into trimmed cells.
725    let parse = |row: &str| -> Vec<String> {
726        split_table_cells(row)
727            .into_iter()
728            .map(|cell| render_inline(cell.trim()))
729            .collect()
730    };
731    if rows.len() < 2 {
732        return;
733    }
734    let header = parse(rows[0]);
735    let _separator = parse(rows[1]);
736    let body: Vec<Vec<String>> = rows[2..].iter().map(|r| parse(r)).collect();
737    let ncols = header.len();
738    if ncols == 0 {
739        return;
740    }
741
742    // Compute column visible widths from header + body.
743    let mut col_w: Vec<usize> = (0..ncols)
744        .map(|c| {
745            std::iter::once(&header)
746                .chain(body.iter())
747                .map(|r| r.get(c).map(|s| visible_width(s)).unwrap_or(0))
748                .max()
749                .unwrap_or(0)
750        })
751        .collect();
752
753    // Account for " | " separators + leading/trailing border usage.
754    // We cap the total so the table fits within `cwidth`.
755    let overhead = 3 * (ncols - 1) + 4; // approximate: "| " + " | "*n + " |"
756    let budget = cwidth.saturating_sub(overhead);
757    let total: usize = col_w.iter().sum();
758    if total > budget {
759        // Shrink proportionally (floor of 1 per column).
760        let scale = budget as f64 / total as f64;
761        for w in col_w.iter_mut() {
762            *w = (*w as f64 * scale).round() as usize;
763            if *w < 1 {
764                *w = 1;
765            }
766        }
767    }
768
769    let join = |cells: &[String]| {
770        cells
771            .iter()
772            .enumerate()
773            .map(|(i, c)| {
774                let w = col_w.get(i).copied().unwrap_or(0);
775                let cell = truncate_to_width(c, w, "…");
776                pad_cell(&cell, w)
777            })
778            .collect::<Vec<_>>()
779            .join("│")
780    };
781
782    let border = |left: &str, mid: &str, joiner: &str, right: &str| -> String {
783        let segs: Vec<String> = col_w.iter().map(|w| mid.repeat(*w)).collect();
784        format!("{left}{}{right}", segs.join(joiner))
785    };
786
787    out.push(format!("{pad}{}", border("┌", "─", "┬", "┐")));
788    out.push(format!("{pad}│{}│", join(&header)));
789    out.push(format!("{pad}{}", border("├", "─", "┼", "┤")));
790    for (row_index, row) in body.iter().enumerate() {
791        let cells: Vec<String> = (0..ncols)
792            .map(|c| row.get(c).cloned().unwrap_or_default())
793            .collect();
794        out.push(format!("{pad}│{}│", join(&cells)));
795        // Keep the table grid visible between body rows as well as between the
796        // header and the first row. This is especially useful for narrow
797        // terminals where adjacent wrapped-looking rows are otherwise hard
798        // to distinguish.
799        if row_index + 1 < body.len() {
800            out.push(format!("{pad}{}", border("├", "─", "┼", "┤")));
801        }
802    }
803    out.push(format!("{pad}{}", border("└", "─", "┴", "┘")));
804}
805
806/// Split a table row without treating escaped pipes or pipes inside code spans
807/// as column separators.
808fn split_table_cells(row: &str) -> Vec<String> {
809    let inner = row.trim().trim_matches('|');
810    let mut cells = vec![String::new()];
811    let mut escaped = false;
812    let mut code_ticks = 0usize;
813
814    for ch in inner.chars() {
815        if escaped {
816            cells.last_mut().expect("table has one cell").push(ch);
817            escaped = false;
818        } else if ch == '\\' {
819            cells.last_mut().expect("table has one cell").push(ch);
820            escaped = true;
821        } else if ch == '`' {
822            code_ticks ^= 1;
823            cells.last_mut().expect("table has one cell").push(ch);
824        } else if ch == '|' && code_ticks == 0 {
825            cells.push(String::new());
826        } else {
827            cells.last_mut().expect("table has one cell").push(ch);
828        }
829    }
830    cells
831}
832
833/// Left-pad/content a cell string to `w` columns (ANSI-aware).
834fn pad_cell(cell: &str, w: usize) -> String {
835    let vw = visible_width(cell);
836    if vw >= w {
837        cell.to_string()
838    } else {
839        format!("{cell}{}", " ".repeat(w - vw))
840    }
841}
842
843/// Wrap `rendered` (an already-styled line possibly with leading pad) into the
844/// available width and push each sub-line into `out`.
845fn push_wrapped(pad: &str, rendered: &str, cwidth: usize, out: &mut Vec<String>) {
846    if cwidth == 0 {
847        out.push(rendered.to_string());
848        return;
849    }
850    // Padding is layout chrome, not part of `rendered`; applying it here keeps
851    // first and continuation lines aligned (the old renderer omitted padding
852    // from the first line of ordinary paragraphs and headings).
853    let wrapped = wrap_text_with_ansi(rendered, cwidth);
854    if wrapped.is_empty() {
855        out.push(String::new());
856        return;
857    }
858    for sub in wrapped {
859        out.push(format!("{pad}{sub}"));
860    }
861}
862
863/// Wrap text after a semantic prefix (list bullet, quote border, etc.). The
864/// first line gets the prefix; every continuation starts under the text rather
865/// than under the marker, which makes long assistant responses much easier to
866/// scan in a narrow terminal.
867fn push_wrapped_with_prefix(
868    pad: &str,
869    prefix: &str,
870    rendered: &str,
871    cwidth: usize,
872    out: &mut Vec<String>,
873) {
874    let prefix_width = visible_width(prefix);
875    let body_width = cwidth.saturating_sub(prefix_width).max(1);
876    let wrapped = wrap_text_with_ansi(rendered, body_width);
877    if wrapped.is_empty() {
878        out.push(format!("{pad}{prefix}"));
879        return;
880    }
881    let continuation = " ".repeat(prefix_width);
882    for (index, line) in wrapped.iter().enumerate() {
883        let line_prefix = if index == 0 { prefix } else { &continuation };
884        // `wrap_text_with_ansi` preserves the separating space at a wrap
885        // boundary. It is useful for paragraphs, but here it would make a
886        // continuation one column farther right than the list's text.
887        let body = if index == 0 {
888            line.as_str()
889        } else {
890            line.trim_start()
891        };
892        out.push(format!("{pad}{line_prefix}{body}"));
893    }
894}
895
896impl Component for Markdown {
897    fn render(&self, width: usize) -> Vec<String> {
898        let content = self.content.lock().map(|c| c.clone()).unwrap_or_default();
899        let theme_revision = crate::theme::theme_revision();
900
901        if let Ok(cache) = self.cache.lock() {
902            if let Some(cached) = cache.as_ref() {
903                if cached.width == width
904                    && cached.content == content
905                    && cached.theme_revision == theme_revision
906                {
907                    return cached.lines.clone();
908                }
909            }
910        }
911
912        let lines = self.render_markdown(width);
913        if let Ok(mut cache) = self.cache.lock() {
914            *cache = Some(MarkdownCache {
915                content,
916                width,
917                theme_revision,
918                lines: lines.clone(),
919            });
920        }
921        lines
922    }
923
924    fn invalidate(&self) {
925        self.clear_cache();
926    }
927
928    fn as_any(&self) -> &dyn Any {
929        self
930    }
931}
932
933#[cfg(test)]
934mod tests {
935    use super::*;
936
937    #[test]
938    fn test_markdown_headers() {
939        let md = Markdown::new("# H1\n## H2\n### H3\n#### H4\n##### H5\n###### H6", 0, 0);
940        let lines = md.render(80);
941        let plain: Vec<String> = lines
942            .iter()
943            .map(|line| crate::ansi::strip_ansi(line))
944            .collect();
945        assert_eq!(plain, vec!["H1", "H2", "H3", "H4", "H5", "H6"]);
946    }
947
948    #[test]
949    fn test_commonmark_inline_parser_handles_nested_spans_and_images() {
950        let md = Markdown::new(
951            "This is ***nested***, ``a ` b``, <https://example.com>, ![plot](plot.png)",
952            0,
953            0,
954        );
955        let rendered = md.render(120).join("\n");
956        let plain = crate::ansi::strip_ansi(&rendered);
957
958        assert_eq!(
959            plain,
960            "This is nested,  a ` b , https://example.com, plot (plot.png)"
961        );
962        assert!(rendered.contains("\x1b[1m"), "nested strong style missing");
963        assert!(rendered.contains("\x1b[3m"), "nested emphasis missing");
964        assert!(
965            rendered.contains("\x1b[48;2;48;52;59m"),
966            "multi-backtick code span should use the code chip"
967        );
968    }
969
970    #[test]
971    fn test_markdown_inline() {
972        let md = Markdown::new("This is **bold** and *italic* text", 0, 0);
973        let lines = md.render(80);
974        assert!(!lines[0].is_empty());
975    }
976
977    #[test]
978    fn test_markdown_code() {
979        let md = Markdown::new("Use `code` here", 0, 0);
980        let lines = md.render(80);
981        assert!(!lines[0].is_empty());
982        assert!(
983            lines[0].contains("\x1b[48;2;48;52;59m"),
984            "inline code should use the theme chip background"
985        );
986    }
987
988    #[test]
989    fn test_markdown_no_panic_on_wide_cjk() {
990        // A CJK + bold line at a narrow width used to panic via the byte-slice
991        // truncation in the old renderer (markdown.rs:136).
992        let md = Markdown::new("**你好世界**这是一段很长的中文内容需要自动换行", 1, 0);
993        let lines = md.render(10);
994        // Must not panic and must produce more than one wrapped line.
995        assert!(lines.len() > 1);
996    }
997
998    #[test]
999    fn test_markdown_wraps_long_line() {
1000        let long = "a".repeat(100);
1001        let md = Markdown::new(long, 0, 0);
1002        let lines = md.render(20);
1003        assert!(lines.len() > 1, "a 100-col line at width 20 should wrap");
1004    }
1005
1006    #[test]
1007    fn test_markdown_code_block_has_background_without_frame() {
1008        let md = Markdown::new("```rust\nlet x = 1;\n```", 0, 0);
1009        let joined = md.render(80).join("\n");
1010        let plain = crate::ansi::strip_ansi(&joined);
1011        assert!(!plain.contains("rust"), "language label leaked: {plain}");
1012        assert!(plain.contains(" let x = 1;"), "missing code body: {plain}");
1013        assert!(
1014            !plain.contains(['╭', '╰', '│']),
1015            "code block must not draw an outer frame: {plain}"
1016        );
1017        assert!(
1018            joined.contains("\x1b[48;2;40;44;52m"),
1019            "code panel background missing: {joined:?}"
1020        );
1021        assert!(
1022            !plain.contains("```"),
1023            "literal fences should be hidden: {plain}"
1024        );
1025    }
1026
1027    #[test]
1028    fn code_fence_language_metadata_is_not_rendered() {
1029        let md = Markdown::new(
1030            "```text\nplain block\n```\n\n```bash\necho hello\n```",
1031            0,
1032            0,
1033        );
1034        let plain = crate::ansi::strip_ansi(&md.render(80).join("\n"));
1035        assert!(plain.contains("plain block"));
1036        assert!(plain.contains("echo hello"));
1037        assert!(!plain.contains("text"), "text label leaked: {plain}");
1038        assert!(!plain.contains("bash"), "bash label leaked: {plain}");
1039    }
1040
1041    #[test]
1042    fn test_mermaid_fence_uses_diagram_renderer() {
1043        let md = Markdown::new("```mermaid\nflowchart LR\nA[Start] --> B[End]\n```", 0, 0);
1044        let plain = crate::ansi::strip_ansi(&md.render(80).join("\n"));
1045        assert!(plain.contains("mermaid"));
1046        assert!(
1047            plain.contains("Start ──▶ End"),
1048            "diagram body missing: {plain}"
1049        );
1050        assert!(
1051            !plain.contains("flowchart LR"),
1052            "raw mermaid header leaked: {plain}"
1053        );
1054    }
1055
1056    #[test]
1057    fn test_markdown_padding_applies_to_first_and_wrapped_lines() {
1058        let md = Markdown::new("abcdefgh", 2, 0);
1059        let lines = md.render(6);
1060        assert_eq!(lines, vec!["  ab", "  cd", "  ef", "  gh"]);
1061    }
1062
1063    #[test]
1064    fn test_markdown_code_expands_tabs_and_clips() {
1065        let md = Markdown::new("```\n\tlet value = 123456;\n```", 0, 0);
1066        let lines = md.render(14);
1067        let plain: Vec<String> = lines
1068            .iter()
1069            .map(|line| crate::ansi::strip_ansi(line))
1070            .collect();
1071        assert!(
1072            plain[0].starts_with("       let"),
1073            "tab/indent lost: {plain:?}"
1074        );
1075        assert!(
1076            plain[0].contains('…'),
1077            "long code line should clip: {plain:?}"
1078        );
1079        assert!(plain.iter().all(|line| visible_width(line) <= 14));
1080    }
1081
1082    #[test]
1083    fn test_markdown_task_list_checkbox() {
1084        let md = Markdown::new("- [x] done\n- [ ] todo", 0, 0);
1085        let joined = md.render(80).join("\n");
1086        assert!(joined.contains('☒'), "checked marker missing");
1087        assert!(joined.contains('☐'), "unchecked marker missing");
1088    }
1089
1090    #[test]
1091    fn test_markdown_table_renders_borders() {
1092        let md = Markdown::new("| a | b |\n|---|---|\n| 1 | 2 |", 0, 0);
1093        let joined = md.render(80).join("\n");
1094        assert!(joined.contains('┌'), "missing top-left");
1095        assert!(joined.contains('┬'), "missing top-mid");
1096        assert!(joined.contains('┐'), "missing top-right");
1097        assert!(joined.contains('└'), "missing bottom-left");
1098    }
1099
1100    #[test]
1101    fn test_markdown_table_has_internal_horizontal_lines() {
1102        let md = Markdown::new(
1103            "| a | b |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |\n| 5 | 6 |",
1104            0,
1105            0,
1106        );
1107        let plain = crate::ansi::strip_ansi(&md.render(80).join("\n"));
1108        assert_eq!(
1109            plain.matches('├').count(),
1110            3,
1111            "missing internal row lines: {plain}"
1112        );
1113        assert_eq!(
1114            plain.matches('┼').count(),
1115            3,
1116            "missing internal separators: {plain}"
1117        );
1118    }
1119
1120    #[test]
1121    fn test_markdown_double_underscore_is_bold() {
1122        let md = Markdown::new("__strong__", 0, 0);
1123        let joined = md.render(80).join("\n");
1124        // `\x1b[1m` is the bold SGR; `\x1b[4m` is underline.
1125        assert!(joined.contains("\x1b[1m"), "`__` should render bold");
1126        assert!(
1127            !joined.contains("\x1b[4m"),
1128            "`__` should NOT render underline"
1129        );
1130    }
1131
1132    #[test]
1133    fn test_markdown_hr_capped() {
1134        let md = Markdown::new("---", 0, 0);
1135        let lines = md.render(200);
1136        // HR is capped at 80 cols, not the full 200.
1137        assert_eq!(visible_width(&lines[0]), 80);
1138    }
1139
1140    #[test]
1141    fn test_markdown_h4_does_not_fall_through() {
1142        let md = Markdown::new("#### nested", 0, 0);
1143        let joined = md.render(80).join("\n");
1144        assert!(joined.contains("\x1b[1m"), "h4 should be bold");
1145    }
1146
1147    #[test]
1148    fn test_markdown_nested_lists_keep_their_text_indent_when_wrapped() {
1149        let md = Markdown::new(
1150            "- parent item that wraps over several terminal columns\n  - child item\n    child continuation",
1151            0,
1152            0,
1153        );
1154        let plain: Vec<String> = md
1155            .render(20)
1156            .iter()
1157            .map(|line| crate::ansi::strip_ansi(line))
1158            .collect();
1159
1160        assert!(
1161            plain[0].starts_with("  • "),
1162            "root marker missing: {plain:?}"
1163        );
1164        assert!(
1165            plain.iter().any(|line| line.starts_with("    wraps")),
1166            "wrapped root text should align after its marker: {plain:?}"
1167        );
1168        assert!(
1169            plain.iter().any(|line| line.starts_with("    • child")),
1170            "nested marker missing: {plain:?}"
1171        );
1172        assert!(
1173            plain.iter().any(|line| line.starts_with("      child")),
1174            "continuation should align after nested marker: {plain:?}"
1175        );
1176    }
1177
1178    #[test]
1179    fn test_markdown_nested_blockquote_and_unclosed_inline_are_literal() {
1180        let md = Markdown::new(
1181            "> > nested quote\ntext with *an unfinished emphasis and `unfinished code",
1182            0,
1183            0,
1184        );
1185        let plain: Vec<String> = md
1186            .render(80)
1187            .iter()
1188            .map(|line| crate::ansi::strip_ansi(line))
1189            .collect();
1190        assert!(plain[0].starts_with("  │ │ nested quote"), "{plain:?}");
1191        assert_eq!(
1192            plain[1],
1193            "text with *an unfinished emphasis and `unfinished code"
1194        );
1195    }
1196
1197    #[test]
1198    fn test_markdown_table_preserves_pipes_in_inline_code() {
1199        let md = Markdown::new("| Name | Value |\n|---|---|\n| **bold** | `a|b` |", 0, 0);
1200        let plain = crate::ansi::strip_ansi(&md.render(80).join("\n"));
1201        assert_eq!(
1202            plain.matches('┬').count(),
1203            1,
1204            "table columns split incorrectly: {plain}"
1205        );
1206        assert!(plain.contains("bold"), "inline formatting lost: {plain}");
1207        assert!(
1208            plain.contains("a|b"),
1209            "code pipe split a table cell: {plain}"
1210        );
1211    }
1212
1213    #[test]
1214    fn test_base_style_reopened_after_inline_code_reset() {
1215        // Regression: inline code (and other colored spans) terminate with an
1216        // `SGR 0` reset, which used to drop the enclosing base style for the
1217        // rest of the line. The base must be re-opened after that reset.
1218        let base = "\x1b[3m\x1b[38;5;244m";
1219        let md = Markdown::new("think `code` after", 0, 0).with_base_style(base);
1220        let rendered = md.render(80).join("\n");
1221        let after = rendered
1222            .split("code")
1223            .nth(1)
1224            .expect("inline code not rendered");
1225        assert!(
1226            after.contains("\x1b[38;5;244m"),
1227            "base style not reopened after inline code: {rendered:?}"
1228        );
1229        assert!(
1230            rendered.ends_with("\x1b[0m"),
1231            "rendered line must be reset-terminated: {rendered:?}"
1232        );
1233    }
1234
1235    #[test]
1236    fn test_base_style_applies_to_every_line() {
1237        // Multi-paragraph thinking must carry the base style on every line,
1238        // not just the first text run.
1239        let base = "\x1b[3m\x1b[38;5;244m";
1240        let md = Markdown::new("first thought\n\nsecond thought", 0, 0).with_base_style(base);
1241        let lines = md.render(80);
1242        for line in lines.iter().filter(|l| !l.is_empty()) {
1243            assert!(
1244                line.contains("\x1b[38;5;244m"),
1245                "base style missing on line: {line:?}"
1246            );
1247        }
1248    }
1249}