mirror of
https://github.com/openai/codex.git
synced 2026-06-01 19:02:59 +00:00
fix(tui): improve multiline markdown list readability (#24351)
## Why Numbered Markdown findings become hard to scan when long items visually run together or when wrapped explanatory paragraphs lose their list indentation. This is especially visible in review output: the next number can look attached to the previous finding, and paragraph continuation rows can jump back toward the left margin instead of staying grouped beneath their item. <table><tr><td> <center>Before</center> <img width="1718" height="836" alt="CleanShot 2026-05-24 at 14 00 49" src="https://github.com/user-attachments/assets/f1ee0023-50fa-4f81-a641-ae08b17b99bd" /> </td></tr> <tr><td> <center>After</center> <img width="1714" height="906" alt="image" src="https://github.com/user-attachments/assets/b123a5e0-a232-47bf-96d5-c935295f7c0a" /> </td></tr> </table> ## What Changed - Insert a blank separator before a sibling list item when the previous item occupies more than one rendered line. - Preserve compact rendering for lists whose sibling items each render on one line. - Preserve list-body leading whitespace when transient streamed assistant rows require another wrapping pass for history display, so wrapped paragraphs stay aligned beneath their item. - Share the existing leading-whitespace prefix logic used by history insertion instead of introducing a second indentation rule. - Keep streamed Markdown output aligned with completed rendering and add snapshots for findings-style spacing and streamed paragraph indentation. ## How to Test 1. Start Codex from this branch and open the recorded repro session `019e563f-7d58-7ff2-8ec7-828f20fa61ca`. 2. Inspect the numbered `Findings` list whose items contain explanatory paragraphs. 3. Confirm each multiline finding is separated from the next numbered finding by one blank line. 4. Confirm wrapped rows of each indented paragraph remain aligned beneath the finding body, rather than returning to the left edge. 5. Render a short one-line numbered or unordered list and confirm its items remain compact without added blank rows. Targeted tests: - `just test -p codex-tui history_cell insert_history markdown_render markdown_stream streaming::controller` - `just argument-comment-lint-from-source -p codex-tui` ## Related Work PR #24346 changes Markdown table column allocation in parallel. This PR is intentionally limited to list-item readability and history wrapping; both branches touch `codex-rs/tui/src/markdown_render.rs`, so a small merge conflict may need resolution depending on merge order.
This commit is contained in:
@@ -283,16 +283,26 @@ impl AgentMessageCell {
|
||||
|
||||
impl HistoryCell for AgentMessageCell {
|
||||
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
|
||||
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<Line<'static>> {
|
||||
|
||||
@@ -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.
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -336,7 +336,7 @@ where
|
||||
indent_stack: Vec<IndentContext>,
|
||||
list_indices: Vec<Option<u64>>,
|
||||
list_needs_blank_before_next_item: Vec<bool>,
|
||||
list_item_contains_code_block: Vec<bool>,
|
||||
list_item_start_line_counts: Vec<usize>,
|
||||
link: Option<LinkState>,
|
||||
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<String>, indent: Option<Span<'static>>) {
|
||||
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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user