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
15pub fn render_markdown<'a>(text: &str, theme: &Theme, highlighter: &Highlighter) -> Vec<Line<'a>> {
20 render_markdown_inner(text, theme, highlighter, None)
21}
22
23pub 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 if raw_line.trim_start().starts_with("```") {
54 if in_code_block {
55 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 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 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 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 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
559fn 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 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 let next_star = chars.peek().map(|(_, c)| *c) == Some('*');
585 if next_star {
586 chars.next(); 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 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 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(); 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 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 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}