Skip to main content

newt_core/agentic/markdown/
stream.rs

1//! Block-aware streaming writer (Step 25.3).
2//!
3//! The agentic loop streams the model's reply token-by-token. We cannot render
4//! a half-open code fence or a table mid-arrival, so this writer line-buffers:
5//! a *completed* inline line is rendered and emitted immediately (so the stream
6//! still feels live), while a multi-line block construct — fenced code, GFM
7//! table, list, blockquote — is **held** until it closes, then rendered whole
8//! via [`render_markdown`]. Holding to completed lines also dissolves the
9//! split-inline-marker problem: a `**` split across two tokens reunites in the
10//! line buffer before the line is ever rendered.
11//!
12//! With `color == false` this is a byte-for-byte passthrough — identical to the
13//! raw token stream today.
14
15use super::{render_markdown, RenderOpts};
16use std::io::{self, Write};
17
18/// A multi-line block being accumulated until it closes.
19#[derive(Clone, PartialEq)]
20enum Hold {
21    /// Inside a fenced code block; carries the opening fence run (``` / ~~~).
22    Fence(String),
23    /// Consecutive blockquote (`>`) lines.
24    Quote,
25    /// A list and its (indented) continuations.
26    List,
27    /// A pipe table. `confirmed` flips true once the delimiter row is seen;
28    /// until then the opening line might just be a paragraph containing `|`.
29    Table { confirmed: bool },
30}
31
32/// Streams Markdown to `out`, rendering block-by-block as content completes.
33pub struct MarkdownStreamWriter<W: Write> {
34    out: W,
35    color: bool,
36    cols: usize,
37    /// The current line being accumulated (no trailing newline yet).
38    line_buf: String,
39    /// The multi-line block currently held open, if any.
40    hold: Option<Hold>,
41    /// Raw source of the held block.
42    block_buf: String,
43}
44
45impl<W: Write> MarkdownStreamWriter<W> {
46    pub fn new(out: W, opts: RenderOpts) -> Self {
47        Self {
48            out,
49            color: opts.color,
50            cols: opts.cols.max(8),
51            line_buf: String::new(),
52            hold: None,
53            block_buf: String::new(),
54        }
55    }
56
57    /// Feed a token delta. Completed lines are processed; the trailing partial
58    /// line stays buffered until its newline arrives (or [`finish`]).
59    pub fn push(&mut self, delta: &str) -> io::Result<()> {
60        if !self.color {
61            return self.out.write_all(delta.as_bytes());
62        }
63        self.line_buf.push_str(delta);
64        while let Some(nl) = self.line_buf.find('\n') {
65            let line: String = self.line_buf.drain(..=nl).collect();
66            // Drop the trailing '\n' — line classifiers want the bare line.
67            let line = line.trim_end_matches('\n').to_string();
68            self.consume_line(line)?;
69        }
70        Ok(())
71    }
72
73    /// Flush the trailing partial line and any still-open block at end of stream.
74    pub fn finish(&mut self) -> io::Result<()> {
75        if self.color && !self.line_buf.is_empty() {
76            let line = std::mem::take(&mut self.line_buf);
77            self.consume_line(line)?;
78        }
79        match self.hold.clone() {
80            // An opening `|` line that never got its delimiter row is just a
81            // paragraph — emit the buffered lines as inline.
82            Some(Hold::Table { confirmed: false }) => {
83                let buf = std::mem::take(&mut self.block_buf);
84                self.hold = None;
85                for l in buf.lines() {
86                    self.emit_inline(l)?;
87                }
88            }
89            Some(_) => self.flush_block()?,
90            None => {}
91        }
92        self.out.flush()
93    }
94
95    fn consume_line(&mut self, line: String) -> io::Result<()> {
96        // `line` may need re-processing in a fresh state after a block flushes
97        // (a non-matching line both *ends* a block and *starts* whatever's next).
98        let mut pending = Some(line);
99        while let Some(line) = pending.take() {
100            match self.hold.clone() {
101                Some(Hold::Fence(marker)) => {
102                    self.append_block(&line);
103                    if closes_fence(&line, &marker) {
104                        self.flush_block()?;
105                    }
106                }
107                Some(Hold::Quote) => {
108                    if is_quote(&line) {
109                        self.append_block(&line);
110                    } else {
111                        self.flush_block()?;
112                        pending = Some(line);
113                    }
114                }
115                Some(Hold::List) => {
116                    if is_blank(&line) {
117                        self.flush_block()?;
118                        self.out.write_all(b"\n")?;
119                    } else if is_list(&line) || is_indented(&line) {
120                        self.append_block(&line);
121                    } else {
122                        self.flush_block()?;
123                        pending = Some(line);
124                    }
125                }
126                Some(Hold::Table { confirmed: false }) => {
127                    if is_delimiter_row(&line) {
128                        self.append_block(&line);
129                        self.hold = Some(Hold::Table { confirmed: true });
130                    } else {
131                        // Not a table after all — emit the opener as inline.
132                        let buf = std::mem::take(&mut self.block_buf);
133                        self.hold = None;
134                        for l in buf.lines() {
135                            self.emit_inline(l)?;
136                        }
137                        pending = Some(line);
138                    }
139                }
140                Some(Hold::Table { confirmed: true }) => {
141                    if has_pipe(&line) && !is_blank(&line) {
142                        self.append_block(&line);
143                    } else {
144                        self.flush_block()?;
145                        pending = Some(line);
146                    }
147                }
148                None => self.classify(line)?,
149            }
150        }
151        Ok(())
152    }
153
154    /// Classify a line with no block open: start a hold, or emit it inline.
155    fn classify(&mut self, line: String) -> io::Result<()> {
156        if is_blank(&line) {
157            self.out.write_all(b"\n")?;
158        } else if let Some(marker) = fence_marker(&line) {
159            self.start_hold(Hold::Fence(marker), &line);
160        } else if is_quote(&line) {
161            self.start_hold(Hold::Quote, &line);
162        } else if is_list(&line) {
163            self.start_hold(Hold::List, &line);
164        } else if has_pipe(&line) {
165            self.start_hold(Hold::Table { confirmed: false }, &line);
166        } else {
167            self.emit_inline(&line)?;
168        }
169        Ok(())
170    }
171
172    fn start_hold(&mut self, kind: Hold, line: &str) {
173        self.hold = Some(kind);
174        self.block_buf.clear();
175        self.append_block(line);
176    }
177
178    fn append_block(&mut self, line: &str) {
179        self.block_buf.push_str(line);
180        self.block_buf.push('\n');
181    }
182
183    /// Render and emit the held block, then clear the hold.
184    fn flush_block(&mut self) -> io::Result<()> {
185        let block = std::mem::take(&mut self.block_buf);
186        self.hold = None;
187        let rendered = render_markdown(
188            &block,
189            RenderOpts {
190                color: true,
191                cols: self.cols,
192            },
193        );
194        self.out.write_all(rendered.as_bytes())?;
195        self.out.write_all(b"\n")
196    }
197
198    /// Render and emit one inline (single-line) construct.
199    fn emit_inline(&mut self, line: &str) -> io::Result<()> {
200        let rendered = render_markdown(
201            line,
202            RenderOpts {
203                color: true,
204                cols: self.cols,
205            },
206        );
207        self.out.write_all(rendered.as_bytes())?;
208        self.out.write_all(b"\n")
209    }
210}
211
212// ---- line classifiers ----
213
214fn is_blank(line: &str) -> bool {
215    line.trim().is_empty()
216}
217
218fn is_quote(line: &str) -> bool {
219    line.trim_start().starts_with('>')
220}
221
222fn is_indented(line: &str) -> bool {
223    line.starts_with(' ') || line.starts_with('\t')
224}
225
226fn has_pipe(line: &str) -> bool {
227    line.contains('|')
228}
229
230/// A bullet (`- * +`) or ordered (`1.` / `1)`) list item.
231fn is_list(line: &str) -> bool {
232    let t = line.trim_start();
233    if let Some(rest) = t.strip_prefix(['-', '*', '+']) {
234        return rest.is_empty() || rest.starts_with(' ');
235    }
236    let digits: String = t.chars().take_while(char::is_ascii_digit).collect();
237    if !digits.is_empty() {
238        if let Some(rest) = t[digits.len()..].strip_prefix(['.', ')']) {
239            return rest.is_empty() || rest.starts_with(' ');
240        }
241    }
242    false
243}
244
245/// The opening run of a code fence (``` or ~~~, length ≥ 3), if any.
246fn fence_marker(line: &str) -> Option<String> {
247    let t = line.trim_start();
248    for ch in ['`', '~'] {
249        let run: String = t.chars().take_while(|&c| c == ch).collect();
250        if run.len() >= 3 {
251            return Some(run);
252        }
253    }
254    None
255}
256
257/// Whether `line` closes a fence opened with `marker`.
258fn closes_fence(line: &str, marker: &str) -> bool {
259    let t = line.trim();
260    let ch = match marker.chars().next() {
261        Some(c) => c,
262        None => return false,
263    };
264    let run = t.chars().take_while(|&c| c == ch).count();
265    run >= marker.len() && !t.is_empty() && t.chars().all(|c| c == ch)
266}
267
268/// A GFM table delimiter row: only `| - : space`, with at least one `-`.
269fn is_delimiter_row(line: &str) -> bool {
270    let t = line.trim();
271    t.contains('-') && t.chars().all(|c| matches!(c, '|' | '-' | ':' | ' '))
272}
273
274#[cfg(test)]
275mod tests {
276    use super::super::RenderOpts;
277    use super::MarkdownStreamWriter;
278
279    /// Stream `src` through the writer in `chunk`-sized byte slices.
280    fn stream(src: &str, chunk: usize, color: bool, cols: usize) -> String {
281        let mut buf: Vec<u8> = Vec::new();
282        {
283            let mut w = MarkdownStreamWriter::new(&mut buf, RenderOpts { color, cols });
284            let bytes = src.as_bytes();
285            let mut i = 0;
286            while i < bytes.len() {
287                let end = (i + chunk).min(bytes.len());
288                // Respect UTF-8 boundaries.
289                let mut e = end;
290                while e < bytes.len() && (bytes[e] & 0xC0) == 0x80 {
291                    e += 1;
292                }
293                w.push(std::str::from_utf8(&bytes[i..e]).unwrap()).unwrap();
294                i = e;
295            }
296            w.finish().unwrap();
297        }
298        String::from_utf8(buf).unwrap()
299    }
300
301    const BOLD: &str = "\x1b[1m";
302    const RESET: &str = "\x1b[0m";
303    const FADE: &str = "\x1b[38;2;90;90;90m";
304
305    #[test]
306    fn inline_line_renders_per_line() {
307        // Whatever the chunking, a completed inline line renders identically.
308        for chunk in [1, 2, 3, 100] {
309            assert_eq!(
310                stream("**bold** x\n", chunk, true, 80),
311                format!("{BOLD}bold{RESET} x\n")
312            );
313        }
314    }
315
316    #[test]
317    fn marker_split_across_chunks_still_bolds() {
318        // The classic failure: `**` split over two tokens reunites in line_buf.
319        let mut buf = Vec::new();
320        {
321            let mut w = MarkdownStreamWriter::new(
322                &mut buf,
323                RenderOpts {
324                    color: true,
325                    cols: 80,
326                },
327            );
328            for d in ["a **bo", "ld** b\n"] {
329                w.push(d).unwrap();
330            }
331            w.finish().unwrap();
332        }
333        assert_eq!(
334            String::from_utf8(buf).unwrap(),
335            format!("a {BOLD}bold{RESET} b\n")
336        );
337    }
338
339    #[test]
340    fn fenced_block_held_until_close() {
341        let out = stream("```\nlet x = 1;\n```\nafter\n", 1, true, 80);
342        assert_eq!(out, format!("  {FADE}let x = 1;{RESET}\nafter\n"));
343    }
344
345    #[test]
346    fn unterminated_fence_flushes_at_finish() {
347        // No dropped content even if the closing fence never arrives.
348        let out = stream("```\nlet x = 1;\n", 1, true, 80);
349        assert_eq!(out, format!("  {FADE}let x = 1;{RESET}\n"));
350    }
351
352    #[test]
353    fn table_streamed_matches_whole_render() {
354        let src = "| a | b |\n|---|---|\n| 1 | 2 |\n";
355        let whole = super::super::render_markdown(
356            src.trim_end(),
357            RenderOpts {
358                color: true,
359                cols: 80,
360            },
361        );
362        // Streamed output is the whole-render plus the writer's trailing newline.
363        assert_eq!(stream(src, 1, true, 80), format!("{whole}\n"));
364    }
365
366    #[test]
367    fn pipe_paragraph_without_delimiter_is_not_a_table() {
368        // `a | b` with no delimiter row must render as inline text, not a table.
369        let out = stream("a | b\n", 1, true, 80);
370        assert!(
371            !out.contains('┌'),
372            "no box for a non-table pipe line: {out:?}"
373        );
374        assert!(
375            out.contains("a | b") || out.contains("a |"),
376            "renders inline: {out:?}"
377        );
378    }
379
380    #[test]
381    fn color_off_is_raw_passthrough_for_any_chunking() {
382        let src = "**x**\n| a | b |\n```\ncode\n```\n> q\n";
383        for chunk in [1, 4, 1000] {
384            assert_eq!(stream(src, chunk, false, 80), src);
385        }
386    }
387}