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