#[cfg(test)]
use ratatui::text::Line;
use std::path::Path;
#[cfg(test)]
use std::path::PathBuf;
#[cfg(test)]
use crate::tui_internal::markdown;
pub(crate) struct MarkdownStreamCollector {
buffer: String,
committed_source_len: usize,
#[cfg(test)]
committed_line_count: usize,
width: Option<usize>,
#[cfg(test)]
cwd: PathBuf,
}
impl MarkdownStreamCollector {
pub fn new(width: Option<usize>, cwd: &Path) -> Self {
#[cfg(not(test))]
let _ = cwd;
Self {
buffer: String::new(),
committed_source_len: 0,
#[cfg(test)]
committed_line_count: 0,
width,
#[cfg(test)]
cwd: cwd.to_path_buf(),
}
}
pub fn set_width(&mut self, width: Option<usize>) {
self.width = width;
}
pub fn clear(&mut self) {
self.buffer.clear();
self.committed_source_len = 0;
#[cfg(test)]
{
self.committed_line_count = 0;
}
}
pub fn push_delta(&mut self, delta: &str) {
tracing::trace!("push_delta: {delta:?}");
self.buffer.push_str(delta);
}
pub fn commit_complete_source(&mut self) -> Option<std::ops::Range<usize>> {
let commit_end = self.buffer.rfind('\n').map(|idx| idx + 1)?;
let commit_start = self.committed_source_len;
if commit_end <= commit_start {
return None;
}
self.committed_source_len = commit_end;
Some(commit_start..commit_end)
}
pub fn committed_source(&self) -> &str {
&self.buffer[..self.committed_source_len]
}
pub fn finalize_and_take_source(&mut self) -> String {
let mut out = std::mem::take(&mut self.buffer);
if !out.is_empty() && !out.ends_with('\n') {
out.push('\n');
}
self.clear();
out
}
#[cfg(test)]
pub fn commit_complete_lines(&mut self) -> Vec<Line<'static>> {
let Some(commit_end) = self.buffer.rfind('\n').map(|idx| idx + 1) else {
return Vec::new();
};
if commit_end <= self.committed_source_len {
return Vec::new();
}
let source = self.buffer[..commit_end].to_string();
let mut rendered: Vec<Line<'static>> = Vec::new();
markdown::append_markdown(&source, self.width, Some(self.cwd.as_path()), &mut rendered);
let mut complete_line_count = rendered.len();
if complete_line_count > 0
&& crate::tui_internal::render::line_utils::is_blank_line_spaces_only(
&rendered[complete_line_count - 1],
)
{
complete_line_count -= 1;
}
if self.committed_line_count >= complete_line_count {
return Vec::new();
}
let out_slice = &rendered[self.committed_line_count..complete_line_count];
let out = out_slice.to_vec();
self.committed_source_len = commit_end;
self.committed_line_count = complete_line_count;
out
}
#[cfg(test)]
pub fn finalize_and_drain(&mut self) -> Vec<Line<'static>> {
let mut source = self.buffer.clone();
if source.is_empty() {
self.clear();
return Vec::new();
}
if !source.ends_with('\n') {
source.push('\n');
};
tracing::debug!(
raw_len = self.buffer.len(),
source_len = source.len(),
"markdown finalize (raw length: {}, rendered length: {})",
self.buffer.len(),
source.len()
);
tracing::trace!("markdown finalize (raw source):\n---\n{source}\n---");
let mut rendered: Vec<Line<'static>> = Vec::new();
markdown::append_markdown(&source, self.width, Some(self.cwd.as_path()), &mut rendered);
let out = if self.committed_line_count >= rendered.len() {
Vec::new()
} else {
rendered[self.committed_line_count..].to_vec()
};
self.clear();
out
}
}
#[cfg(test)]
fn test_cwd() -> PathBuf {
std::env::temp_dir()
}
#[cfg(test)]
pub(crate) fn simulate_stream_markdown_for_tests(
deltas: &[&str],
finalize: bool,
) -> Vec<Line<'static>> {
let mut collector = MarkdownStreamCollector::new( None, &test_cwd());
let mut out = Vec::new();
for d in deltas {
collector.push_delta(d);
if d.contains('\n') {
out.extend(collector.commit_complete_lines());
}
}
if finalize {
out.extend(collector.finalize_and_drain());
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::style::Color;
#[tokio::test]
async fn no_commit_until_newline() {
let mut c = super::MarkdownStreamCollector::new( None, &super::test_cwd());
c.push_delta("Hello, world");
let out = c.commit_complete_lines();
assert!(out.is_empty(), "should not commit without newline");
c.push_delta("!\n");
let out2 = c.commit_complete_lines();
assert_eq!(out2.len(), 1, "one completed line after newline");
}
#[tokio::test]
async fn finalize_commits_partial_line() {
let mut c = super::MarkdownStreamCollector::new( None, &super::test_cwd());
c.push_delta("Line without newline");
let out = c.finalize_and_drain();
assert_eq!(out.len(), 1);
}
#[tokio::test]
async fn e2e_stream_blockquote_simple_is_green() {
let out = super::simulate_stream_markdown_for_tests(&["> Hello\n"], true);
assert_eq!(out.len(), 1);
let l = &out[0];
assert_eq!(
l.style.fg,
Some(Color::Green),
"expected blockquote line fg green, got {:?}",
l.style.fg
);
}
#[tokio::test]
async fn e2e_stream_blockquote_nested_is_green() {
let out = super::simulate_stream_markdown_for_tests(
&["> Level 1\n>> Level 2\n"],
true,
);
let non_blank: Vec<_> = out
.into_iter()
.filter(|l| {
let s = l
.spans
.iter()
.map(|sp| sp.content.clone())
.collect::<Vec<_>>()
.join("");
let t = s.trim();
!(t.is_empty() || t == ">")
})
.collect();
assert_eq!(non_blank.len(), 2);
assert_eq!(non_blank[0].style.fg, Some(Color::Green));
assert_eq!(non_blank[1].style.fg, Some(Color::Green));
}
#[tokio::test]
async fn e2e_stream_blockquote_with_list_items_is_green() {
let out = super::simulate_stream_markdown_for_tests(
&["> - item 1\n> - item 2\n"],
true,
);
assert_eq!(out.len(), 2);
assert_eq!(out[0].style.fg, Some(Color::Green));
assert_eq!(out[1].style.fg, Some(Color::Green));
}
#[tokio::test]
async fn e2e_stream_nested_mixed_lists_ordered_marker_is_light_blue() {
let md = [
"1. First\n",
" - Second level\n",
" 1. Third level (ordered)\n",
" - Fourth level (bullet)\n",
" - Fifth level to test indent consistency\n",
];
let out = super::simulate_stream_markdown_for_tests(&md, true);
let find_idx = out.iter().position(|l| {
l.spans
.iter()
.map(|s| s.content.clone())
.collect::<String>()
.contains("Third level (ordered)")
});
let idx = find_idx.expect("expected third-level ordered line");
let line = &out[idx];
let has_light_blue = line
.spans
.iter()
.any(|s| s.style.fg == Some(ratatui::style::Color::LightBlue));
assert!(
has_light_blue,
"expected an ordered-list marker span with light blue fg on: {line:?}"
);
}
#[tokio::test]
async fn e2e_stream_blockquote_wrap_preserves_green_style() {
let long = "> This is a very long quoted line that should wrap across multiple columns to verify style preservation.";
let out = super::simulate_stream_markdown_for_tests(&[long, "\n"], true);
let wrapped = crate::tui_internal::wrapping::word_wrap_lines(
out.iter(),
crate::tui_internal::wrapping::RtOptions::new( 24),
);
let non_blank: Vec<_> = wrapped
.into_iter()
.filter(|l| {
let s = l
.spans
.iter()
.map(|sp| sp.content.clone())
.collect::<Vec<_>>()
.join("");
!s.trim().is_empty()
})
.collect();
assert!(
non_blank.len() >= 2,
"expected wrapped blockquote to span multiple lines"
);
for (i, l) in non_blank.iter().enumerate() {
assert_eq!(
l.spans[0].style.fg,
Some(Color::Green),
"wrapped line {} should preserve green style, got {:?}",
i,
l.spans[0].style.fg
);
}
}
#[tokio::test]
async fn heading_starts_on_new_line_when_following_paragraph() {
let mut c = super::MarkdownStreamCollector::new( None, &super::test_cwd());
c.push_delta("Hello.\n");
let out1 = c.commit_complete_lines();
let s1: Vec<String> = out1
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.clone())
.collect::<Vec<_>>()
.join("")
})
.collect();
assert_eq!(
out1.len(),
1,
"first commit should contain only the paragraph line, got {}: {:?}",
out1.len(),
s1
);
c.push_delta("## Heading\n");
let out2 = c.commit_complete_lines();
let s2: Vec<String> = out2
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.clone())
.collect::<Vec<_>>()
.join("")
})
.collect();
assert_eq!(
s2,
vec!["", "## Heading"],
"expected a blank separator then the heading line"
);
let line_to_string = |l: &ratatui::text::Line<'_>| -> String {
l.spans
.iter()
.map(|s| s.content.clone())
.collect::<Vec<_>>()
.join("")
};
assert_eq!(line_to_string(&out1[0]), "Hello.");
assert_eq!(line_to_string(&out2[1]), "## Heading");
}
#[tokio::test]
async fn heading_not_inlined_when_split_across_chunks() {
let mut c = super::MarkdownStreamCollector::new( None, &super::test_cwd());
c.push_delta("Sounds good!");
assert!(c.commit_complete_lines().is_empty());
c.push_delta("\n## Adding Bird subcommand");
let out1 = c.commit_complete_lines();
let s1: Vec<String> = out1
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.clone())
.collect::<Vec<_>>()
.join("")
})
.collect();
assert_eq!(
s1,
vec!["Sounds good!"],
"expected paragraph followed by blank separator before heading chunk"
);
c.push_delta("\n");
let out2 = c.commit_complete_lines();
let s2: Vec<String> = out2
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.clone())
.collect::<Vec<_>>()
.join("")
})
.collect();
assert_eq!(
s2,
vec!["", "## Adding Bird subcommand"],
"expected the heading line only on the final commit"
);
let mut rendered: Vec<ratatui::text::Line<'static>> = Vec::new();
let test_cwd = super::test_cwd();
crate::tui_internal::markdown::append_markdown(
"Hello.\n",
None,
Some(test_cwd.as_path()),
&mut rendered,
);
let rendered_strings: Vec<String> = rendered
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.clone())
.collect::<Vec<_>>()
.join("")
})
.collect();
assert_eq!(
rendered_strings,
vec!["Hello."],
"unexpected markdown lines: {rendered_strings:?}"
);
}
fn lines_to_plain_strings(lines: &[ratatui::text::Line<'_>]) -> Vec<String> {
lines
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.clone())
.collect::<Vec<_>>()
.join("")
})
.collect()
}
#[tokio::test]
async fn table_header_commits_without_holdback() {
let mut c = super::MarkdownStreamCollector::new( None, &super::test_cwd());
c.push_delta("| A | B |\n");
let out1 = c.commit_complete_lines();
let out1_str = lines_to_plain_strings(&out1);
assert_eq!(out1_str, vec!["| A | B |".to_string()]);
c.push_delta("| --- | --- |\n");
let out = c.commit_complete_lines();
let out_str = lines_to_plain_strings(&out);
assert!(
!out_str.is_empty(),
"expected output to continue committing after delimiter: {out_str:?}"
);
c.push_delta("| 1 | 2 |\n");
let out2 = c.commit_complete_lines();
assert!(
!out2.is_empty(),
"expected output to continue committing after body row"
);
c.push_delta("\n");
let _ = c.commit_complete_lines();
}
#[tokio::test]
async fn pipe_text_without_table_prefix_is_not_delayed() {
let mut c = super::MarkdownStreamCollector::new( None, &super::test_cwd());
c.push_delta("Escaped pipe in text: a | b | c\n");
let out = c.commit_complete_lines();
let out_str = lines_to_plain_strings(&out);
assert_eq!(out_str, vec!["Escaped pipe in text: a | b | c".to_string()]);
}
#[tokio::test]
async fn lists_and_fences_commit_without_duplication() {
assert_streamed_equals_full(&["- a\n- ", "b\n- c\n"]).await;
assert_streamed_equals_full(&["```", "\nco", "de 1\ncode 2\n", "```\n"]).await;
}
#[tokio::test]
async fn utf8_boundary_safety_and_wide_chars() {
let input = "🙂🙂🙂\n汉字漢字\nA\u{0003}0\u{0304}\n";
let deltas = vec![
"🙂",
"🙂",
"🙂\n汉",
"字漢",
"字\nA",
"\u{0003}",
"0",
"\u{0304}",
"\n",
];
let streamed = simulate_stream_markdown_for_tests(&deltas, true);
let streamed_str = lines_to_plain_strings(&streamed);
let mut rendered_all: Vec<ratatui::text::Line<'static>> = Vec::new();
let test_cwd = super::test_cwd();
crate::tui_internal::markdown::append_markdown(
input,
None,
Some(test_cwd.as_path()),
&mut rendered_all,
);
let rendered_all_str = lines_to_plain_strings(&rendered_all);
assert_eq!(
streamed_str, rendered_all_str,
"utf8/wide-char streaming should equal full render without duplication or truncation"
);
}
#[tokio::test]
async fn e2e_stream_deep_nested_third_level_marker_is_light_blue() {
let md = "1. First\n - Second level\n 1. Third level (ordered)\n - Fourth level (bullet)\n - Fifth level to test indent consistency\n";
let streamed = super::simulate_stream_markdown_for_tests(&[md], true);
let streamed_strs = lines_to_plain_strings(&streamed);
let target_suffix = "1. Third level (ordered)";
let mut found = None;
for line in &streamed {
let s: String = line.spans.iter().map(|sp| sp.content.clone()).collect();
if s.contains(target_suffix) {
found = Some(line.clone());
break;
}
}
let line = found.unwrap_or_else(|| {
panic!("expected to find the third-level ordered list line; got: {streamed_strs:?}")
});
assert!(
!line.spans.is_empty(),
"expected non-empty spans for the third-level line"
);
let marker_span = &line.spans[0];
assert_eq!(
marker_span.style.fg,
Some(Color::LightBlue),
"expected LightBlue 3rd-level ordered marker, got {:?}",
marker_span.style.fg
);
let mut content_fg = None;
for sp in &line.spans[1..] {
let t = sp.content.trim();
if !t.is_empty() {
content_fg = Some(sp.style.fg);
break;
}
}
assert_eq!(
content_fg.flatten(),
None,
"expected default color for 3rd-level content, got {content_fg:?}"
);
}
#[tokio::test]
async fn empty_fenced_block_is_dropped_and_separator_preserved_before_heading() {
let deltas = vec!["```bash\n```\n", "## Heading\n"]; let streamed = simulate_stream_markdown_for_tests(&deltas, true);
let texts = lines_to_plain_strings(&streamed);
assert!(
texts.iter().all(|s| !s.contains("```")),
"no fence markers expected: {texts:?}"
);
assert!(
texts.iter().any(|s| s == "## Heading"),
"expected heading line: {texts:?}"
);
}
#[tokio::test]
async fn paragraph_then_empty_fence_then_heading_keeps_heading_on_new_line() {
let deltas = vec!["Para.\n", "```\n```\n", "## Title\n"]; let streamed = simulate_stream_markdown_for_tests(&deltas, true);
let texts = lines_to_plain_strings(&streamed);
let para_idx = match texts.iter().position(|s| s == "Para.") {
Some(i) => i,
None => panic!("para present"),
};
let head_idx = match texts.iter().position(|s| s == "## Title") {
Some(i) => i,
None => panic!("heading present"),
};
assert!(
head_idx > para_idx,
"heading should not merge with paragraph: {texts:?}"
);
}
#[tokio::test]
async fn loose_list_with_split_dashes_matches_full_render() {
let deltas = vec!["- item.\n\n", "-"];
let streamed = simulate_stream_markdown_for_tests(&deltas, true);
let streamed_strs = lines_to_plain_strings(&streamed);
let full: String = deltas.iter().copied().collect();
let mut rendered_all: Vec<ratatui::text::Line<'static>> = Vec::new();
let test_cwd = super::test_cwd();
crate::tui_internal::markdown::append_markdown(
&full,
None,
Some(test_cwd.as_path()),
&mut rendered_all,
);
let rendered_all_strs = lines_to_plain_strings(&rendered_all);
assert_eq!(
streamed_strs, rendered_all_strs,
"streamed output should match full render without dangling '-' lines"
);
}
#[tokio::test]
async fn loose_vs_tight_list_items_streaming_matches_full() {
let deltas = vec![
"\n\n",
"Loose",
" vs",
".",
" tight",
" list",
" items",
":\n",
"1",
".",
" Tight",
" item",
"\n",
"2",
".",
" Another",
" tight",
" item",
"\n\n",
"1",
".",
" Loose",
" item",
" with",
" its",
" own",
" paragraph",
".\n\n",
" ",
" This",
" paragraph",
" belongs",
" to",
" the",
" same",
" list",
" item",
".\n\n",
"2",
".",
" Second",
" loose",
" item",
" with",
" a",
" nested",
" list",
" after",
" a",
" blank",
" line",
".\n\n",
" ",
" -",
" Nested",
" bullet",
" under",
" a",
" loose",
" item",
"\n",
" ",
" -",
" Another",
" nested",
" bullet",
"\n\n",
];
let streamed = simulate_stream_markdown_for_tests(&deltas, true);
let streamed_strs = lines_to_plain_strings(&streamed);
let full: String = deltas.iter().copied().collect();
let mut rendered_all: Vec<ratatui::text::Line<'static>> = Vec::new();
let test_cwd = super::test_cwd();
crate::tui_internal::markdown::append_markdown(
&full,
None,
Some(test_cwd.as_path()),
&mut rendered_all,
);
let expected = vec![
"Loose vs. tight list items:".to_string(),
"".to_string(),
"1. Tight item".to_string(),
"2. Another tight item".to_string(),
"3. Loose item with its own paragraph.".to_string(),
"".to_string(),
" This paragraph belongs to the same list item.".to_string(),
"".to_string(),
"4. Second loose item with a nested list after a blank line.".to_string(),
" - Nested bullet under a loose item".to_string(),
" - Another nested bullet".to_string(),
];
assert_eq!(
streamed_strs, expected,
"expected exact rendered lines for loose/tight section"
);
}
async fn assert_streamed_equals_full(deltas: &[&str]) {
let streamed = simulate_stream_markdown_for_tests(deltas, true);
let streamed_strs = lines_to_plain_strings(&streamed);
let full: String = deltas.iter().copied().collect();
let mut rendered: Vec<ratatui::text::Line<'static>> = Vec::new();
let test_cwd = super::test_cwd();
crate::tui_internal::markdown::append_markdown(
&full,
None,
Some(test_cwd.as_path()),
&mut rendered,
);
let rendered_strs = lines_to_plain_strings(&rendered);
assert_eq!(streamed_strs, rendered_strs, "full:\n---\n{full}\n---");
}
#[tokio::test]
async fn fuzz_class_bullet_duplication_variant_1() {
assert_streamed_equals_full(&[
"aph.\n- let one\n- bull",
"et two\n\n second paragraph \n",
])
.await;
}
#[tokio::test]
async fn fuzz_class_bullet_duplication_variant_2() {
assert_streamed_equals_full(&[
"- e\n c",
"e\n- bullet two\n\n second paragraph in bullet two\n",
])
.await;
}
#[tokio::test]
async fn streaming_html_block_then_text_matches_full() {
assert_streamed_equals_full(&[
"HTML block:\n",
"<div>inline block</div>\n",
"more stuff\n",
])
.await;
}
#[tokio::test]
async fn table_like_lines_inside_fenced_code_are_not_held() {
assert_streamed_equals_full(&["```\n", "| a | b |\n", "```\n"]).await;
}
#[tokio::test]
async fn collector_source_chunks_round_trip_into_agent_fence_unwrapping() {
let deltas = [
"```md\n",
"| A | B |\n",
"|---|---|\n",
"| 1 | 2 |\n",
"```\n",
];
let mut collector =
super::MarkdownStreamCollector::new( None, &super::test_cwd());
let mut committed_source = String::new();
for delta in deltas {
collector.push_delta(delta);
if delta.contains('\n')
&& let Some(range) = collector.commit_complete_source()
{
committed_source.push_str(&collector.committed_source()[range]);
}
}
assert_eq!(collector.committed_source(), committed_source);
let raw_source = collector.finalize_and_take_source();
let mut rendered = Vec::new();
crate::tui_internal::markdown::append_markdown_agent(&raw_source, None, &mut rendered);
let rendered_strs = lines_to_plain_strings(&rendered);
assert!(
rendered_strs.iter().any(|line| line.contains('━')),
"expected markdown-fenced table to render with a separator: {rendered_strs:?}"
);
assert!(
!rendered_strs.iter().any(|line| line.trim() == "| A | B |"),
"did not expect raw table header after markdown-fence unwrapping: {rendered_strs:?}"
);
}
#[test]
fn finalizing_empty_collector_returns_empty_source() {
let mut collector =
super::MarkdownStreamCollector::new( None, &super::test_cwd());
assert_eq!(collector.finalize_and_take_source(), String::new());
}
}