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 — that's always safe.
166    // Paragraphs, headings, blockquotes, horizontal rules all end at blank lines.
167    if let Some(pos) = text.rfind("\n\n") {
168        return (text[..pos].to_string(), trim_leading_newlines(&text[pos + 2..]));
169    }
170
171    // Check for completed fenced code block (``` or ~~~).
172    let lines: Vec<&str> = text.lines().collect();
173    if lines.len() >= 2 {
174        let first = lines[0];
175        if (first.starts_with("```") || first.starts_with("~~~")) && first.len() >= 3 {
176            let fence = &first[..3];
177            for i in 1..lines.len() {
178                if lines[i].trim().starts_with(fence) && lines[i].trim().len() >= 3
179                    && lines[i].trim().chars().take(3).all(|c| c == fence.chars().next().unwrap())
180                {
181                    let end_pos = text
182                        .char_indices()
183                        .nth(text.lines().take(i + 1).map(|l| l.len() + 1).sum::<usize>().saturating_sub(1))
184                        .map(|(idx, _)| idx)
185                        .unwrap_or(text.len());
186                    return (text[..end_pos].to_string(), trim_leading_newlines(&text[end_pos..]));
187                }
188            }
189            // Fenced block started but not closed — buffer it entirely
190            return (String::new(), text.to_string());
191        }
192    }
193
194    // Check for table. A table has: header line, separator line, then rows.
195    // It's complete when a non-table, non-empty line appears after rows,
196    // or when it has at least one data row and the buffer ends.
197    if let Some(table_end) = find_complete_table_end(&lines) {
198        if table_end <= lines.len() {
199            let end_pos = if table_end == lines.len() {
200                text.len()
201            } else {
202                text
203                    .char_indices()
204                    .nth(text.lines().take(table_end).map(|l| l.len() + 1).sum::<usize>().saturating_sub(1))
205                    .map(|(idx, _)| idx)
206                    .unwrap_or(text.len())
207            };
208            return (text[..end_pos].to_string(), trim_leading_newlines(&text[end_pos..]));
209        }
210        return (String::new(), text.to_string());
211    }
212
213    // Guard: incomplete table — header + separator detected but find_complete_table_end
214    // found no terminator (blank line or non-table line). Buffer everything unconditionally.
215    if lines.len() >= 2
216        && lines[0].trim().starts_with('|')
217        && lines[0].trim().ends_with('|')
218        && lines[1].trim().starts_with('|')
219        && lines[1].trim().ends_with('|')
220    {
221        let sep = lines[1].trim();
222        let is_separator = sep
223            .chars()
224            .filter(|&c| c != ' ' && c != '|' && c != '-' && c != ':')
225            .count()
226            == 0;
227        if is_separator {
228            return (String::new(), text.to_string());
229        }
230    }
231
232    // Check for definition list — needs the term line + at least one ": " definition line
233    if let Some(def_end) = find_complete_definition_list_end(&lines) {
234        let end_pos = text
235            .char_indices()
236            .nth(text.lines().take(def_end).map(|l| l.len() + 1).sum::<usize>().saturating_sub(1))
237            .map(|(idx, _)| idx)
238            .unwrap_or(text.len());
239        return (text[..end_pos].to_string(), trim_leading_newlines(&text[end_pos..]));
240    }
241
242    // Guard: incomplete definition list — term present but no ": " definition line yet
243    if lines.len() >= 2
244        && is_definition_list_term(lines[0].trim())
245        && !lines[1].trim().starts_with(": ")
246    {
247        return (String::new(), text.to_string());
248    }
249
250    // Check for HTML block — starts with a tag like <div>, needs closing </div> or blank line
251    if let Some(html_end) = find_complete_html_block_end(&lines) {
252        let end_pos = text
253            .char_indices()
254            .nth(text.lines().take(html_end).map(|l| l.len() + 1).sum::<usize>().saturating_sub(1))
255            .map(|(idx, _)| idx)
256            .unwrap_or(text.len());
257        return (text[..end_pos].to_string(), trim_leading_newlines(&text[end_pos..]));
258    }
259
260    // Guard: incomplete HTML block — opening tag present but no closing tag or blank line
261    if is_html_block_tag(lines[0].trim()) {
262        return (String::new(), text.to_string());
263    }
264
265    // Check for indented code block — followed by non-indented, non-empty line or blank line
266    if let Some(code_end) = find_complete_indented_code_end(&lines) {
267        let end_pos = text
268            .char_indices()
269            .nth(text.lines().take(code_end).map(|l| l.len() + 1).sum::<usize>().saturating_sub(1))
270            .map(|(idx, _)| idx)
271            .unwrap_or(text.len());
272        return (text[..end_pos].to_string(), trim_leading_newlines(&text[end_pos..]));
273    }
274
275    // Guard: incomplete indented code block — first line is indented but no end marker yet
276    if (lines[0].starts_with("    ") || (lines[0].starts_with('\t') && lines[0].len() > 1))
277        && lines.len() == 1
278    {
279        return (String::new(), text.to_string());
280    }
281
282    // Check for complete lists (ordered, unordered, task) — a list ends when a non-list line appears
283    if let Some(list_end) = find_complete_list_end(&lines) {
284        let end_pos = text
285            .char_indices()
286            .nth(text.lines().take(list_end).map(|l| l.len() + 1).sum::<usize>().saturating_sub(1))
287            .map(|(idx, _)| idx)
288            .unwrap_or(text.len());
289        return (text[..end_pos].to_string(), trim_leading_newlines(&text[end_pos..]));
290    }
291
292    // Guard: incomplete list — first line is a list item but no terminator yet
293    if is_any_list_item(lines[0].trim()) {
294        return (String::new(), text.to_string());
295    }
296
297    // Check for footnote definitions — they can be multiline (continuation lines indented)
298    if let Some(fn_end) = find_complete_footnote_end(&lines) {
299        let end_pos = text
300            .char_indices()
301            .nth(text.lines().take(fn_end).map(|l| l.len() + 1).sum::<usize>().saturating_sub(1))
302            .map(|(idx, _)| idx)
303            .unwrap_or(text.len());
304        return (text[..end_pos].to_string(), trim_leading_newlines(&text[end_pos..]));
305    }
306
307    // Guard: incomplete footnote — [^label]: line present but continuation/content still arriving
308    if is_footnote_line(lines[0].trim()) {
309        return (String::new(), text.to_string());
310    }
311
312    // Single-line elements: headings, horizontal rules, blockquotes (single line), paragraphs
313    // If the last line is a heading or HR, emit everything
314    if let Some(last) = lines.last() {
315        let trimmed = last.trim();
316        if trimmed.starts_with('#') && trimmed.len() > 1 && trimmed.as_bytes().get(1) == Some(&b' ') {
317            // ATX heading — complete as a single line. Split off heading + preceding lines.
318            if lines.len() > 1 {
319                let end_pos = text
320                    .char_indices()
321                    .nth(text.lines().take(lines.len() - 1).map(|l| l.len() + 1).sum::<usize>().saturating_sub(1))
322                    .map(|(idx, _)| idx)
323                    .unwrap_or(text.len());
324                return (text[..end_pos].to_string(), text[end_pos..].to_string());
325            }
326            return (text.to_string(), String::new());
327        }
328        if trimmed == "---" || trimmed == "***" || trimmed == "___" {
329            return (text.to_string(), String::new());
330        }
331        if trimmed.starts_with('>') {
332            // Blockquote: emit everything before the blockquote line
333            if lines.len() > 1 {
334                let end_pos = text
335                    .char_indices()
336                    .nth(text.lines().take(lines.len() - 1).map(|l| l.len() + 1).sum::<usize>().saturating_sub(1))
337                    .map(|(idx, _)| idx)
338                    .unwrap_or(text.len());
339                return (text[..end_pos].to_string(), text[end_pos..].to_string());
340            }
341            return (text.to_string(), String::new());
342        }
343    }
344
345    // If the text ends with a newline, it's a complete paragraph or set of paragraphs
346    if text.ends_with('\n') {
347        return (text.to_string(), String::new());
348    }
349
350    // Scan backwards from the last \n to find a complete element boundary.
351    // If the preceding line looks standalone (heading, HR, blockquote), split there.
352    if let Some(last_nl) = text.rfind('\n') {
353        let prefix = &text[..last_nl];
354        let pre_lines: Vec<&str> = prefix.lines().collect();
355        if let Some(pre_last) = pre_lines.last() {
356            if is_standalone_line(pre_last) {
357                return (text[..last_nl + 1].to_string(), text[last_nl + 1..].to_string());
358            }
359        }
360    }
361
362    // Buffer the text — more may arrive that belongs to the same paragraph
363    (String::new(), text.to_string())
364}
365
366fn is_standalone_line(line: &str) -> bool {
367    let line = line.trim();
368    if line.starts_with('#') {
369        let level = line.chars().take_while(|&c| c == '#').count();
370        return level <= 6 && line.len() > level && line.as_bytes().get(level) == Some(&b' ');
371    }
372    line == "---" || line == "***" || line == "___" || line.starts_with('>')
373}
374
375fn trim_leading_newlines(s: &str) -> String {
376    s.trim_start_matches('\n').to_string()
377}
378
379fn find_complete_table_end(lines: &[&str]) -> Option<usize> {
380    if lines.len() < 2 {
381        return None;
382    }
383    let header = lines[0].trim();
384    let sep = lines[1].trim();
385    if !header.starts_with('|') || !header.ends_with('|')
386        || !sep.starts_with('|') || !sep.ends_with('|')
387    {
388        return None;
389    }
390    let is_sep = sep
391        .chars()
392        .filter(|&c| c != ' ' && c != '|' && c != '-' && c != ':')
393        .count()
394        == 0;
395    if !is_sep {
396        return None;
397    }
398    let header_cols = header.split('|').filter(|s| !s.is_empty()).count();
399    let mut data_row_count = 0;
400    for i in 2..lines.len() {
401        let tmp = lines[i].trim();
402        if tmp.is_empty() {
403            return Some(i + 1);
404        }
405        if !tmp.starts_with('|') || !tmp.ends_with('|') {
406            return Some(i);
407        }
408        let cols = tmp.split('|').filter(|s| !s.is_empty()).count();
409        if cols != header_cols {
410            return Some(i);
411        }
412        data_row_count += 1;
413    }
414    if data_row_count > 0 {
415        Some(lines.len())
416    } else {
417        None
418    }
419}
420
421fn find_complete_definition_list_end(lines: &[&str]) -> Option<usize> {
422    if lines.len() < 2 {
423        return None;
424    }
425    let first = lines[0].trim();
426    if first.starts_with('#') || first.starts_with('>') || first.starts_with('|')
427        || first.starts_with('-') || first.starts_with('*') || first.starts_with('`')
428        || first.is_empty()
429    {
430        return None;
431    }
432    if !lines[1].trim().starts_with(": ") {
433        return None;
434    }
435    let mut i = 2;
436    while i < lines.len() {
437        let tmp = lines[i].trim();
438        if tmp.starts_with(": ") {
439            i += 1;
440        } else if tmp.is_empty() {
441            return Some(i + 1);
442        } else {
443            return Some(i);
444        }
445    }
446    None
447}
448
449fn find_complete_html_block_end(lines: &[&str]) -> Option<usize> {
450    let first = lines[0].trim();
451    if !first.starts_with('<') {
452        return None;
453    }
454    let rest = &first[1..];
455    let tag_end = rest.find(|c: char| c == '>' || c.is_whitespace())?;
456    let tag = &rest[..tag_end];
457    let lower = tag.to_lowercase();
458    let valid = matches!(
459        lower.as_str(),
460        "div" | "pre" | "table" | "script" | "style" | "section"
461            | "article" | "nav" | "footer" | "header" | "aside" | "main"
462            | "blockquote" | "form" | "fieldset" | "details" | "dialog"
463            | "figure" | "figcaption" | "dl" | "ol" | "ul" | "h1" | "h2"
464            | "h3" | "h4" | "h5" | "h6"
465    );
466    if !valid {
467        return None;
468    }
469    let close = format!("</{}>", tag);
470    for i in 1..lines.len() {
471        if lines[i].to_lowercase().contains(&close) {
472            return Some(i + 1);
473        }
474        if lines[i].trim().is_empty() {
475            return Some(i + 1);
476        }
477    }
478    None
479}
480
481fn find_complete_indented_code_end(lines: &[&str]) -> Option<usize> {
482    let first = lines[0];
483    if !first.starts_with("    ") && !(first.starts_with('\t') && first.len() > 1) {
484        return None;
485    }
486    for i in 1..lines.len() {
487        let l = lines[i];
488        if l.starts_with("    ") || (l.starts_with('\t') && l.len() > 1) {
489            continue;
490        }
491        if l.is_empty() {
492            continue;
493        }
494        return Some(i);
495    }
496    None
497}
498
499fn find_complete_list_end(lines: &[&str]) -> Option<usize> {
500    let first = lines[0].trim();
501    let is_unordered = first.starts_with("* ") || first.starts_with("- ") || first.starts_with("+ ");
502    let is_task = first.starts_with("- [ ] ") || first.starts_with("- [x] ") || first.starts_with("- [X] ")
503        || first.starts_with("* [ ] ") || first.starts_with("* [x] ") || first.starts_with("* [X] ");
504    let is_ordered = first.find(". ").map_or(false, |pos| first[..pos].parse::<u64>().is_ok());
505
506    if !is_unordered && !is_task && !is_ordered {
507        return None;
508    }
509
510    for i in 1..lines.len() {
511        let tmp = lines[i].trim();
512        if tmp.is_empty() {
513            return Some(i + 1);
514        }
515
516        if is_unordered || is_task {
517            let still_list = tmp.starts_with("* ") || tmp.starts_with("- ") || tmp.starts_with("+ ")
518                || (is_task && (tmp.starts_with("- [ ] ") || tmp.starts_with("- [x] ") || tmp.starts_with("- [X] ")
519                    || tmp.starts_with("* [ ] ") || tmp.starts_with("* [x] ") || tmp.starts_with("* [X] ")));
520            if !still_list {
521                return Some(i);
522            }
523        }
524        if is_ordered {
525            if tmp.find(". ").map_or(true, |pos| tmp[..pos].parse::<u64>().is_err()) {
526                return Some(i);
527            }
528        }
529    }
530    None
531}
532
533fn find_complete_footnote_end(lines: &[&str]) -> Option<usize> {
534    let first = lines[0].trim();
535    if !first.starts_with("[^") {
536        return None;
537    }
538    let close_br = first.find("]:")?;
539    if close_br <= 2 {
540        return None;
541    }
542    for i in 1..lines.len() {
543        let tmp = lines[i];
544        if tmp.trim().is_empty() {
545            // blank line ends footnote
546            return Some(i + 1);
547        }
548        if !tmp.starts_with("    ") {
549            return Some(i);
550        }
551    }
552    None
553}
554
555fn is_definition_list_term(line: &str) -> bool {
556    let l = line.trim();
557    !l.starts_with('#') && !l.starts_with('>') && !l.starts_with('|')
558        && !l.starts_with('-') && !l.starts_with('*') && !l.starts_with('`')
559        && !l.is_empty()
560}
561
562fn is_html_block_tag(line: &str) -> bool {
563    let l = line.trim();
564    if !l.starts_with('<') {
565        return false;
566    }
567    let rest = &l[1..];
568    let tag_end = rest.find(|c: char| c == '>' || c.is_whitespace());
569    let Some(tag_end) = tag_end else { return false };
570    let tag = &rest[..tag_end];
571    let lower = tag.to_lowercase();
572    matches!(
573        lower.as_str(),
574        "div" | "pre" | "table" | "script" | "style" | "section"
575            | "article" | "nav" | "footer" | "header" | "aside" | "main"
576            | "blockquote" | "form" | "fieldset" | "details" | "dialog"
577            | "figure" | "figcaption" | "dl" | "ol" | "ul" | "h1" | "h2"
578            | "h3" | "h4" | "h5" | "h6"
579    )
580}
581
582fn is_any_list_item(line: &str) -> bool {
583    let l = line.trim();
584    // Unordered
585    if l.starts_with("* ") || l.starts_with("- ") || l.starts_with("+ ") {
586        return true;
587    }
588    // Task
589    if l.starts_with("- [ ] ") || l.starts_with("- [x] ") || l.starts_with("- [X] ")
590        || l.starts_with("* [ ] ") || l.starts_with("* [x] ") || l.starts_with("* [X] ")
591    {
592        return true;
593    }
594    // Ordered
595    l.find(". ").map_or(false, |pos| l[..pos].parse::<u64>().is_ok())
596}
597
598fn is_footnote_line(line: &str) -> bool {
599    let l = line.trim();
600    if !l.starts_with("[^") {
601        return false;
602    }
603    let close = l.find("]:");
604    close.map_or(false, |c| c > 2)
605}
606
607#[cfg(test)]
608mod tests {
609    use super::*;
610
611    #[test]
612    fn test_split_at_blank_line() {
613        let (complete, remaining) = split_at_complete_boundary("hello\n\nworld");
614        assert_eq!(complete, "hello");
615        assert_eq!(remaining, "world");
616    }
617
618    #[test]
619    fn test_split_no_boundary() {
620        let (complete, remaining) = split_at_complete_boundary("hello world");
621        assert_eq!(complete, "");
622        assert_eq!(remaining, "hello world");
623    }
624
625    #[test]
626    fn test_split_trailing_newline() {
627        let (complete, remaining) = split_at_complete_boundary("hello\n");
628        assert_eq!(complete, "hello\n");
629        assert_eq!(remaining, "");
630    }
631
632    #[test]
633    fn test_split_complete_fenced_block() {
634        let input = "```rust\nlet x = 1;\n```\nsome text";
635        let (complete, remaining) = split_at_complete_boundary(input);
636        assert!(complete.contains("```"));
637        assert!(complete.contains("```"));
638        assert_eq!(remaining, "some text");
639    }
640
641    #[test]
642    fn test_split_incomplete_fenced_block() {
643        let input = "```rust\nlet x = 1;\nstill writing";
644        let (complete, remaining) = split_at_complete_boundary(input);
645        assert_eq!(complete, "");
646        assert_eq!(remaining, input);
647    }
648
649    #[test]
650    fn test_split_complete_table() {
651        let input = "| a | b |\n|---|---|\n| 1 | 2 |\nnext";
652        let (complete, remaining) = split_at_complete_boundary(input);
653        assert!(complete.contains("| a"));
654        assert!(!complete.ends_with('\n'));
655        assert_eq!(remaining, "next");
656    }
657
658    #[test]
659    fn test_split_complete_heading() {
660        let (complete, remaining) = split_at_complete_boundary("### Hello\nmore");
661        assert_eq!(complete, "### Hello\n");
662        assert_eq!(remaining, "more");
663    }
664
665    #[test]
666    fn test_stream_renderer_paragraph_then_flush() {
667        let mut sr = StreamRenderer::new(80, ThemeMode::Dark);
668        let lines = sr.push("Hello world.");
669        assert!(lines.is_empty(), "unterminated paragraph should buffer");
670        let remaining = sr.flush_remaining();
671        assert!(!remaining.is_empty());
672    }
673
674    #[test]
675    fn test_stream_renderer_incremental() {
676        let mut sr = StreamRenderer::new(80, ThemeMode::Dark);
677        let lines1 = sr.push("First paragraph.");
678        assert!(lines1.is_empty() || lines1.iter().any(|l| l.contains("First")));
679        let lines2 = sr.push("\n\nSecond paragraph.");
680        assert!(!lines2.is_empty());
681        let final_lines = sr.flush_remaining();
682        assert!(!final_lines.is_empty() || lines2.iter().any(|l| l.contains("Second")));
683    }
684
685    #[test]
686    fn test_stream_renderer_fenced_block() {
687        let mut sr = StreamRenderer::new(80, ThemeMode::Dark);
688        let lines1 = sr.push("```rust\nlet x = 1;\n```\n");
689        assert!(!lines1.is_empty());
690        let remaining = sr.flush_remaining();
691        assert!(remaining.is_empty());
692    }
693
694    #[test]
695    fn test_stream_renderer_table() {
696        let mut sr = StreamRenderer::new(80, ThemeMode::Dark);
697        let lines = sr.push("| a | b |\n|---|---|\n| 1 | 2 |\n");
698        assert!(!lines.is_empty());
699        assert!(lines.iter().any(|l| l.contains('│') || l.contains('+')));
700    }
701
702    #[test]
703    fn test_stream_renderer_ascii_borders() {
704        let mut sr = StreamRenderer::new(80, ThemeMode::Dark).with_ascii_table_borders(true);
705        let lines = sr.push("| a | b |\n|---|---|\n| 1 | 2 |\n");
706        assert!(lines.iter().any(|l| l.contains('+')));
707    }
708
709    #[test]
710    fn test_stream_renderer_code_theme() {
711        let mut sr = StreamRenderer::new(80, ThemeMode::Dark).with_code_theme("base16-ocean.dark");
712        let lines = sr.push("```rust\nlet x = 1;\n```\n");
713        assert!(!lines.is_empty());
714    }
715}