Skip to main content

gpui_cn/composer/
permission.rs

1use gpui_kit::{
2    App, AppContext as _, Context, ElementId, Entity, EventEmitter, IntoElement,
3    ParentElement as _, RenderOnce, SharedString, StyleRefinement, Styled, Subscription, Window,
4    assets::IconName,
5    base::{Align, Selectable, StyledExt as _},
6    div,
7    prelude::FluentBuilder as _,
8    px, rems,
9};
10
11use crate::{
12    ActiveTheme as _, Button, ButtonSize, DropdownMenu, Icon, MenuEntry, MenuEvent, MenuItem,
13    MenuState,
14    collapse::{self, Need},
15    menu::{MenuLook, OpenSlot, label_block, line_slot, row_line},
16};
17
18/// How many characters of its label the permission trigger keeps before it
19/// becomes an icon. It keeps fewer than the other controls
20/// (`collapse::MIN_LABEL_CHARS`) because the mode names are short words,
21/// so six characters already tell them apart.
22const MIN_LABEL_CHARS: usize = 6;
23
24/// The key of the menu row that reports [`PermissionEvent::LearnMore`].
25const LEARN_MORE: &str = "learn-more";
26
27/// How a permission mode reads: an ordinary choice, or a risky one that
28/// shows in the warning color.
29#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
30#[non_exhaustive]
31pub enum PermissionTone {
32    /// Muted, like the other labels. The default.
33    #[default]
34    Normal,
35    /// The warning color, for a mode that runs without asking.
36    Warning,
37}
38
39/// How much an agent may do without asking, as one entry of a
40/// [`PermissionMenu`]: an id, a label, a line of description, and an icon.
41///
42/// The id comes from the domain and is unique in the menu. The default
43/// set is [`PermissionMode::defaults`]; an application defines its own
44/// modes the same way.
45///
46/// ```
47/// use gpui_cn::PermissionMode;
48/// use gpui_kit::assets::IconName;
49///
50/// let strict = PermissionMode::new("strict", "Strict")
51///     .description("Ask before every change")
52///     .icon(IconName::Check);
53/// assert_eq!(strict.label().as_ref(), "Strict");
54/// ```
55#[derive(Clone)]
56#[non_exhaustive]
57pub struct PermissionMode {
58    id: SharedString,
59    label: SharedString,
60    description: Option<SharedString>,
61    icon: Option<Icon>,
62    tone: PermissionTone,
63}
64
65impl PermissionMode {
66    /// The id of the default `Auto` mode.
67    pub const AUTO: &'static str = "auto";
68    /// The id of the default `Manual` mode.
69    pub const MANUAL: &'static str = "manual";
70    /// The id of the default `Plan` mode.
71    pub const PLAN: &'static str = "plan";
72    /// The id of the default `Bypass all` mode.
73    pub const BYPASS: &'static str = "bypass";
74
75    /// A mode with a stable id and the label it shows.
76    pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
77        Self {
78            id: id.into(),
79            label: label.into(),
80            description: None,
81            icon: None,
82            tone: PermissionTone::Normal,
83        }
84    }
85
86    /// A line under the label in the menu.
87    pub fn description(mut self, description: impl Into<SharedString>) -> Self {
88        self.description = Some(description.into());
89        self
90    }
91
92    /// The icon before the label, in the menu and on the trigger.
93    pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
94        self.icon = Some(icon.into());
95        self
96    }
97
98    /// Marks the mode risky or ordinary. A risky one shows in the warning
99    /// color on the trigger and in the menu.
100    pub fn tone(mut self, tone: PermissionTone) -> Self {
101        self.tone = tone;
102        self
103    }
104
105    /// The tone.
106    pub fn tone_of(&self) -> PermissionTone {
107        self.tone
108    }
109
110    /// The id.
111    pub fn id(&self) -> &SharedString {
112        &self.id
113    }
114
115    /// The label.
116    pub fn label(&self) -> &SharedString {
117        &self.label
118    }
119
120    /// The line under the label.
121    pub fn description_text(&self) -> Option<&SharedString> {
122        self.description.as_ref()
123    }
124
125    /// The icon.
126    pub fn icon_of(&self) -> Option<&Icon> {
127        self.icon.as_ref()
128    }
129
130    /// The four default modes: `Auto`, `Manual`, `Plan`, and `Bypass all`.
131    pub fn defaults() -> Vec<Self> {
132        vec![
133            Self::new(Self::AUTO, "Auto")
134                .description("Run safe actions, ask before risky ones")
135                .icon(IconName::Gauge),
136            Self::new(Self::MANUAL, "Manual")
137                .description("Ask before every action")
138                .icon(IconName::Hand),
139            Self::new(Self::PLAN, "Plan")
140                .description("Read only: propose a plan and wait")
141                .icon(IconName::ClipboardList),
142            Self::new(Self::BYPASS, "Bypass all")
143                .description("Run everything without asking")
144                .icon(IconName::ShieldAlert)
145                .tone(PermissionTone::Warning),
146        ]
147    }
148}
149
150/// What a [`PermissionState`] reports.
151#[derive(Clone, Debug, PartialEq, Eq)]
152#[non_exhaustive]
153pub enum PermissionEvent {
154    /// The selected mode changed. The payload is its id.
155    Changed(SharedString),
156    /// The "Learn more" row was chosen.
157    LearnMore,
158}
159
160/// The modes, the selected one, and the open menu of a
161/// [`PermissionMenu`].
162///
163/// Owned by the view that shows the menu, which observes it so a change
164/// renders. The selection is kept by id.
165pub struct PermissionState {
166    modes: Vec<PermissionMode>,
167    selected: SharedString,
168    menu: Entity<MenuState>,
169    _menu_events: Subscription,
170}
171
172impl EventEmitter<PermissionEvent> for PermissionState {}
173
174impl PermissionState {
175    /// A state over the default modes with `Auto` selected.
176    pub fn new(cx: &mut Context<Self>) -> Self {
177        let menu = cx.new(MenuState::new);
178        let subscription = cx.subscribe(&menu, |this, _, event: &MenuEvent, cx| {
179            if let MenuEvent::Activated(key) = event {
180                if key.as_ref() == LEARN_MORE {
181                    cx.emit(PermissionEvent::LearnMore);
182                } else {
183                    this.set_selected(key.clone(), cx);
184                }
185            }
186        });
187        Self {
188            modes: PermissionMode::defaults(),
189            selected: PermissionMode::AUTO.into(),
190            menu,
191            _menu_events: subscription,
192        }
193    }
194
195    /// Replaces the modes. The first one is selected when the selected
196    /// id is not among them.
197    pub fn with_modes(mut self, modes: impl IntoIterator<Item = PermissionMode>) -> Self {
198        self.modes = modes.into_iter().collect();
199        if !self.modes.iter().any(|mode| mode.id == self.selected)
200            && let Some(first) = self.modes.first()
201        {
202            self.selected = first.id.clone();
203        }
204        self
205    }
206
207    /// Selects the mode with `id`, if there is one.
208    pub fn with_selected(mut self, id: impl Into<SharedString>) -> Self {
209        let id = id.into();
210        if self.modes.iter().any(|mode| mode.id == id) {
211            self.selected = id;
212        }
213        self
214    }
215
216    /// The modes, in order.
217    pub fn modes(&self) -> &[PermissionMode] {
218        &self.modes
219    }
220
221    /// The id of the selected mode.
222    pub fn selected(&self) -> &SharedString {
223        &self.selected
224    }
225
226    /// The selected mode.
227    pub fn selected_mode(&self) -> Option<&PermissionMode> {
228        self.modes.iter().find(|mode| mode.id == self.selected)
229    }
230
231    /// Selects the mode with `id` and emits `Changed`. An unknown id, or
232    /// the mode already selected, changes nothing.
233    pub fn set_selected(&mut self, id: impl Into<SharedString>, cx: &mut Context<Self>) {
234        let id = id.into();
235        if id == self.selected || !self.modes.iter().any(|mode| mode.id == id) {
236            return;
237        }
238        self.selected = id.clone();
239        cx.emit(PermissionEvent::Changed(id));
240        cx.notify();
241    }
242
243    /// Replaces the modes, keeping the selection when it survives and
244    /// otherwise moving it to the first mode.
245    pub fn set_modes(
246        &mut self,
247        modes: impl IntoIterator<Item = PermissionMode>,
248        cx: &mut Context<Self>,
249    ) {
250        self.modes = modes.into_iter().collect();
251        if !self.modes.iter().any(|mode| mode.id == self.selected)
252            && let Some(first) = self.modes.first()
253        {
254            self.selected = first.id.clone();
255            cx.emit(PermissionEvent::Changed(self.selected.clone()));
256        }
257        cx.notify();
258    }
259
260    /// The state of the menu this picker opens.
261    pub fn menu(&self) -> &Entity<MenuState> {
262        &self.menu
263    }
264}
265
266type TriggerBuilder = Box<dyn FnOnce(bool) -> gpui_kit::AnyElement>;
267
268/// A picker for a [`PermissionMode`]: a quiet trigger that shows the
269/// selected mode, and a menu of every mode with its description, the
270/// selected one checked, under a header with a "Learn more" row.
271///
272/// The trigger is plain text with an icon until the pointer, a press, or
273/// the open menu gives it a surface. [`trigger`](Self::trigger) replaces
274/// it. The menu is a [`DropdownMenu`], so keyboard, dismissal, and focus
275/// are the menu's.
276///
277/// ```no_run
278/// use gpui_cn::{PermissionMenu, PermissionState};
279/// use gpui_kit::{AppContext as _, Context};
280///
281/// fn picker(cx: &mut Context<()>) -> PermissionMenu {
282///     let state = cx.new(PermissionState::new);
283///     PermissionMenu::new("permission", &state)
284/// }
285/// ```
286///
287/// The default icons (the mic, the shield, the file kinds, the git branch) are
288/// not in the default `gpui_kit::assets::Assets`: register
289/// [`ComposerAssets`](crate::ComposerAssets).
290#[derive(IntoElement)]
291#[non_exhaustive]
292pub struct PermissionMenu {
293    id: ElementId,
294    state: Entity<PermissionState>,
295    trigger: Option<TriggerBuilder>,
296    learn_more: bool,
297    align: Align,
298    icon_only: bool,
299    style: StyleRefinement,
300}
301
302impl PermissionMenu {
303    /// A permission menu on `state`, with a stable id.
304    pub fn new(id: impl Into<ElementId>, state: &Entity<PermissionState>) -> Self {
305        Self {
306            id: id.into(),
307            state: state.clone(),
308            trigger: None,
309            learn_more: true,
310            align: Align::Start,
311            icon_only: false,
312            style: StyleRefinement::default(),
313        }
314    }
315
316    /// Replaces the trigger. It shows as selected while the menu is open.
317    pub fn trigger(mut self, trigger: impl Selectable + IntoElement + 'static) -> Self {
318        self.trigger = Some(Box::new(move |open| trigger.open(open).into_any_element()));
319        self
320    }
321
322    /// Whether the menu has its header with the "Learn more" row. On by
323    /// default.
324    pub fn learn_more(mut self, learn_more: bool) -> Self {
325        self.learn_more = learn_more;
326        self
327    }
328
329    /// Which edge of the trigger the menu lines up with. The default is
330    /// `Start`.
331    pub fn align(mut self, align: Align) -> Self {
332        self.align = align;
333        self
334    }
335
336    /// Shows the icon of the chosen mode alone, with its label in a
337    /// tooltip and no chevron, as a narrow toolbar does. A mode with no
338    /// icon ignores it.
339    pub fn icon_only(mut self, icon_only: bool) -> Self {
340        self.icon_only = icon_only;
341        self
342    }
343
344    /// What the trigger needs of a row: its width with the label at the
345    /// minimum, and its width as an icon alone.
346    pub(crate) fn need(&self, window: &Window, cx: &App) -> Need {
347        let theme = cx.theme();
348        let padding = theme.metrics.control_padding_sm;
349        let rem = window.rem_size();
350        let gap = rems(0.375).to_pixels(rem);
351        let (label, has_icon) = match self.state.read(cx).selected_mode() {
352            Some(mode) => (mode.label.clone(), mode.icon.is_some()),
353            None => ("Permission".into(), false),
354        };
355        let label = collapse::text_width(&label, window)
356            .min(collapse::min_label_width(window, MIN_LABEL_CHARS));
357        let icon = rems(1.).to_pixels(rem);
358        let lead = if has_icon { icon + gap } else { px(0.) };
359        Need::flexible(
360            padding * 2. + lead + label,
361            has_icon.then_some(theme.metrics.control_sm),
362        )
363    }
364}
365
366impl Styled for PermissionMenu {
367    fn style(&mut self) -> &mut StyleRefinement {
368        &mut self.style
369    }
370}
371
372/// The rows of the menu, built as it opens.
373fn entries(state: &Entity<PermissionState>, learn_more: bool, cx: &App) -> Vec<MenuEntry> {
374    let state = state.read(cx);
375    let mut entries = Vec::new();
376    if learn_more {
377        entries.push(
378            MenuItem::new(LEARN_MORE, "Learn more")
379                .render(|_, window, cx| {
380                    let look = MenuLook::of(cx.theme(), window.rem_size());
381                    let link = cx.theme().link;
382                    row_line()
383                        .justify_between()
384                        .items_center()
385                        .child(
386                            div()
387                                .text_color(look.muted_foreground)
388                                .child("Permission mode"),
389                        )
390                        .child(div().text_color(link).child("Learn more"))
391                })
392                .into(),
393        );
394        entries.push(MenuEntry::Separator);
395    }
396    entries.extend(state.modes.iter().map(|mode| {
397        let selected = mode.id == state.selected;
398        let (label, description, icon, tone) = (
399            mode.label.clone(),
400            mode.description.clone(),
401            mode.icon.clone(),
402            mode.tone,
403        );
404        MenuItem::new(mode.id.clone(), mode.label.clone())
405            .render(move |_, window, cx| {
406                let look = MenuLook::of(cx.theme(), window.rem_size());
407                let warning = cx.theme().warning;
408                let icon_color = match tone {
409                    PermissionTone::Warning => warning,
410                    PermissionTone::Normal => look.muted_foreground,
411                };
412                let icon = icon
413                    .clone()
414                    .map(|icon| line_slot(&look).child(icon.size_4().text_color(icon_color)));
415                let mut block = label_block(label.clone(), &[], description.clone(), &look);
416                if tone == PermissionTone::Warning {
417                    block = div()
418                        .flex_1()
419                        .min_w_0()
420                        .text_color(warning)
421                        .child(block)
422                        .into_any_element();
423                }
424                row_line()
425                    .children(icon)
426                    .child(block)
427                    .when(selected, |this| {
428                        this.child(
429                            line_slot(&look).child(
430                                Icon::from(IconName::Check)
431                                    .size_4()
432                                    .text_color(look.indicator),
433                            ),
434                        )
435                    })
436            })
437            .into()
438    }));
439    entries
440}
441
442impl RenderOnce for PermissionMenu {
443    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
444        let menu_state = self.state.read(cx).menu.clone();
445        let learn_more = self.learn_more;
446        let state = self.state.clone();
447        let need = self.need(window, cx);
448        let menu = DropdownMenu::new(self.id.clone(), &menu_state)
449            .align(self.align)
450            .min_w(match (self.icon_only, need.icon_only) {
451                (true, Some(icon_only)) => icon_only,
452                _ => need.min,
453            })
454            .refine_style(&self.style)
455            .items(move |_, cx| entries(&state, learn_more, cx));
456        match self.trigger {
457            Some(build) => menu.trigger(OpenSlot::built(build)).into_any_element(),
458            None => {
459                let button = Button::new(ElementId::NamedChild(self.id.into(), "trigger".into()))
460                    .ghost()
461                    .size(ButtonSize::Sm)
462                    .flex_shrink(1.)
463                    .accessibility_label("Permission mode");
464                let theme = cx.theme();
465                let (warning, muted) = (theme.warning, theme.muted_foreground());
466                let button = match self.state.read(cx).selected_mode().cloned() {
467                    Some(mode) => {
468                        let color = match mode.tone {
469                            PermissionTone::Warning => warning,
470                            PermissionTone::Normal => muted,
471                        };
472                        match mode.icon.clone() {
473                            Some(icon) if self.icon_only => button
474                                .icon(icon)
475                                .text_color(color)
476                                .tooltip(mode.label.clone()),
477                            icon => {
478                                let keep = collapse::text_width(&mode.label, window)
479                                    .min(collapse::min_label_width(window, MIN_LABEL_CHARS));
480                                button
481                                    .when_some(icon, |button, icon| button.icon(icon))
482                                    .child(
483                                        div()
484                                            .min_w(keep)
485                                            .flex_shrink(1.)
486                                            .overflow_hidden()
487                                            .whitespace_nowrap()
488                                            .text_ellipsis()
489                                            .child(mode.label.clone()),
490                                    )
491                                    .text_color(color)
492                            }
493                        }
494                    }
495                    None => button.label("Permission").text_color(muted),
496                };
497                menu.trigger(button).into_any_element()
498            }
499        }
500    }
501}
502
503#[cfg(test)]
504mod tests {
505    use super::*;
506
507    #[test]
508    fn only_the_mode_that_runs_without_asking_is_a_warning_by_default() {
509        let tones: Vec<_> = PermissionMode::defaults()
510            .iter()
511            .map(|mode| (mode.id().to_string(), mode.tone_of()))
512            .collect();
513        for (id, tone) in tones {
514            let want = if id == PermissionMode::BYPASS {
515                PermissionTone::Warning
516            } else {
517                PermissionTone::Normal
518            };
519            assert_eq!(tone, want, "{id}");
520        }
521        assert_eq!(
522            PermissionMode::new("x", "X").tone_of(),
523            PermissionTone::Normal
524        );
525    }
526}