Skip to main content

gpui_ai/
plan.rs

1//! Plan cards: an agent's proposed steps as one reviewable card.
2//!
3//! A [`PlanCard`] lists ordered [`PlanStep`]s with per-step status glyphs,
4//! shows the plan's overall [`PlanState`] as a badge, and, while the plan is
5//! still proposed, offers Approve / Reject (and optionally Edit). Decisions
6//! and step activations are reported as [`PlanEvent`]s keyed by stable IDs;
7//! the application owns execution and feeds statuses back through the
8//! snapshot.
9
10use crate::control::ControlMetricsExt as _;
11use crate::control::QuietSurfaceExt as _;
12use crate::decoration::{DecoratedExt as _, Decoration};
13use crate::{
14    ButtonLabelExt as _,
15    control::composed_button,
16    cues::{self, Cue},
17    handlers::SharedHandler,
18    motion::{ArrivalRoster, MotionTokens, acknowledged_state},
19    status::{StatusBadge, StatusTone},
20    surface::{card, description, eyebrow, meta, title},
21    theme::SemanticStyledExt as _,
22};
23use gpui::{
24    AnyElement, App, ClickEvent, ElementId, FontWeight, InteractiveElement as _, IntoElement,
25    ParentElement as _, RenderOnce, Role, SharedString, StatefulInteractiveElement as _,
26    StyleRefinement, Styled, Window, div, prelude::FluentBuilder as _,
27};
28use gpui_component::{
29    ActiveTheme as _, Icon, IconName, Sizable as _, StyledExt as _,
30    button::{Button, ButtonVariants as _},
31    h_flex,
32    spinner::Spinner,
33    v_flex,
34};
35use std::rc::Rc;
36
37/// Where one step stands.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
39pub enum PlanStepStatus {
40    /// Not started.
41    #[default]
42    Pending,
43    /// In progress.
44    Running,
45    /// Finished successfully.
46    Done,
47    /// Stopped with an error.
48    Failed,
49    /// Deliberately not executed.
50    Skipped,
51}
52
53impl PlanStepStatus {
54    /// The short status word read to assistive technology.
55    pub fn label(self) -> &'static str {
56        match self {
57            Self::Pending => "pending",
58            Self::Running => "running",
59            Self::Done => "done",
60            Self::Failed => "failed",
61            Self::Skipped => "skipped",
62        }
63    }
64}
65
66/// One ordered step of a plan.
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct PlanStep {
69    id: SharedString,
70    title: SharedString,
71    detail: Option<SharedString>,
72    status: PlanStepStatus,
73}
74
75impl PlanStep {
76    /// Creates a pending step with a stable identifier.
77    pub fn new(id: impl Into<SharedString>, title: impl Into<SharedString>) -> Self {
78        Self {
79            id: id.into(),
80            title: title.into(),
81            detail: None,
82            status: PlanStepStatus::Pending,
83        }
84    }
85
86    /// Adds a supporting line (what the step touches, why it is needed).
87    pub fn detail(mut self, detail: impl Into<SharedString>) -> Self {
88        self.detail = Some(detail.into());
89        self
90    }
91
92    /// Sets the status.
93    pub fn status(mut self, status: PlanStepStatus) -> Self {
94        self.status = status;
95        self
96    }
97
98    /// Returns the stable step identifier.
99    pub fn id(&self) -> &SharedString {
100        &self.id
101    }
102
103    /// Returns the step title.
104    pub fn title(&self) -> &SharedString {
105        &self.title
106    }
107
108    /// Returns the supporting line, if any.
109    pub fn detail_text(&self) -> Option<&SharedString> {
110        self.detail.as_ref()
111    }
112
113    /// Returns the status.
114    pub fn step_status(&self) -> PlanStepStatus {
115        self.status
116    }
117}
118
119/// The plan's overall lifecycle.
120#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
121pub enum PlanState {
122    /// Waiting for a decision; Approve / Reject are offered.
123    #[default]
124    Proposed,
125    /// Accepted, not yet started.
126    Approved,
127    /// Declined.
128    Rejected,
129    /// Steps are executing.
130    Running,
131    /// Every step finished.
132    Completed,
133    /// Execution stopped on a failure.
134    Failed,
135}
136
137impl PlanState {
138    /// The badge label.
139    pub fn label(self) -> &'static str {
140        match self {
141            Self::Proposed => "Proposed",
142            Self::Approved => "Approved",
143            Self::Rejected => "Rejected",
144            Self::Running => "Running",
145            Self::Completed => "Completed",
146            Self::Failed => "Failed",
147        }
148    }
149
150    fn tone(self) -> StatusTone {
151        match self {
152            Self::Proposed | Self::Running => StatusTone::Info,
153            Self::Approved | Self::Completed => StatusTone::Success,
154            Self::Rejected => StatusTone::Neutral,
155            Self::Failed => StatusTone::Danger,
156        }
157    }
158}
159
160/// An interaction emitted by [`PlanCard`].
161#[derive(Debug, Clone, PartialEq, Eq)]
162pub enum PlanEvent {
163    /// The plan was approved.
164    Approved {
165        /// Stable plan identifier.
166        id: SharedString,
167    },
168    /// The plan was rejected.
169    Rejected {
170        /// Stable plan identifier.
171        id: SharedString,
172    },
173    /// The user asked to change the plan before deciding.
174    EditRequested {
175        /// Stable plan identifier.
176        id: SharedString,
177    },
178    /// The user activated one step (to inspect or jump to it).
179    StepActivated {
180        /// Stable plan identifier.
181        id: SharedString,
182        /// Stable step identifier.
183        step_id: SharedString,
184    },
185}
186
187/// A proposed plan with ordered steps, a state badge, and decision controls.
188///
189/// # Example
190///
191/// ```
192/// # use gpui_ai::prelude::*;
193/// PlanCard::new("rollout", "Switch bulk orders to Alpenrose")
194///     .description("Three steps; the last one sends email.")
195///     .steps([
196///         PlanStep::new("compare", "Compare unit prices").status(PlanStepStatus::Done),
197///         PlanStep::new("draft", "Draft the new order"),
198///         PlanStep::new("send", "Send confirmations").detail("Emails 3 suppliers"),
199///     ])
200///     .editable(true)
201///     .on_event(|event, _, _| { /* PlanEvent::Approved { id } … */ });
202/// ```
203#[derive(IntoElement)]
204pub struct PlanCard {
205    id: SharedString,
206    style: StyleRefinement,
207    decoration: Decoration,
208    title: SharedString,
209    description: Option<SharedString>,
210    steps: Vec<PlanStep>,
211    state: PlanState,
212    note: Option<SharedString>,
213    editable: bool,
214    on_event: Option<SharedHandler<PlanEvent>>,
215}
216
217impl PlanCard {
218    /// Layers painted into this card's frame: one under the content, one
219    /// over it, both clipped to its own shape and neither affecting layout.
220    ///
221    /// This crate ships no effects of its own — what goes in a decoration is
222    /// the application's expression.
223    pub fn decoration(mut self, decoration: Decoration) -> Self {
224        self.decoration = decoration;
225        self
226    }
227
228    /// Creates a proposed plan.
229    pub fn new(id: impl Into<SharedString>, title: impl Into<SharedString>) -> Self {
230        Self {
231            id: id.into(),
232            style: StyleRefinement::default(),
233            decoration: Decoration::default(),
234            title: title.into(),
235            description: None,
236            steps: Vec::new(),
237            state: PlanState::Proposed,
238            note: None,
239            editable: false,
240            on_event: None,
241        }
242    }
243
244    /// Sets supporting detail under the title.
245    pub fn description(mut self, description: impl Into<SharedString>) -> Self {
246        self.description = Some(description.into());
247        self
248    }
249
250    /// Sets the steps, in order.
251    pub fn steps(mut self, steps: impl IntoIterator<Item = PlanStep>) -> Self {
252        self.steps = steps.into_iter().collect();
253        self
254    }
255
256    /// Sets the lifecycle state (default [`PlanState::Proposed`]).
257    pub fn state(mut self, state: PlanState) -> Self {
258        self.state = state;
259        self
260    }
261
262    /// Adds a footnote shown once the plan is decided ("Approved by Oscar").
263    pub fn note(mut self, note: impl Into<SharedString>) -> Self {
264        self.note = Some(note.into());
265        self
266    }
267
268    /// Offers an Edit control while the plan is proposed.
269    pub fn editable(mut self, editable: bool) -> Self {
270        self.editable = editable;
271        self
272    }
273
274    /// Handles typed interactions. Without a handler the card is static.
275    pub fn on_event(
276        mut self,
277        handler: impl Fn(&PlanEvent, &mut Window, &mut App) + 'static,
278    ) -> Self {
279        self.on_event = Some(Rc::new(handler));
280        self
281    }
282
283    /// Counts finished steps.
284    fn done_count(&self) -> usize {
285        self.steps
286            .iter()
287            .filter(|step| step.status == PlanStepStatus::Done)
288            .count()
289    }
290}
291
292impl Styled for PlanCard {
293    fn style(&mut self) -> &mut StyleRefinement {
294        &mut self.style
295    }
296}
297
298impl RenderOnce for PlanCard {
299    fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
300        let tokens = cx.theme().semantic_tokens();
301        // Taken once: each layer is placed at most once, and a
302        // component with no decoration adds no elements at all.
303        let mut decoration = std::mem::take(&mut self.decoration);
304        let done = self.done_count();
305        let handler = self.on_event;
306        let plan_id = self.id.clone();
307        let total = self.steps.len();
308        let label: SharedString = format!(
309            "Plan: {}, {done} of {total} steps done, {}",
310            self.title,
311            self.state.label().to_ascii_lowercase()
312        )
313        .into();
314        let root_id = ElementId::from(self.id.clone());
315        let debug_id = self.id.to_string();
316
317        // Steps the card has already shown never re-animate: the roster
318        // persists in keyed window state, the initial plan joins at rest,
319        // and only steps appended to a mounted card settle in — on the
320        // capped cascade, so a long plan accumulates neither delay nor
321        // offscreen work.
322        let motion = MotionTokens::read(cx).clone();
323        let roster = window.use_keyed_state((root_id.clone(), "arrivals"), cx, |_, _| {
324            ArrivalRoster::new()
325        });
326        roster.update(cx, |roster, cx| {
327            roster.note(
328                self.steps.iter().map(|step| {
329                    ElementId::Name(SharedString::from(format!("plan-step-{}", step.id)))
330                }),
331                true,
332                &motion,
333                cx.background_executor().now(),
334            );
335        });
336        let mut steps = Vec::with_capacity(total);
337        for (index, step) in self.steps.iter().enumerate() {
338            let arrival = roster.update(cx, |roster, cx| {
339                roster.progress(
340                    &ElementId::Name(SharedString::from(format!("plan-step-{}", step.id))),
341                    window,
342                    cx,
343                )
344            });
345            steps.push(render_step(
346                &root_id,
347                &plan_id,
348                index,
349                total,
350                step,
351                arrival,
352                handler.clone(),
353                window,
354                cx,
355            ));
356        }
357
358        let footer: Option<AnyElement> = match (self.state, handler) {
359            (PlanState::Proposed, Some(handler)) => {
360                let approve_id = plan_id.clone();
361                let reject_id = plan_id.clone();
362                let edit_id = plan_id.clone();
363                let approve_handler = handler.clone();
364                let reject_handler = handler.clone();
365                let approve_debug = debug_id.clone();
366                let reject_debug = debug_id.clone();
367                let edit_debug = debug_id.clone();
368                Some(
369                    h_flex()
370                        .items_center()
371                        .gap(tokens.spacing.sm)
372                        .child(
373                            div()
374                                .debug_selector(move || format!("plan-approve-{approve_debug}"))
375                                .child(
376                                    Button::new((root_id.clone(), "approve"))
377                                        .primary()
378                                        .small()
379                                        .control_metrics(cx)
380                                        .accessibility_id(format!("{plan_id}-approve"))
381                                        .text_label("Approve plan")
382                                        .on_click(move |_: &ClickEvent, window, cx| {
383                                            cues::emit(cx, Cue::Decided { approved: true });
384                                            approve_handler(
385                                                &PlanEvent::Approved {
386                                                    id: approve_id.clone(),
387                                                },
388                                                window,
389                                                cx,
390                                            )
391                                        }),
392                                ),
393                        )
394                        .child(
395                            div()
396                                .debug_selector(move || format!("plan-reject-{reject_debug}"))
397                                .child(
398                                    Button::new((root_id.clone(), "reject"))
399                                        .outline()
400                                        .small()
401                                        .control_metrics(cx)
402                                        .accessibility_id(format!("{plan_id}-reject"))
403                                        .text_label("Reject")
404                                        .on_click(move |_: &ClickEvent, window, cx| {
405                                            cues::emit(cx, Cue::Decided { approved: false });
406                                            reject_handler(
407                                                &PlanEvent::Rejected {
408                                                    id: reject_id.clone(),
409                                                },
410                                                window,
411                                                cx,
412                                            )
413                                        }),
414                                ),
415                        )
416                        .when(self.editable, |this| {
417                            this.child(
418                                div()
419                                    .debug_selector(move || format!("plan-edit-{edit_debug}"))
420                                    .child(
421                                        Button::new((root_id.clone(), "edit"))
422                                            .ghost()
423                                            .small()
424                                            .control_metrics(cx)
425                                            .accessibility_id(format!("{plan_id}-edit"))
426                                            .text_label("Edit plan")
427                                            .on_click(move |_: &ClickEvent, window, cx| {
428                                                handler(
429                                                    &PlanEvent::EditRequested {
430                                                        id: edit_id.clone(),
431                                                    },
432                                                    window,
433                                                    cx,
434                                                )
435                                            }),
436                                    ),
437                            )
438                        })
439                        .into_any_element(),
440                )
441            }
442            _ => self
443                .note
444                .clone()
445                .map(|note| meta(note, cx).into_any_element()),
446        };
447
448        card(self.id.clone(), cx)
449            .decoration_under(&mut decoration)
450            .role(Role::Group)
451            .aria_label(label)
452            .when_some(self.description.clone(), |this, text| {
453                this.aria_description(text)
454            })
455            .debug_selector(move || format!("plan-{debug_id}"))
456            .when(self.state == PlanState::Proposed, |this| {
457                this.border_color(cx.theme().warning)
458            })
459            .child(
460                h_flex()
461                    .items_center()
462                    .justify_between()
463                    .gap(tokens.spacing.sm)
464                    .child(eyebrow("Plan", cx))
465                    .child(
466                        h_flex()
467                            .items_center()
468                            .gap(tokens.spacing.sm)
469                            .child(meta(format!("{done} / {total}"), cx))
470                            .child(
471                                StatusBadge::new(
472                                    (ElementId::from(self.id.clone()), "state"),
473                                    self.state.label(),
474                                )
475                                .tone(self.state.tone())
476                                .active(self.state == PlanState::Running),
477                            ),
478                    ),
479            )
480            .child(title(self.title, cx))
481            .when_some(self.description, |this, text| {
482                this.child(description(text, cx))
483            })
484            .child(
485                v_flex()
486                    .id((ElementId::from(self.id.clone()), "steps"))
487                    .role(Role::List)
488                    .aria_label("Steps")
489                    .w_full()
490                    .min_w_0()
491                    .children(steps),
492            )
493            .children(footer)
494            .decoration_over(&mut decoration)
495            .refine_style(&self.style)
496    }
497}
498
499#[allow(clippy::too_many_arguments)]
500fn render_step(
501    root_id: &ElementId,
502    plan_id: &SharedString,
503    index: usize,
504    total: usize,
505    step: &PlanStep,
506    arrival: Option<f32>,
507    handler: Option<SharedHandler<PlanEvent>>,
508    window: &mut Window,
509    cx: &mut App,
510) -> AnyElement {
511    let tokens = cx.theme().semantic_tokens();
512    let step_id = ElementId::from((root_id.clone(), format!("step-{}", step.id)));
513    let plan_debug = plan_id.clone();
514    let step_debug = step.id.clone();
515    let label: SharedString = format!(
516        "Step {}: {}, {}",
517        index + 1,
518        step.title,
519        step.status.label()
520    )
521    .into();
522    let is_last = index + 1 == total;
523
524    let glyph_size = tokens.spacing.lg;
525    // Terminal marks settle into the fixed glyph ring once, after the
526    // controlled status changes; the status a step mounts with is exempt,
527    // and error paths share the same quick, overshoot-free acknowledgment.
528    let acknowledged = acknowledged_state(
529        ElementId::from((step_id.clone(), "glyph")),
530        step.status as u64,
531        window,
532        cx,
533    );
534    let (glyph, ring, fill): (AnyElement, gpui::Hsla, gpui::Hsla) = match step.status {
535        PlanStepStatus::Pending => (
536            div()
537                .text_token(tokens.typography.xs)
538                .font_weight(FontWeight::SEMIBOLD)
539                .text_color(cx.theme().muted_foreground)
540                .child((index + 1).to_string())
541                .into_any_element(),
542            cx.theme().border,
543            cx.theme().transparent,
544        ),
545        PlanStepStatus::Running => (
546            Spinner::new().xsmall().into_any_element(),
547            cx.theme().primary,
548            cx.theme().primary.opacity(0.1),
549        ),
550        PlanStepStatus::Done => (
551            Icon::new(IconName::Check)
552                .xsmall()
553                .text_color(cx.theme().success)
554                .opacity(acknowledged)
555                .into_any_element(),
556            cx.theme().success.opacity(0.5),
557            cx.theme().success.opacity(0.12),
558        ),
559        PlanStepStatus::Failed => (
560            Icon::new(IconName::CircleX)
561                .xsmall()
562                .text_color(cx.theme().danger)
563                .opacity(acknowledged)
564                .into_any_element(),
565            cx.theme().danger.opacity(0.5),
566            cx.theme().danger.opacity(0.12),
567        ),
568        PlanStepStatus::Skipped => (
569            Icon::new(IconName::Dash)
570                .xsmall()
571                .text_color(cx.theme().muted_foreground)
572                .opacity(acknowledged)
573                .into_any_element(),
574            cx.theme().border,
575            cx.theme().muted.opacity(0.5),
576        ),
577    };
578    let rail = v_flex()
579        .flex_none()
580        .items_center()
581        .w(glyph_size)
582        .child(
583            div()
584                .flex_none()
585                .size(glyph_size)
586                .flex()
587                .items_center()
588                .justify_center()
589                .rounded(tokens.radius.full)
590                .border_1()
591                .border_color(ring)
592                .bg(fill)
593                .child(glyph),
594        )
595        .when(!is_last, |this| {
596            this.child(
597                div()
598                    .flex_1()
599                    .min_h(tokens.spacing.sm)
600                    .border_l_1()
601                    .border_color(cx.theme().border),
602            )
603        });
604    let body = div()
605        .flex()
606        .flex_col()
607        .min_w_0()
608        .flex_1()
609        .gap(tokens.spacing.xxs)
610        .pb(if is_last {
611            tokens.spacing.xxs
612        } else {
613            tokens.spacing.sm
614        })
615        .child(
616            div()
617                .text_token(tokens.typography.sm)
618                .text_color(match step.status {
619                    PlanStepStatus::Skipped => cx.theme().muted_foreground,
620                    _ => cx.theme().foreground,
621                })
622                .child(step.title.clone()),
623        )
624        .when_some(step.detail.clone(), |this, detail| {
625            this.child(crate::surface::hint(detail, cx))
626        });
627
628    let row = match handler {
629        Some(handler) => {
630            let event = PlanEvent::StepActivated {
631                id: plan_id.clone(),
632                step_id: step.id.clone(),
633            };
634            composed_button(step_id.clone(), label)
635                .debug_selector(move || format!("plan-step-{plan_debug}-{step_debug}"))
636                .flex()
637                .items_stretch()
638                .w_full()
639                .min_w_0()
640                .gap(tokens.spacing.sm)
641                .quiet_press_surface(
642                    ElementId::from((step_id, "press")),
643                    tokens.radius.sm,
644                    window,
645                    cx,
646                )
647                .child(rail)
648                .child(body)
649                .on_click(move |_: &ClickEvent, window, cx| handler(&event, window, cx))
650                .into_any_element()
651        }
652        None => h_flex()
653            .id(step_id)
654            .role(Role::ListItem)
655            .aria_label(label)
656            .debug_selector(move || format!("plan-step-{plan_debug}-{step_debug}"))
657            .items_stretch()
658            .w_full()
659            .min_w_0()
660            .gap(tokens.spacing.sm)
661            .child(rail)
662            .child(body)
663            .into_any_element(),
664    };
665    let wrapped = div().w_full().min_w_0().child(row);
666    match arrival {
667        // A freshly appended step settles in on its assigned beat; every
668        // other step — the whole initial plan included — renders at rest.
669        Some(progress) => wrapped
670            .opacity(progress)
671            .top(tokens.spacing.xxs * (1.0 - progress) * crate::motion::travel(cx))
672            .into_any_element(),
673        None => wrapped.into_any_element(),
674    }
675}
676
677#[cfg(test)]
678mod tests {
679    use super::*;
680    use gpui::{Context, Render, TestAppContext, VisualTestContext, px};
681
682    struct PlanProbe {
683        count: usize,
684    }
685
686    impl Render for PlanProbe {
687        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
688            div().w(px(360.)).h(px(480.)).child(
689                PlanCard::new("probe-plan", "Rollout")
690                    .steps((0..self.count).map(|ix| PlanStep::new(format!("step-{ix}"), "Step"))),
691            )
692        }
693    }
694
695    #[gpui::test]
696    fn a_proposed_plan_mounts_at_rest_and_new_steps_settle_in(cx: &mut TestAppContext) {
697        cx.update(crate::init);
698        let (probe, cx) = cx.add_window_view(|_, _| PlanProbe { count: 3 });
699        let cx: &mut VisualTestContext = cx;
700        cx.update(|window, cx| window.draw(cx).clear(cx));
701        crate::motion::take_reveal_frame_requests();
702        cx.update(|window, cx| window.draw(cx).clear(cx));
703        assert_eq!(
704            crate::motion::take_reveal_frame_requests(),
705            0,
706            "a plan's initial steps are a proposal to read, not a cascade"
707        );
708
709        probe.update(cx, |probe, cx| {
710            probe.count = 4;
711            cx.notify();
712        });
713        cx.update(|window, cx| window.draw(cx).clear(cx));
714        assert!(
715            crate::motion::take_reveal_frame_requests() > 0,
716            "a step appended to a mounted plan must settle in"
717        );
718    }
719
720    #[test]
721    fn plan_state_labels_and_tones_follow_the_lifecycle() {
722        assert_eq!(PlanState::Proposed.label(), "Proposed");
723        assert_eq!(PlanState::Completed.tone(), StatusTone::Success);
724        assert_eq!(PlanState::Failed.tone(), StatusTone::Danger);
725        assert_eq!(PlanState::Rejected.tone(), StatusTone::Neutral);
726    }
727
728    #[test]
729    fn done_steps_are_counted_for_the_accessible_name() {
730        let card = PlanCard::new("p", "Plan").steps([
731            PlanStep::new("a", "A").status(PlanStepStatus::Done),
732            PlanStep::new("b", "B").status(PlanStepStatus::Running),
733            PlanStep::new("c", "C"),
734        ]);
735        assert_eq!(card.done_count(), 1);
736    }
737}