From bb769b937a4d3d10fcbcaf20abaf77ad4fdfa3a6 Mon Sep 17 00:00:00 2001
From: iam-liam <117163129+iam-liam@users.noreply.github.com>
Date: Thu, 19 Mar 2026 15:39:03 +0000
Subject: [PATCH] markdown: Render checkboxes in markdown table cells (#50595)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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 ``** — 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**
**Markdown checkbox after**
## 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)
---
crates/markdown/src/markdown.rs | 65 +++++++++++++++++++
.../markdown_preview/src/markdown_parser.rs | 29 +++++++++
.../markdown_preview/src/markdown_renderer.rs | 18 +++++
3 files changed, 112 insertions(+)
diff --git a/crates/markdown/src/markdown.rs b/crates/markdown/src/markdown.rs
index 087b7153704c215ec27eae653879ffe9f11ebf09..7605c53c788363b4eec42a2151a258a137ce84a6 100644
--- a/crates/markdown/src/markdown.rs
+++ b/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) {
+ 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 = 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,
diff --git a/crates/markdown_preview/src/markdown_parser.rs b/crates/markdown_preview/src/markdown_parser.rs
index 29ea273f49578bd6ad408a8d57b891f572705c07..40a1ed804f750a7e3173a76643ad1f6b1a362bd3 100644
--- a/crates/markdown_preview/src/markdown_parser.rs
+++ b/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(
diff --git a/crates/markdown_preview/src/markdown_renderer.rs b/crates/markdown_preview/src/markdown_renderer.rs
index a6666fd97dccf2adef7e70ac374314cd55ff5821..96adc670d91f51b343c388403fa5aa7ebad678ee 100644
--- a/crates/markdown_preview/src/markdown_renderer.rs
+++ b/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(