Skip to main content

agent_core_tui/
markdown.rs

1//! Markdown parsing utilities for TUI rendering
2//!
3//! Uses pulldown-cmark for CommonMark parsing.
4
5use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
6use ratatui::{
7    style::{Color, Modifier, Style},
8    text::{Line, Span},
9};
10
11use super::table::{is_table_line, is_table_separator, render_table};
12use super::themes::Theme;
13
14// Prefixes for message formatting
15const ASSISTANT_PREFIX: &str = "\u{25C6} "; // diamond
16const CONTINUATION: &str = "  ";
17
18/// Parse markdown text into styled ratatui spans
19pub fn parse_to_spans(text: &str, theme: &Theme) -> Vec<Span<'static>> {
20    let mut options = Options::empty();
21    options.insert(Options::ENABLE_STRIKETHROUGH);
22
23    let parser = Parser::new_ext(text, options);
24    let mut spans = Vec::new();
25    let mut style_stack: Vec<Modifier> = Vec::new();
26    let mut color_stack: Vec<Color> = Vec::new();
27    let mut link_url_stack: Vec<String> = Vec::new();
28
29    for event in parser {
30        match event {
31            // Inline formatting start
32            Event::Start(Tag::Strong) => {
33                style_stack.push(theme.bold());
34            }
35            Event::Start(Tag::Emphasis) => {
36                style_stack.push(theme.italic());
37            }
38            Event::Start(Tag::Strikethrough) => {
39                style_stack.push(theme.strikethrough());
40            }
41
42            // Inline formatting end
43            Event::End(TagEnd::Strong)
44            | Event::End(TagEnd::Emphasis)
45            | Event::End(TagEnd::Strikethrough) => {
46                style_stack.pop();
47            }
48
49            // Text content
50            Event::Text(t) => {
51                let style = build_style(&style_stack, &color_stack);
52                spans.push(Span::styled(t.into_string(), style));
53            }
54
55            // Inline code gets special styling
56            Event::Code(code) => {
57                spans.push(Span::styled(code.into_string(), theme.inline_code()));
58            }
59
60            // Soft breaks become spaces
61            Event::SoftBreak => {
62                spans.push(Span::raw(" "));
63            }
64
65            // Hard breaks preserved
66            Event::HardBreak => {
67                spans.push(Span::raw("\n"));
68            }
69
70            // Skip block-level events - we handle text line by line
71            Event::Start(Tag::Paragraph)
72            | Event::End(TagEnd::Paragraph)
73            | Event::Start(Tag::Heading { .. })
74            | Event::End(TagEnd::Heading(_)) => {}
75
76            // Links - render text in link color, then append URL
77            Event::Start(Tag::Link { dest_url, .. }) => {
78                // Extract color from theme link_text style
79                if let Some(color) = theme.link_text().fg {
80                    color_stack.push(color);
81                }
82                // Store URL to append after link text
83                link_url_stack.push(dest_url.into_string());
84            }
85            Event::End(TagEnd::Link) => {
86                color_stack.pop();
87                // Append URL after link text
88                if let Some(url) = link_url_stack.pop() {
89                    if !url.is_empty() {
90                        spans.push(Span::styled(format!(" ({})", url), theme.link_url()));
91                    }
92                }
93            }
94
95            // Other events we don't handle yet
96            _ => {}
97        }
98    }
99
100    spans
101}
102
103/// Build a Style from the current modifier and color stacks
104fn build_style(modifiers: &[Modifier], colors: &[Color]) -> Style {
105    let mut style = Style::default();
106
107    // Apply all modifiers
108    for modifier in modifiers {
109        style = style.add_modifier(*modifier);
110    }
111
112    // Apply the most recent color if any
113    if let Some(&color) = colors.last() {
114        style = style.fg(color);
115    }
116
117    style
118}
119
120/// Parse markdown and split into words with their styles
121///
122/// Useful for word-wrapping while preserving styles
123pub fn parse_to_styled_words(text: &str, theme: &Theme) -> Vec<(String, Style)> {
124    let spans = parse_to_spans(text, theme);
125    let mut words = Vec::new();
126
127    for span in spans {
128        let content = span.content.to_string();
129        let style = span.style;
130
131        // Split each span's content into words
132        for word in content.split_whitespace() {
133            words.push((word.to_string(), style));
134        }
135    }
136
137    words
138}
139
140/// Content segment type for splitting text and tables
141pub enum ContentSegment {
142    Text(String),
143    Table(Vec<String>),
144    /// Code block with optional language hint.
145    CodeBlock {
146        code: String,
147        /// Language hint (e.g., "rust", "python") - parsed from markdown but
148        /// not yet used. Reserved for future syntax highlighting support.
149        #[allow(dead_code)]
150        language: Option<String>,
151    },
152}
153
154/// Wrap text with a first-line prefix and continuation prefix
155///
156/// Uses pulldown-cmark for inline styling (bold, italic, code)
157pub fn wrap_with_prefix(
158    text: &str,
159    first_prefix: &str,
160    first_prefix_style: Style,
161    cont_prefix: &str,
162    max_width: usize,
163    theme: &Theme,
164) -> Vec<Line<'static>> {
165    let mut lines = Vec::new();
166    let text_width = max_width.saturating_sub(first_prefix.chars().count());
167
168    if text_width == 0 || text.is_empty() {
169        // Parse markdown even for short text
170        let spans = parse_to_spans(text, theme);
171        let mut result_spans = vec![Span::styled(first_prefix.to_string(), first_prefix_style)];
172        result_spans.extend(spans);
173        return vec![Line::from(result_spans)];
174    }
175
176    // Parse markdown into styled words for wrapping
177    let styled_words = parse_to_styled_words(text, theme);
178
179    // Word-wrap the styled words
180    let mut current_line_spans: Vec<Span<'static>> = Vec::new();
181    let mut current_line_len = 0usize;
182    let mut is_first_line = true;
183
184    for (word, style) in styled_words {
185        let word_len = word.chars().count();
186        let would_be_len = if current_line_len == 0 {
187            word_len
188        } else {
189            current_line_len + 1 + word_len
190        };
191
192        if would_be_len > text_width && current_line_len > 0 {
193            // Emit current line
194            let prefix = if is_first_line { first_prefix } else { cont_prefix };
195            let prefix_style = if is_first_line { first_prefix_style } else { Style::default() };
196            let mut line_spans = vec![Span::styled(prefix.to_string(), prefix_style)];
197            line_spans.extend(current_line_spans.drain(..));
198            lines.push(Line::from(line_spans));
199
200            current_line_spans.push(Span::styled(word, style));
201            current_line_len = word_len;
202            is_first_line = false;
203        } else {
204            if current_line_len > 0 {
205                current_line_spans.push(Span::raw(" "));
206                current_line_len += 1;
207            }
208            current_line_spans.push(Span::styled(word, style));
209            current_line_len += word_len;
210        }
211    }
212
213    // Emit remaining text
214    if !current_line_spans.is_empty() || is_first_line {
215        let prefix = if is_first_line { first_prefix } else { cont_prefix };
216        let prefix_style = if is_first_line { first_prefix_style } else { Style::default() };
217        let mut line_spans = vec![Span::styled(prefix.to_string(), prefix_style)];
218        line_spans.extend(current_line_spans);
219        lines.push(Line::from(line_spans));
220    }
221
222    lines
223}
224
225/// Check if text starts with a heading marker using pulldown-cmark
226///
227/// This is more robust than string matching and handles edge cases correctly
228pub fn detect_heading_level(text: &str) -> Option<u8> {
229    let parser = Parser::new(text);
230    for event in parser {
231        if let Event::Start(Tag::Heading { level, .. }) = event {
232            return Some(level as u8);
233        }
234    }
235    None
236}
237
238/// Get style for heading level
239pub fn heading_style(level: u8, theme: &Theme) -> Style {
240    match level {
241        1 => theme.heading_1(),
242        2 => theme.heading_2(),
243        3 => theme.heading_3(),
244        _ => theme.heading_4(),
245    }
246}
247
248/// Check if a line starts a fenced code block (``` or ~~~)
249fn is_code_fence(line: &str) -> Option<&str> {
250    let trimmed = line.trim();
251    if trimmed.starts_with("```") {
252        Some(trimmed.strip_prefix("```").unwrap_or("").trim())
253    } else if trimmed.starts_with("~~~") {
254        Some(trimmed.strip_prefix("~~~").unwrap_or("").trim())
255    } else {
256        None
257    }
258}
259
260/// Check if a line ends a fenced code block
261fn is_code_fence_end(line: &str) -> bool {
262    let trimmed = line.trim();
263    trimmed == "```" || trimmed == "~~~"
264}
265
266/// Split content into text, table, and code block segments
267pub fn split_content_segments(content: &str) -> Vec<ContentSegment> {
268    let lines: Vec<&str> = content.lines().collect();
269    let mut segments = Vec::new();
270    let mut current_text = String::new();
271    let mut i = 0;
272
273    while i < lines.len() {
274        // Check for fenced code block
275        if let Some(lang) = is_code_fence(lines[i]) {
276            // Found a code block! First, save any accumulated text
277            if !current_text.is_empty() {
278                segments.push(ContentSegment::Text(current_text));
279                current_text = String::new();
280            }
281
282            let language = if lang.is_empty() { None } else { Some(lang.to_string()) };
283            i += 1; // Skip the opening fence
284
285            // Collect code block content until closing fence
286            let mut code_content = String::new();
287            while i < lines.len() && !is_code_fence_end(lines[i]) {
288                if !code_content.is_empty() {
289                    code_content.push('\n');
290                }
291                code_content.push_str(lines[i]);
292                i += 1;
293            }
294
295            // Skip the closing fence if present
296            if i < lines.len() && is_code_fence_end(lines[i]) {
297                i += 1;
298            }
299
300            segments.push(ContentSegment::CodeBlock { code: code_content, language });
301        }
302        // Check if this might be a table (line with | and next line is separator)
303        else if is_table_line(lines[i]) && i + 1 < lines.len() && is_table_separator(lines[i + 1]) {
304            // Found a table! First, save any accumulated text
305            if !current_text.is_empty() {
306                segments.push(ContentSegment::Text(current_text));
307                current_text = String::new();
308            }
309
310            // Collect all table lines
311            let mut table_lines = Vec::new();
312            while i < lines.len() && is_table_line(lines[i]) {
313                table_lines.push(lines[i].to_string());
314                i += 1;
315            }
316            segments.push(ContentSegment::Table(table_lines));
317        } else {
318            // Regular text line
319            if !current_text.is_empty() {
320                current_text.push('\n');
321            }
322            current_text.push_str(lines[i]);
323            i += 1;
324        }
325    }
326
327    // Don't forget remaining text
328    if !current_text.is_empty() {
329        segments.push(ContentSegment::Text(current_text));
330    }
331
332    segments
333}
334
335/// Render markdown content with diamond prefix and manual wrapping
336pub fn render_markdown_with_prefix(content: &str, max_width: usize, theme: &Theme) -> Vec<Line<'static>> {
337    let segments = split_content_segments(content);
338
339    let mut all_lines = Vec::new();
340    let mut is_first_line = true;
341
342    for segment in segments {
343        match segment {
344            ContentSegment::Text(text) => {
345                // Process each line
346                for line in text.lines() {
347                    let line = line.trim();
348                    if line.is_empty() {
349                        // Add blank line for paragraph breaks
350                        all_lines.push(Line::from(""));
351                        continue;
352                    }
353
354                    // Check for heading
355                    if let Some(level) = detect_heading_level(line) {
356                        let heading_text = line.trim_start_matches('#').trim();
357                        let base_style = heading_style(level, theme);
358                        let prefix = if is_first_line { ASSISTANT_PREFIX } else { CONTINUATION };
359                        let prefix_style = if is_first_line {
360                            theme.assistant_prefix()
361                        } else {
362                            Style::default()
363                        };
364
365                        // Parse heading text for inline markdown (bold, italic, etc.)
366                        let parsed_spans = parse_to_spans(heading_text, theme);
367                        let mut line_spans = vec![Span::styled(prefix.to_string(), prefix_style)];
368
369                        if parsed_spans.is_empty() {
370                            line_spans.push(Span::styled(heading_text.to_string(), base_style));
371                        } else {
372                            for span in parsed_spans {
373                                // Merge base heading style with inline style
374                                let merged_style = base_style.patch(span.style);
375                                line_spans.push(Span::styled(span.content.to_string(), merged_style));
376                            }
377                        }
378
379                        all_lines.push(Line::from(line_spans));
380                        is_first_line = false;
381                        continue;
382                    }
383
384                    // Regular line - wrap with prefix
385                    let prefix = if is_first_line { ASSISTANT_PREFIX } else { CONTINUATION };
386                    let prefix_style = if is_first_line {
387                        theme.assistant_prefix()
388                    } else {
389                        Style::default()
390                    };
391
392                    let lines = wrap_with_prefix(
393                        line,
394                        prefix,
395                        prefix_style,
396                        CONTINUATION,
397                        max_width,
398                        theme,
399                    );
400                    all_lines.extend(lines);
401                    is_first_line = false;
402                }
403            }
404            ContentSegment::Table(table_lines) => {
405                let lines = render_table(&table_lines, theme);
406                all_lines.extend(lines);
407                is_first_line = false;
408            }
409            ContentSegment::CodeBlock { code, language: _ } => {
410                let lines = render_code_block(&code, is_first_line, theme);
411                all_lines.extend(lines);
412                is_first_line = false;
413            }
414        }
415    }
416    all_lines
417}
418
419/// Render a code block with indentation and special styling
420fn render_code_block(code: &str, is_first_line: bool, theme: &Theme) -> Vec<Line<'static>> {
421    const CODE_INDENT: &str = "    "; // 4 spaces for code block indentation
422    let code_style = theme.code_block();
423    let prefix_style = theme.assistant_prefix();
424
425    let mut lines = Vec::new();
426
427    // Add a blank line before code block if not first
428    if !is_first_line {
429        lines.push(Line::from(""));
430    }
431
432    for (i, line) in code.lines().enumerate() {
433        let mut spans = Vec::new();
434
435        // First line of code block gets the assistant prefix, rest get continuation
436        if i == 0 && is_first_line {
437            spans.push(Span::styled(ASSISTANT_PREFIX, prefix_style));
438        } else {
439            spans.push(Span::raw(CONTINUATION));
440        }
441
442        // Add code indentation and the code line
443        spans.push(Span::styled(format!("{}{}", CODE_INDENT, line), code_style));
444
445        lines.push(Line::from(spans));
446    }
447
448    // Add a blank line after code block
449    lines.push(Line::from(""));
450
451    lines
452}
453
454#[cfg(test)]
455mod tests {
456    use super::*;
457
458    #[test]
459    fn test_plain_text() {
460        let theme = Theme::default();
461        let spans = parse_to_spans("hello world", &theme);
462        assert_eq!(spans.len(), 1);
463        assert_eq!(spans[0].content, "hello world");
464    }
465
466    #[test]
467    fn test_bold() {
468        let theme = Theme::default();
469        let spans = parse_to_spans("**bold**", &theme);
470        assert_eq!(spans.len(), 1);
471        assert_eq!(spans[0].content, "bold");
472        assert!(spans[0].style.add_modifier == Modifier::BOLD.into());
473    }
474
475    #[test]
476    fn test_italic() {
477        let theme = Theme::default();
478        let spans = parse_to_spans("*italic*", &theme);
479        assert_eq!(spans.len(), 1);
480        assert_eq!(spans[0].content, "italic");
481    }
482
483    #[test]
484    fn test_mixed_formatting() {
485        let theme = Theme::default();
486        let spans = parse_to_spans("normal **bold** and *italic*", &theme);
487        assert!(spans.len() >= 3);
488    }
489
490    #[test]
491    fn test_inline_code() {
492        let theme = Theme::default();
493        let spans = parse_to_spans("use `code` here", &theme);
494        assert!(spans.iter().any(|s| s.content == "code"));
495    }
496
497    #[test]
498    fn test_styled_words() {
499        let theme = Theme::default();
500        let words = parse_to_styled_words("hello **bold** world", &theme);
501        assert_eq!(words.len(), 3);
502        assert_eq!(words[0].0, "hello");
503        assert_eq!(words[1].0, "bold");
504        assert_eq!(words[2].0, "world");
505    }
506
507    #[test]
508    fn test_entirely_bold_line() {
509        let theme = Theme::default();
510        let input = "**The Midnight Adventure**";
511        let spans = parse_to_spans(input, &theme);
512
513        assert!(!spans.is_empty(), "Should have at least one span");
514        assert!(
515            spans[0].style.add_modifier.contains(Modifier::BOLD),
516            "First span should be bold"
517        );
518    }
519
520    #[test]
521    fn test_link_parsing() {
522        let theme = Theme::default();
523        let input = "[The Rust Book](https://doc.rust-lang.org/book/)";
524        let spans = parse_to_spans(input, &theme);
525
526        assert!(
527            spans.iter().any(|s| s.content.contains("Rust Book")),
528            "Should contain link text"
529        );
530        assert!(
531            spans.iter().any(|s| s.content.contains("doc.rust-lang.org")),
532            "Should contain URL"
533        );
534    }
535
536    #[test]
537    fn test_heading_detection() {
538        // Valid headings
539        assert_eq!(detect_heading_level("# Heading 1"), Some(1));
540        assert_eq!(detect_heading_level("## Heading 2"), Some(2));
541        assert_eq!(detect_heading_level("### Heading 3"), Some(3));
542        assert_eq!(detect_heading_level("###### Heading 6"), Some(6));
543        assert_eq!(detect_heading_level("# "), Some(1)); // Empty heading
544
545        // Invalid headings
546        assert_eq!(detect_heading_level("Not a heading"), None);
547        assert_eq!(detect_heading_level("#NoSpace"), None); // No space after #
548        assert_eq!(detect_heading_level("####### Too many"), None); // 7 hashes
549    }
550
551    #[test]
552    fn test_render_markdown_with_indented_link() {
553        let theme = Theme::default();
554        let content = "Here is a link:\n    [The Rust Book](https://doc.rust-lang.org/book/)";
555        let lines = render_markdown_with_prefix(content, 80, &theme);
556
557        let all_text: String = lines
558            .iter()
559            .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
560            .collect();
561
562        assert!(all_text.contains("The Rust Book"), "Should contain link text");
563        assert!(
564            !all_text.contains("](https://"),
565            "URL should not appear in literal markdown syntax"
566        );
567    }
568
569    #[test]
570    fn test_styled_words_bold() {
571        let theme = Theme::default();
572        let words = parse_to_styled_words("**The Midnight Adventure**", &theme);
573        assert_eq!(words.len(), 3);
574        // All words should have BOLD
575        for (word, style) in &words {
576            assert!(
577                style.add_modifier.contains(Modifier::BOLD),
578                "Word {:?} should be bold",
579                word
580            );
581        }
582    }
583
584    #[test]
585    fn test_code_block_detection() {
586        let content = "Some text\n```go\nfunc main() {\n    println(\"hello\")\n}\n```\nMore text";
587        let segments = split_content_segments(content);
588
589        assert_eq!(segments.len(), 3);
590
591        // First segment is text
592        match &segments[0] {
593            ContentSegment::Text(t) => assert_eq!(t, "Some text"),
594            _ => panic!("Expected Text segment"),
595        }
596
597        // Second segment is code block
598        match &segments[1] {
599            ContentSegment::CodeBlock { code, language } => {
600                assert_eq!(language.as_deref(), Some("go"));
601                assert!(code.contains("func main()"));
602                assert!(code.contains("println"));
603            }
604            _ => panic!("Expected CodeBlock segment"),
605        }
606
607        // Third segment is text
608        match &segments[2] {
609            ContentSegment::Text(t) => assert_eq!(t, "More text"),
610            _ => panic!("Expected Text segment"),
611        }
612    }
613
614    #[test]
615    fn test_code_block_no_language() {
616        let content = "```\ncode here\n```";
617        let segments = split_content_segments(content);
618
619        assert_eq!(segments.len(), 1);
620        match &segments[0] {
621            ContentSegment::CodeBlock { code, language } => {
622                assert!(language.is_none());
623                assert_eq!(code, "code here");
624            }
625            _ => panic!("Expected CodeBlock segment"),
626        }
627    }
628}