diff --git a/codex-rs/tui/src/history_cell/messages.rs b/codex-rs/tui/src/history_cell/messages.rs index 046d190434..19a6ec2903 100644 --- a/codex-rs/tui/src/history_cell/messages.rs +++ b/codex-rs/tui/src/history_cell/messages.rs @@ -283,16 +283,26 @@ impl AgentMessageCell { impl HistoryCell for AgentMessageCell { fn display_lines(&self, width: u16) -> Vec> { - adaptive_wrap_lines( - &self.lines, - RtOptions::new(width as usize) - .initial_indent(if self.is_first_line { - "• ".dim().into() - } else { - " ".into() - }) - .subsequent_indent(" ".into()), - ) + let mut wrapped = Vec::new(); + for (index, line) in self.lines.iter().enumerate() { + let initial_indent = if index == 0 && self.is_first_line { + "• ".dim().into() + } else { + " ".into() + }; + let mut subsequent_indent = Line::from(" "); + subsequent_indent + .spans + .extend(crate::insert_history::leading_whitespace_prefix(line).spans); + let line_wrapped = adaptive_wrap_line( + line, + RtOptions::new(width as usize) + .initial_indent(initial_indent) + .subsequent_indent(subsequent_indent), + ); + push_owned_lines(&line_wrapped, &mut wrapped); + } + wrapped } fn raw_lines(&self) -> Vec> { diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__streamed_agent_list_paragraph_preserves_item_indent_when_wrapped.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__streamed_agent_list_paragraph_preserves_item_indent_when_wrapped.snap new file mode 100644 index 0000000000..2fefd57ef9 --- /dev/null +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__streamed_agent_list_paragraph_preserves_item_indent_when_wrapped.snap @@ -0,0 +1,10 @@ +--- +source: tui/src/history_cell/tests.rs +expression: "lines.join(\"\\n\")" +--- +• 1. Correctness issue: server tool-search completions are + rejected. + + In next_prompt_suggestion.rs, ToolSearchCall records its + call id, but a paired output is ignored and suppresses + suggestions. diff --git a/codex-rs/tui/src/history_cell/tests.rs b/codex-rs/tui/src/history_cell/tests.rs index ca1bae863a..7f846d39fb 100644 --- a/codex-rs/tui/src/history_cell/tests.rs +++ b/codex-rs/tui/src/history_cell/tests.rs @@ -2294,6 +2294,30 @@ fn agent_markdown_cell_does_not_split_words_after_inline_markdown() { ); } +#[test] +fn streamed_agent_list_paragraph_preserves_item_indent_when_wrapped() { + let cell = AgentMessageCell::new( + vec![ + Line::from("1. Correctness issue: server tool-search completions are rejected."), + Line::default(), + Line::from( + " In next_prompt_suggestion.rs, ToolSearchCall records its call id, but a paired output is ignored and suppresses suggestions.", + ), + ], + /*is_first_line*/ true, + ); + + let lines = render_lines(&cell.display_lines(/*width*/ 64)); + assert!( + lines + .iter() + .filter(|line| line.contains("paired output") || line.contains("suggestions.")) + .all(|line| line.starts_with(" ")), + "expected all wrapped paragraph rows to retain the assistant gutter and list indent: {lines:?}", + ); + insta::assert_snapshot!(lines.join("\n")); +} + #[test] fn agent_markdown_cell_narrow_width_shows_prefix_only() { let source = "narrow width coverage\n"; diff --git a/codex-rs/tui/src/insert_history.rs b/codex-rs/tui/src/insert_history.rs index f2c64a1098..ede7d03c78 100644 --- a/codex-rs/tui/src/insert_history.rs +++ b/codex-rs/tui/src/insert_history.rs @@ -166,7 +166,7 @@ where Ok(()) } -fn leading_whitespace_prefix(line: &Line<'_>) -> Line<'static> { +pub(crate) fn leading_whitespace_prefix(line: &Line<'_>) -> Line<'static> { let mut spans = Vec::new(); for span in &line.spans { let prefix_end = span diff --git a/codex-rs/tui/src/markdown_render.rs b/codex-rs/tui/src/markdown_render.rs index d0c2b87d5f..75125a1ac1 100644 --- a/codex-rs/tui/src/markdown_render.rs +++ b/codex-rs/tui/src/markdown_render.rs @@ -336,7 +336,7 @@ where indent_stack: Vec, list_indices: Vec>, list_needs_blank_before_next_item: Vec, - list_item_contains_code_block: Vec, + list_item_start_line_counts: Vec, link: Option, needs_newline: bool, pending_marker_line: bool, @@ -370,7 +370,7 @@ where indent_stack: Vec::new(), list_indices: Vec::new(), list_needs_blank_before_next_item: Vec::new(), - list_item_contains_code_block: Vec::new(), + list_item_start_line_counts: Vec::new(), link: None, needs_newline: false, pending_marker_line: false, @@ -480,7 +480,9 @@ where TagEnd::CodeBlock => self.end_codeblock(), TagEnd::List(_) => self.end_list(), TagEnd::Item => { - if self.list_item_contains_code_block.pop().unwrap_or(false) + self.flush_current_line(); + let start_line_count = self.list_item_start_line_counts.pop().unwrap_or_default(); + if self.text.lines.len().saturating_sub(start_line_count) > 1 && let Some(needs_blank) = self.list_needs_blank_before_next_item.last_mut() { *needs_blank = true; @@ -737,8 +739,9 @@ where { self.push_blank_line(); } + self.flush_current_line(); + self.list_item_start_line_counts.push(self.text.lines.len()); self.pending_marker_line = true; - self.list_item_contains_code_block.push(false); let depth = self.list_indices.len(); let is_ordered = self .list_indices @@ -778,9 +781,6 @@ where } fn start_codeblock(&mut self, lang: Option, indent: Option>) { - for item_contains_code_block in &mut self.list_item_contains_code_block { - *item_contains_code_block = true; - } self.flush_current_line(); if !self.text.lines.is_empty() { self.push_blank_line(); diff --git a/codex-rs/tui/src/markdown_render_tests.rs b/codex-rs/tui/src/markdown_render_tests.rs index 2c6d41e333..d02fef76dd 100644 --- a/codex-rs/tui/src/markdown_render_tests.rs +++ b/codex-rs/tui/src/markdown_render_tests.rs @@ -531,6 +531,7 @@ fn nested_unordered_in_ordered() { Line::from_iter(["1. ".light_blue(), "Outer".into()]), Line::from_iter([" - ", "Inner A"]), Line::from_iter([" - ", "Inner B"]), + Line::default(), Line::from_iter(["2. ".light_blue(), "Next".into()]), ]); assert_eq!(text, expected); @@ -544,6 +545,7 @@ fn nested_ordered_in_unordered() { Line::from_iter(["- ", "Outer"]), Line::from_iter([" 1. ".light_blue(), "One".into()]), Line::from_iter([" 2. ".light_blue(), "Two".into()]), + Line::default(), Line::from_iter(["- ", "Last"]), ]); assert_eq!(text, expected); @@ -557,6 +559,7 @@ fn loose_list_item_multiple_paragraphs() { Line::from_iter(["1. ".light_blue(), "First paragraph".into()]), Line::default(), Line::from_iter([" ", "Second paragraph of same item"]), + Line::default(), Line::from_iter(["2. ".light_blue(), "Next item".into()]), ]); assert_eq!(text, expected); @@ -581,6 +584,7 @@ fn deeply_nested_mixed_three_levels() { Line::from_iter(["1. ".light_blue(), "A".into()]), Line::from_iter([" - ", "B"]), Line::from_iter([" 1. ".light_blue(), "C".into()]), + Line::default(), Line::from_iter(["2. ".light_blue(), "D".into()]), ]); assert_eq!(text, expected); @@ -1181,6 +1185,42 @@ fn list_item_after_simple_item_stays_compact() { assert_eq!(plain_lines(&text), vec!["1. First", "2. Second"]); } +#[test] +fn multiline_finding_items_are_separated_snapshot() { + let md = r#"**Findings** + +1. **Correctness issue: server tool-search completions are always rejected.** + + In `next_prompt_suggestion.rs`, the output is ignored, suppressing suggestions after completed searches. + + Minimal correction: count matching outputs and suppress only missing ones. + +2. **High-confidence simplification: remove the unused error channel.** + + The implementation resolves failures to `None`, so its contract can be narrower. + +3. **High-confidence churn reduction: consolidate table-driven filter tests.** +"#; + let text = render_markdown_text(md); + assert_snapshot!(plain_lines(&text).join("\n")); +} + +#[test] +fn wrapped_list_item_is_separated_from_next_sibling() { + let md = "1. This item wraps onto another visible rendered line\n2. Next item\n"; + let text = render_markdown_text_with_width(md, Some(/*width*/ 24)); + assert_eq!( + plain_lines(&text), + vec![ + "1. This item wraps onto", + " another visible", + " rendered line", + "", + "2. Next item", + ] + ); +} + #[test] fn mixed_url_markdown_wraps_prose_without_splitting_words_snapshot() { let md = "This paragraph keeps **strikethrough** intact near a [link](https://example.com/path) while enough surrounding prose forces wrapping."; @@ -1391,6 +1431,7 @@ fn nested_item_continuation_paragraph_is_indented() { Line::from_iter([" - ", "B"]), Line::default(), Line::from_iter([" ", "Continuation for B"]), + Line::default(), Line::from_iter(["2. ".light_blue(), "C".into()]), ]); assert_eq!(text, expected); diff --git a/codex-rs/tui/src/markdown_stream.rs b/codex-rs/tui/src/markdown_stream.rs index 66b5cab121..ad8cc68e7d 100644 --- a/codex-rs/tui/src/markdown_stream.rs +++ b/codex-rs/tui/src/markdown_stream.rs @@ -789,6 +789,7 @@ mod tests { "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(), diff --git a/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__markdown_render_complex_snapshot.snap b/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__markdown_render_complex_snapshot.snap index 34cbd1c63e..1e32d7c61d 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__markdown_render_complex_snapshot.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__markdown_render_complex_snapshot.snap @@ -16,6 +16,7 @@ Image: alt text - Unordered list item 1 - Nested bullet with italics inner + - Unordered list item 2 with strikethrough 1. Ordered item one diff --git a/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__multiline_finding_items_are_separated_snapshot.snap b/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__multiline_finding_items_are_separated_snapshot.snap new file mode 100644 index 0000000000..a74cf9f0d4 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__multiline_finding_items_are_separated_snapshot.snap @@ -0,0 +1,17 @@ +--- +source: tui/src/markdown_render_tests.rs +expression: "plain_lines(&text).join(\"\\n\")" +--- +Findings + +1. Correctness issue: server tool-search completions are always rejected. + + In next_prompt_suggestion.rs, the output is ignored, suppressing suggestions after completed searches. + + Minimal correction: count matching outputs and suppress only missing ones. + +2. High-confidence simplification: remove the unused error channel. + + The implementation resolves failures to None, so its contract can be narrower. + +3. High-confidence churn reduction: consolidate table-driven filter tests. diff --git a/codex-rs/tui/src/streaming/controller.rs b/codex-rs/tui/src/streaming/controller.rs index 0427af24c2..a653f1b210 100644 --- a/codex-rs/tui/src/streaming/controller.rs +++ b/codex-rs/tui/src/streaming/controller.rs @@ -1189,6 +1189,7 @@ mod tests { "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(),