markdown: Render checkboxes in markdown table cells (#50595)

iam-liam created

Render `[x]` and `[ ]` as checkbox widgets when they appear as the sole
content of a markdown table cell. Previously these were displayed as raw
text. List-item checkboxes were already rendered correctly; this extends
the same treatment to table cells.

Fixes #50045.

## What this does

- Table cells containing only `[x]`, `[X]`, or `[ ]` now render as
visual checkboxes instead of plain text
- Both markdown rendering paths are covered: the `markdown` crate (agent
panel, chat) and the `markdown_preview` crate (file preview)
- Checkboxes are display-only, matching the existing list-item checkbox
behavior

## How it works

pulldown-cmark splits `[x]` in table cells into three separate `Text`
events (`[`, `x`, `]`) rather than emitting a `TaskListMarker` event
(which only fires for list items per the GFM spec). The fix operates at
each crate's natural interception point:

- **`markdown` crate**: After all text events for a table cell have been
buffered, `replace_pending_checkbox()` checks the accumulated text
before the cell div is finalized. If it matches the checkbox pattern,
the pending text is replaced with a `Checkbox` widget.
- **`markdown_preview` crate**: In `render_markdown_text()`, text chunks
whose trimmed content matches the checkbox pattern are rendered as
`MarkdownCheckbox` widgets instead of `InteractiveText`.

## Scope

Three files, purely additive:

- `crates/markdown/src/markdown.rs` — `replace_pending_checkbox()` on
builder, called at `TableCell` end
- `crates/markdown_preview/src/markdown_renderer.rs` — checkbox
detection in `render_markdown_text()`
- `crates/markdown_preview/src/markdown_parser.rs` — test only

No changes to parser data models, GPUI, or any shared infrastructure.

## What's not in scope

- **HTML `<input type="checkbox">`** — pulldown-cmark strips these as
raw HTML. Supporting them requires HTML tag parsing, which is a separate
concern.
- **Interactive (click-to-toggle) checkboxes in tables** — table
checkboxes are display-only. List-item checkboxes in Zed support
Cmd+click toggling, but extending that to table cells would require
tracking source ranges across the split parser events, which is a
separate enhancement.

## Follow-up

Table checkbox interactivity (Cmd+click toggle) is straightforward to
add as a follow-up — the source ranges are already available in
`markdown_preview`, and the `markdown` crate would need minor callback
plumbing.

## Screenshots

**Markdown checkbox before**

<img width="1603" height="863" alt="md-checkbox-before-1"
src="https://github.com/user-attachments/assets/8539d79d-c74f-4d14-a3e5-525e4d0083aa"
/>

<img width="1599" height="892" alt="md-checkbox-before-2"
src="https://github.com/user-attachments/assets/7badfab1-651f-4fab-8879-deb109c56670"
/>

**Markdown checkbox after**

<img width="1832" height="889" alt="md-checkbox-after-1"
src="https://github.com/user-attachments/assets/463b6334-9f50-41c0-ab7e-24d238244873"
/>

<img width="1795" height="886" alt="md-checkbox-after-2"
src="https://github.com/user-attachments/assets/57d3d9de-1d23-42ba-bc0a-5aa0c699b13d"
/>

## Test plan

**Unit tests** (2 new):
- `test_table_with_checkboxes` (markdown_preview) — parser delivers
`[x]`/`[ ]` text into table cell structures
- `test_table_checkbox_detection` (markdown) — parser events accumulate
checkbox text in table cells, confirming `replace_pending_checkbox`
detection logic

**Automated**:
- [x] `cargo test -p markdown` — 27 tests pass (26 existing + 1 new)
- [x] `cargo test -p markdown_preview` — 61 tests pass (60 existing + 1
new)

**Manual** (verified against `test-checkbox-table.md`):
- [x] Basic `[x]`/`[ ]` in a status column
- [x] Checkbox-only column alongside text
- [x] Multiple checkbox columns in one table
- [x] Left, center, and right column alignments
- [x] Uppercase `[X]` variant
- [x] Leading/trailing whitespace in cell
- [x] Checkboxes alongside other inline elements (links, bold text)
- [x] Single-column and minimal two-column tables
- [x] Normal table text unaffected by detection
- [x] List checkboxes still render correctly (regression)
- [x] Agent panel: asked agent to output table with checkbox columns

Release Notes:

- Fixed `[x]` and `[ ]` checkboxes not rendering in markdown table cells
(#50045)

Change summary

crates/markdown/src/markdown.rs                  | 65 ++++++++++++++++++
crates/markdown_preview/src/markdown_parser.rs   | 29 ++++++++
crates/markdown_preview/src/markdown_renderer.rs | 18 ++++
3 files changed, 112 insertions(+)

Detailed changes

crates/markdown/src/markdown.rs 🔗

@@ -1441,6 +1441,7 @@ impl Element for MarkdownElement {
                         builder.table.end_row();
                     }
                     MarkdownTagEnd::TableCell => {
+                        builder.replace_pending_checkbox(range);
                         builder.pop_div();
                         builder.table.end_cell();
                     }
@@ -1926,6 +1927,28 @@ impl MarkdownElementBuilder {
         }
     }
 
+    fn replace_pending_checkbox(&mut self, source_range: &Range<usize>) {
+        let trimmed = self.pending_line.text.trim();
+        if trimmed == "[x]" || trimmed == "[X]" || trimmed == "[ ]" {
+            let checked = trimmed != "[ ]";
+            self.pending_line = PendingLine::default();
+            let checkbox = Checkbox::new(
+                ElementId::Name(
+                    format!("table_checkbox_{}_{}", source_range.start, source_range.end).into(),
+                ),
+                if checked {
+                    ToggleState::Selected
+                } else {
+                    ToggleState::Unselected
+                },
+            )
+            .fill()
+            .visualization_only(true)
+            .into_any_element();
+            self.div_stack.last_mut().unwrap().extend([checkbox]);
+        }
+    }
+
     fn flush_text(&mut self) {
         let line = mem::take(&mut self.pending_line);
         if line.text.is_empty() {
@@ -2493,6 +2516,48 @@ mod tests {
         assert_eq!(second_word, "b");
     }
 
+    #[test]
+    fn test_table_checkbox_detection() {
+        let md = "| Done |\n|------|\n| [x] |\n| [ ] |";
+        let (events, _, _) = crate::parser::parse_markdown(md);
+
+        let mut in_table = false;
+        let mut cell_texts: Vec<String> = Vec::new();
+        let mut current_cell = String::new();
+
+        for (range, event) in &events {
+            match event {
+                MarkdownEvent::Start(MarkdownTag::Table(_)) => in_table = true,
+                MarkdownEvent::End(MarkdownTagEnd::Table) => in_table = false,
+                MarkdownEvent::Start(MarkdownTag::TableCell) => current_cell.clear(),
+                MarkdownEvent::End(MarkdownTagEnd::TableCell) => {
+                    if in_table {
+                        cell_texts.push(current_cell.clone());
+                    }
+                }
+                MarkdownEvent::Text if in_table => {
+                    current_cell.push_str(&md[range.clone()]);
+                }
+                _ => {}
+            }
+        }
+
+        let checkbox_cells: Vec<&String> = cell_texts
+            .iter()
+            .filter(|t| {
+                let trimmed = t.trim();
+                trimmed == "[x]" || trimmed == "[X]" || trimmed == "[ ]"
+            })
+            .collect();
+        assert_eq!(
+            checkbox_cells.len(),
+            2,
+            "Expected 2 checkbox cells, got: {cell_texts:?}"
+        );
+        assert_eq!(checkbox_cells[0].trim(), "[x]");
+        assert_eq!(checkbox_cells[1].trim(), "[ ]");
+    }
+
     #[gpui::test]
     fn test_inline_code_word_selection_excludes_backticks(cx: &mut TestAppContext) {
         // Test that double-clicking on inline code selects just the code content,

crates/markdown_preview/src/markdown_parser.rs 🔗

@@ -2776,6 +2776,35 @@ Some other content
         );
     }
 
+    #[gpui::test]
+    async fn test_table_with_checkboxes() {
+        let markdown = "\
+| Done | Task    |
+|------|---------|
+| [x]  | Fix bug |
+| [ ]  | Add feature |";
+
+        let parsed = parse(markdown).await;
+        let table = match &parsed.children[0] {
+            ParsedMarkdownElement::Table(table) => table,
+            other => panic!("Expected table, got: {:?}", other),
+        };
+
+        let first_cell = &table.body[0].columns[0];
+        let first_cell_text = match &first_cell.children[0] {
+            MarkdownParagraphChunk::Text(t) => t.contents.to_string(),
+            other => panic!("Expected text chunk, got: {:?}", other),
+        };
+        assert_eq!(first_cell_text.trim(), "[x]");
+
+        let second_cell = &table.body[1].columns[0];
+        let second_cell_text = match &second_cell.children[0] {
+            MarkdownParagraphChunk::Text(t) => t.contents.to_string(),
+            other => panic!("Expected text chunk, got: {:?}", other),
+        };
+        assert_eq!(second_cell_text.trim(), "[ ]");
+    }
+
     #[gpui::test]
     async fn test_list_basic() {
         let parsed = parse(

crates/markdown_preview/src/markdown_renderer.rs 🔗

@@ -891,6 +891,24 @@ fn render_markdown_text(parsed_new: &MarkdownParagraph, cx: &mut RenderContext)
     for parsed_region in parsed_new {
         match parsed_region {
             MarkdownParagraphChunk::Text(parsed) => {
+                let trimmed = parsed.contents.trim();
+                if trimmed == "[x]" || trimmed == "[X]" || trimmed == "[ ]" {
+                    let checked = trimmed != "[ ]";
+                    let element = div()
+                        .child(MarkdownCheckbox::new(
+                            cx.next_id(&parsed.source_range),
+                            if checked {
+                                ToggleState::Selected
+                            } else {
+                                ToggleState::Unselected
+                            },
+                            cx.clone(),
+                        ))
+                        .into_any();
+                    any_element.push(element);
+                    continue;
+                }
+
                 let element_id = cx.next_id(&parsed.source_range);
 
                 let highlights = gpui::combine_highlights(