Skip to main content

gpui_ai/
approval.rs

1//! Human-in-the-loop approval gates for agent actions.
2
3use crate::ButtonLabelExt as _;
4use crate::control::ControlMetricsExt as _;
5use crate::cues::{self, Cue};
6use crate::decoration::{DecoratedExt as _, Decoration};
7use crate::handlers::SharedHandler;
8use crate::motion::acknowledged_state;
9use crate::status::{StatusBadge, StatusTone};
10use crate::surface::{card, description, inset, meta, title};
11use gpui::{
12    AnyElement, App, ClickEvent, ElementId, InteractiveElement as _, IntoElement, ParentElement,
13    RenderOnce, Role, SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled,
14    Window, div, prelude::FluentBuilder as _,
15};
16use gpui_component::{
17    ActiveTheme as _, Sizable as _, StyledExt as _,
18    button::{Button, ButtonVariants as _},
19    h_flex,
20};
21use std::rc::Rc;
22
23/// Whether the gate is still open or how it was closed.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
25pub enum ApprovalDecision {
26    /// Waiting for the human.
27    #[default]
28    Pending,
29    /// The action was approved.
30    Approved,
31    /// The action was rejected.
32    Rejected,
33}
34
35impl ApprovalDecision {
36    /// The badge label for a closed gate.
37    pub fn label(self) -> &'static str {
38        match self {
39            Self::Pending => "Pending",
40            Self::Approved => "Approved",
41            Self::Rejected => "Rejected",
42        }
43    }
44}
45
46/// How consequential the proposed action is.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
48pub enum ApprovalTone {
49    /// Reversible or routine; a warning border says "decide".
50    #[default]
51    Default,
52    /// Irreversible; a danger border and a danger approve button.
53    Destructive,
54}
55
56/// A decision gate: the agent proposes an action, the human approves or
57/// rejects it.
58///
59/// The card body is a children slot, so any payload — a diff, a table, plain
60/// text — can be placed inside via [`ParentElement`] methods.
61///
62/// # Example
63///
64/// ```
65/// # use gpui_ai::prelude::*;
66/// ApprovalCard::new("gate-1", "Send order confirmation to 3 suppliers?")
67///     .description("Emails will go out immediately and cannot be recalled.")
68///     .on_event(|event, _, _| match event {
69///         ApprovalEvent::Approved { .. } => { /* proceed */ }
70///         ApprovalEvent::ApprovedAlways { .. } => { /* remember approval and proceed */ }
71///         ApprovalEvent::Rejected { .. } => { /* cancel */ }
72///     });
73/// ```
74#[derive(IntoElement)]
75pub struct ApprovalCard {
76    id: SharedString,
77    style: StyleRefinement,
78    decoration: Decoration,
79    title: SharedString,
80    description: Option<SharedString>,
81    approve_label: SharedString,
82    reject_label: SharedString,
83    children: Vec<AnyElement>,
84    decision: ApprovalDecision,
85    tone: ApprovalTone,
86    allow_always: bool,
87    note: Option<SharedString>,
88    on_event: Option<SharedHandler<ApprovalEvent>>,
89}
90
91impl ApprovalCard {
92    /// Layers painted into this card's frame: one under the content, one
93    /// over it, both clipped to its own shape and neither affecting layout.
94    ///
95    /// This crate ships no effects of its own — what goes in a decoration is
96    /// the application's expression.
97    pub fn decoration(mut self, decoration: Decoration) -> Self {
98        self.decoration = decoration;
99        self
100    }
101
102    /// Creates a gate with the question being asked of the human.
103    pub fn new(id: impl Into<SharedString>, title: impl Into<SharedString>) -> Self {
104        Self {
105            id: id.into(),
106            style: StyleRefinement::default(),
107            decoration: Decoration::default(),
108            title: title.into(),
109            description: None,
110            approve_label: "Approve".into(),
111            reject_label: "Reject".into(),
112            children: Vec::new(),
113            decision: ApprovalDecision::Pending,
114            tone: ApprovalTone::Default,
115            allow_always: false,
116            note: None,
117            on_event: None,
118        }
119    }
120
121    /// Closes the gate: resolved cards show the decision instead of buttons.
122    pub fn decision(mut self, decision: ApprovalDecision) -> Self {
123        self.decision = decision;
124        self
125    }
126
127    /// Marks the action as irreversible (danger border and approve button).
128    pub fn tone(mut self, tone: ApprovalTone) -> Self {
129        self.tone = tone;
130        self
131    }
132
133    /// Offers "Always allow", reported as [`ApprovalEvent::ApprovedAlways`].
134    pub fn allow_always(mut self, allow_always: bool) -> Self {
135        self.allow_always = allow_always;
136        self
137    }
138
139    /// Adds a footnote shown once the gate is closed ("Approved by Oscar").
140    pub fn note(mut self, note: impl Into<SharedString>) -> Self {
141        self.note = Some(note.into());
142        self
143    }
144
145    /// Sets supporting detail — consequences, scope, reversibility.
146    pub fn description(mut self, description: impl Into<SharedString>) -> Self {
147        self.description = Some(description.into());
148        self
149    }
150
151    /// Overrides the approve button label (default "Approve").
152    pub fn approve_label(mut self, label: impl Into<SharedString>) -> Self {
153        self.approve_label = label.into();
154        self
155    }
156
157    /// Overrides the reject button label (default "Reject").
158    pub fn reject_label(mut self, label: impl Into<SharedString>) -> Self {
159        self.reject_label = label.into();
160        self
161    }
162
163    /// Handles typed approval decisions.
164    pub fn on_event(
165        mut self,
166        handler: impl Fn(&ApprovalEvent, &mut Window, &mut App) + 'static,
167    ) -> Self {
168        self.on_event = Some(Rc::new(handler));
169        self
170    }
171}
172
173impl ParentElement for ApprovalCard {
174    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
175        self.children.extend(elements);
176    }
177}
178
179impl Styled for ApprovalCard {
180    fn style(&mut self) -> &mut StyleRefinement {
181        &mut self.style
182    }
183}
184
185impl RenderOnce for ApprovalCard {
186    fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
187        let tokens = cx.theme().semantic_tokens();
188        // Taken once: each layer is placed at most once, and a
189        // component with no decoration adds no elements at all.
190        let mut decoration = std::mem::take(&mut self.decoration);
191        let has_payload = !self.children.is_empty();
192        let handler = self.on_event;
193        let approve_event = ApprovalEvent::Approved {
194            id: self.id.clone(),
195        };
196        let reject_event = ApprovalEvent::Rejected {
197            id: self.id.clone(),
198        };
199        let always_event = ApprovalEvent::ApprovedAlways {
200            id: self.id.clone(),
201        };
202        let accessibility_label = self.title.clone();
203        let accessibility_description = self.description.clone();
204        let root_id = ElementId::from(self.id.clone());
205        let debug_id = self.id.to_string();
206        let decided = self.decision != ApprovalDecision::Pending;
207        let border = match (self.decision, self.tone) {
208            (ApprovalDecision::Pending, ApprovalTone::Default) => cx.theme().warning,
209            (ApprovalDecision::Pending, ApprovalTone::Destructive) => cx.theme().danger,
210            _ => cx.theme().border,
211        };
212        let destructive = self.tone == ApprovalTone::Destructive;
213        let approve_debug = debug_id.clone();
214        let reject_debug = debug_id.clone();
215        let always_debug = debug_id.clone();
216        let decision_debug = debug_id.clone();
217        let settled = acknowledged_state(
218            ElementId::from((root_id.clone(), "resolved")),
219            self.decision as u64,
220            window,
221            cx,
222        );
223        let footer: AnyElement = if decided {
224            // The event fired the moment the button was pressed; this is
225            // acknowledgment, staged after the controlled state. It plays
226            // once per decision — a card that mounts already decided shows
227            // its resolved text without motion, and rapid decision changes
228            // retarget by playing the new state's own acknowledgment.
229            // The badge and the note share a text baseline, so a note long
230            // enough to wrap stays hung from the badge's own line instead
231            // of floating the badge against the middle of a block.
232            h_flex()
233                .items_baseline()
234                .gap(tokens.spacing.sm)
235                .opacity(settled)
236                .top(tokens.spacing.xxs * (1.0 - settled) * crate::motion::travel(cx))
237                .child(
238                    div()
239                        .flex_none()
240                        .debug_selector(move || format!("approval-decision-{decision_debug}"))
241                        .child(
242                            StatusBadge::new((root_id.clone(), "decision"), self.decision.label())
243                                .tone(match self.decision {
244                                    ApprovalDecision::Approved => StatusTone::Success,
245                                    _ => StatusTone::Neutral,
246                                }),
247                        ),
248                )
249                .when_some(self.note.clone(), |this, note| {
250                    this.child(div().min_w_0().child(meta(note, cx)))
251                })
252                .into_any_element()
253        } else {
254            h_flex()
255                .flex_wrap()
256                .items_center()
257                .gap(tokens.spacing.sm)
258                .when_some(handler.clone(), |this, handler| {
259                    this.child(
260                        div()
261                            .debug_selector(move || format!("approval-approve-{approve_debug}"))
262                            .child(
263                                Button::new(format!("{}-approve", self.id))
264                                    .map(|button| {
265                                        if destructive {
266                                            button.danger()
267                                        } else {
268                                            button.primary()
269                                        }
270                                    })
271                                    .small()
272                                    .control_metrics(cx)
273                                    .accessibility_id(format!("{}-approve", self.id))
274                                    .text_label(self.approve_label.clone())
275                                    .on_click(move |_: &ClickEvent, window, cx| {
276                                        cues::emit(cx, Cue::Decided { approved: true });
277                                        handler(&approve_event, window, cx)
278                                    }),
279                            ),
280                    )
281                })
282                .when_some(handler.clone(), |this, handler| {
283                    this.child(
284                        div()
285                            .debug_selector(move || format!("approval-reject-{reject_debug}"))
286                            .child(
287                                Button::new(format!("{}-reject", self.id))
288                                    .outline()
289                                    .small()
290                                    .control_metrics(cx)
291                                    .accessibility_id(format!("{}-reject", self.id))
292                                    .text_label(self.reject_label.clone())
293                                    .on_click(move |_: &ClickEvent, window, cx| {
294                                        cues::emit(cx, Cue::Decided { approved: false });
295                                        handler(&reject_event, window, cx)
296                                    }),
297                            ),
298                    )
299                })
300                .when_some(handler.filter(|_| self.allow_always), |this, handler| {
301                    this.child(
302                        div()
303                            .debug_selector(move || format!("approval-always-{always_debug}"))
304                            .child(
305                                Button::new(format!("{}-always", self.id))
306                                    .ghost()
307                                    .small()
308                                    .control_metrics(cx)
309                                    .accessibility_id(format!("{}-always", self.id))
310                                    .text_label("Always allow")
311                                    .on_click(move |_: &ClickEvent, window, cx| {
312                                        cues::emit(cx, Cue::Decided { approved: true });
313                                        handler(&always_event, window, cx)
314                                    }),
315                            ),
316                    )
317                })
318                .into_any_element()
319        };
320
321        // A decision gate is the one card that earns a semantic accent: the
322        // warning border says "stop and decide" before any text is read.
323        card(self.id.clone(), cx)
324            .decoration_under(&mut decoration)
325            .role(Role::Group)
326            .aria_label(accessibility_label)
327            .when_some(accessibility_description, |this, description| {
328                this.aria_description(description)
329            })
330            .border_color(border)
331            .when(decided, |this| this.bg(cx.theme().muted.opacity(0.25)))
332            .child(title(self.title, cx))
333            .when_some(self.description, |this, text| {
334                this.child(description(text, cx))
335            })
336            .when(has_payload, |this| {
337                this.child(
338                    inset(cx)
339                        .flex()
340                        .flex_col()
341                        .gap(tokens.spacing.xs)
342                        .children(self.children),
343                )
344            })
345            .child(footer)
346            .decoration_over(&mut decoration)
347            .refine_style(&self.style)
348    }
349}
350/// An interaction emitted by [`ApprovalCard`].
351#[derive(Debug, Clone, PartialEq, Eq)]
352pub enum ApprovalEvent {
353    /// The proposed action was approved.
354    Approved {
355        /// Stable gate identifier.
356        id: SharedString,
357    },
358    /// The proposed action was rejected.
359    Rejected {
360        /// Stable gate identifier.
361        id: SharedString,
362    },
363    /// The action was approved for this and every later occurrence.
364    ApprovedAlways {
365        /// Stable gate identifier.
366        id: SharedString,
367    },
368}