Skip to main content

imp_tui/
markdown.rs

1use ratatui::style::{Color, Modifier, Style};
2use ratatui::text::{Line, Span};
3use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
4
5use crate::highlight::Highlighter;
6use crate::theme::Theme;
7
8#[derive(Clone, Copy, Debug, PartialEq, Eq)]
9enum TableAlignment {
10    Left,
11    Center,
12    Right,
13}
14
15/// Render markdown text to styled ratatui Lines.
16///
17/// Handles: headers, pipe tables, bold, italic, inline code, code blocks (with
18/// syntax highlighting), lists, and links.
19pub fn render_markdown<'a>(text: &str, theme: &Theme, highlighter: &Highlighter) -> Vec<Line<'a>> {
20    render_markdown_inner(text, theme, highlighter, None)
21}
22
23/// Render markdown while constraining tables to a target content width.
24///
25/// This is primarily used by the chat view so markdown tables can wrap inside
26/// the available pane width instead of being broken by outer line wrapping.
27pub fn render_markdown_with_width<'a>(
28    text: &str,
29    theme: &Theme,
30    highlighter: &Highlighter,
31    width: usize,
32) -> Vec<Line<'a>> {
33    render_markdown_inner(text, theme, highlighter, Some(width))
34}
35
36fn render_markdown_inner<'a>(
37    text: &str,
38    theme: &Theme,
39    highlighter: &Highlighter,
40    table_width: Option<usize>,
41) -> Vec<Line<'a>> {
42    let mut lines: Vec<Line<'a>> = Vec::new();
43    let mut in_code_block = false;
44    let mut code_lang = String::new();
45    let mut code_buf = String::new();
46    let raw_lines: Vec<&str> = text.lines().collect();
47    let mut idx = 0;
48
49    while idx < raw_lines.len() {
50        let raw_line = raw_lines[idx];
51
52        // Fenced code block toggle
53        if raw_line.trim_start().starts_with("```") {
54            if in_code_block {
55                // End of code block — highlight and emit
56                let highlighted = highlighter.highlight_code(&code_buf, &code_lang);
57                for hl_line in highlighted {
58                    lines.push(hl_line);
59                }
60                code_buf.clear();
61                code_lang.clear();
62                in_code_block = false;
63            } else {
64                // Start of code block
65                code_lang = raw_line
66                    .trim_start()
67                    .trim_start_matches('`')
68                    .trim()
69                    .to_string();
70                in_code_block = true;
71            }
72            idx += 1;
73            continue;
74        }
75
76        if in_code_block {
77            if !code_buf.is_empty() {
78                code_buf.push('\n');
79            }
80            code_buf.push_str(raw_line);
81            idx += 1;
82            continue;
83        }
84
85        if let Some((table_lines, consumed)) =
86            render_table_block(&raw_lines[idx..], theme, table_width)
87        {
88            lines.extend(table_lines);
89            idx += consumed;
90            continue;
91        }
92
93        // Headers
94        if let Some(stripped) = raw_line.strip_prefix("### ") {
95            lines.push(Line::from(Span::styled(
96                stripped.to_string(),
97                Style::default()
98                    .fg(theme.header_fg)
99                    .add_modifier(Modifier::BOLD),
100            )));
101            idx += 1;
102            continue;
103        }
104        if let Some(stripped) = raw_line.strip_prefix("## ") {
105            lines.push(Line::from(Span::styled(
106                stripped.to_string(),
107                Style::default()
108                    .fg(theme.header_fg)
109                    .add_modifier(Modifier::BOLD | Modifier::UNDERLINED),
110            )));
111            idx += 1;
112            continue;
113        }
114        if let Some(stripped) = raw_line.strip_prefix("# ") {
115            lines.push(Line::from(Span::styled(
116                stripped.to_string(),
117                Style::default()
118                    .fg(theme.header_fg)
119                    .add_modifier(Modifier::BOLD | Modifier::UNDERLINED),
120            )));
121            idx += 1;
122            continue;
123        }
124
125        // List items (merge - and * handling)
126        let (indent, rest) = if let Some(stripped) = raw_line
127            .strip_prefix("- ")
128            .or_else(|| raw_line.strip_prefix("* "))
129        {
130            ("  • ".to_string(), stripped)
131        } else if is_ordered_list(raw_line) {
132            let dot = raw_line.find('.').unwrap_or(0);
133            let prefix = format!("  {}. ", &raw_line[..dot]);
134            let rest_start = (dot + 2).min(raw_line.len());
135            (prefix, raw_line[rest_start..].trim_start())
136        } else {
137            (String::new(), raw_line)
138        };
139
140        let mut spans = Vec::new();
141        if !indent.is_empty() {
142            spans.push(Span::raw(indent));
143        }
144        spans.extend(parse_inline(rest, theme));
145        lines.push(Line::from(spans));
146        idx += 1;
147    }
148
149    // Handle unclosed code block
150    if in_code_block && !code_buf.is_empty() {
151        let highlighted = highlighter.highlight_code(&code_buf, &code_lang);
152        for hl_line in highlighted {
153            lines.push(hl_line);
154        }
155    }
156
157    lines
158}
159
160fn render_table_block<'a>(
161    lines: &[&str],
162    theme: &Theme,
163    max_width: Option<usize>,
164) -> Option<(Vec<Line<'a>>, usize)> {
165    if lines.len() < 2 {
166        return None;
167    }
168
169    let header = parse_table_row(lines[0])?;
170    let alignments = parse_table_separator(lines[1])?;
171    if header.len() != alignments.len() {
172        return None;
173    }
174
175    let mut rows = Vec::new();
176    let mut consumed = 2;
177
178    while let Some(line) = lines.get(consumed) {
179        if line.trim().is_empty() {
180            break;
181        }
182
183        match parse_table_row(line) {
184            Some(row) if row.len() == alignments.len() => {
185                rows.push(row);
186                consumed += 1;
187            }
188            _ => break,
189        }
190    }
191
192    Some((
193        build_table_lines(header, rows, alignments, theme, max_width),
194        consumed,
195    ))
196}
197
198fn parse_table_row(line: &str) -> Option<Vec<String>> {
199    let trimmed = line.trim();
200    if !trimmed.contains('|') {
201        return None;
202    }
203
204    let mut parts: Vec<&str> = trimmed.split('|').collect();
205    if trimmed.starts_with('|') && !parts.is_empty() {
206        parts.remove(0);
207    }
208    if trimmed.ends_with('|') && !parts.is_empty() {
209        parts.pop();
210    }
211
212    if parts.is_empty() {
213        return None;
214    }
215
216    Some(
217        parts
218            .into_iter()
219            .map(|part| part.trim().to_string())
220            .collect(),
221    )
222}
223
224fn parse_table_separator(line: &str) -> Option<Vec<TableAlignment>> {
225    let cells = parse_table_row(line)?;
226    if cells.is_empty() {
227        return None;
228    }
229
230    cells
231        .into_iter()
232        .map(|cell| parse_table_alignment(&cell))
233        .collect()
234}
235
236fn parse_table_alignment(cell: &str) -> Option<TableAlignment> {
237    let trimmed = cell.trim();
238    let dashes = trimmed.chars().filter(|&ch| ch == '-').count();
239    if dashes < 3 || !trimmed.chars().all(|ch| ch == '-' || ch == ':') {
240        return None;
241    }
242
243    Some(match (trimmed.starts_with(':'), trimmed.ends_with(':')) {
244        (true, true) => TableAlignment::Center,
245        (false, true) => TableAlignment::Right,
246        _ => TableAlignment::Left,
247    })
248}
249
250fn build_table_lines<'a>(
251    header: Vec<String>,
252    rows: Vec<Vec<String>>,
253    alignments: Vec<TableAlignment>,
254    theme: &Theme,
255    max_width: Option<usize>,
256) -> Vec<Line<'a>> {
257    let header_cells: Vec<Vec<Span<'a>>> = header
258        .iter()
259        .map(|cell| bold_spans(parse_inline(cell, theme)))
260        .collect();
261    let body_cells: Vec<Vec<Vec<Span<'a>>>> = rows
262        .iter()
263        .map(|row| row.iter().map(|cell| parse_inline(cell, theme)).collect())
264        .collect();
265
266    let natural_widths = natural_table_widths(&header_cells, &body_cells);
267    let widths = fit_table_widths(&natural_widths, max_width);
268
269    let mut rendered = Vec::new();
270    rendered.push(table_border('┌', '─', '┬', '┐', &widths, theme));
271    rendered.extend(table_row_lines(&header_cells, &widths, &alignments, theme));
272    rendered.push(table_border('├', '─', '┼', '┤', &widths, theme));
273
274    for row in &body_cells {
275        rendered.extend(table_row_lines(row, &widths, &alignments, theme));
276    }
277
278    rendered.push(table_border('└', '─', '┴', '┘', &widths, theme));
279    rendered
280}
281
282fn natural_table_widths<'a>(header: &[Vec<Span<'a>>], rows: &[Vec<Vec<Span<'a>>>]) -> Vec<usize> {
283    let mut widths: Vec<usize> = header
284        .iter()
285        .map(|cell| spans_display_width(cell).max(1))
286        .collect();
287
288    for row in rows {
289        for (idx, cell) in row.iter().enumerate() {
290            widths[idx] = widths[idx].max(spans_display_width(cell).max(1));
291        }
292    }
293
294    widths
295}
296
297fn fit_table_widths(widths: &[usize], max_width: Option<usize>) -> Vec<usize> {
298    let mut fitted = widths.to_vec();
299
300    let Some(max_width) = max_width else {
301        return fitted;
302    };
303
304    if fitted.is_empty() {
305        return fitted;
306    }
307
308    let border_overhead = fitted.len() * 3 + 1;
309    if max_width <= border_overhead {
310        return vec![1; fitted.len()];
311    }
312
313    let available = max_width - border_overhead;
314    let mut total: usize = fitted.iter().sum();
315    if total <= available {
316        return fitted;
317    }
318
319    while total > available {
320        let mut reduced = false;
321        let mut widest_idx = None;
322        let mut widest = 0;
323
324        for (idx, width) in fitted.iter().copied().enumerate() {
325            if width > 1 && width >= widest {
326                widest = width;
327                widest_idx = Some(idx);
328            }
329        }
330
331        if let Some(idx) = widest_idx {
332            fitted[idx] -= 1;
333            total -= 1;
334            reduced = true;
335        }
336
337        if !reduced {
338            break;
339        }
340    }
341
342    fitted
343}
344
345fn table_border<'a>(
346    left: char,
347    fill: char,
348    junction: char,
349    right: char,
350    widths: &[usize],
351    theme: &Theme,
352) -> Line<'a> {
353    let border_style = theme.muted_style();
354    let mut spans = Vec::new();
355    spans.push(Span::styled(left.to_string(), border_style));
356
357    for (idx, width) in widths.iter().enumerate() {
358        spans.push(Span::styled(
359            fill.to_string().repeat(*width + 2),
360            border_style,
361        ));
362        spans.push(Span::styled(
363            if idx + 1 == widths.len() {
364                right.to_string()
365            } else {
366                junction.to_string()
367            },
368            border_style,
369        ));
370    }
371
372    Line::from(spans)
373}
374
375fn table_row_lines<'a>(
376    cells: &[Vec<Span<'a>>],
377    widths: &[usize],
378    alignments: &[TableAlignment],
379    theme: &Theme,
380) -> Vec<Line<'a>> {
381    let wrapped_cells: Vec<Vec<Vec<Span<'a>>>> = cells
382        .iter()
383        .enumerate()
384        .map(|(idx, cell)| wrap_spans(cell, widths[idx]))
385        .collect();
386    let row_height = wrapped_cells.iter().map(Vec::len).max().unwrap_or(1);
387    let border_style = theme.muted_style();
388    let mut lines = Vec::with_capacity(row_height);
389
390    for line_idx in 0..row_height {
391        let mut spans = Vec::new();
392        spans.push(Span::styled("│", border_style));
393
394        for col_idx in 0..cells.len() {
395            let content = wrapped_cells[col_idx]
396                .get(line_idx)
397                .cloned()
398                .unwrap_or_default();
399            let content_width = spans_display_width(&content);
400            let remaining = widths[col_idx].saturating_sub(content_width);
401            let (left_pad, right_pad) = alignment_padding(remaining, alignments[col_idx]);
402
403            spans.push(Span::raw(" "));
404            if left_pad > 0 {
405                spans.push(Span::raw(" ".repeat(left_pad)));
406            }
407            spans.extend(content);
408            if right_pad > 0 {
409                spans.push(Span::raw(" ".repeat(right_pad)));
410            }
411            spans.push(Span::raw(" "));
412            spans.push(Span::styled("│", border_style));
413        }
414
415        lines.push(Line::from(spans));
416    }
417
418    lines
419}
420
421fn alignment_padding(remaining: usize, alignment: TableAlignment) -> (usize, usize) {
422    match alignment {
423        TableAlignment::Left => (0, remaining),
424        TableAlignment::Center => {
425            let left = remaining / 2;
426            (left, remaining - left)
427        }
428        TableAlignment::Right => (remaining, 0),
429    }
430}
431
432fn wrap_spans<'a>(spans: &[Span<'a>], width: usize) -> Vec<Vec<Span<'a>>> {
433    let chars = flatten_spans_chars(spans);
434    if chars.is_empty() {
435        return vec![Vec::new()];
436    }
437
438    wrap_styled_chars_by_width(&chars, width.max(1))
439        .into_iter()
440        .map(chars_to_spans)
441        .collect()
442}
443
444fn flatten_spans_chars(spans: &[Span<'_>]) -> Vec<(char, Style)> {
445    let mut chars = Vec::new();
446    for span in spans {
447        for ch in span.content.chars() {
448            chars.push((ch, span.style));
449        }
450    }
451    chars
452}
453
454fn wrap_styled_chars_by_width(chars: &[(char, Style)], width: usize) -> Vec<Vec<(char, Style)>> {
455    let mut chunks = Vec::new();
456    let mut start = 0;
457    let width = width.max(1);
458
459    while start < chars.len() {
460        let mut end = start;
461        let mut used = 0;
462        let mut last_space = None;
463
464        while end < chars.len() {
465            let ch = chars[end].0;
466            let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
467            let next = used + ch_width;
468
469            if end > start && next > width {
470                break;
471            }
472
473            if ch.is_whitespace() {
474                last_space = Some(end);
475            }
476
477            used = next;
478            end += 1;
479
480            if used >= width && end < chars.len() {
481                break;
482            }
483        }
484
485        if end == start {
486            end = (start + 1).min(chars.len());
487        }
488
489        let break_at = if end < chars.len() {
490            last_space.filter(|&idx| idx > start)
491        } else {
492            None
493        };
494
495        if let Some(space_idx) = break_at {
496            chunks.push(chars[start..space_idx].to_vec());
497            start = space_idx + 1;
498            while start < chars.len() && chars[start].0.is_whitespace() {
499                start += 1;
500            }
501        } else {
502            chunks.push(chars[start..end].to_vec());
503            start = end;
504        }
505    }
506
507    if chunks.is_empty() {
508        chunks.push(Vec::new());
509    }
510
511    chunks
512}
513
514fn chars_to_spans<'a>(chars: Vec<(char, Style)>) -> Vec<Span<'a>> {
515    if chars.is_empty() {
516        return Vec::new();
517    }
518
519    let mut spans = Vec::new();
520    let mut current_style = chars[0].1;
521    let mut current_text = String::new();
522
523    for (ch, style) in chars {
524        if style == current_style {
525            current_text.push(ch);
526        } else {
527            spans.push(Span::styled(current_text, current_style));
528            current_text = ch.to_string();
529            current_style = style;
530        }
531    }
532
533    if !current_text.is_empty() {
534        spans.push(Span::styled(current_text, current_style));
535    }
536
537    spans
538}
539
540fn spans_display_width(spans: &[Span<'_>]) -> usize {
541    spans
542        .iter()
543        .map(|span| UnicodeWidthStr::width(span.content.as_ref()))
544        .sum()
545}
546
547fn bold_spans<'a>(spans: Vec<Span<'a>>) -> Vec<Span<'a>> {
548    spans
549        .into_iter()
550        .map(|span| {
551            Span::styled(
552                span.content.to_string(),
553                span.style.add_modifier(Modifier::BOLD),
554            )
555        })
556        .collect()
557}
558
559/// Parse inline markdown formatting: **bold**, *italic*, `code`, [links](url).
560fn parse_inline<'a>(text: &str, theme: &Theme) -> Vec<Span<'a>> {
561    let mut spans = Vec::new();
562    let mut chars = text.char_indices().peekable();
563    let mut buf = String::new();
564
565    while let Some((i, ch)) = chars.next() {
566        match ch {
567            '`' => {
568                // Inline code
569                if !buf.is_empty() {
570                    spans.push(Span::raw(buf.clone()));
571                    buf.clear();
572                }
573                let mut code = String::new();
574                for (_, c) in chars.by_ref() {
575                    if c == '`' {
576                        break;
577                    }
578                    code.push(c);
579                }
580                spans.push(Span::styled(code, theme.code_inline_style()));
581            }
582            '*' => {
583                // Bold or italic
584                let next_star = chars.peek().map(|(_, c)| *c) == Some('*');
585                if next_star {
586                    // Bold: **text**
587                    chars.next(); // consume second *
588                    if !buf.is_empty() {
589                        spans.push(Span::raw(buf.clone()));
590                        buf.clear();
591                    }
592                    let mut bold_text = String::new();
593                    while let Some((_, c)) = chars.next() {
594                        if c == '*' && chars.peek().map(|(_, c)| *c) == Some('*') {
595                            chars.next();
596                            break;
597                        }
598                        bold_text.push(c);
599                    }
600                    spans.push(Span::styled(
601                        bold_text,
602                        Style::default().add_modifier(Modifier::BOLD),
603                    ));
604                } else {
605                    // Italic: *text*
606                    if !buf.is_empty() {
607                        spans.push(Span::raw(buf.clone()));
608                        buf.clear();
609                    }
610                    let mut italic_text = String::new();
611                    for (_, c) in chars.by_ref() {
612                        if c == '*' {
613                            break;
614                        }
615                        italic_text.push(c);
616                    }
617                    spans.push(Span::styled(
618                        italic_text,
619                        Style::default().add_modifier(Modifier::ITALIC),
620                    ));
621                }
622            }
623            '[' => {
624                // Link: [text](url)
625                if !buf.is_empty() {
626                    spans.push(Span::raw(buf.clone()));
627                    buf.clear();
628                }
629                let mut link_text = String::new();
630                let mut found_close = false;
631                for (_, c) in chars.by_ref() {
632                    if c == ']' {
633                        found_close = true;
634                        break;
635                    }
636                    link_text.push(c);
637                }
638                if found_close && chars.peek().map(|(_, c)| *c) == Some('(') {
639                    chars.next(); // consume (
640                    let mut _url = String::new();
641                    for (_, c) in chars.by_ref() {
642                        if c == ')' {
643                            break;
644                        }
645                        _url.push(c);
646                    }
647                    spans.push(Span::styled(
648                        link_text,
649                        Style::default()
650                            .fg(Color::Blue)
651                            .add_modifier(Modifier::UNDERLINED),
652                    ));
653                } else {
654                    // Not a valid link, emit as-is
655                    buf.push('[');
656                    buf.push_str(&link_text);
657                    if found_close {
658                        buf.push(']');
659                    }
660                }
661            }
662            _ => {
663                let _ = i;
664                buf.push(ch);
665            }
666        }
667    }
668
669    if !buf.is_empty() {
670        spans.push(Span::raw(buf));
671    }
672
673    spans
674}
675
676fn is_ordered_list(line: &str) -> bool {
677    let trimmed = line.trim_start();
678    if let Some(dot_pos) = trimmed.find('.') {
679        if dot_pos > 0 && dot_pos <= 3 {
680            let prefix = &trimmed[..dot_pos];
681            // Require "N. " or "N." at end — must have space after dot if content follows
682            let after_dot = &trimmed[dot_pos + 1..];
683            if !after_dot.is_empty() && !after_dot.starts_with(' ') {
684                return false;
685            }
686            return prefix.chars().all(|c| c.is_ascii_digit());
687        }
688    }
689    false
690}
691
692#[cfg(test)]
693mod tests {
694    use super::{render_markdown, render_markdown_with_width};
695    use crate::highlight::Highlighter;
696    use crate::theme::Theme;
697    use unicode_width::UnicodeWidthStr;
698
699    fn plain_lines(lines: Vec<ratatui::text::Line<'_>>) -> Vec<String> {
700        lines
701            .into_iter()
702            .map(|line| line.spans.into_iter().map(|span| span.content).collect())
703            .collect()
704    }
705
706    #[test]
707    fn renders_pipe_table_as_box_table() {
708        let text = "| Current prompt content | Better home | Why |\n|---|---|---|\n| Tone, brevity, independence | Preferences | Per-user, not per-agent identity |\n| AGENTS.md project map | Context assembly | Loaded per-session based on cwd |";
709
710        let rendered = plain_lines(render_markdown(
711            text,
712            &Theme::default(),
713            &Highlighter::new(),
714        ));
715
716        assert_eq!(rendered.len(), 6);
717        assert!(rendered[0].starts_with('┌'));
718        assert!(rendered[1].contains("Current prompt content"));
719        assert!(rendered[1].contains("Better home"));
720        assert!(rendered[2].starts_with('├'));
721        assert!(rendered[3].contains("Tone, brevity, independence"));
722        assert!(rendered[4].contains("AGENTS.md project map"));
723        assert!(rendered[5].starts_with('└'));
724    }
725
726    #[test]
727    fn wraps_tables_to_requested_width() {
728        let text = "| Column A | Column B |\n|---|---|\n| a very long bit of text that should wrap | another long bit that should also wrap |";
729
730        let rendered = plain_lines(render_markdown_with_width(
731            text,
732            &Theme::default(),
733            &Highlighter::new(),
734            30,
735        ));
736
737        assert!(rendered
738            .iter()
739            .all(|line| UnicodeWidthStr::width(line.as_str()) <= 30));
740        assert!(rendered.iter().any(|line| line.contains("that should")));
741        assert!(rendered.first().is_some_and(|line| line.starts_with('┌')));
742        assert!(rendered.last().is_some_and(|line| line.starts_with('└')));
743    }
744
745    #[test]
746    fn honors_table_alignment_markers() {
747        let text = "| Left | Center | Right |\n| :--- | :---: | ---: |\n| a | b | c |";
748
749        let rendered = plain_lines(render_markdown(
750            text,
751            &Theme::default(),
752            &Highlighter::new(),
753        ));
754        let row = &rendered[3];
755
756        assert!(row.starts_with("│ a"));
757        assert!(row.contains("│   b    │"));
758        assert!(row.ends_with("    c │"));
759    }
760
761    #[test]
762    fn leaves_non_table_pipe_text_alone() {
763        let text = "this | is not a table";
764
765        let rendered = plain_lines(render_markdown(
766            text,
767            &Theme::default(),
768            &Highlighter::new(),
769        ));
770
771        assert_eq!(rendered, vec!["this | is not a table"]);
772    }
773}