Skip to main content

newt_core/agentic/markdown/
mod.rs

1//! Markdown → ANSI rendering for assistant chat output (Step 25.1, #568).
2//!
3//! newt's chat surface is a **plain scroller** (`docs/decisions/plain_scroller_tui.md`):
4//! no alternate screen, no widgets, no repainting already-scrolled lines. So we
5//! parse Markdown with `pulldown-cmark` and render it ourselves to styled,
6//! word-wrapped **scrolled lines** on `crossterm` SGR codes — never a ratatui
7//! surface. Color is auto-gated by the caller; with color off this is a
8//! byte-for-byte passthrough (and under `--no-default-features` the whole
9//! renderer is replaced by a passthrough shim, so the headless wyvern tier
10//! carries no markdown dependencies).
11//!
12//! Renders inline emphasis, headings, lists (bullet/ordered/task), blockquotes,
13//! thematic breaks, fenced code (dim by default; syntect-highlighted under the
14//! optional `markdown-syntect` feature, 25.6), and GFM tables (box-drawing,
15//! width-fit). Block-aware streaming is in `stream.rs` (25.3); the config +
16//! `/markdown` toggle (25.4) lives in newt-tui; the wyvern source-tidy is
17//! `agentic::tidy_markdown_tables` (25.5).
18
19mod emitter;
20mod inline;
21mod stream;
22mod syntect;
23mod table;
24mod width;
25
26pub use stream::MarkdownStreamWriter;
27
28use emitter::Emitter;
29use pulldown_cmark::{Options, Parser};
30
31/// Rendering inputs. `cols` is injected by the caller (the TUI passes
32/// `term_cols()`); the renderer never probes the terminal itself, keeping it
33/// pure and deterministic for the mocked unit tier.
34#[derive(Debug, Clone, Copy)]
35pub struct RenderOpts {
36    /// Render styled ANSI when `true`; raw passthrough when `false`.
37    pub color: bool,
38    /// Wrap width in display columns.
39    pub cols: usize,
40}
41
42/// Render a complete Markdown document to a styled ANSI string (no trailing
43/// newline — the caller owns the final line break). With `color == false` the
44/// source is returned verbatim.
45pub fn render_markdown(src: &str, opts: RenderOpts) -> String {
46    if !opts.color {
47        return src.to_string();
48    }
49    let mut options = Options::empty();
50    options.insert(Options::ENABLE_STRIKETHROUGH);
51    options.insert(Options::ENABLE_TASKLISTS);
52    options.insert(Options::ENABLE_TABLES);
53    let parser = Parser::new_ext(src, options);
54    let mut em = Emitter::new(opts.cols);
55    for ev in parser {
56        em.handle(ev);
57    }
58    em.finish()
59}
60
61#[cfg(test)]
62mod tests {
63    use super::{render_markdown, RenderOpts};
64
65    // SGR fragments, mirrored from inline.rs so expectations read clearly.
66    const BOLD: &str = "\x1b[1m";
67    const ITALIC: &str = "\x1b[3m";
68    const UNDER: &str = "\x1b[4m";
69    const STRIKE: &str = "\x1b[9m";
70    const RESET: &str = "\x1b[0m";
71    const ORANGE: &str = "\x1b[38;2;220;60;20m";
72    const FADE: &str = "\x1b[38;2;90;90;90m";
73
74    fn r(src: &str) -> String {
75        render_markdown(
76            src,
77            RenderOpts {
78                color: true,
79                cols: 80,
80            },
81        )
82    }
83    fn rw(src: &str, cols: usize) -> String {
84        render_markdown(src, RenderOpts { color: true, cols })
85    }
86
87    #[test]
88    fn plain_text_passes_through_unstyled() {
89        assert_eq!(r("hello world"), "hello world");
90    }
91
92    #[test]
93    fn inline_emphasis_variants() {
94        assert_eq!(r("**bold**"), format!("{BOLD}bold{RESET}"));
95        assert_eq!(r("*it*"), format!("{ITALIC}it{RESET}"));
96        assert_eq!(r("~~s~~"), format!("{STRIKE}s{RESET}"));
97        assert_eq!(r("`c`"), format!("{FADE}c{RESET}"));
98    }
99
100    #[test]
101    fn emphasis_adjacent_to_text_has_no_spurious_space() {
102        // The whole reason wrap operates on cells, not words.
103        assert_eq!(r("un**bold**ed"), format!("un{BOLD}bold{RESET}ed"));
104    }
105
106    #[test]
107    fn heading_is_bold_orange() {
108        assert_eq!(r("# Title"), format!("{BOLD}{ORANGE}Title{RESET}"));
109    }
110
111    #[test]
112    fn paragraphs_are_separated_by_a_blank_line() {
113        assert_eq!(r("a\n\nb"), "a\n\nb");
114    }
115
116    #[test]
117    fn greedy_word_wrap_breaks_at_spaces() {
118        assert_eq!(rw("alpha beta gamma", 11), "alpha beta\ngamma");
119    }
120
121    #[test]
122    fn wrap_counts_display_width_not_chars() {
123        // 日本語 = 6 cols, 語 = 2 cols. At budget 8 they cannot share a line
124        // (6+1+2 = 9). A char-count budget would see 3+1+1 = 5 and NOT wrap —
125        // so this discriminates display-width awareness from char counting.
126        assert_eq!(rw("日本語 語", 8), "日本語\n語");
127    }
128
129    #[test]
130    fn bullet_list_tight() {
131        assert_eq!(r("- one\n- two"), "• one\n• two");
132    }
133
134    #[test]
135    fn ordered_list_numbers() {
136        assert_eq!(r("1. a\n2. b"), "1. a\n2. b");
137    }
138
139    #[test]
140    fn task_list_markers() {
141        assert_eq!(r("- [x] done\n- [ ] todo"), "• ✓ done\n• ☐ todo");
142    }
143
144    #[test]
145    fn nested_list_indents_under_parent() {
146        // Two-space marker width → child marker sits at column 2.
147        assert_eq!(r("- a\n  - b"), "• a\n  • b");
148    }
149
150    #[test]
151    fn blockquote_has_a_dim_bar() {
152        assert_eq!(r("> quote"), format!("{FADE}│ {RESET}quote"));
153    }
154
155    #[test]
156    fn fenced_code_is_dim_and_inset_not_reflowed() {
157        assert_eq!(
158            r("```\nlet x = 1;\n```"),
159            format!("  {FADE}let x = 1;{RESET}")
160        );
161    }
162
163    // Step 25.6: with the feature, a fenced block is syntect-highlighted rather
164    // than uniformly dim. Only compiles/runs under `--features markdown-syntect`.
165    #[cfg(feature = "markdown-syntect")]
166    #[test]
167    fn syntect_highlights_a_rust_code_block() {
168        let out = r("```rust\nfn main() {}\n```");
169        // Color codes interleave tokens, so check content after stripping ANSI.
170        assert!(
171            strip(&out).contains("fn main"),
172            "content preserved: {out:?}"
173        );
174        // Real per-token colors → 24-bit fg escapes beyond the uniform FADE dim,
175        // so it is NOT the plain stub rendering.
176        assert!(out.contains("\x1b[38;2;"), "has 24-bit color: {out:?}");
177        assert_ne!(
178            out,
179            format!("  {FADE}fn main() {{}}{RESET}"),
180            "not the plain dim stub"
181        );
182    }
183
184    #[test]
185    fn thematic_break_is_a_full_width_rule() {
186        assert_eq!(r("---"), format!("{FADE}{}{RESET}", "─".repeat(80)));
187    }
188
189    #[test]
190    fn link_text_is_underlined_with_dim_url() {
191        // The space before the dim URL is a wrap separator (regenerated
192        // unstyled), so it sits between the two styled runs.
193        assert_eq!(
194            r("[text](https://x.io)"),
195            format!("{UNDER}text{RESET} {FADE}(https://x.io){RESET}")
196        );
197    }
198
199    #[test]
200    fn color_off_is_byte_for_byte_passthrough() {
201        for s in [
202            "**x** and `y`",
203            "# h\n\n- a\n- b",
204            "| a | b |\n| - | - |\n| 1 | 2 |",
205            "> quote\n\n```\ncode\n```",
206            "",
207        ] {
208            assert_eq!(
209                render_markdown(
210                    s,
211                    RenderOpts {
212                        color: false,
213                        cols: 80
214                    }
215                ),
216                s,
217                "color-off must return source verbatim"
218            );
219        }
220    }
221
222    #[test]
223    fn empty_input_renders_empty() {
224        assert_eq!(r(""), "");
225    }
226
227    // ---- Step 25.2: GFM tables ----
228
229    /// Strip SGR escape sequences so the box-drawing skeleton can be asserted
230    /// independently of the styling.
231    fn strip(s: &str) -> String {
232        let mut out = String::new();
233        let mut chars = s.chars();
234        while let Some(c) = chars.next() {
235            if c == '\x1b' {
236                for n in chars.by_ref() {
237                    if n == 'm' {
238                        break;
239                    }
240                }
241            } else {
242                out.push(c);
243            }
244        }
245        out
246    }
247
248    #[test]
249    fn table_box_drawing_skeleton() {
250        let t = "| a | b |\n|---|---|\n| 1 | 2 |";
251        assert_eq!(
252            strip(&r(t)),
253            "┌───┬───┐\n│ a │ b │\n├───┼───┤\n│ 1 │ 2 │\n└───┴───┘"
254        );
255    }
256
257    #[test]
258    fn table_header_is_bold_borders_are_dim() {
259        let out = r("| a |\n|---|\n| 1 |");
260        assert!(
261            out.contains(&format!("{BOLD}a")),
262            "header cell must be bold"
263        );
264        assert!(out.contains(FADE), "borders must be dim");
265        // A body cell carries no styling.
266        assert!(strip(&out).contains("│ 1 │"));
267    }
268
269    #[test]
270    fn table_right_alignment_pads_on_the_left() {
271        // `:--` would be left, `--:` is right. Column width 2 ("ab"); "x" → " x".
272        let t = "| ab |\n|---:|\n| x |";
273        assert!(
274            strip(&r(t)).contains("│  x │"),
275            "right-aligned cell pads left"
276        );
277    }
278
279    #[test]
280    fn table_overflow_truncates_with_ellipsis() {
281        // Narrow budget forces the 8-wide cell down; it truncates to `abcde…`.
282        let t = "| name |\n|------|\n| abcdefgh |";
283        assert!(
284            strip(&rw(t, 10)).contains("abcde…"),
285            "overflowing cell truncates with an ellipsis"
286        );
287    }
288
289    #[test]
290    fn table_column_width_counts_cjk_display_width() {
291        // 日本語 = 6 display cols → top rule spans 6+2 = 8 dashes (not 3+2 = 5).
292        let t = "| 日本語 |\n|--------|\n| x |";
293        assert!(
294            strip(&r(t)).contains("┌────────┐"),
295            "CJK header sized by display width, not char count"
296        );
297    }
298
299    #[test]
300    fn table_inside_blockquote_is_prefixed_with_the_bar() {
301        let out = r("> | a |\n> |---|\n> | 1 |");
302        // Every rendered table line opens with the quote bar, then the box.
303        for line in out.lines() {
304            let plain = strip(line);
305            assert!(
306                plain.starts_with("│ ┌")
307                    || plain.starts_with("│ │")
308                    || plain.starts_with("│ ├")
309                    || plain.starts_with("│ └"),
310                "quoted table line must open with the bar then box: {plain:?}"
311            );
312        }
313    }
314}