diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index c2de6eb164..1287523712 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -4831,6 +4831,7 @@ mod tests { message: "tiny width coverage for wrapped user history".to_string(), text_elements: Vec::new(), local_image_paths: Vec::new(), + remote_image_urls: Vec::new(), }; let agent_message_cell = AgentMessageCell::new(vec!["tiny width agent line".into()], true); let reasoning_cell = ReasoningSummaryCell::new( @@ -4967,6 +4968,7 @@ mod tests { message: "hello".to_string(), text_elements: Vec::new(), local_image_paths: Vec::new(), + remote_image_urls: Vec::new(), }) as Arc; let head = Arc::new(AgentMessageCell::new(vec![Line::from("line 1")], true)) as Arc; diff --git a/codex-rs/tui/src/markdown_render.rs b/codex-rs/tui/src/markdown_render.rs index 53afbf59a5..51ab16ceed 100644 --- a/codex-rs/tui/src/markdown_render.rs +++ b/codex-rs/tui/src/markdown_render.rs @@ -1485,9 +1485,7 @@ where tag_start += 1; } - if bytes - .get(tag_start) - .is_some_and(u8::is_ascii_alphabetic) + if bytes.get(tag_start).is_some_and(u8::is_ascii_alphabetic) && bytes .get(tag_start + 1..) .is_some_and(|suffix| suffix.contains(&b'>')) diff --git a/codex-rs/tui/src/streaming/controller.rs b/codex-rs/tui/src/streaming/controller.rs index b98c65ffc1..af03237a5d 100644 --- a/codex-rs/tui/src/streaming/controller.rs +++ b/codex-rs/tui/src/streaming/controller.rs @@ -177,7 +177,13 @@ impl StreamCore { /// Batch drain: dequeue up to `max_lines`, update the emitted count. fn tick_batch(&mut self, max_lines: usize) -> Vec> { - let step = self.state.drain_n(max_lines.max(1)); + if max_lines == 0 { + return Vec::new(); + } + let step = self.state.drain_n(max_lines); + if step.is_empty() { + return step; + } let emitted_start = self.emitted_stable_len; self.emitted_stable_len += step.len(); self.advance_emitted_source_boundary(emitted_start, self.emitted_stable_len); @@ -205,6 +211,10 @@ impl StreamCore { self.rendered_lines[start..].to_vec() } + fn has_tail(&self) -> bool { + self.enqueued_stable_len < self.rendered_lines.len() + } + /// Update rendering width and rebuild queued stable lines for the new layout. /// /// Re-renders once at the new width and remaps already-emitted progress by @@ -550,7 +560,7 @@ impl StreamController { } pub(crate) fn has_live_tail(&self) -> bool { - !self.current_tail_lines().is_empty() + self.core.has_tail() } pub(crate) fn set_width(&mut self, width: Option) { @@ -1161,6 +1171,35 @@ mod tests { ); } + #[test] + fn controller_tick_batch_zero_is_noop() { + let mut ctrl = stream_controller(Some(80)); + assert!(ctrl.push("line one\n")); + assert_eq!(ctrl.queued_lines(), 1); + + let (cell, idle) = ctrl.on_commit_tick_batch(0); + assert!(cell.is_none(), "batch size 0 should not emit lines"); + assert!(!idle, "batch size 0 should not drain queued lines"); + assert_eq!( + ctrl.queued_lines(), + 1, + "queue depth should remain unchanged" + ); + } + + #[test] + fn controller_has_live_tail_reflects_tail_presence() { + let mut ctrl = stream_controller(Some(80)); + assert!(!ctrl.has_live_tail()); + + ctrl.core.rendered_lines = vec![Line::from("tail line")]; + ctrl.core.enqueued_stable_len = 0; + assert!(ctrl.has_live_tail()); + + ctrl.core.enqueued_stable_len = 1; + assert!(!ctrl.has_live_tail()); + } + #[test] fn controller_set_width_partial_drain_no_lost_lines() { let mut ctrl = stream_controller(Some(40)); @@ -1257,8 +1296,8 @@ mod tests { ); } - #[tokio::test] - async fn controller_loose_vs_tight_with_commit_ticks_matches_full() { + #[test] + fn controller_loose_vs_tight_with_commit_ticks_matches_full() { let mut ctrl = stream_controller(/*width*/ None); let mut lines = Vec::new(); @@ -1375,8 +1414,8 @@ mod tests { ); } - #[tokio::test] - async fn controller_streamed_table_matches_full_render_widths() { + #[test] + fn controller_streamed_table_matches_full_render_widths() { let deltas = vec![ "| Key | Description |\n", "| --- | --- |\n", @@ -1394,8 +1433,8 @@ mod tests { assert_eq!(streamed, expected); } - #[tokio::test] - async fn controller_holds_blockquoted_table_tail_until_stable() { + #[test] + fn controller_holds_blockquoted_table_tail_until_stable() { let deltas = vec![ "> | A | B |\n", "> | --- | --- |\n", @@ -1477,8 +1516,8 @@ mod tests { assert_eq!(streamed, expected); } - #[tokio::test] - async fn controller_does_not_hold_back_pipe_prose_without_table_delimiter() { + #[test] + fn controller_does_not_hold_back_pipe_prose_without_table_delimiter() { let mut ctrl = stream_controller(Some(80)); ctrl.push("status | owner | note\n"); @@ -1515,8 +1554,8 @@ mod tests { ); } - #[tokio::test] - async fn controller_handles_table_immediately_after_heading() { + #[test] + fn controller_handles_table_immediately_after_heading() { let deltas = vec![ "### 1) Basic table\n", "| Name | Role | Status |\n", @@ -1536,8 +1575,8 @@ mod tests { assert_eq!(streamed, expected); } - #[tokio::test] - async fn controller_renders_unicode_for_multi_table_response_shape() { + #[test] + fn controller_renders_unicode_for_multi_table_response_shape() { let source = "Absolutely. Here are several different Markdown table patterns you can use for rendering tests.\n\n| Name | Role | Location |\n|-------|-----------|----------|\n| Ava | Engineer | NYC |\n| Malik | Designer | Berlin |\n| Priya | PM | Remote |\n\n| Item | Qty | Price | In Stock |\n|:------------|----:|------:|:--------:|\n| Keyboard | 2 | 49.99 | Yes |\n| Mouse | 10 @@ -1558,8 +1597,8 @@ mod tests { ); } - #[tokio::test] - async fn controller_renders_unicode_for_no_outer_pipes_table_shape() { + #[test] + fn controller_renders_unicode_for_no_outer_pipes_table_shape() { let source = "### 1) Basic\n\n| Name | Role | Active |\n|---|---|---|\n| Alice | Engineer | Yes |\n| Bob | Designer | No |\n\n### 2) No outer pipes\n\nCol A | Col B | Col C\n--- | --- | ---\nx | y | z\n10 | 20 | 30\n\n### 3) Another table\n\n| Key | Value |\n|---|---|\n| a | b |\n"; @@ -1584,8 +1623,8 @@ mod tests { ); } - #[tokio::test] - async fn controller_stabilizes_first_no_outer_pipes_table_in_response() { + #[test] + fn controller_stabilizes_first_no_outer_pipes_table_in_response() { let deltas = vec![ "### No outer pipes first\n\n", "Col A | Col B | Col C\n", @@ -1615,8 +1654,8 @@ mod tests { ); } - #[tokio::test] - async fn controller_stabilizes_two_column_no_outer_table_in_response() { + #[test] + fn controller_stabilizes_two_column_no_outer_table_in_response() { let deltas = vec![ "A | B\n", "--- | ---\n", @@ -1642,8 +1681,8 @@ mod tests { ); } - #[tokio::test] - async fn controller_converts_no_outer_table_between_preboxed_sections() { + #[test] + fn controller_converts_no_outer_table_between_preboxed_sections() { let source = " ┌───────┬──────────┬────────┐\n │ Name │ Role │ Active │\n ├───────┼──────────┼────────┤\n │ Alice │ Engineer │ Yes │\n │ Bob │ Designer │ No │\n │ Cara │ PM │ Yes │\n └───────┴──────────┴────────┘\n\n ### 3) No outer pipes\n\n Col A | Col B | Col C\n --- | --- | ---\n x | y | z\n 10 | 20 | 30\n\n ┌─────────────────┬────────┬────────────────────────┐\n │ Example │ Output │ Notes │\n ├─────────────────┼────────┼────────────────────────┤\n │ a | b │ `a │ b` │\n │ npm run test │ ok │ Inline code formatting │\n │ SELECT * FROM t │ 3 rows │ SQL snippet │\n └─────────────────┴────────┴────────────────────────┘\n"; let deltas = source @@ -1670,8 +1709,8 @@ mod tests { ); } - #[tokio::test] - async fn controller_keeps_markdown_fenced_tables_mutable_until_finalize() { + #[test] + fn controller_keeps_markdown_fenced_tables_mutable_until_finalize() { let source = "```md\n| A | B |\n|---|---|\n| 1 | 2 |\n```\n"; let deltas = vec![ "```md\n", @@ -1697,8 +1736,8 @@ mod tests { ); } - #[tokio::test] - async fn controller_keeps_markdown_fenced_no_outer_tables_mutable_until_finalize() { + #[test] + fn controller_keeps_markdown_fenced_no_outer_tables_mutable_until_finalize() { let source = "```md\nCol A | Col B | Col C\n--- | --- | ---\nx | y | z\n10 | 20 | 30\n```\n"; let deltas = vec![ @@ -1728,8 +1767,8 @@ mod tests { ); } - #[tokio::test] - async fn controller_keeps_non_markdown_fenced_tables_as_code() { + #[test] + fn controller_keeps_non_markdown_fenced_tables_as_code() { let source = "```sh\n| A | B |\n|---|---|\n| 1 | 2 |\n```\n"; let deltas = vec![ "```sh\n", @@ -1755,8 +1794,8 @@ mod tests { ); } - #[tokio::test] - async fn plan_controller_streamed_table_matches_finalize_render() { + #[test] + fn plan_controller_streamed_table_matches_finalize_render() { let deltas = vec![ "## Build plan\n\n", "| Step | Owner |\n", @@ -1783,8 +1822,8 @@ mod tests { ); } - #[tokio::test] - async fn plan_controller_streamed_markdown_fenced_table_matches_finalize_render() { + #[test] + fn plan_controller_streamed_markdown_fenced_table_matches_finalize_render() { let deltas = vec![ "## Build plan\n\n", "```md\n",