newt_core/agentic/markdown/
stream.rs1use super::{render_markdown, RenderOpts};
16use std::io::{self, Write};
17
18#[derive(Clone, PartialEq)]
20enum Hold {
21 Fence(String),
23 Quote,
25 List,
27 Table { confirmed: bool },
30}
31
32pub struct MarkdownStreamWriter<W: Write> {
34 out: W,
35 color: bool,
36 cols: usize,
37 line_buf: String,
39 hold: Option<Hold>,
41 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 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 let line = line.trim_end_matches('\n').to_string();
68 self.consume_line(line)?;
69 }
70 Ok(())
71 }
72
73 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 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 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 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 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 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 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
212fn 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
230fn 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
245fn 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
257fn 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
268fn 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 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 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 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 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 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 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 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}