fix(tui): preserve streamed markdown order

This commit is contained in:
Felipe Coury
2026-04-29 16:22:14 -03:00
parent 0df9a141a5
commit ef3c451543
4 changed files with 173 additions and 2 deletions

View File

@@ -4289,10 +4289,19 @@ impl ChatWidget {
scope,
now,
);
let emitted_cells = !outcome.cells.is_empty();
for cell in outcome.cells {
self.bottom_pane.hide_status_indicator();
self.add_boxed_history(cell);
}
if emitted_cells {
if self.stream_controller.is_some() {
self.sync_agent_stream_active_tail();
}
if self.plan_stream_controller.is_some() {
self.sync_plan_stream_active_tail();
}
}
if outcome.has_controller && outcome.all_idle {
self.maybe_restore_status_indicator_after_stream_idle();

View File

@@ -106,8 +106,22 @@ pub(super) fn normalize_table_boundaries(input: &str) -> Cow<'_, str> {
let mut out = String::with_capacity(input.len());
let mut changed = false;
let mut index = 0;
let mut code_fence: Option<(char, usize)> = None;
while index < lines.len() {
if index + 1 < lines.len()
if let Some(fence) = code_fence {
out.push_str(lines[index]);
if is_closing_code_fence(lines[index], fence) {
code_fence = None;
}
index += 1;
} else if let Some(fence) = opening_code_fence(lines[index]) {
code_fence = Some(fence);
out.push_str(lines[index]);
index += 1;
} else if is_indented_code_line(lines[index]) {
out.push_str(lines[index]);
index += 1;
} else if index + 1 < lines.len()
&& is_table_row_source(lines[index])
&& is_table_delimiter_source(lines[index + 1])
{
@@ -137,6 +151,47 @@ pub(super) fn normalize_table_boundaries(input: &str) -> Cow<'_, str> {
}
}
fn opening_code_fence(line: &str) -> Option<(char, usize)> {
let trimmed = strip_fence_indent(line)?;
let mut chars = trimmed.chars();
let marker = chars.next()?;
if marker != '`' && marker != '~' {
return None;
}
let marker_count = 1 + chars.take_while(|ch| *ch == marker).count();
(marker_count >= 3).then_some((marker, marker_count))
}
fn is_closing_code_fence(line: &str, (marker, opening_count): (char, usize)) -> bool {
let Some(trimmed) = strip_fence_indent(line) else {
return false;
};
let marker_count = trimmed.chars().take_while(|ch| *ch == marker).count();
marker_count >= opening_count
&& trimmed[marker.len_utf8() * marker_count..]
.trim()
.is_empty()
}
fn strip_fence_indent(line: &str) -> Option<&str> {
let mut spaces = 0usize;
for (index, ch) in line.char_indices() {
if ch != ' ' {
return (spaces <= 3).then_some(&line[index..]);
}
spaces += 1;
if spaces > 3 {
return None;
}
}
Some("")
}
fn is_indented_code_line(line: &str) -> bool {
line.starts_with(" ") || line.starts_with('\t')
}
fn is_table_row_source(line: &str) -> bool {
let trimmed = line.trim();
!trimmed.is_empty() && trimmed.contains('|')

View File

@@ -127,6 +127,38 @@ fn table_inline_links_and_html_breaks_stay_inside_table() {
);
}
#[test]
fn table_boundary_normalization_does_not_mutate_code_blocks() {
let markdown = "```\n| A | B |\n| --- | --- |\n```\nAfter.\n";
let rendered = render_markdown_text(markdown);
assert_eq!(
rendered,
Text::from_iter([
Line::from_iter(["", "| A | B |"]),
Line::from_iter(["", "| --- | --- |"]),
Line::default(),
Line::from("After."),
]),
);
}
#[test]
fn table_boundary_normalization_does_not_mutate_indented_code_blocks() {
let markdown = " | A | B |\n | --- | --- |\nAfter.\n";
let rendered = render_markdown_text(markdown);
assert_eq!(
rendered,
Text::from_iter([
Line::from_iter([" ", "| A | B |"]),
Line::from_iter([" ", "| --- | --- |"]),
Line::default(),
Line::from("After."),
]),
);
}
#[test]
fn empty() {
assert_eq!(render_markdown_text(""), Text::default());

View File

@@ -290,6 +290,9 @@ impl StreamController {
}
pub(crate) fn active_tail_cell(&self) -> Option<Box<dyn HistoryCell>> {
if self.core.queued_lines() > 0 {
return None;
}
let lines = self.core.active_tail_lines();
if lines.is_empty() {
return None;
@@ -396,6 +399,9 @@ impl PlanStreamController {
}
pub(crate) fn active_tail_cell(&self) -> Option<Box<dyn HistoryCell>> {
if self.core.queued_lines() > 0 {
return None;
}
let lines = self.core.active_tail_lines();
if lines.is_empty() {
return None;
@@ -675,10 +681,79 @@ mod tests {
ctrl.queued_lines() > 0,
"table should enter stable queue after a later block appears"
);
assert_eq!(
active_tail_plain_strings(&ctrl),
Vec::<String>::new(),
"later block should wait behind the queued stable table",
);
let (_cell, idle) = ctrl.on_commit_tick_batch(/*max_lines*/ usize::MAX);
assert!(idle);
let new_tail = active_tail_plain_strings(&ctrl);
assert!(
new_tail.iter().any(|line| line.contains("After table.")),
"later block should become new active tail: {new_tail:?}",
"later block should become active tail after queued table drains: {new_tail:?}",
);
}
#[test]
fn active_tail_waits_for_queued_stable_blocks() {
let mut ctrl = stream_controller(/*width*/ Some(80));
assert!(ctrl.push("first\n\nsecond\n"));
assert_eq!(
active_tail_plain_strings(&ctrl),
Vec::<String>::new(),
"new tail must not render ahead of queued stable content",
);
let (cell, idle) = ctrl.on_commit_tick();
let emitted = lines_to_plain_strings(
&cell
.expect("expected queued stable block to emit first")
.transcript_lines(u16::MAX),
);
assert_eq!(emitted, vec!["• first"]);
assert!(idle);
assert_eq!(active_tail_plain_strings(&ctrl), vec![" second"]);
}
#[test]
fn plan_active_tail_waits_for_queued_stable_blocks() {
let mut ctrl = plan_stream_controller(/*width*/ Some(80));
assert!(ctrl.push("first\n\nsecond\n"));
assert!(
ctrl.active_tail_cell().is_none(),
"new plan tail must not render ahead of queued stable content",
);
let (cell, idle) = ctrl.on_commit_tick();
let emitted = lines_to_plain_strings(
&cell
.expect("expected queued stable plan block to emit first")
.transcript_lines(u16::MAX),
);
assert!(
emitted.iter().any(|line| line.contains("Proposed Plan"))
&& emitted.iter().any(|line| line.contains("first")),
"first plan block should emit before active tail: {emitted:?}",
);
assert!(idle);
let tail = lines_to_plain_strings(
&ctrl
.active_tail_cell()
.expect("expected active tail after queue drains")
.transcript_lines(u16::MAX),
);
assert!(
tail.iter().all(|line| !line.contains("Proposed Plan"))
&& tail.iter().any(|line| line.contains("second")),
"tail should continue the existing plan cell without a duplicate header: {tail:?}",
);
}