diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index adaf159209..418c262abd 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -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(); diff --git a/codex-rs/tui/src/markdown_render/table.rs b/codex-rs/tui/src/markdown_render/table.rs index 93734d7155..a62600cf66 100644 --- a/codex-rs/tui/src/markdown_render/table.rs +++ b/codex-rs/tui/src/markdown_render/table.rs @@ -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('|') diff --git a/codex-rs/tui/src/markdown_render_tests.rs b/codex-rs/tui/src/markdown_render_tests.rs index 67370fccef..805f5aa81f 100644 --- a/codex-rs/tui/src/markdown_render_tests.rs +++ b/codex-rs/tui/src/markdown_render_tests.rs @@ -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()); diff --git a/codex-rs/tui/src/streaming/controller.rs b/codex-rs/tui/src/streaming/controller.rs index 996d3f0193..78296d3ff5 100644 --- a/codex-rs/tui/src/streaming/controller.rs +++ b/codex-rs/tui/src/streaming/controller.rs @@ -290,6 +290,9 @@ impl StreamController { } pub(crate) fn active_tail_cell(&self) -> Option> { + 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> { + 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::::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::::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:?}", ); }