Skip to main content

smart_markdown/
stream_renderer.rs

1use crate::parser::parse_document;
2use crate::renderer::render_element_with_options;
3use crate::ThemeMode;
4
5/// Incrementally renders markdown text chunks as they arrive.
6///
7/// `StreamRenderer` is designed for streaming LLM responses: as the model
8/// generates markdown text chunk by chunk, this renderer produces complete,
9/// renderable lines as soon as enough input has been buffered to form a
10/// complete markdown element (e.g. a paragraph ended by a blank line, a
11/// complete table, a closed fenced code block).
12///
13/// # Examples
14///
15/// ```rust
16/// use smart_markdown::{StreamRenderer, ThemeMode, is_light_terminal};
17///
18/// let width = terminal_size::terminal_size()
19///     .map(|(w, _)| w.0 as usize)
20///     .unwrap_or(80);
21/// let theme = if is_light_terminal() { ThemeMode::Light } else { ThemeMode::Dark };
22/// let mut sr = StreamRenderer::new(width, theme)
23///     .with_ascii_table_borders(true)
24///     .with_code_theme("base16-ocean.dark");
25///
26/// // Feed chunks as they arrive from the LLM
27/// for line in sr.push("# Hello\n\n") {
28///     println!("{line}");
29/// }
30/// for line in sr.push("this is **bold** text") {
31///     println!("{line}");
32/// }
33///
34/// // Flush anything still buffered at the end
35/// for line in sr.flush_remaining() {
36///     println!("{line}");
37/// }
38/// ```
39pub struct StreamRenderer {
40    buffer: String,
41    width: usize,
42    theme_mode: ThemeMode,
43    code_theme: Option<String>,
44    ascii_table_borders: bool,
45    rendered_count: usize,
46}
47
48impl StreamRenderer {
49    /// Create a new stream renderer.
50    ///
51    /// - `width`: terminal width in columns (e.g. from the `terminal_size` crate).
52    /// - `theme_mode`: controls syntax highlighting theme for code blocks.
53    pub fn new(width: usize, theme_mode: ThemeMode) -> Self {
54        StreamRenderer {
55            buffer: String::new(),
56            width,
57            theme_mode,
58            code_theme: None,
59            ascii_table_borders: false,
60            rendered_count: 0,
61        }
62    }
63
64    /// Set a custom syntax highlighting theme by name.
65    ///
66    /// See [`crate::highlight::list_themes`] for available theme names.
67    pub fn with_code_theme(mut self, theme: &str) -> Self {
68        self.code_theme = Some(theme.to_string());
69        self
70    }
71
72    /// Use ASCII-only table borders (`+`, `-`, `|`) instead of Unicode
73    /// box-drawing characters (`┌`, `─`, `│`, etc.).
74    ///
75    /// Useful for terminals where Unicode box-drawing renders poorly
76    /// (e.g. light-background themes without proper color inversion).
77    pub fn with_ascii_table_borders(mut self, ascii: bool) -> Self {
78        self.ascii_table_borders = ascii;
79        self
80    }
81
82    /// Push additional text chunks.
83    ///
84    /// Returns rendered complete lines as they become available.
85    /// Incomplete markdown (partial fenced blocks, tables, paragraphs)
86    /// is buffered internally.
87    pub fn push(&mut self, text: &str) -> Vec<String> {
88        self.buffer.push_str(text);
89        self.emit_complete()
90    }
91
92    /// Flush any remaining buffered content and return the final lines.
93    ///
94    /// Call this once at the end of the stream to emit any markdown that
95    /// hasn't been completed by a blank line or structural close.
96    pub fn flush_remaining(&mut self) -> Vec<String> {
97        if self.buffer.trim().is_empty() {
98            return Vec::new();
99        }
100        if !self.buffer.ends_with('\n') {
101            self.buffer.push('\n');
102        }
103        let elements = parse_document(&self.buffer);
104        let total = elements.len();
105        let new_elements: Vec<_> = elements
106            .into_iter()
107            .skip(self.rendered_count)
108            .collect();
109        self.rendered_count = total;
110
111        let mut output: Vec<String> = Vec::new();
112        for elem in &new_elements {
113            output.extend(render_element_with_options(
114                elem,
115                self.width,
116                self.theme_mode,
117                self.code_theme.as_deref(),
118                self.ascii_table_borders,
119            ));
120        }
121        self.buffer.clear();
122        self.rendered_count = 0;
123        output
124    }
125
126    fn emit_complete(&mut self) -> Vec<String> {
127        let (complete, remaining) = split_at_complete_boundary(&self.buffer);
128        if complete.is_empty() {
129            return Vec::new();
130        }
131
132        let elements = parse_document(&complete);
133        let total = elements.len();
134        let new_elements: Vec<_> = elements
135            .into_iter()
136            .skip(self.rendered_count)
137            .collect();
138        self.rendered_count = total;
139
140        let mut output: Vec<String> = Vec::new();
141        for elem in &new_elements {
142            output.extend(render_element_with_options(
143                elem,
144                self.width,
145                self.theme_mode,
146                self.code_theme.as_deref(),
147                self.ascii_table_borders,
148            ));
149        }
150
151        self.buffer = remaining;
152        self.rendered_count = 0;
153        output
154    }
155}
156
157/// Split buffer at the last complete markdown element boundary.
158/// Returns (complete_prefix, remainder) where complete_prefix ends at a
159/// safe boundary (blank line, end of a fenced block, etc.).
160fn split_at_complete_boundary(text: &str) -> (String, String) {
161    if text.is_empty() {
162        return (String::new(), String::new());
163    }
164
165    // Find the last double-newline (blank line) boundary — safe for most elements,
166    // but must not split a table (header|sep without data rows would emit a
167    // zero-row border box, then orphan subsequent content).
168    if let Some(pos) = text.rfind("\n\n") {
169        let prefix = &text[..pos];
170        if let Some(last_line) = prefix.lines().last()
171            && is_table_separator(last_line.trim())
172        {
173            return (String::new(), text.to_string());
174        }
175        return (prefix.to_string(), trim_leading_newlines(&text[pos + 2..]));
176    }
177
178    // Check for completed fenced code block (``` or ~~~).
179    let lines: Vec<&str> = text.lines().collect();
180    if lines.len() >= 2 {
181        let first = lines[0];
182        if (first.starts_with("```") || first.starts_with("~~~")) && first.len() >= 3 {
183            let fence = &first[..3];
184            for (i, line) in lines.iter().enumerate().skip(1) {
185                if line.trim().starts_with(fence) && line.trim().len() >= 3
186                    && line.trim().chars().take(3).all(|c| c == fence.chars().next().unwrap())
187                {
188                    let end_pos = text
189                        .char_indices()
190                        .nth(text.lines().take(i + 1).map(|l| l.len() + 1).sum::<usize>().saturating_sub(1))
191                        .map(|(idx, _)| idx)
192                        .unwrap_or(text.len());
193                    return (text[..end_pos].to_string(), trim_leading_newlines(&text[end_pos..]));
194                }
195            }
196            // Fenced block started but not closed — buffer it entirely
197            return (String::new(), text.to_string());
198        }
199    }
200
201    // Check for table. A table has: header line, separator line, then rows.
202    // It's complete when a terminator follows data rows.
203    if let Some(table_end) = find_complete_table_end(&lines) {
204        let end_pos = text
205            .char_indices()
206            .nth(text.lines().take(table_end).map(|l| l.len() + 1).sum::<usize>().saturating_sub(1))
207            .map(|(idx, _)| idx)
208            .unwrap_or(text.len());
209        return (text[..end_pos].to_string(), trim_leading_newlines(&text[end_pos..]));
210    }
211
212    // Guard: incomplete table — header + separator detected but find_complete_table_end
213    // found no terminator (blank line or non-table line). Buffer everything unconditionally.
214    if lines.len() >= 2
215        && lines[0].trim().starts_with('|')
216        && lines[0].trim().ends_with('|')
217        && lines[1].trim().starts_with('|')
218        && lines[1].trim().ends_with('|')
219    {
220        let sep = lines[1].trim();
221        let is_separator = sep
222            .chars()
223            .filter(|&c| c != ' ' && c != '|' && c != '-' && c != ':')
224            .count()
225            == 0;
226        if is_separator {
227            return (String::new(), text.to_string());
228        }
229    }
230
231    // Check for definition list — needs the term line + at least one ": " definition line
232    if let Some(def_end) = find_complete_definition_list_end(&lines) {
233        let end_pos = text
234            .char_indices()
235            .nth(text.lines().take(def_end).map(|l| l.len() + 1).sum::<usize>().saturating_sub(1))
236            .map(|(idx, _)| idx)
237            .unwrap_or(text.len());
238        return (text[..end_pos].to_string(), trim_leading_newlines(&text[end_pos..]));
239    }
240
241    // Guard: incomplete definition list — term present but no ": " definition line yet
242    if lines.len() >= 2
243        && is_definition_list_term(lines[0].trim())
244        && !lines[1].trim().starts_with(": ")
245    {
246        return (String::new(), text.to_string());
247    }
248
249    // Check for HTML block — starts with a tag like <div>, needs closing </div> or blank line
250    if let Some(html_end) = find_complete_html_block_end(&lines) {
251        let end_pos = text
252            .char_indices()
253            .nth(text.lines().take(html_end).map(|l| l.len() + 1).sum::<usize>().saturating_sub(1))
254            .map(|(idx, _)| idx)
255            .unwrap_or(text.len());
256        return (text[..end_pos].to_string(), trim_leading_newlines(&text[end_pos..]));
257    }
258
259    // Guard: incomplete HTML block — opening tag present but no closing tag or blank line
260    if is_html_block_tag(lines[0].trim()) {
261        return (String::new(), text.to_string());
262    }
263
264    // Check for indented code block — followed by non-indented, non-empty line or blank line
265    if let Some(code_end) = find_complete_indented_code_end(&lines) {
266        let end_pos = text
267            .char_indices()
268            .nth(text.lines().take(code_end).map(|l| l.len() + 1).sum::<usize>().saturating_sub(1))
269            .map(|(idx, _)| idx)
270            .unwrap_or(text.len());
271        return (text[..end_pos].to_string(), trim_leading_newlines(&text[end_pos..]));
272    }
273
274    // Guard: incomplete indented code block — first line is indented but no end marker yet
275    if (lines[0].starts_with("    ") || (lines[0].starts_with('\t') && lines[0].len() > 1))
276        && lines.len() == 1
277    {
278        return (String::new(), text.to_string());
279    }
280
281    // Check for complete lists (ordered, unordered, task) — a list ends when a non-list line appears
282    if let Some(list_end) = find_complete_list_end(&lines) {
283        let end_pos = text
284            .char_indices()
285            .nth(text.lines().take(list_end).map(|l| l.len() + 1).sum::<usize>().saturating_sub(1))
286            .map(|(idx, _)| idx)
287            .unwrap_or(text.len());
288        return (text[..end_pos].to_string(), trim_leading_newlines(&text[end_pos..]));
289    }
290
291    // Guard: incomplete list — first line is a list item but no terminator yet
292    if is_any_list_item(lines[0].trim()) {
293        return (String::new(), text.to_string());
294    }
295
296    // Check for footnote definitions — they can be multiline (continuation lines indented)
297    if let Some(fn_end) = find_complete_footnote_end(&lines) {
298        let end_pos = text
299            .char_indices()
300            .nth(text.lines().take(fn_end).map(|l| l.len() + 1).sum::<usize>().saturating_sub(1))
301            .map(|(idx, _)| idx)
302            .unwrap_or(text.len());
303        return (text[..end_pos].to_string(), trim_leading_newlines(&text[end_pos..]));
304    }
305
306    // Guard: incomplete footnote — [^label]: line present but continuation/content still arriving
307    if is_footnote_line(lines[0].trim()) {
308        return (String::new(), text.to_string());
309    }
310
311    // Single-line elements: headings, horizontal rules, blockquotes (single line), paragraphs
312    // If the last line is a heading or HR, emit everything
313    if let Some(last) = lines.last() {
314        let trimmed = last.trim();
315        if trimmed.starts_with('#') && trimmed.len() > 1 && trimmed.as_bytes().get(1) == Some(&b' ') {
316            // ATX heading — complete as a single line. Split off heading + preceding lines.
317            if lines.len() > 1 {
318                let end_pos = text
319                    .char_indices()
320                    .nth(text.lines().take(lines.len() - 1).map(|l| l.len() + 1).sum::<usize>().saturating_sub(1))
321                    .map(|(idx, _)| idx)
322                    .unwrap_or(text.len());
323                return (text[..end_pos].to_string(), text[end_pos..].to_string());
324            }
325            return (text.to_string(), String::new());
326        }
327        if trimmed == "---" || trimmed == "***" || trimmed == "___" {
328            return (text.to_string(), String::new());
329        }
330        if trimmed.starts_with('>') {
331            // Blockquote: emit everything before the blockquote line
332            if lines.len() > 1 {
333                let end_pos = text
334                    .char_indices()
335                    .nth(text.lines().take(lines.len() - 1).map(|l| l.len() + 1).sum::<usize>().saturating_sub(1))
336                    .map(|(idx, _)| idx)
337                    .unwrap_or(text.len());
338                return (text[..end_pos].to_string(), text[end_pos..].to_string());
339            }
340            return (text.to_string(), String::new());
341        }
342    }
343
344    // Guard: single-line table header — looks like table row start, buffer for future chunks
345    if lines.len() == 1 {
346        let trimmed = lines[0].trim();
347        if trimmed.starts_with('|') && trimmed.ends_with('|') {
348            return (String::new(), text.to_string());
349        }
350    }
351
352    // If the text ends with a newline, it's a complete paragraph or set of paragraphs
353    if text.ends_with('\n') {
354        return (text.to_string(), String::new());
355    }
356
357    // Scan backwards from the last \n to find a complete element boundary.
358    // If the preceding line looks standalone (heading, HR, blockquote), split there.
359    if let Some(last_nl) = text.rfind('\n') {
360        let prefix = &text[..last_nl];
361        let pre_lines: Vec<&str> = prefix.lines().collect();
362        if let Some(pre_last) = pre_lines.last()
363            && is_standalone_line(pre_last) {
364                return (text[..last_nl + 1].to_string(), text[last_nl + 1..].to_string());
365            }
366    }
367
368    // Buffer the text — more may arrive that belongs to the same paragraph
369    (String::new(), text.to_string())
370}
371
372fn is_standalone_line(line: &str) -> bool {
373    let line = line.trim();
374    if line.starts_with('#') {
375        let level = line.chars().take_while(|&c| c == '#').count();
376        return level <= 6 && line.len() > level && line.as_bytes().get(level) == Some(&b' ');
377    }
378    line == "---" || line == "***" || line == "___" || line.starts_with('>')
379}
380
381fn trim_leading_newlines(s: &str) -> String {
382    s.trim_start_matches('\n').to_string()
383}
384
385fn is_table_separator(line: &str) -> bool {
386    let l = line.trim();
387    if !l.starts_with('|') || !l.ends_with('|') {
388        return false;
389    }
390    l.chars()
391        .filter(|&c| c != ' ' && c != '|' && c != '-' && c != ':')
392        .count()
393        == 0
394}
395
396fn find_complete_table_end(lines: &[&str]) -> Option<usize> {
397    if lines.len() < 2 {
398        return None;
399    }
400    let header = lines[0].trim();
401    let sep = lines[1].trim();
402    if !header.starts_with('|') || !header.ends_with('|')
403        || !sep.starts_with('|') || !sep.ends_with('|')
404    {
405        return None;
406    }
407    let is_sep = sep
408        .chars()
409        .filter(|&c| c != ' ' && c != '|' && c != '-' && c != ':')
410        .count()
411        == 0;
412    if !is_sep {
413        return None;
414    }
415    let header_cols = header.split('|').filter(|s| !s.is_empty()).count();
416    let mut seen_data = false;
417    for (i, tmp) in lines.iter().enumerate().skip(2) {
418        let tmp = tmp.trim();
419        if tmp.is_empty() {
420            if seen_data {
421                return Some(i + 1);
422            }
423            continue;
424        }
425        seen_data = true;
426        if !tmp.starts_with('|') || !tmp.ends_with('|') {
427            return Some(i);
428        }
429        let cols = tmp.split('|').filter(|s| !s.is_empty()).count();
430        if cols != header_cols {
431            return Some(i);
432        }
433    }
434    None
435}
436
437fn find_complete_definition_list_end(lines: &[&str]) -> Option<usize> {
438    if lines.len() < 2 {
439        return None;
440    }
441    let first = lines[0].trim();
442    if first.starts_with('#') || first.starts_with('>') || first.starts_with('|')
443        || first.starts_with('-') || first.starts_with('*') || first.starts_with('`')
444        || first.is_empty()
445    {
446        return None;
447    }
448    if !lines[1].trim().starts_with(": ") {
449        return None;
450    }
451    let mut i = 2;
452    while i < lines.len() {
453        let tmp = lines[i].trim();
454        if tmp.starts_with(": ") {
455            i += 1;
456        } else if tmp.is_empty() {
457            return Some(i + 1);
458        } else {
459            return Some(i);
460        }
461    }
462    None
463}
464
465fn find_complete_html_block_end(lines: &[&str]) -> Option<usize> {
466    let first = lines[0].trim();
467    if !first.starts_with('<') {
468        return None;
469    }
470    let rest = &first[1..];
471    let tag_end = rest.find(|c: char| c == '>' || c.is_whitespace())?;
472    let tag = &rest[..tag_end];
473    let lower = tag.to_lowercase();
474    let valid = matches!(
475        lower.as_str(),
476        "div" | "pre" | "table" | "script" | "style" | "section"
477            | "article" | "nav" | "footer" | "header" | "aside" | "main"
478            | "blockquote" | "form" | "fieldset" | "details" | "dialog"
479            | "figure" | "figcaption" | "dl" | "ol" | "ul" | "h1" | "h2"
480            | "h3" | "h4" | "h5" | "h6"
481    );
482    if !valid {
483        return None;
484    }
485    let close = format!("</{}>", tag);
486    for (i, line) in lines.iter().enumerate().skip(1) {
487        if line.to_lowercase().contains(&close) {
488            return Some(i + 1);
489        }
490        if line.trim().is_empty() {
491            return Some(i + 1);
492        }
493    }
494    None
495}
496
497fn find_complete_indented_code_end(lines: &[&str]) -> Option<usize> {
498    let first = lines[0];
499    if !(first.starts_with("    ") || first.starts_with('\t') && first.len() > 1) {
500        return None;
501    }
502    for (i, l) in lines.iter().enumerate().skip(1) {
503        if l.starts_with("    ") || (l.starts_with('\t') && l.len() > 1) {
504            continue;
505        }
506        if l.is_empty() {
507            continue;
508        }
509        return Some(i);
510    }
511    None
512}
513
514fn find_complete_list_end(lines: &[&str]) -> Option<usize> {
515    let first = lines[0].trim();
516    let is_unordered = first.starts_with("* ") || first.starts_with("- ") || first.starts_with("+ ");
517    let is_task = first.starts_with("- [ ] ") || first.starts_with("- [x] ") || first.starts_with("- [X] ")
518        || first.starts_with("* [ ] ") || first.starts_with("* [x] ") || first.starts_with("* [X] ");
519    let is_ordered = first.find(". ").is_some_and(|pos| first[..pos].parse::<u64>().is_ok());
520
521    if !is_unordered && !is_task && !is_ordered {
522        return None;
523    }
524
525    for (i, tmp) in lines.iter().enumerate().skip(1) {
526        let tmp = tmp.trim();
527        if tmp.is_empty() {
528            return Some(i + 1);
529        }
530
531        if is_unordered || is_task {
532            let still_list = tmp.starts_with("* ") || tmp.starts_with("- ") || tmp.starts_with("+ ")
533                || (is_task && (tmp.starts_with("- [ ] ") || tmp.starts_with("- [x] ") || tmp.starts_with("- [X] ")
534                    || tmp.starts_with("* [ ] ") || tmp.starts_with("* [x] ") || tmp.starts_with("* [X] ")));
535            if !still_list {
536                return Some(i);
537            }
538        }
539        if is_ordered
540            && tmp.find(". ").is_none_or(|pos| tmp[..pos].parse::<u64>().is_err()) {
541                return Some(i);
542            }
543    }
544    None
545}
546
547fn find_complete_footnote_end(lines: &[&str]) -> Option<usize> {
548    let first = lines[0].trim();
549    if !first.starts_with("[^") {
550        return None;
551    }
552    let close_br = first.find("]:")?;
553    if close_br <= 2 {
554        return None;
555    }
556    for (i, tmp) in lines.iter().enumerate().skip(1) {
557        if tmp.trim().is_empty() {
558            // blank line ends footnote
559            return Some(i + 1);
560        }
561        if !tmp.starts_with("    ") {
562            return Some(i);
563        }
564    }
565    None
566}
567
568fn is_definition_list_term(line: &str) -> bool {
569    let l = line.trim();
570    !l.starts_with('#') && !l.starts_with('>') && !l.starts_with('|')
571        && !l.starts_with('-') && !l.starts_with('*') && !l.starts_with('`')
572        && !l.is_empty()
573}
574
575fn is_html_block_tag(line: &str) -> bool {
576    let l = line.trim();
577    if !l.starts_with('<') {
578        return false;
579    }
580    let rest = &l[1..];
581    let tag_end = rest.find(|c: char| c == '>' || c.is_whitespace());
582    let Some(tag_end) = tag_end else { return false };
583    let tag = &rest[..tag_end];
584    let lower = tag.to_lowercase();
585    matches!(
586        lower.as_str(),
587        "div" | "pre" | "table" | "script" | "style" | "section"
588            | "article" | "nav" | "footer" | "header" | "aside" | "main"
589            | "blockquote" | "form" | "fieldset" | "details" | "dialog"
590            | "figure" | "figcaption" | "dl" | "ol" | "ul" | "h1" | "h2"
591            | "h3" | "h4" | "h5" | "h6"
592    )
593}
594
595fn is_any_list_item(line: &str) -> bool {
596    let l = line.trim();
597    // Unordered
598    if l.starts_with("* ") || l.starts_with("- ") || l.starts_with("+ ") {
599        return true;
600    }
601    // Task
602    if l.starts_with("- [ ] ") || l.starts_with("- [x] ") || l.starts_with("- [X] ")
603        || l.starts_with("* [ ] ") || l.starts_with("* [x] ") || l.starts_with("* [X] ")
604    {
605        return true;
606    }
607    // Ordered
608    l.find(". ").is_some_and(|pos| l[..pos].parse::<u64>().is_ok())
609}
610
611fn is_footnote_line(line: &str) -> bool {
612    let l = line.trim();
613    if !l.starts_with("[^") {
614        return false;
615    }
616    let close = l.find("]:");
617    close.is_some_and(|c| c > 2)
618}
619
620#[cfg(test)]
621mod tests {
622    use super::*;
623
624    #[test]
625    fn test_split_at_blank_line() {
626        let (complete, remaining) = split_at_complete_boundary("hello\n\nworld");
627        assert_eq!(complete, "hello");
628        assert_eq!(remaining, "world");
629    }
630
631    #[test]
632    fn test_split_no_boundary() {
633        let (complete, remaining) = split_at_complete_boundary("hello world");
634        assert_eq!(complete, "");
635        assert_eq!(remaining, "hello world");
636    }
637
638    #[test]
639    fn test_split_trailing_newline() {
640        let (complete, remaining) = split_at_complete_boundary("hello\n");
641        assert_eq!(complete, "hello\n");
642        assert_eq!(remaining, "");
643    }
644
645    #[test]
646    fn test_split_complete_fenced_block() {
647        let input = "```rust\nlet x = 1;\n```\nsome text";
648        let (complete, remaining) = split_at_complete_boundary(input);
649        assert!(complete.contains("```"));
650        assert!(complete.contains("```"));
651        assert_eq!(remaining, "some text");
652    }
653
654    #[test]
655    fn test_split_incomplete_fenced_block() {
656        let input = "```rust\nlet x = 1;\nstill writing";
657        let (complete, remaining) = split_at_complete_boundary(input);
658        assert_eq!(complete, "");
659        assert_eq!(remaining, input);
660    }
661
662    #[test]
663    fn test_split_complete_table() {
664        let input = "| a | b |\n|---|---|\n| 1 | 2 |\nnext";
665        let (complete, remaining) = split_at_complete_boundary(input);
666        assert!(complete.contains("| a"));
667        assert!(!complete.ends_with('\n'));
668        assert_eq!(remaining, "next");
669    }
670
671    #[test]
672    fn test_split_complete_heading() {
673        let (complete, remaining) = split_at_complete_boundary("### Hello\nmore");
674        assert_eq!(complete, "### Hello\n");
675        assert_eq!(remaining, "more");
676    }
677
678    #[test]
679    fn test_stream_renderer_paragraph_then_flush() {
680        let mut sr = StreamRenderer::new(80, ThemeMode::Dark);
681        let lines = sr.push("Hello world.");
682        assert!(lines.is_empty(), "unterminated paragraph should buffer");
683        let remaining = sr.flush_remaining();
684        assert!(!remaining.is_empty());
685    }
686
687    #[test]
688    fn test_stream_renderer_incremental() {
689        let mut sr = StreamRenderer::new(80, ThemeMode::Dark);
690        let lines1 = sr.push("First paragraph.");
691        assert!(lines1.is_empty() || lines1.iter().any(|l| l.contains("First")));
692        let lines2 = sr.push("\n\nSecond paragraph.");
693        assert!(!lines2.is_empty());
694        let final_lines = sr.flush_remaining();
695        assert!(!final_lines.is_empty() || lines2.iter().any(|l| l.contains("Second")));
696    }
697
698    #[test]
699    fn test_stream_renderer_fenced_block() {
700        let mut sr = StreamRenderer::new(80, ThemeMode::Dark);
701        let lines1 = sr.push("```rust\nlet x = 1;\n```\n");
702        assert!(!lines1.is_empty());
703        let remaining = sr.flush_remaining();
704        assert!(remaining.is_empty());
705    }
706
707    #[test]
708    fn test_stream_renderer_table() {
709        let mut sr = StreamRenderer::new(80, ThemeMode::Dark);
710        sr.push("| a | b |\n|---|---|\n| 1 | 2 |\n");
711        let lines = sr.flush_remaining();
712        assert!(!lines.is_empty());
713        assert!(lines.iter().any(|l| l.contains('│') || l.contains('+')));
714    }
715
716    #[test]
717    fn test_stream_renderer_ascii_borders() {
718        let mut sr = StreamRenderer::new(80, ThemeMode::Dark).with_ascii_table_borders(true);
719        sr.push("| a | b |\n|---|---|\n| 1 | 2 |\n");
720        let lines = sr.flush_remaining();
721        assert!(lines.iter().any(|l| l.contains('+')));
722    }
723
724    #[test]
725    fn test_stream_renderer_code_theme() {
726        let mut sr = StreamRenderer::new(80, ThemeMode::Dark).with_code_theme("base16-ocean.dark");
727        let lines = sr.push("```rust\nlet x = 1;\n```\n");
728        assert!(!lines.is_empty());
729    }
730}