mirror of
https://github.com/openai/codex.git
synced 2026-10-18 17:18:52 +00:00
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:
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
+2
-2
@@ -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; "
|
||||
" │ "
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
+4
-4
@@ -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
|
||||
━━━━━━ ━━━━━━━
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 & \\* |\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}"));
|
||||
|
||||
+32
-32
@@ -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
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ expression: "markdown_text(source, 60)"
|
||||
> │ ├◄────┘
|
||||
> └─────────┘
|
||||
|
||||
- Diagram:
|
||||
• Diagram:
|
||||
|
||||
┌───┐ ┌───┐
|
||||
│ A │ │ B │
|
||||
|
||||
+10
-4
@@ -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 & \* |
|
||||
|
||||
---
|
||||
|
||||
TuiRendering { mermaid: true, math: true, tables: true, lists: false }
|
||||
- [ ] Pending
|
||||
- [x] Done
|
||||
- Ordinary
|
||||
|
||||
+73
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
+5
-5
@@ -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)
|
||||
|
||||
———
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+2
-2
@@ -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: [],
|
||||
|
||||
Reference in New Issue
Block a user