Render TUI Markdown lists with Unicode bullets and checkboxes (#47322)

## What changed

Render unordered bullets as `•` and task markers as `☐` or `☑`, preserving ordered numbering and continuation indentation in streaming and completed Markdown. Add `tui.rendering.lists`, enabled by default; setting it to `false` retains hyphen bullets and textual task markers.

## Testing

Add snapshot coverage for nested, ordered, quoted, and wrapped task lists, plus code and escaped markers. Add character-by-character streaming tests that compare incremental and emitted output with full rendering, with list rendering enabled and disabled.

GitOrigin-RevId: df9209d8c42f277dd2a2d7073ea19a6feb7dd706
This commit is contained in:
Eric Traut
2026-09-22 16:34:07 +00:00
committed by copyberry
parent 13a966fc65
commit 5a11c45606
23 changed files with 385 additions and 100 deletions
+3
View File
@@ -15,6 +15,8 @@ pub struct TuiRendering {
pub math: bool,
/// Render pipe tables, including tables inside Markdown fences.
pub tables: bool,
/// Render Markdown bullets and task-list markers as Unicode symbols.
pub lists: bool,
}
impl Default for TuiRendering {
@@ -23,6 +25,7 @@ impl Default for TuiRendering {
mermaid: true,
math: true,
tables: true,
lists: true,
}
}
}
+2 -1
View File
@@ -107,7 +107,7 @@ fn memories_version_selects_pipeline_without_changing_other_defaults() {
#[test]
fn rendering_preferences_default_individually_and_ignore_animation_switch() {
for key in ["mermaid", "math", "tables"] {
for key in ["mermaid", "math", "tables", "lists"] {
let tui: Tui =
toml::from_str(&format!("animations = false\n[rendering]\n{key} = false\n")).unwrap();
assert_eq!(
@@ -116,6 +116,7 @@ fn rendering_preferences_default_individually_and_ignore_animation_switch() {
mermaid: key != "mermaid",
math: key != "math",
tables: key != "tables",
lists: key != "lists",
}
);
}
+6
View File
@@ -4513,6 +4513,7 @@
}
],
"default": {
"lists": true,
"math": true,
"mermaid": true,
"tables": true
@@ -5656,6 +5657,11 @@
"additionalProperties": false,
"description": "Optional rich renderers. Disabled renderers preserve the original source.",
"properties": {
"lists": {
"default": true,
"description": "Render Markdown bullets and task-list markers as Unicode symbols.",
"type": "boolean"
},
"math": {
"default": true,
"description": "Render math expressions using Unicode notation.",
@@ -153,10 +153,10 @@ async fn list_spacing_completion_preserves_the_scrolled_reader() -> Result<()> {
let mut app = crate::app::test_support::make_test_app().await;
app.transcript_cells = vec![Arc::new(history_cell::AgentMessageCell::new(
vec![
"- First item wraps onto".into(),
"• First item wraps onto".into(),
" a second row".into(),
"- b".into(),
"- c".into(),
"• b".into(),
"• c".into(),
],
/*is_first_line*/ true,
))];
@@ -174,7 +174,7 @@ async fn list_spacing_completion_preserves_the_scrolled_reader() -> Result<()> {
.scroll(&app.transcript_cells, /*rows*/ 3);
app.transcript_view
.render(area, &mut before, &app.transcript_cells);
assert!(buffer_text(&before).contains("- c"));
assert!(buffer_text(&before).contains("• c"));
app.handle_consolidate_agent_message(
&mut tui,
"- First item wraps onto a second row\n- b\n- c".into(),
@@ -13,10 +13,10 @@ expression: normalize_agent_center_snapshot(&cached)
" │ Last message "
" │ ## Findings "
" │ "
" │ - Fixed parsing and tokens with a long "
" │ • Fixed parsing and tokens with a long "
" │ explanation that wraps. "
" │ "
" │ - Kept compatibility. "
" │ • Kept compatibility. "
" │ "
" │ let token = 1; "
" │ "
+1 -1
View File
@@ -37,5 +37,5 @@ fn finalized_plan_file_citation_renders_as_local_path_snapshot() {
let rendered = ratatui::text::Text::from(plan.display_lines(/*width*/ 80));
insta::assert_snapshot!(rendered, @"• Proposed Plan\n \n \n - Quarterly Report.xlsx");
insta::assert_snapshot!(rendered, @"• Proposed Plan\n \n \n • Quarterly Report.xlsx");
}
@@ -7,8 +7,8 @@ rich before:
› Please format this
for copying
• - first item
- second item
• • first item
• second item
Col Value
━━━━━━ ━━━━━━━
@@ -43,8 +43,8 @@ rich after:
› Please format this
for copying
• - first item
- second item
• • first item
• second item
Col Value
━━━━━━ ━━━━━━━
+2
View File
@@ -108,6 +108,7 @@ shimmer = false
mermaid = false
math = false
tables = false
lists = false
[history]
persistence = "none"
max_bytes = 4096
@@ -146,6 +147,7 @@ fast_default_opt_out = true
mermaid: false,
math: false,
tables: false,
lists: false,
};
expected.show_tooltips = false;
expected.show_server_version_notice = false;
+61 -8
View File
@@ -1,7 +1,7 @@
//! Low-level markdown event renderer for the TUI transcript.
//!
//! This module consumes `pulldown-cmark` events and emits styled `ratatui`
//! lines, including table layout, Mermaid previews, width-aware wrapping, and local file-link
//! lines, including table layout, task-list checkboxes, Mermaid previews, wrapping, and local file-link
//! display. It is the final rendering stage used by higher-level helpers in
//! `markdown.rs`. Launch-time `tui.rendering` preferences preserve disabled features as source.
//!
@@ -82,6 +82,7 @@ pub(crate) mod preferences;
mod source_tables;
mod streaming;
mod table_key_value;
mod task_lists;
mod web_links;
use file_citations::FileCitations;
@@ -95,6 +96,9 @@ pub(crate) use streaming::render_streaming_markdown_lines_with_width_and_cwd;
#[cfg(test)]
#[path = "markdown_render/list_spacing_tests.rs"]
mod list_spacing_tests;
#[cfg(test)]
#[path = "markdown_render/task_list_tests.rs"]
mod task_list_tests;
pub(crate) use web_links::hide_web_link_destination;
use web_links::style_bare_web_urls;
@@ -367,6 +371,7 @@ pub(crate) fn render_markdown_lines_with_width_cwd_and_hidden_link_destinations(
let mut options = Options::empty();
options.insert(Options::ENABLE_STRIKETHROUGH);
options.insert(Options::ENABLE_TABLES);
options.set(Options::ENABLE_TASKLISTS, preferences::current().lists);
let math = math::MathMarkdown::new(input, options, width);
let parser = DecodedTextMerge::new(source_tables::preserve(
input,
@@ -419,6 +424,7 @@ where
link: Option<LinkState>,
needs_newline: bool,
pending_marker_line: bool,
last_task_marker_end: usize,
in_paragraph: bool,
in_code_block: bool,
code_block_lang: Option<String>,
@@ -463,6 +469,7 @@ where
link: None,
needs_newline: false,
pending_marker_line: false,
last_task_marker_end: 0,
in_paragraph: false,
in_code_block: false,
code_block_lang: None,
@@ -514,7 +521,16 @@ where
Event::Html(html) => self.html(html, /*inline*/ false),
Event::InlineHtml(html) => self.html(html, /*inline*/ true),
Event::FootnoteReference(_) => {}
Event::TaskListMarker(_) => {}
Event::TaskListMarker(checked) => {
// The parser can emit a recovered empty item's marker in a later paragraph.
if range.start >= self.last_task_marker_end {
self.last_task_marker_end = range.end;
self.task_list_marker(checked);
if self.current_line_content.is_none() {
self.push_line(Line::default());
}
}
}
}
}
@@ -553,7 +569,39 @@ where
self.start_codeblock(lang, indent)
}
Tag::List(start) => self.start_list(start),
Tag::Item => self.start_item(),
Tag::Item => {
self.start_item();
if let Some((next, next_range)) = self.iter.next() {
// Recover markers consumed without an event before block content or empty items.
let content_start = if matches!(next, Event::End(TagEnd::Item)) {
range.end
} else {
next_range.start
};
if preferences::current().lists
&& let Some(prefix) = self.input.get(range.start..content_start)
&& let Some(prefix) = prefix.lines().next()
&& let Some((_, marker)) = prefix.trim().split_once(char::is_whitespace)
&& let Some(checked) = match marker.trim() {
"[ ]" => Some(false),
"[x]" | "[X]" => Some(true),
_ => None,
}
{
self.last_task_marker_end = content_start;
self.task_list_marker(checked);
if matches!(
next,
Event::Start(Tag::List(_) | Tag::HtmlBlock)
| Event::End(TagEnd::Item)
| Event::Rule
) {
self.push_line(Line::default());
}
}
self.handle_event(next, next_range);
}
}
Tag::Emphasis => self.push_inline_style(self.styles.emphasis),
Tag::Strong => self.push_inline_style(self.styles.strong),
Tag::Strikethrough => self.push_inline_style(self.styles.strikethrough),
@@ -892,7 +940,12 @@ where
let marker = if let Some(last_index) = self.list_indices.last_mut() {
match last_index {
None => Some(vec![Span::styled(
" ".repeat(width - 1) + "- ",
" ".repeat(width - 1)
+ if preferences::current().lists {
"• "
} else {
"- "
},
self.styles.unordered_list_marker,
)]),
Some(index) => {
@@ -2272,7 +2325,7 @@ mod tests {
let lines = lines_to_strings(&rendered);
assert_eq!(
lines,
vec!["- first second".to_string(), " third fourth".to_string(),]
vec!["• first second".to_string(), " third fourth".to_string(),]
);
}
@@ -2285,10 +2338,10 @@ mod tests {
assert_eq!(
lines,
vec![
"- outer item with".to_string(),
"• outer item with".to_string(),
" several words to".to_string(),
" wrap".to_string(),
" - inner item".to_string(),
" • inner item".to_string(),
" that also".to_string(),
" needs wrapping".to_string(),
]
@@ -2344,7 +2397,7 @@ mod tests {
assert_eq!(
lines,
vec![
"- list item".to_string(),
"• list item".to_string(),
" > block quote inside".to_string(),
" > list that wraps".to_string(),
]
@@ -12,6 +12,7 @@ fn disabled_renderers_preserve_source_independently() {
"```mermaid\nflowchart LR\nA[Request] --> B[Reply]\n```\n",
"Inline $\\alpha_1$ and \\(x^{2}\\).\n\n\\[\n\\frac{a}{b}\n\\]\n",
"| **Name** | Value |\n|---|---|\n| `a` | path\\_1 &amp; \\* |\n",
"- [ ] Pending\n- [x] Done\n- Ordinary\n",
];
let defaults = samples.map(render_markdown_text);
let mut snapshot = Vec::new();
@@ -28,11 +29,21 @@ fn disabled_renderers_preserve_source_independently() {
tables: false,
..Default::default()
},
TuiRendering {
lists: false,
..Default::default()
},
] {
init(rendering);
for (index, source) in samples.iter().enumerate() {
let rendered = render_markdown_text(source);
if [rendering.mermaid, rendering.math, rendering.tables][index] {
if [
rendering.mermaid,
rendering.math,
rendering.tables,
rendering.lists,
][index]
{
assert_eq!(rendered, defaults[index]);
} else {
snapshot.push(format!("{rendering:?}\n{rendered}"));
@@ -24,86 +24,86 @@ Uniform:
---
Compact:
- Parent
- Short
- Small
- Other
• Parent
• Short
• Small
• Other
> - Quote
> - This quoted item has
> • Quote
> • This quoted item has
> enough words to wrap
> - End
> • End
---
Uniform:
- Parent
- Short
- Small
• Parent
• Short
• Small
- Other
• Other
> - Quote
> • Quote
>
> - This quoted item has
> • This quoted item has
> enough words to wrap
>
> - End
> • End
---
Compact:
- First paragraph
• First paragraph
Second paragraph
- Next
- Last
• Next
• Last
---
Uniform:
- First paragraph
• First paragraph
Second paragraph
- Next
• Next
- Last
• Last
---
Compact:
- One
-
• One
•
code
- Three
• Three
after paragraph
---
Uniform:
- One
• One
-
•
code
- Three
• Three
after paragraph
---
Compact:
- https://example.com/a-very-long-path-with-many-segments
- Short
- End
• https://example.com/a-very-long-path-with-many-segments
• Short
• End
---
Uniform:
- https://example.com/a-very-long-path-with-many-segments
• https://example.com/a-very-long-path-with-many-segments
- Short
• Short
- End
• End
@@ -14,7 +14,7 @@ expression: "markdown_text(source, 60)"
> │ ├◄────┘
> └─────────┘
- Diagram:
• Diagram:
┌───┐ ┌───┐
│ A │ │ B │
@@ -1,15 +1,14 @@
---
source: tui/src/markdown_render/preferences_tests.rs
assertion_line: 34
expression: "snapshot.join(\"\\n\\n---\\n\\n\")"
---
TuiRendering { mermaid: false, math: true, tables: true }
TuiRendering { mermaid: false, math: true, tables: true, lists: true }
flowchart LR
A[Request] --> B[Reply]
---
TuiRendering { mermaid: true, math: false, tables: true }
TuiRendering { mermaid: true, math: false, tables: true, lists: true }
Inline $\alpha_1$ and \(x^{2}\).
\[
@@ -18,7 +17,14 @@ Inline $\alpha_1$ and \(x^{2}\).
---
TuiRendering { mermaid: true, math: true, tables: false }
TuiRendering { mermaid: true, math: true, tables: false, lists: true }
| **Name** | Value |
|---|---|
| `a` | path\_1 &amp; \* |
---
TuiRendering { mermaid: true, math: true, tables: true, lists: false }
- [ ] Pending
- [x] Done
- Ordinary
@@ -0,0 +1,73 @@
---
source: tui/src/markdown_render/task_list_tests.rs
expression: "stages.join(\"\\n\\n---\\n\\n\")"
---
☐ Unchecked
☑ Checked
☑ Uppercase
• Ordinary
---
☐ Parent task with
enough words to wrap
☑ Nested task
• Ordinary child
☑ Done
---
9. ☐ Numbered task with
enough words to
wrap
10. ☑ Done
11. Ordinary
---
> ☐ Quoted task with
> enough words to wrap
>
> ☑ Done
---
☐ First paragraph
Second paragraph
☑ Next task
---
• [ ]
• [x]
---
☑ Bold, italic, and code
- [x] inline
- [x] fenced
[ ] Plain text
• [x] Escaped
---
☐
• child
☑ # title
☑ > quote
☑
———
---
•
[x] code, not a task
---
☑ <b>done</b>
• Parent
☑
Continued paragraph
---
☑ <div>done</div>
---
☑ <div>done</div>
---
> • Parent
> ☑
>
> Continued paragraph
@@ -46,6 +46,10 @@ pub(crate) fn render_streaming_markdown_lines_with_width_and_cwd(
let mut options = Options::empty();
options.insert(Options::ENABLE_STRIKETHROUGH);
options.insert(Options::ENABLE_TABLES);
options.set(
Options::ENABLE_TASKLISTS,
super::preferences::current().lists,
);
let citations = FileCitations::new(input, options);
let math = MathMarkdown::new(&citations.markdown, options, width);
let parser = Parser::new_ext(&math.markdown, options);
@@ -0,0 +1,43 @@
//! Task markers share list indentation in streaming and completed Markdown.
use super::ListSpacing;
use super::render_markdown_text_with_width;
use super::render_streaming_markdown_lines_with_width_and_cwd;
use pretty_assertions::assert_eq;
use ratatui::text::Text;
#[test]
fn lists_preserve_layout() {
let sources = [
"- [ ] Unchecked\n- [x] Checked\n- [X] Uppercase\n- Ordinary\n",
"- [ ] Parent task with enough words to wrap\n - [x] Nested task\n - Ordinary child\n- [x] Done\n",
"9. [ ] Numbered task with enough words to wrap\n10. [x] Done\n11. Ordinary\n",
"> - [ ] Quoted task with enough words to wrap\n> - [x] Done\n",
"- [ ] First paragraph\n\n Second paragraph\n\n- [x] Next task\n",
"- [ ]\n- [x]\n",
"- [x] **Bold**, *italic*, and `code`\n\n`- [x] inline`\n\n```text\n- [x] fenced\n```\n\n[ ] Plain text\n\n- \\[x] Escaped\n",
"- [ ] \n - child\n\n- [x] # title\n\n- [x] > quote\n\n- [x] ---\n",
"- [x] code, not a task\n",
"- [x] <b>done</b>\n\n- Parent\n - [x] \n\n Continued paragraph\n",
"- [x] <div>done</div>\n",
"- [x] \n <div>done</div>\n",
"> - Parent\n> - [x] \n>\n> Continued paragraph\n",
];
let mut stages = Vec::new();
for source in sources {
let rendered = render_markdown_text_with_width(source, Some(24));
let streamed = render_streaming_markdown_lines_with_width_and_cwd(
source,
Some(24),
/*cwd*/ None,
&|_| false,
ListSpacing::AfterMultiline,
);
assert_eq!(
Text::from(crate::terminal_hyperlinks::visible_lines(streamed.lines)),
rendered
);
stages.push(rendered.to_string());
}
insta::assert_snapshot!(stages.join("\n---\n"));
}
@@ -0,0 +1,36 @@
//! Task-list checkboxes replace unordered bullets and retain ordered numbering.
//! Keep the hanging indent aligned with the task text, including in loose lists.
use super::Writer;
use pulldown_cmark::Event;
use ratatui::text::Span;
use std::ops::Range;
impl<'a, 'policy, I> Writer<'a, 'policy, I>
where
I: Iterator<Item = (Event<'a>, Range<usize>)>,
{
pub(super) fn task_list_marker(&mut self, checked: bool) {
let Some(context) = self
.indent_stack
.iter_mut()
.rev()
.find(|context| context.is_list)
else {
return;
};
if let Some(marker) = context.marker.as_mut()
&& let Some(span) = marker.last_mut()
{
let prefix = span.content.strip_suffix("• ").unwrap_or(&span.content);
let checkbox = if checked { "☑" } else { "☐" };
span.content = format!("{prefix}{checkbox} ").into();
context.prefix = vec![Span::from(" ".repeat(Self::spans_display_width(marker)))];
}
if self.current_line_content.is_some() {
// Loose lists start their paragraph before the task marker arrives.
self.current_initial_indent = self.prefix_spans(/*pending_marker_line*/ true);
self.current_subsequent_indent = self.prefix_spans(/*pending_marker_line*/ false);
}
}
}
+31 -31
View File
@@ -236,8 +236,8 @@ fn blockquote_with_list_items() {
let md = "> - item 1\n> - item 2\n";
let text = render_markdown_text(md);
let expected = Text::from_iter([
Line::from_iter(["> ", "- ", "item 1"]).green(),
Line::from_iter(["> ", "- ", "item 2"]).green(),
Line::from_iter(["> ", "• ", "item 1"]).green(),
Line::from_iter(["> ", "• ", "item 2"]).green(),
]);
assert_eq!(text, expected);
}
@@ -268,7 +268,7 @@ fn blockquote_list_then_nested_blockquote() {
let md = "> - parent\n> > child\n";
let text = render_markdown_text(md);
let expected = Text::from_iter([
Line::from_iter(["> ", "- ", "parent"]).green(),
Line::from_iter(["> ", "• ", "parent"]).green(),
Line::from_iter(["> ", " ", "> ", "child"]).green(),
]);
assert_eq!(text, expected);
@@ -346,7 +346,7 @@ fn blockquote_in_unordered_list_on_next_line() {
.collect::<String>()
})
.collect();
assert_eq!(lines, vec!["- > quoted".to_string()]);
assert_eq!(lines, vec!["• > quoted".to_string()]);
}
#[test]
@@ -389,7 +389,7 @@ fn blockquote_inside_nested_list() {
.collect::<String>()
})
.collect();
assert_eq!(lines, vec!["1. A", " - B", " > inner"]);
assert_eq!(lines, vec!["1. A", " • B", " > inner"]);
}
#[test]
@@ -566,7 +566,7 @@ fn nested_blockquote_with_inline_and_fenced_code() {
#[test]
fn list_unordered_single() {
let text = render_markdown_text("- List item 1\n");
let expected = Text::from_iter([Line::from_iter(["- ", "List item 1"])]);
let expected = Text::from_iter([Line::from_iter(["• ", "List item 1"])]);
assert_eq!(text, expected);
}
@@ -574,8 +574,8 @@ fn list_unordered_single() {
fn list_unordered_multiple() {
let text = render_markdown_text("- List item 1\n- List item 2\n");
let expected = Text::from_iter([
Line::from_iter(["- ", "List item 1"]),
Line::from_iter(["- ", "List item 2"]),
Line::from_iter(["• ", "List item 1"]),
Line::from_iter(["• ", "List item 2"]),
]);
assert_eq!(text, expected);
}
@@ -594,8 +594,8 @@ fn list_ordered() {
fn list_nested() {
let text = render_markdown_text("- List item 1\n - Nested list item 1\n");
let expected = Text::from_iter([
Line::from_iter(["- ", "List item 1"]),
Line::from_iter([" - ", "Nested list item 1"]),
Line::from_iter(["• ", "List item 1"]),
Line::from_iter([" • ", "Nested list item 1"]),
]);
assert_eq!(text, expected);
}
@@ -616,8 +616,8 @@ fn nested_unordered_in_ordered() {
let text = render_markdown_text(md);
let expected = Text::from_iter([
Line::from_iter(["1. ".fg(accent_color()), "Outer".into()]),
Line::from_iter([" - ", "Inner A"]),
Line::from_iter([" - ", "Inner B"]),
Line::from_iter([" • ", "Inner A"]),
Line::from_iter([" • ", "Inner B"]),
Line::default(),
Line::from_iter(["2. ".fg(accent_color()), "Next".into()]),
]);
@@ -629,11 +629,11 @@ fn nested_ordered_in_unordered() {
let md = "- Outer\n 1. One\n 2. Two\n- Last\n";
let text = render_markdown_text(md);
let expected = Text::from_iter([
Line::from_iter(["- ", "Outer"]),
Line::from_iter(["• ", "Outer"]),
Line::from_iter([" 1. ".fg(accent_color()), "One".into()]),
Line::from_iter([" 2. ".fg(accent_color()), "Two".into()]),
Line::default(),
Line::from_iter(["- ", "Last"]),
Line::from_iter(["• ", "Last"]),
]);
assert_eq!(text, expected);
}
@@ -657,7 +657,7 @@ fn tight_item_with_soft_break() {
let md = "- item line1\n item line2\n";
let text = render_markdown_text(md);
let expected = Text::from_iter([
Line::from_iter(["- ", "item line1"]),
Line::from_iter(["• ", "item line1"]),
Line::from_iter([" ", "item line2"]),
]);
assert_eq!(text, expected);
@@ -669,7 +669,7 @@ fn deeply_nested_mixed_three_levels() {
let text = render_markdown_text(md);
let expected = Text::from_iter([
Line::from_iter(["1. ".fg(accent_color()), "A".into()]),
Line::from_iter([" - ", "B"]),
Line::from_iter([" • ", "B"]),
Line::from_iter([" 1. ".fg(accent_color()), "C".into()]),
Line::default(),
Line::from_iter(["2. ".fg(accent_color()), "D".into()]),
@@ -1240,7 +1240,7 @@ fn unordered_list_local_file_link_stays_inline_with_following_text() {
assert_eq!(
rendered,
vec![
"- binary (codex-rs/README.md:93): core is the agent/business logic, tui",
"• binary (codex-rs/README.md:93): core is the agent/business logic, tui",
" is the terminal UI, exec is the headless automation surface, and cli",
" is the top-level multitool binary.",
]
@@ -1266,7 +1266,7 @@ fn unordered_list_local_file_link_soft_break_before_colon_stays_inline() {
.collect::<Vec<_>>();
assert_eq!(
rendered,
vec!["- binary (codex-rs/README.md:93): core is the agent/business logic.",]
vec!["• binary (codex-rs/README.md:93): core is the agent/business logic.",]
);
}
@@ -1290,8 +1290,8 @@ fn consecutive_unordered_list_local_file_links_do_not_detach_paths() {
assert_eq!(
rendered,
vec![
"- binary (codex-rs/README.md:93): cli is the top-level multitool binary.",
"- expectations (codex-rs/core/README.md:1): codex-core owns the real",
"• binary (codex-rs/README.md:93): cli is the top-level multitool binary.",
"• expectations (codex-rs/core/README.md:1): codex-core owns the real",
" runtime behavior.",
]
);
@@ -1483,7 +1483,7 @@ fn code_block_inside_unordered_list_item_is_indented() {
.collect::<String>()
})
.collect();
assert_eq!(lines, vec!["- Item", "", " code line"]);
assert_eq!(lines, vec!["• Item", "", " code line"]);
}
#[test]
@@ -1500,7 +1500,7 @@ fn code_block_multiple_lines_inside_unordered_list() {
.collect::<String>()
})
.collect();
assert_eq!(lines, vec!["- Item", "", " first", " second"]);
assert_eq!(lines, vec!["• Item", "", " first", " second"]);
}
#[test]
@@ -1517,7 +1517,7 @@ fn code_block_inside_unordered_list_item_multiple_lines() {
.collect::<String>()
})
.collect();
assert_eq!(lines, vec!["- Item", "", " first", " second"]);
assert_eq!(lines, vec!["• Item", "", " first", " second"]);
}
#[test]
@@ -1545,7 +1545,7 @@ fn outer_list_item_after_nested_code_block_keeps_blank_separator() {
lines,
vec![
"1. First:",
" - Nested:",
" • Nested:",
"",
" fn first() {}",
"",
@@ -1696,7 +1696,7 @@ fn ordered_item_with_code_block_and_nested_bullet() {
"2. item 2".to_string(),
String::new(),
" code".to_string(),
" - PROCESS_START (a OnceLock<Instant>) keeps the start time for the entire process.".to_string(),
" • PROCESS_START (a OnceLock<Instant>) keeps the start time for the entire process.".to_string(),
]
);
}
@@ -1707,14 +1707,14 @@ fn nested_five_levels_mixed_lists() {
let text = render_markdown_text(md);
let expected = Text::from_iter([
Line::from_iter(["1. ".fg(accent_color()), "First".into()]),
Line::from_iter([" - ", "Second level"]),
Line::from_iter([" • ", "Second level"]),
Line::from_iter([
" 1. ".fg(accent_color()),
"Third level (ordered)".into(),
]),
Line::from_iter([" - ", "Fourth level (bullet)"]),
Line::from_iter([" • ", "Fourth level (bullet)"]),
Line::from_iter([
" - ",
" • ",
"Fifth level to test indent consistency",
]),
]);
@@ -1757,7 +1757,7 @@ fn html_continuation_paragraph_in_unordered_item_indented() {
let md = "- Item\n\n <em>continued</em>\n";
let text = render_markdown_text(md);
let expected = Text::from_iter([
Line::from_iter(["- ", "Item"]),
Line::from_iter(["• ", "Item"]),
Line::default(),
Line::from_iter([" ", "<em>", "continued", "</em>"]),
]);
@@ -1781,7 +1781,7 @@ fn unordered_item_continuation_paragraph_is_indented() {
assert_eq!(
lines,
vec![
"- Intro".to_string(),
"• Intro".to_string(),
String::new(),
" Continuation paragraph line 1".to_string(),
" Continuation paragraph line 2".to_string(),
@@ -1807,7 +1807,7 @@ fn nested_item_continuation_paragraph_is_indented() {
let text = render_markdown_text(md);
let expected = Text::from_iter([
Line::from_iter(["1. ".fg(accent_color()), "A".into()]),
Line::from_iter([" - ", "B"]),
Line::from_iter([" • ", "B"]),
Line::default(),
Line::from_iter([" ", "Continuation for B"]),
Line::default(),
+2 -2
View File
@@ -796,8 +796,8 @@ mod tests {
" 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(),
" • Nested bullet under a loose item".to_string(),
" • Another nested bullet".to_string(),
];
assert_eq!(
streamed_strs, expected,
@@ -14,17 +14,17 @@ Image: alt text
>
> > Blockquote level 2 with inline code
- Unordered list item 1
- Nested bullet with italics inner
• Unordered list item 1
• Nested bullet with italics inner
- Unordered list item 2 with strikethrough
• Unordered list item 2 with strikethrough
1. Ordered item one
2. Ordered item two with sublist:
1. Alt-numbered subitem
- [ ] Task: unchecked
- [x] Task: checked with link home (https://example.org)
☐ Task: unchecked
☑ Task: checked with link home (https://example.org)
———
+2 -2
View File
@@ -1357,8 +1357,8 @@ mod tests {
" 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(),
" • Nested bullet under a loose item".to_string(),
" • Another nested bullet".to_string(),
];
assert_eq!(
streamed, expected,
@@ -5,6 +5,52 @@ use crate::markdown_render::preferences;
use codex_config::types::TuiRendering;
use pretty_assertions::assert_eq;
#[test]
fn lists_match_incremental_and_emitted_responses() {
let cwd = std::env::temp_dir();
let source = concat!(
"- [ ] First task with enough words to wrap\n",
" - [x] Nested task\n- [X] Done\n\n",
"> 9. [ ] [link](https://example.com)\n> 10. [x] Done\n\n",
"After.\n\nOne.\n\nTwo.\n\nThree.\n"
);
for lists in [true, false] {
preferences::init(TuiRendering {
lists,
..Default::default()
});
let mut stream = StreamCore::new(
Some(24),
&cwd,
HistoryRenderMode::Rich,
/*inline_visualization_context*/ None,
);
let render = |source: &str| {
render_source(
source,
Some(24),
&cwd,
HistoryRenderMode::Rich,
/*inline_visualization_context*/ None,
)
};
let mut emitted = Vec::new();
let mut committed = String::new();
// Split inside markers as well as words to exercise partial task-list input.
for character in source.chars() {
committed.push(character);
stream.push_delta(&character.to_string());
emitted.extend(stream.tick_batch(usize::MAX));
if character == '\n' {
assert_eq!(stream.render.lines, render(&committed));
}
}
let (remaining, _) = stream.finalize_remaining();
emitted.extend(remaining);
assert_eq!(emitted, render(source), "lists={lists}");
}
}
#[test]
fn rendering_preferences_match_incremental_final_and_emitted_responses() {
let cwd = std::env::temp_dir();
@@ -24,6 +70,7 @@ fn rendering_preferences_match_incremental_final_and_emitted_responses() {
mermaid,
math,
tables,
..Default::default()
});
// A disabled table must retain even the prose preceding it inside its fence.
let mut source = if tables {
@@ -17,7 +17,7 @@ expression: rendered_cases
},
HyperlinkLine {
line: Line::from_iter([
Span::from("- "),
Span::from("• "),
Span::from("Quarterly Report.xlsx").fg(Color::Rgb(166, 227, 161)),
]),
hyperlinks: [],
@@ -45,7 +45,7 @@ expression: rendered_cases
},
HyperlinkLine {
line: Line::from_iter([
Span::from("- "),
Span::from("• "),
Span::from("Résumé *final* ✨.xlsx").fg(Color::Rgb(166, 227, 161)),
]),
hyperlinks: [],