Preserve TUI markdown list spacing after code blocks (#19706)

## Why

Fixes #19702.

The TUI markdown renderer could visually attach the next list marker to
a fenced code block inside the previous list item, even when the source
markdown included a blank line before the next item. That made
block-heavy loose lists harder to read, while the desired behavior is
still to keep simple lists compact.

## What changed

- Track whether the current rendered list item contains a code block.
- Preserve one blank separator before the following list marker only
when the previous item contained a code block.
- Add regression coverage for both paths: code-block list items keep the
separator, and simple loose list items stay compact.

## Verification

- `cargo test -p codex-tui markdown_render`

I also manually verified that the bug exists before and is fixed after.

## Before
<img width="437" height="240" alt="Screenshot 2026-04-26 at 1 19 01 PM"
src="https://github.com/user-attachments/assets/3bc9d64d-2dba-40d9-9d6b-a1d0b3c0f728"
/>

## After
<img width="410" height="269" alt="Screenshot 2026-04-26 at 1 18 54 PM"
src="https://github.com/user-attachments/assets/19c15bee-da32-455e-a7cb-e05eb85f4ea0"
/>
This commit is contained in:
Eric Traut
2026-04-27 13:40:46 -07:00
committed by GitHub
parent 0bd25ab374
commit 52c06b8759
3 changed files with 84 additions and 0 deletions

View File

@@ -154,6 +154,8 @@ where
inline_styles: Vec<Style>,
indent_stack: Vec<IndentContext>,
list_indices: Vec<Option<u64>>,
list_needs_blank_before_next_item: Vec<bool>,
list_item_contains_code_block: Vec<bool>,
link: Option<LinkState>,
needs_newline: bool,
pending_marker_line: bool,
@@ -184,6 +186,8 @@ where
inline_styles: Vec::new(),
indent_stack: Vec::new(),
list_indices: Vec::new(),
list_needs_blank_before_next_item: Vec::new(),
list_item_contains_code_block: Vec::new(),
link: None,
needs_newline: false,
pending_marker_line: false,
@@ -292,6 +296,11 @@ where
TagEnd::CodeBlock => self.end_codeblock(),
TagEnd::List(_) => self.end_list(),
TagEnd::Item => {
if self.list_item_contains_code_block.pop().unwrap_or(false)
&& let Some(needs_blank) = self.list_needs_blank_before_next_item.last_mut()
{
*needs_blank = true;
}
self.indent_stack.pop();
self.pending_marker_line = false;
}
@@ -476,15 +485,26 @@ where
self.push_line(Line::default());
}
self.list_indices.push(index);
self.list_needs_blank_before_next_item.push(false);
}
fn end_list(&mut self) {
self.list_indices.pop();
self.list_needs_blank_before_next_item.pop();
self.needs_newline = true;
}
fn start_item(&mut self) {
if self
.list_needs_blank_before_next_item
.last_mut()
.map(std::mem::take)
.unwrap_or(false)
{
self.push_blank_line();
}
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
@@ -524,6 +544,9 @@ 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();

View File

@@ -15,6 +15,18 @@ fn render_markdown_text_for_cwd(input: &str, cwd: &Path) -> Text<'static> {
render_markdown_text_with_width_and_cwd(input, /*width*/ None, Some(cwd))
}
fn plain_lines(text: &Text<'_>) -> Vec<String> {
text.lines
.iter()
.map(|line| {
line.spans
.iter()
.map(|span| span.content.clone())
.collect::<String>()
})
.collect()
}
#[test]
fn empty() {
assert_eq!(render_markdown_text(""), Text::default());
@@ -1128,6 +1140,46 @@ fn code_block_inside_unordered_list_item_multiple_lines() {
assert_eq!(lines, vec!["- Item", "", " first", " second"]);
}
#[test]
fn list_item_after_code_block_keeps_blank_separator() {
let md = "1. First:\n\n ```rust\n fn first() {}\n ```\n\n2. Second:\n";
let text = render_markdown_text(md);
let lines = plain_lines(&text);
assert_eq!(
lines,
vec!["1. First:", "", " fn first() {}", "", "2. Second:"]
);
assert_snapshot!(
"list_item_after_code_block_keeps_blank_separator",
lines.join("\n")
);
}
#[test]
fn outer_list_item_after_nested_code_block_keeps_blank_separator() {
let md = "1. First:\n - Nested:\n\n ```rust\n fn first() {}\n ```\n\n2. Second:\n";
let text = render_markdown_text(md);
let lines = plain_lines(&text);
assert_eq!(
lines,
vec![
"1. First:",
" - Nested:",
"",
" fn first() {}",
"",
"2. Second:",
]
);
}
#[test]
fn list_item_after_simple_item_stays_compact() {
let md = "1. First\n\n2. Second\n";
let text = render_markdown_text(md);
assert_eq!(plain_lines(&text), vec!["1. First", "2. Second"]);
}
#[test]
fn markdown_render_complex_snapshot() {
let md = r#"# H1: Markdown Streaming Test

View File

@@ -0,0 +1,9 @@
---
source: tui/src/markdown_render_tests.rs
expression: "lines.join(\"\\n\")"
---
1. First:
fn first() {}
2. Second: