Skip to main content

gpui_ai/
todo_list.rs

1//! Agent plan display: a to-do list with live completion status.
2
3use crate::control::QuietSurfaceExt as _;
4use crate::control::composed_button;
5use crate::decoration::{DecoratedExt as _, Decoration};
6use crate::handlers::SharedHandler;
7use crate::motion::{ArrivalRoster, MotionTokens, acknowledged_state};
8use crate::surface::CardFrameExt as _;
9use crate::theme::SemanticStyledExt as _;
10use gpui::{
11    App, ClickEvent, ElementId, FontWeight, InteractiveElement as _, IntoElement,
12    ParentElement as _, RenderOnce, Role, SharedString, StatefulInteractiveElement as _,
13    StyleRefinement, Styled, Window, accesskit, div, prelude::FluentBuilder as _,
14};
15use gpui_base::motion::{Transition, transition};
16use gpui_component::{
17    ActiveTheme as _, Icon, IconName, Sizable as _, StyledExt as _, h_flex, spinner::Spinner,
18    v_flex,
19};
20
21/// Status of one [`TodoItem`].
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
23pub enum TodoStatus {
24    /// Not started.
25    #[default]
26    Pending,
27    /// The step the agent is on now; shows a spinner.
28    Active,
29    /// Completed.
30    Done,
31}
32
33/// One entry in a [`TodoList`].
34#[derive(Debug, Clone)]
35pub struct TodoItem {
36    id: SharedString,
37    label: SharedString,
38    status: TodoStatus,
39}
40
41impl TodoItem {
42    /// Creates a pending item.
43    pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
44        Self {
45            id: id.into(),
46            label: label.into(),
47            status: TodoStatus::default(),
48        }
49    }
50
51    /// Sets the status. Default is [`TodoStatus::Pending`].
52    pub fn status(mut self, status: TodoStatus) -> Self {
53        self.status = status;
54        self
55    }
56
57    /// Shorthand for `.status(TodoStatus::Done)`.
58    pub fn done(self) -> Self {
59        self.status(TodoStatus::Done)
60    }
61
62    fn toggled_event(&self) -> TodoListEvent {
63        TodoListEvent::Toggled {
64            id: self.id.clone(),
65        }
66    }
67
68    fn accessibility_toggled(&self) -> accesskit::Toggled {
69        match self.status {
70            TodoStatus::Done => accesskit::Toggled::True,
71            TodoStatus::Pending | TodoStatus::Active => accesskit::Toggled::False,
72        }
73    }
74}
75
76/// An interaction emitted by [`TodoList`].
77#[derive(Debug, Clone, PartialEq, Eq)]
78pub enum TodoListEvent {
79    /// A plan item was selected for toggling.
80    Toggled {
81        /// Stable plan-item identifier.
82        id: SharedString,
83    },
84}
85
86/// An agent's plan as a checklist, updating as steps complete.
87///
88/// Differs from [`TaskRow`](crate::task::TaskRow) in intent: task rows show
89/// independent background work with timing and failure detail; a to-do list
90/// shows one plan executing in order.
91///
92/// # Example
93///
94/// ```
95/// # use gpui_ai::prelude::*;
96/// TodoList::new("plan")
97///     .title("Refactor plan")
98///     .items([
99///         TodoItem::new("read", "Read existing schema").done(),
100///         TodoItem::new("write", "Write migration").status(TodoStatus::Active),
101///         TodoItem::new("test", "Run test suite"),
102///     ]);
103/// ```
104#[derive(IntoElement)]
105pub struct TodoList {
106    id: ElementId,
107    style: StyleRefinement,
108    decoration: Decoration,
109    title: Option<SharedString>,
110    items: Vec<TodoItem>,
111    on_event: Option<SharedHandler<TodoListEvent>>,
112}
113
114impl TodoList {
115    /// Layers painted into this list's frame: one under the content, one
116    /// over it, both clipped to its own shape and neither affecting layout.
117    ///
118    /// This crate ships no effects of its own — what goes in a decoration is
119    /// the application's expression.
120    pub fn decoration(mut self, decoration: Decoration) -> Self {
121        self.decoration = decoration;
122        self
123    }
124
125    /// Creates an empty list.
126    pub fn new(id: impl Into<ElementId>) -> Self {
127        Self {
128            id: id.into(),
129            style: StyleRefinement::default(),
130            decoration: Decoration::default(),
131            title: None,
132            items: Vec::new(),
133            on_event: None,
134        }
135    }
136
137    /// Adds a header title with a completion count.
138    pub fn title(mut self, title: impl Into<SharedString>) -> Self {
139        self.title = Some(title.into());
140        self
141    }
142
143    /// Sets the plan items, in execution order.
144    pub fn items(mut self, items: impl IntoIterator<Item = TodoItem>) -> Self {
145        self.items = items.into_iter().collect();
146        self
147    }
148
149    /// Handles typed plan-item interactions.
150    pub fn on_event(
151        mut self,
152        handler: impl Fn(&TodoListEvent, &mut Window, &mut App) + 'static,
153    ) -> Self {
154        self.on_event = Some(std::rc::Rc::new(handler));
155        self
156    }
157}
158
159impl Styled for TodoList {
160    fn style(&mut self) -> &mut StyleRefinement {
161        &mut self.style
162    }
163}
164
165impl RenderOnce for TodoList {
166    fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
167        let tokens = cx.theme().semantic_tokens();
168        // Taken once: each layer is placed at most once, and a
169        // component with no decoration adds no elements at all.
170        let mut decoration = std::mem::take(&mut self.decoration);
171        let motion = MotionTokens::read(cx).clone();
172        let done = self
173            .items
174            .iter()
175            .filter(|item| item.status == TodoStatus::Done)
176            .count();
177        let total = self.items.len();
178        let handler = self.on_event;
179
180        // One shared fill carries the plan's completion: it retargets from
181        // its current sample when steps complete or reopen, starts at the
182        // supplied value on first render, and snaps under reduced motion —
183        // the transition contract, not local policy.
184        let fraction = if total == 0 {
185            0.0
186        } else {
187            done as f32 / total as f32
188        };
189        let fill = transition(
190            (self.id.clone(), "todo-fill"),
191            fraction,
192            Transition::new(crate::motion::retarget_duration(cx, motion.standard()))
193                .ease(crate::motion::ease_in_out_quart),
194            window,
195            cx,
196        );
197
198        // Items the list has already shown never re-animate; new stable IDs
199        // appended to a mounted list settle in on the capped cascade, and
200        // the initial load joins at rest.
201        let roster = window.use_keyed_state((self.id.clone(), "arrivals"), cx, |_, _| {
202            ArrivalRoster::new()
203        });
204        roster.update(cx, |roster, cx| {
205            roster.note(
206                self.items
207                    .iter()
208                    .map(|item| ElementId::Name(SharedString::from(format!("todo-{}", item.id)))),
209                true,
210                &motion,
211                cx.background_executor().now(),
212            );
213        });
214        let list_name = self.title.clone().unwrap_or_else(|| "To-do list".into());
215        let root_id = self.id.clone();
216        let accessibility_label: SharedString =
217            format!("{list_name}, {done} of {total} complete").into();
218
219        v_flex()
220            .id(self.id)
221            .role(Role::List)
222            .aria_label(accessibility_label)
223            .p(tokens.spacing.md)
224            .gap(tokens.spacing.xs)
225            .card_frame(cx)
226            .decoration_under(&mut decoration)
227            .when_some(self.title, |this, title| {
228                this.child(
229                    h_flex()
230                        .items_center()
231                        .justify_between()
232                        .pb(tokens.spacing.xs)
233                        // The title yields and the count holds: a shortened
234                        // heading still reads, a dropped count does not.
235                        .child(
236                            div()
237                                .min_w_0()
238                                .truncate()
239                                .text_token(tokens.typography.xs)
240                                .font_weight(FontWeight::SEMIBOLD)
241                                .text_color(cx.theme().muted_foreground)
242                                .child(title),
243                        )
244                        .child(
245                            div()
246                                .flex_none()
247                                .debug_selector(|| "todo-list-count".to_owned())
248                                .text_token(tokens.typography.xs)
249                                .font_family(cx.theme().mono_font_family.clone())
250                                .text_color(cx.theme().muted_foreground)
251                                .child(format!("{done}/{total}")),
252                        ),
253                )
254            })
255            .child(
256                // The shared completion fill under the header; the track is
257                // always full width, the fill is the sampled fraction.
258                div()
259                    .w_full()
260                    .h(tokens.spacing.xxs)
261                    .rounded(tokens.radius.full)
262                    .bg(cx.theme().muted)
263                    .child(
264                        div()
265                            .h_full()
266                            .w(gpui::relative(fill))
267                            .rounded(tokens.radius.full)
268                            .bg(cx.theme().primary),
269                    ),
270            )
271            .children(self.items.into_iter().map(|item| {
272                let item_id = item.id.clone();
273                let accessibility_label = item.label.clone();
274                let accessibility_toggled = item.accessibility_toggled();
275                let accessibility_description =
276                    (item.status == TodoStatus::Active).then_some("In progress");
277                let acknowledged = acknowledged_state(
278                    ElementId::from((ElementId::from((root_id.clone(), item.id.clone())), "glyph")),
279                    item.status as u64,
280                    window,
281                    cx,
282                );
283                let indicator = match item.status {
284                    TodoStatus::Pending => div()
285                        .size_3()
286                        .rounded(tokens.radius.full)
287                        .border_1()
288                        .border_color(cx.theme().muted_foreground)
289                        .into_any_element(),
290                    TodoStatus::Active => Spinner::new()
291                        .xsmall()
292                        .color(cx.theme().info)
293                        .into_any_element(),
294                    // The durable completion mark settles in once, after the
295                    // controlled status changes; loaded-done items are the
296                    // mount state and exempt.
297                    TodoStatus::Done => Icon::new(IconName::CircleCheck)
298                        .small()
299                        .text_color(cx.theme().success)
300                        .opacity(acknowledged)
301                        .into_any_element(),
302                };
303                let arrival = roster.update(cx, |roster, cx| {
304                    roster.progress(
305                        &ElementId::Name(SharedString::from(format!("todo-{}", item.id))),
306                        window,
307                        cx,
308                    )
309                });
310
311                let event = item.toggled_event();
312                let row = h_flex()
313                    .w_full()
314                    .items_start()
315                    .gap(tokens.spacing.sm)
316                    .child(
317                        // A first-line slot: ring, spinner, and check all
318                        // centre in the same box, a status change never
319                        // nudges the label sideways, and a wrapping label
320                        // leaves the glyph seated on its first line.
321                        crate::surface::leading_glyph_slot(crate::sizing::slot_md(cx), indicator),
322                    )
323                    .child(
324                        div()
325                            .flex_1()
326                            .min_w_0()
327                            .text_token(tokens.typography.sm)
328                            .map(|this| match item.status {
329                                TodoStatus::Done => {
330                                    this.line_through().text_color(cx.theme().muted_foreground)
331                                }
332                                TodoStatus::Active => this.text_color(cx.theme().foreground),
333                                TodoStatus::Pending => this.text_color(cx.theme().muted_foreground),
334                            })
335                            .child(item.label),
336                    );
337
338                let row = match arrival {
339                    Some(progress) => row
340                        .opacity(progress)
341                        .top(tokens.spacing.xxs * (1.0 - progress) * crate::motion::travel(cx)),
342                    None => row,
343                };
344
345                match handler.clone() {
346                    Some(handler) => composed_button(item.id.clone(), accessibility_label)
347                        .w_full()
348                        .role(Role::CheckBox)
349                        .aria_toggled(accessibility_toggled)
350                        .px(tokens.spacing.xs)
351                        .py(tokens.spacing.xxs)
352                        .quiet_press_surface(
353                            ElementId::from((ElementId::from(item.id.clone()), "press")),
354                            tokens.radius.sm,
355                            window,
356                            cx,
357                        )
358                        .when_some(accessibility_description, |this, description| {
359                            this.aria_description(description)
360                        })
361                        .child(row)
362                        .on_click(move |_: &ClickEvent, window, cx| handler(&event, window, cx))
363                        .into_any_element(),
364                    None => div()
365                        .id(item_id)
366                        .role(Role::ListItem)
367                        .aria_label(accessibility_label)
368                        .when_some(accessibility_description, |this, description| {
369                            this.aria_description(description)
370                        })
371                        .child(row)
372                        .into_any_element(),
373                }
374            }))
375            .decoration_over(&mut decoration)
376            .refine_style(&self.style)
377    }
378}
379
380#[cfg(test)]
381mod tests {
382    use super::*;
383    use gpui::{Context, Render, TestAppContext, VisualTestContext, px};
384
385    struct ListProbe {
386        count: usize,
387    }
388
389    impl Render for ListProbe {
390        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
391            div().w(px(320.)).h(px(320.)).child(
392                TodoList::new("probe-plan")
393                    .title("Plan")
394                    .items((0..self.count).map(|ix| TodoItem::new(format!("step-{ix}"), "Step"))),
395            )
396        }
397    }
398
399    fn draw(cx: &mut VisualTestContext) {
400        cx.update(|window, cx| window.draw(cx).clear(cx));
401    }
402
403    #[gpui::test]
404    fn a_loaded_list_rests_and_an_appended_item_settles_in(cx: &mut TestAppContext) {
405        cx.update(crate::init);
406        let (probe, cx) = cx.add_window_view(|_, _| ListProbe { count: 3 });
407        let cx: &mut VisualTestContext = cx;
408        draw(cx);
409        crate::motion::take_reveal_frame_requests();
410        draw(cx);
411        assert_eq!(
412            crate::motion::take_reveal_frame_requests(),
413            0,
414            "the initial load joins at rest"
415        );
416
417        probe.update(cx, |probe, cx| {
418            probe.count = 4;
419            cx.notify();
420        });
421        draw(cx);
422        assert!(
423            crate::motion::take_reveal_frame_requests() > 0,
424            "an item appended to a mounted list must settle in"
425        );
426
427        cx.executor()
428            .advance_clock(std::time::Duration::from_secs(2));
429        draw(cx);
430        crate::motion::take_reveal_frame_requests();
431        probe.update(cx, |_, cx| cx.notify());
432        draw(cx);
433        assert_eq!(
434            crate::motion::take_reveal_frame_requests(),
435            0,
436            "a re-render must not replay an acknowledged arrival"
437        );
438    }
439
440    #[test]
441    fn duplicate_labels_emit_distinct_stable_ids() {
442        let first = TodoItem::new("first", "Same step");
443        let second = TodoItem::new("second", "Same step");
444        assert_eq!(
445            first.toggled_event(),
446            TodoListEvent::Toggled { id: "first".into() }
447        );
448        assert_eq!(
449            second.toggled_event(),
450            TodoListEvent::Toggled {
451                id: "second".into()
452            }
453        );
454    }
455
456    #[test]
457    fn accessibility_checked_state_tracks_completion() {
458        assert_eq!(
459            TodoItem::new("pending", "Pending").accessibility_toggled(),
460            accesskit::Toggled::False
461        );
462        assert_eq!(
463            TodoItem::new("active", "Active")
464                .status(TodoStatus::Active)
465                .accessibility_toggled(),
466            accesskit::Toggled::False
467        );
468        assert_eq!(
469            TodoItem::new("done", "Done").done().accessibility_toggled(),
470            accesskit::Toggled::True
471        );
472    }
473}