Skip to main content

ui/components/ai/
completion_popover.rs

1use std::rc::Rc;
2
3use gpui::{Anchor, AnyElement, Bounds, Pixels, anchored, deferred, point};
4
5use crate::prelude::*;
6use crate::score;
7
8/// A single selectable entry offered by a [`CompletionPopover`] (e.g. a
9/// `/`-command sourced from the agent runtime). Deliberately plain data —
10/// carries no dependency on any particular agent/runtime crate.
11#[derive(Clone, Debug, PartialEq)]
12pub struct CompletionItem {
13    pub label: SharedString,
14    pub description: Option<SharedString>,
15    pub insert_text: SharedString,
16}
17
18impl CompletionItem {
19    pub fn new(label: impl Into<SharedString>, insert_text: impl Into<SharedString>) -> Self {
20        Self {
21            label: label.into(),
22            description: None,
23            insert_text: insert_text.into(),
24        }
25    }
26
27    pub fn description(mut self, description: impl Into<SharedString>) -> Self {
28        self.description = Some(description.into());
29        self
30    }
31}
32
33/// Fuzzy-filters `items` by `query` against each item's label
34/// (`command_palette::fuzzy::score`), best match first. An empty `query`
35/// returns every item in its original order.
36pub fn filter_completions<'a>(items: &'a [CompletionItem], query: &str) -> Vec<&'a CompletionItem> {
37    let mut matches: Vec<(&CompletionItem, i32)> = items
38        .iter()
39        .filter_map(|item| score(query, item.label.as_ref()).map(|s| (item, s)))
40        .collect();
41    matches.sort_by(|a, b| b.1.cmp(&a.1));
42    matches.into_iter().map(|(item, _)| item).collect()
43}
44
45/// A floating, fuzzy-filtered completion list. Positioned via the same
46/// `deferred`/`anchored` idiom `Combobox`'s option list uses
47/// (`crates/ui/src/components/combobox.rs`) rather than `PopoverMenu`,
48/// since the trigger here is a typed `/` inside a `TextInput`, not a
49/// clickable button `PopoverMenu` expects.
50///
51/// Stateless builder: the caller (a stateful component owning the input)
52/// tracks `selected_ix`/open state and rebuilds this each render, then calls
53/// [`CompletionPopover::render`] to obtain the floating element. Up/Down/Esc
54/// navigation is the caller's responsibility (via [`filter_completions`]);
55/// this type only renders the list and dispatches clicks.
56pub struct CompletionPopover {
57    items: Vec<CompletionItem>,
58    query: SharedString,
59    selected_ix: usize,
60    anchor: Bounds<Pixels>,
61    width: Pixels,
62    on_select: Rc<dyn Fn(SharedString, &mut Window, &mut App) + 'static>,
63}
64
65impl CompletionPopover {
66    pub fn new(
67        items: Vec<CompletionItem>,
68        query: impl Into<SharedString>,
69        selected_ix: usize,
70        anchor: Bounds<Pixels>,
71        on_select: impl Fn(SharedString, &mut Window, &mut App) + 'static,
72    ) -> Self {
73        Self {
74            items,
75            query: query.into(),
76            selected_ix,
77            anchor,
78            width: px(280.),
79            on_select: Rc::new(on_select),
80        }
81    }
82
83    pub fn width(mut self, width: Pixels) -> Self {
84        self.width = width;
85        self
86    }
87
88    /// Builds the floating element, anchored above-left of `anchor` (the
89    /// input row's screen bounds).
90    pub fn render(self, cx: &mut App) -> AnyElement {
91        let matched = filter_completions(&self.items, &self.query);
92        let selected_ix = self.selected_ix.min(matched.len().saturating_sub(1));
93        let hover = semantic::hover_bg(cx);
94
95        let mut list = v_flex()
96            .id("completion-popover-list")
97            .w(self.width)
98            .max_h(px(240.))
99            .overflow_y_scroll()
100            .p_1()
101            .gap_0p5()
102            .rounded_md()
103            .bg(semantic::elevated_surface(cx))
104            .border_1()
105            .border_color(semantic::border(cx))
106            .shadow_level(Shadow::Lg);
107
108        if matched.is_empty() {
109            list = list.child(
110                div()
111                    .px_3()
112                    .py_2()
113                    .child(Label::new("No matching commands").color(Color::Muted)),
114            );
115        }
116
117        for (ix, item) in matched.into_iter().enumerate() {
118            let insert_text = item.insert_text.clone();
119            let on_select = self.on_select.clone();
120            list = list.child(
121                v_flex()
122                    .id(("completion-popover-item", ix))
123                    .w_full()
124                    .px_3()
125                    .py_1p5()
126                    .gap_0p5()
127                    .rounded_md()
128                    .cursor_pointer()
129                    .when(ix == selected_ix, |this| this.bg(hover))
130                    .hover(move |style| style.bg(hover))
131                    .on_click(move |_, window, cx| on_select(insert_text.clone(), window, cx))
132                    .child(Label::new(item.label.clone()))
133                    .when_some(item.description.clone(), |this, description| {
134                        this.child(
135                            Label::new(description)
136                                .size(LabelSize::Small)
137                                .color(Color::Muted),
138                        )
139                    }),
140            );
141        }
142
143        deferred(
144            anchored()
145                .snap_to_window_with_margin(px(8.))
146                .anchor(Anchor::BottomLeft)
147                .position(point(self.anchor.origin.x, self.anchor.origin.y - px(4.)))
148                .child(div().occlude().child(list)),
149        )
150        .with_priority(1)
151        .into_any_element()
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    fn items() -> Vec<CompletionItem> {
160        vec![
161            CompletionItem::new("help", "/help"),
162            CompletionItem::new("explain", "/explain"),
163            CompletionItem::new("tests", "/tests"),
164        ]
165    }
166
167    #[test]
168    fn empty_query_returns_all_items_in_order() {
169        let items = items();
170        let matched = filter_completions(&items, "");
171        assert_eq!(matched.len(), 3);
172        assert_eq!(matched[0].label.as_ref(), "help");
173    }
174
175    #[test]
176    fn query_filters_by_label_subsequence() {
177        let items = items();
178        let matched = filter_completions(&items, "exp");
179        assert_eq!(matched.len(), 1);
180        assert_eq!(matched[0].label.as_ref(), "explain");
181    }
182
183    #[test]
184    fn no_match_returns_empty() {
185        let items = items();
186        assert!(filter_completions(&items, "zzz").is_empty());
187    }
188}