agent_ui: Fix show markdown list checked state (#43567)

Remco Smits and Danilo Leal created

Closes #37527

This PR adds support for showing the list state of a list item inside
the agent UI.

**Before**
<img width="643" height="505" alt="Screenshot 2025-11-26 at 16 21 31"
src="https://github.com/user-attachments/assets/30c78022-4096-4fe4-a6cc-db208d03900f"
/>

**After**
<img width="640" height="503" alt="Screenshot 2025-11-26 at 16 41 32"
src="https://github.com/user-attachments/assets/ece14172-79a5-4d5e-a577-4b87db04280f"
/>

Release Notes:
- Agent UI now show the checked state of a list item

---------

Co-authored-by: Danilo Leal <daniloleal09@gmail.com>

Change summary

crates/markdown/src/markdown.rs    | 30 ++++++++++++++--
crates/ui/src/components/toggle.rs | 57 +++++++++++++++++++++----------
2 files changed, 63 insertions(+), 24 deletions(-)

Detailed changes

crates/markdown/src/markdown.rs 🔗

@@ -7,6 +7,7 @@ use gpui::HitboxBehavior;
 use language::LanguageName;
 use log::Level;
 pub use path_range::{LineCol, PathWithRange};
+use ui::Checkbox;
 
 use std::borrow::Cow;
 use std::iter;
@@ -795,7 +796,7 @@ impl Element for MarkdownElement {
         let mut code_block_ids = HashSet::default();
 
         let mut current_img_block_range: Option<Range<usize>> = None;
-        for (range, event) in parsed_markdown.events.iter() {
+        for (index, (range, event)) in parsed_markdown.events.iter().enumerate() {
             // Skip alt text for images that rendered
             if let Some(current_img_block_range) = &current_img_block_range
                 && current_img_block_range.end > range.end
@@ -945,13 +946,29 @@ impl Element for MarkdownElement {
                         MarkdownTag::HtmlBlock => builder.push_div(div(), range, markdown_end),
                         MarkdownTag::List(bullet_index) => {
                             builder.push_list(*bullet_index);
-                            builder.push_div(div().pl_4(), range, markdown_end);
+                            builder.push_div(div().pl_2p5(), range, markdown_end);
                         }
                         MarkdownTag::Item => {
-                            let bullet = if let Some(bullet_index) = builder.next_bullet_index() {
-                                format!("{}.", bullet_index)
+                            let bullet = if let Some((_, MarkdownEvent::TaskListMarker(checked))) =
+                                parsed_markdown.events.get(index.saturating_add(1))
+                            {
+                                let source = &parsed_markdown.source()[range.clone()];
+
+                                Checkbox::new(
+                                    ElementId::Name(source.to_string().into()),
+                                    if *checked {
+                                        ToggleState::Selected
+                                    } else {
+                                        ToggleState::Unselected
+                                    },
+                                )
+                                .fill()
+                                .visualization_only(true)
+                                .into_any_element()
+                            } else if let Some(bullet_index) = builder.next_bullet_index() {
+                                div().child(format!("{}.", bullet_index)).into_any_element()
                             } else {
-                                "•".to_string()
+                                div().child("•").into_any_element()
                             };
                             builder.push_div(
                                 div()
@@ -1226,6 +1243,9 @@ impl Element for MarkdownElement {
                 }
                 MarkdownEvent::SoftBreak => builder.push_text(" ", range.clone()),
                 MarkdownEvent::HardBreak => builder.push_text("\n", range.clone()),
+                MarkdownEvent::TaskListMarker(_) => {
+                    // handled inside the `MarkdownTag::Item` case
+                }
                 _ => log::debug!("unsupported markdown event {:?}", event),
             }
         }

crates/ui/src/components/toggle.rs 🔗

@@ -44,15 +44,16 @@ pub enum ToggleStyle {
 pub struct Checkbox {
     id: ElementId,
     toggle_state: ToggleState,
+    style: ToggleStyle,
     disabled: bool,
     placeholder: bool,
-    on_click: Option<Box<dyn Fn(&ToggleState, &ClickEvent, &mut Window, &mut App) + 'static>>,
     filled: bool,
-    style: ToggleStyle,
-    tooltip: Option<Box<dyn Fn(&mut Window, &mut App) -> AnyView>>,
+    visualization: bool,
     label: Option<SharedString>,
     label_size: LabelSize,
     label_color: Color,
+    tooltip: Option<Box<dyn Fn(&mut Window, &mut App) -> AnyView>>,
+    on_click: Option<Box<dyn Fn(&ToggleState, &ClickEvent, &mut Window, &mut App) + 'static>>,
 }
 
 impl Checkbox {
@@ -61,15 +62,16 @@ impl Checkbox {
         Self {
             id: id.into(),
             toggle_state: checked,
+            style: ToggleStyle::default(),
             disabled: false,
-            on_click: None,
+            placeholder: false,
             filled: false,
-            style: ToggleStyle::default(),
-            tooltip: None,
+            visualization: false,
             label: None,
             label_size: LabelSize::Default,
             label_color: Color::Muted,
-            placeholder: false,
+            tooltip: None,
+            on_click: None,
         }
     }
 
@@ -110,6 +112,13 @@ impl Checkbox {
         self
     }
 
+    /// Makes the checkbox look enabled but without pointer cursor and hover styles.
+    /// Primarily used for uninteractive markdown previews.
+    pub fn visualization_only(mut self, visualization: bool) -> Self {
+        self.visualization = visualization;
+        self
+    }
+
     /// Sets the style of the checkbox using the specified [`ToggleStyle`].
     pub fn style(mut self, style: ToggleStyle) -> Self {
         self.style = style;
@@ -209,11 +218,10 @@ impl RenderOnce for Checkbox {
         let size = Self::container_size();
 
         let checkbox = h_flex()
+            .group(group_id.clone())
             .id(self.id.clone())
-            .justify_center()
-            .items_center()
             .size(size)
-            .group(group_id.clone())
+            .justify_center()
             .child(
                 div()
                     .flex()
@@ -230,7 +238,7 @@ impl RenderOnce for Checkbox {
                     .when(self.disabled, |this| {
                         this.bg(cx.theme().colors().element_disabled.opacity(0.6))
                     })
-                    .when(!self.disabled, |this| {
+                    .when(!self.disabled && !self.visualization, |this| {
                         this.group_hover(group_id.clone(), |el| el.border_color(hover_border_color))
                     })
                     .when(self.placeholder, |this| {
@@ -250,20 +258,14 @@ impl RenderOnce for Checkbox {
             .map(|this| {
                 if self.disabled {
                     this.cursor_not_allowed()
+                } else if self.visualization {
+                    this.cursor_default()
                 } else {
                     this.cursor_pointer()
                 }
             })
             .gap(DynamicSpacing::Base06.rems(cx))
             .child(checkbox)
-            .when_some(
-                self.on_click.filter(|_| !self.disabled),
-                |this, on_click| {
-                    this.on_click(move |click, window, cx| {
-                        on_click(&self.toggle_state.inverse(), click, window, cx)
-                    })
-                },
-            )
             .when_some(self.label, |this, label| {
                 this.child(
                     Label::new(label)
@@ -274,6 +276,14 @@ impl RenderOnce for Checkbox {
             .when_some(self.tooltip, |this, tooltip| {
                 this.tooltip(move |window, cx| tooltip(window, cx))
             })
+            .when_some(
+                self.on_click.filter(|_| !self.disabled),
+                |this, on_click| {
+                    this.on_click(move |click, window, cx| {
+                        on_click(&self.toggle_state.inverse(), click, window, cx)
+                    })
+                },
+            )
     }
 }
 
@@ -914,6 +924,15 @@ impl Component for Checkbox {
                                 .into_any_element(),
                         )],
                     ),
+                    example_group_with_title(
+                        "Extra",
+                        vec![single_example(
+                            "Visualization-Only",
+                            Checkbox::new("viz_only", ToggleState::Selected)
+                                .visualization_only(true)
+                                .into_any_element(),
+                        )],
+                    ),
                 ])
                 .into_any_element(),
         )