Skip to main content

gpui_kit/agent/
approval.rs

1//! A request for permission to do one specific thing.
2//!
3//! Everything here is arranged around one rule: **the default is refusal.**
4//! Nothing in this component makes approving easier than declining by
5//! accident.
6//!
7//! - The keyboard lands on the decline control when the prompt appears, the
8//!   way [`Dialog`](crate::overlay::Dialog) opens a destructive confirmation
9//!   on cancel.
10//! - Return acts on the control that has the keyboard and on nothing else, so
11//!   a return key pressed at the wrong moment declines. Approving with the
12//!   keyboard takes a deliberate tab first.
13//! - Escape declines, which is what escape does everywhere else in this
14//!   library, and is the safe direction here rather than merely the
15//!   conventional one.
16//! - A resolved prompt installs no handler at all.
17//!
18//! # An unscoped "always" cannot be built
19//!
20//! [`AlwaysScope`] has no variant that means "always, everywhere". Every
21//! variant either is the session itself or carries the one thing it is always
22//! for, and the wording on the control is derived from that variant, so a
23//! control offering "always" without saying what "always" covers is not a
24//! thing a caller can construct.
25//!
26//! # A prompt that was never answered was not refused
27//!
28//! [`ApprovalStatus`] keeps `Declined`, `Expired` and `Superseded` apart.
29//! They are three different facts about what happened — a person said no,
30//! nobody said anything in time, and a later request took this one's place —
31//! and each is rendered and published differently.
32
33use gpui::{
34    App, Context, EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement,
35    KeyDownEvent, ParentElement, Render, SharedString, Styled, Window, div, prelude::FluentBuilder,
36};
37use gpui_kit_semantics::{NodeSpec, Role, Semantic};
38use gpui_kit_theme::{ActiveTheme, Elevation, Radius, Space, TypeScale};
39
40use crate::controls::button::{Button, ButtonVariant};
41use crate::display::badge::Tone;
42use crate::display::description_list::{DescriptionItem, DescriptionList};
43use crate::display::status::StatusLine;
44use crate::foundation::{Ident, StyledExt, text};
45use crate::strings::{ActiveStrings, StringKey};
46
47/// What a standing approval covers.
48///
49/// There is deliberately no bare `Always`. A caller states which of these
50/// four kinds of "always" it is offering, and the control words itself from
51/// that, so an unscoped standing permission has no representation.
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub enum AlwaysScope {
54    /// Until this session ends. The session is the scope, so it names nothing
55    /// further.
56    Session,
57    /// Every future use of one named tool.
58    Tool(SharedString),
59    /// Anything under one named place on disk.
60    Path(SharedString),
61    /// Every future request to one named host.
62    Host(SharedString),
63}
64
65impl AlwaysScope {
66    pub fn tool(name: impl Into<SharedString>) -> Self {
67        Self::Tool(name.into())
68    }
69
70    pub fn path(path: impl Into<SharedString>) -> Self {
71        Self::Path(path.into())
72    }
73
74    pub fn host(host: impl Into<SharedString>) -> Self {
75        Self::Host(host.into())
76    }
77
78    /// The stable name of the kind of scope, used for the control's id and
79    /// published in the semantic tree.
80    pub fn name(&self) -> &'static str {
81        match self {
82            Self::Session => "session",
83            Self::Tool(_) => "tool",
84            Self::Path(_) => "path",
85            Self::Host(_) => "host",
86        }
87    }
88
89    /// The thing the scope covers, when it is one named thing.
90    pub fn subject(&self) -> Option<&SharedString> {
91        match self {
92            Self::Session => None,
93            Self::Tool(name) | Self::Path(name) | Self::Host(name) => Some(name),
94        }
95    }
96
97    /// The wording that appears on the control, which always states the
98    /// scope.
99    pub fn label(&self, cx: &App) -> SharedString {
100        let strings = cx.strings();
101        match self {
102            Self::Session => strings.text(StringKey::ApprovalAlwaysSession),
103            Self::Tool(name) => strings.format(StringKey::ApprovalAlwaysTool, &[name]),
104            Self::Path(path) => strings.format(StringKey::ApprovalAlwaysPath, &[path]),
105            Self::Host(host) => strings.format(StringKey::ApprovalAlwaysHost, &[host]),
106        }
107    }
108}
109
110/// How far an approval reaches.
111#[derive(Debug, Clone, PartialEq, Eq)]
112pub enum ApprovalDecision {
113    /// This request and nothing else.
114    Once,
115    /// A standing permission, which always states what it covers.
116    Always(AlwaysScope),
117}
118
119impl ApprovalDecision {
120    /// The wording for the reach of the decision, for the sentence a resolved
121    /// prompt shows.
122    pub fn label(&self, cx: &App) -> SharedString {
123        match self {
124            Self::Once => cx.strings().text(StringKey::ApprovalOnceScope),
125            Self::Always(scope) => scope.label(cx),
126        }
127    }
128}
129
130/// What has happened to this request.
131///
132/// Declined, expired and superseded are three different things and are never
133/// collapsed into one another.
134#[derive(Debug, Clone, PartialEq, Eq, Default)]
135pub enum ApprovalStatus {
136    /// Nobody has answered yet. The only status that offers controls.
137    #[default]
138    Pending,
139    /// Somebody answered, and the answer was no.
140    Declined,
141    /// Somebody answered, and said how far the answer reaches.
142    Approved(ApprovalDecision),
143    /// The window in which this could be answered closed. Nobody refused it.
144    Expired,
145    /// A later request took this one's place. The wording naming the
146    /// replacement belongs to the host and is shown verbatim.
147    Superseded { by: SharedString },
148}
149
150impl ApprovalStatus {
151    /// The stable name published in the semantic tree.
152    pub fn name(&self) -> &'static str {
153        match self {
154            Self::Pending => "pending",
155            Self::Declined => "declined",
156            Self::Approved(_) => "approved",
157            Self::Expired => "expired",
158            Self::Superseded { .. } => "superseded",
159        }
160    }
161
162    fn is_pending(&self) -> bool {
163        matches!(self, Self::Pending)
164    }
165}
166
167/// What the prompt reports. It applies none of it.
168#[derive(Debug, Clone, PartialEq, Eq)]
169pub enum ApprovalEvent {
170    /// The typist approved, and this is how far the approval reaches.
171    Approved(ApprovalDecision),
172    /// The typist declined. Also what escape and a stray return key report.
173    Declined,
174}
175
176impl EventEmitter<ApprovalEvent> for ApprovalPrompt {}
177
178/// One request for permission, with the answer left to the caller.
179///
180/// It is a view rather than a builder because where the keyboard is has to
181/// survive a frame, and where the keyboard is *is* the safety property.
182pub struct ApprovalPrompt {
183    ident: Ident,
184    focus_handle: FocusHandle,
185    decline_focus: FocusHandle,
186    approve_focus: FocusHandle,
187    always_focus: Vec<FocusHandle>,
188    /// Exactly what is being asked for, in the caller's words. Required by
189    /// the constructor: a prompt with nothing specific to say is not one this
190    /// component can render.
191    action: SharedString,
192    details: Vec<DescriptionItem>,
193    always: Vec<AlwaysScope>,
194    status: ApprovalStatus,
195    /// Cleared by the first frame that can act on it, as in `Dialog`.
196    pending_focus: bool,
197}
198
199impl std::fmt::Debug for ApprovalPrompt {
200    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
201        formatter
202            .debug_struct("ApprovalPrompt")
203            .field("ident", &self.ident)
204            .field("action", &self.action)
205            .field("details", &self.details.len())
206            .field("always", &self.always)
207            .field("status", &self.status)
208            .finish()
209    }
210}
211
212impl ApprovalPrompt {
213    /// `action` is what is about to happen, stated specifically. There is no
214    /// way to describe the request as a category instead.
215    pub fn new(
216        ident: impl Into<Ident>,
217        action: impl Into<SharedString>,
218        _window: &mut Window,
219        cx: &mut Context<Self>,
220    ) -> Self {
221        Self {
222            ident: ident.into(),
223            focus_handle: cx.focus_handle(),
224            decline_focus: cx.focus_handle(),
225            approve_focus: cx.focus_handle(),
226            always_focus: Vec::new(),
227            action: action.into(),
228            details: Vec::new(),
229            always: Vec::new(),
230            status: ApprovalStatus::Pending,
231            pending_focus: true,
232        }
233    }
234
235    /// One more specific fact about the request: the exact path, the exact
236    /// command, the exact host.
237    pub fn detail(mut self, detail: DescriptionItem) -> Self {
238        self.details.push(detail);
239        self
240    }
241
242    pub fn details(mut self, details: impl IntoIterator<Item = DescriptionItem>) -> Self {
243        self.details.extend(details);
244        self
245    }
246
247    /// Offers a standing approval. The scope is part of the argument, so the
248    /// control cannot be worded without it.
249    pub fn always(mut self, scope: AlwaysScope) -> Self {
250        self.always.push(scope);
251        self
252    }
253
254    pub fn status(mut self, status: ApprovalStatus) -> Self {
255        self.status = status;
256        self
257    }
258
259    pub fn current_status(&self) -> &ApprovalStatus {
260        &self.status
261    }
262
263    /// Records what happened to the request. A host that expires or supersedes
264    /// a prompt says so through here rather than by removing it, so the reader
265    /// finds out why the controls went away.
266    pub fn set_status(&mut self, status: ApprovalStatus, cx: &mut Context<Self>) {
267        self.status = status;
268        cx.notify();
269    }
270
271    /// Reports an approval. Refused unless the prompt is still pending, so a
272    /// host calling this on a resolved prompt cannot resurrect it.
273    pub fn approve(&mut self, decision: ApprovalDecision, cx: &mut Context<Self>) {
274        if !self.status.is_pending() {
275            return;
276        }
277        cx.emit(ApprovalEvent::Approved(decision));
278    }
279
280    pub fn decline(&mut self, cx: &mut Context<Self>) {
281        if !self.status.is_pending() {
282            return;
283        }
284        cx.emit(ApprovalEvent::Declined);
285    }
286
287    /// The controls, in the order tab visits them. Decline is first, so the
288    /// keyboard has to travel to reach an approval.
289    fn stops(&self) -> Vec<FocusHandle> {
290        let mut stops = vec![self.decline_focus.clone(), self.approve_focus.clone()];
291        stops.extend(self.always_focus.iter().cloned());
292        stops
293    }
294
295    /// Moves the keyboard within the prompt's own controls.
296    ///
297    /// This is not a trap: an approval prompt sits inline in a page, and the
298    /// keyboard leaves it the way it leaves anything else. It is an order,
299    /// which the decline-first rule needs in order to mean anything.
300    fn step_focus(&mut self, back: bool, window: &mut Window, cx: &mut Context<Self>) {
301        let stops = self.stops();
302        let at = stops
303            .iter()
304            .position(|handle| handle.is_focused(window))
305            .map(|at| {
306                if back {
307                    (at + stops.len() - 1) % stops.len()
308                } else {
309                    (at + 1) % stops.len()
310                }
311            })
312            .unwrap_or(0);
313        stops[at].clone().focus(window, cx);
314    }
315
316    /// Return acts on whatever holds the keyboard, and approval is never what
317    /// holds it by default. Escape declines.
318    fn on_key(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
319        if !self.status.is_pending() {
320            return;
321        }
322        if event.keystroke.key.as_str() == "tab" {
323            self.step_focus(event.keystroke.modifiers.shift, window, cx);
324            cx.stop_propagation();
325            return;
326        }
327        match event.keystroke.key.as_str() {
328            "escape" => {
329                self.decline(cx);
330                cx.stop_propagation();
331            }
332            "enter" => {
333                if self.approve_focus.is_focused(window) {
334                    self.approve(ApprovalDecision::Once, cx);
335                } else if let Some(scope) = self
336                    .always_focus
337                    .iter()
338                    .position(|handle| handle.is_focused(window))
339                    .and_then(|index| self.always.get(index).cloned())
340                {
341                    self.approve(ApprovalDecision::Always(scope), cx);
342                } else {
343                    // Anything else with the keyboard, including nothing at
344                    // all, means the typist has not deliberately reached an
345                    // approval. Declining is the answer this component gives
346                    // when it is not sure.
347                    self.decline(cx);
348                }
349                cx.stop_propagation();
350            }
351            _ => {}
352        }
353    }
354
355    fn outcome(&self, cx: &App) -> Option<(SharedString, Tone)> {
356        let strings = cx.strings();
357        match &self.status {
358            ApprovalStatus::Pending => None,
359            ApprovalStatus::Declined => {
360                Some((strings.text(StringKey::ApprovalDeclined), Tone::Danger))
361            }
362            ApprovalStatus::Approved(decision) => Some((
363                strings.format(StringKey::ApprovalApproved, &[&decision.label(cx)]),
364                Tone::Success,
365            )),
366            ApprovalStatus::Expired => {
367                Some((strings.text(StringKey::ApprovalExpired), Tone::Warning))
368            }
369            ApprovalStatus::Superseded { by } => Some((
370                strings.format(StringKey::ApprovalSuperseded, &[by]),
371                Tone::Neutral,
372            )),
373        }
374    }
375}
376
377impl Focusable for ApprovalPrompt {
378    fn focus_handle(&self, _cx: &App) -> FocusHandle {
379        self.focus_handle.clone()
380    }
381}
382
383impl Render for ApprovalPrompt {
384    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
385        let theme = cx.theme().clone();
386        let pending = self.status.is_pending();
387
388        while self.always_focus.len() < self.always.len() {
389            self.always_focus.push(cx.focus_handle());
390        }
391
392        if pending && self.pending_focus {
393            // The handle can only take focus once this frame has put it in the
394            // dispatch tree.
395            self.pending_focus = false;
396            self.decline_focus.clone().focus(window, cx);
397        }
398
399        let outcome = self.outcome(cx);
400        let prompt = cx.entity().downgrade();
401
402        let decline = pending.then(|| {
403            let prompt = prompt.clone();
404            Button::new(self.ident.child("decline"))
405                .label(cx.strings().text(StringKey::ApprovalDecline))
406                .secondary()
407                .semantic_parent(self.ident.semantic_id())
408                .track_focus(&self.decline_focus)
409                .on_click(move |_window, cx| {
410                    prompt.update(cx, |prompt, cx| prompt.decline(cx)).ok();
411                })
412        });
413
414        let approve = pending.then(|| {
415            let prompt = prompt.clone();
416            Button::new(self.ident.child("approve"))
417                .label(cx.strings().text(StringKey::ApprovalApproveOnce))
418                .variant(ButtonVariant::Primary)
419                .semantic_parent(self.ident.semantic_id())
420                .track_focus(&self.approve_focus)
421                .on_click(move |_window, cx| {
422                    prompt
423                        .update(cx, |prompt, cx| prompt.approve(ApprovalDecision::Once, cx))
424                        .ok();
425                })
426        });
427
428        let always: Vec<_> = if pending {
429            self.always
430                .iter()
431                .zip(self.always_focus.iter())
432                .map(|(scope, handle)| {
433                    let prompt = prompt.clone();
434                    let chosen = scope.clone();
435                    Button::new(self.ident.child("always").child(scope.name()))
436                        .label(scope.label(cx))
437                        .ghost()
438                        .semantic_parent(self.ident.semantic_id())
439                        .track_focus(handle)
440                        .on_click(move |_window, cx| {
441                            let chosen = chosen.clone();
442                            prompt
443                                .update(cx, |prompt, cx| {
444                                    prompt.approve(ApprovalDecision::Always(chosen), cx)
445                                })
446                                .ok();
447                        })
448                })
449                .collect()
450        } else {
451            Vec::new()
452        };
453
454        let details = (!self.details.is_empty())
455            .then(|| DescriptionList::new(self.ident.child("detail")).items(self.details.clone()));
456
457        let spec = NodeSpec::new(self.ident.semantic_id(), Role::Form)
458            .text(self.action.clone())
459            .value(SharedString::new_static(self.status.name()))
460            .focus(&self.focus_handle);
461
462        div()
463            .column()
464            .w_full()
465            .gap_token(&theme, Space::Md)
466            .p_token(&theme, Space::Lg)
467            .radius(&theme, Radius::Card)
468            .frame(&theme, gpui_kit_theme::Surface::Raised, Elevation::Raised)
469            .track_focus(&self.focus_handle)
470            .when(pending, |element| {
471                element.on_key_down(cx.listener(Self::on_key))
472            })
473            .child(
474                text(&theme, TypeScale::Body, self.action.clone()).semantic_in(
475                    cx,
476                    NodeSpec::new(self.ident.child("action").semantic_id(), Role::Text)
477                        .text(self.action.clone())
478                        .parent(self.ident.semantic_id()),
479                ),
480            )
481            .children(details)
482            .when_some(outcome, |element, (text, tone)| {
483                element
484                    .child(div().child(StatusLine::new(text, tone).id(self.ident.child("outcome"))))
485            })
486            .when(pending, |element| {
487                element.child(
488                    div()
489                        .column()
490                        .gap_token(&theme, Space::Sm)
491                        .child(
492                            div()
493                                .row()
494                                .gap_token(&theme, Space::Sm)
495                                .children(decline)
496                                .children(approve),
497                        )
498                        .when(!always.is_empty(), |element| {
499                            element.child(
500                                div()
501                                    .flex()
502                                    .flex_row()
503                                    .flex_wrap()
504                                    .gap_token(&theme, Space::Sm)
505                                    .children(always),
506                            )
507                        }),
508                )
509            })
510            .semantic_in(cx, spec)
511    }
512}