assistant2: Sketch in context picker (#21560)

Marshall Bowers created

This PR sketches in a context picker into the message editor in
Assistant 2. Not functional yet.

<img width="1138" alt="Screenshot 2024-12-04 at 5 45 19 PM"
src="https://github.com/user-attachments/assets/053d6224-de76-4fde-914b-41fe835761eb">

Release Notes:

- N/A

Change summary

Cargo.lock                              |   1 
crates/assistant2/Cargo.toml            |   1 
crates/assistant2/src/assistant.rs      |   1 
crates/assistant2/src/context_picker.rs | 197 +++++++++++++++++++++++++++
crates/assistant2/src/message_editor.rs |  48 +++--
5 files changed, 227 insertions(+), 21 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -470,6 +470,7 @@ dependencies = [
  "language_models",
  "log",
  "markdown",
+ "picker",
  "project",
  "proto",
  "serde",

crates/assistant2/Cargo.toml 🔗

@@ -29,6 +29,7 @@ language_model_selector.workspace = true
 language_models.workspace = true
 log.workspace = true
 markdown.workspace = true
+picker.workspace = true
 project.workspace = true
 proto.workspace = true
 serde.workspace = true

crates/assistant2/src/context_picker.rs 🔗

@@ -0,0 +1,197 @@
+use std::sync::Arc;
+
+use gpui::{DismissEvent, SharedString, Task, WeakView};
+use picker::{Picker, PickerDelegate, PickerEditorPosition};
+use ui::{prelude::*, ListItem, ListItemSpacing, PopoverMenu, PopoverTrigger, Tooltip};
+
+use crate::message_editor::MessageEditor;
+
+#[derive(IntoElement)]
+pub(super) struct ContextPicker<T: PopoverTrigger> {
+    message_editor: WeakView<MessageEditor>,
+    trigger: T,
+}
+
+#[derive(Clone)]
+struct ContextPickerEntry {
+    name: SharedString,
+    description: SharedString,
+    icon: IconName,
+}
+
+pub(crate) struct ContextPickerDelegate {
+    all_entries: Vec<ContextPickerEntry>,
+    filtered_entries: Vec<ContextPickerEntry>,
+    message_editor: WeakView<MessageEditor>,
+    selected_ix: usize,
+}
+
+impl<T: PopoverTrigger> ContextPicker<T> {
+    pub(crate) fn new(message_editor: WeakView<MessageEditor>, trigger: T) -> Self {
+        ContextPicker {
+            message_editor,
+            trigger,
+        }
+    }
+}
+
+impl PickerDelegate for ContextPickerDelegate {
+    type ListItem = ListItem;
+
+    fn match_count(&self) -> usize {
+        self.filtered_entries.len()
+    }
+
+    fn selected_index(&self) -> usize {
+        self.selected_ix
+    }
+
+    fn set_selected_index(&mut self, ix: usize, cx: &mut ViewContext<Picker<Self>>) {
+        self.selected_ix = ix.min(self.filtered_entries.len().saturating_sub(1));
+        cx.notify();
+    }
+
+    fn placeholder_text(&self, _cx: &mut WindowContext) -> Arc<str> {
+        "Select a context source…".into()
+    }
+
+    fn update_matches(&mut self, query: String, cx: &mut ViewContext<Picker<Self>>) -> Task<()> {
+        let all_commands = self.all_entries.clone();
+        cx.spawn(|this, mut cx| async move {
+            let filtered_commands = cx
+                .background_executor()
+                .spawn(async move {
+                    if query.is_empty() {
+                        all_commands
+                    } else {
+                        all_commands
+                            .into_iter()
+                            .filter(|model_info| {
+                                model_info
+                                    .name
+                                    .to_lowercase()
+                                    .contains(&query.to_lowercase())
+                            })
+                            .collect()
+                    }
+                })
+                .await;
+
+            this.update(&mut cx, |this, cx| {
+                this.delegate.filtered_entries = filtered_commands;
+                this.delegate.set_selected_index(0, cx);
+                cx.notify();
+            })
+            .ok();
+        })
+    }
+
+    fn confirm(&mut self, _secondary: bool, cx: &mut ViewContext<Picker<Self>>) {
+        if let Some(entry) = self.filtered_entries.get(self.selected_ix) {
+            self.message_editor
+                .update(cx, |_message_editor, _cx| {
+                    println!("Insert context from {}", entry.name);
+                })
+                .ok();
+            cx.emit(DismissEvent);
+        }
+    }
+
+    fn dismissed(&mut self, _cx: &mut ViewContext<Picker<Self>>) {}
+
+    fn editor_position(&self) -> PickerEditorPosition {
+        PickerEditorPosition::End
+    }
+
+    fn render_match(
+        &self,
+        ix: usize,
+        selected: bool,
+        _cx: &mut ViewContext<Picker<Self>>,
+    ) -> Option<Self::ListItem> {
+        let entry = self.filtered_entries.get(ix)?;
+
+        Some(
+            ListItem::new(ix)
+                .inset(true)
+                .spacing(ListItemSpacing::Dense)
+                .selected(selected)
+                .tooltip({
+                    let description = entry.description.clone();
+                    move |cx| cx.new_view(|_cx| Tooltip::new(description.clone())).into()
+                })
+                .child(
+                    v_flex()
+                        .group(format!("context-entry-label-{ix}"))
+                        .w_full()
+                        .py_0p5()
+                        .min_w(px(250.))
+                        .max_w(px(400.))
+                        .child(
+                            h_flex()
+                                .gap_1p5()
+                                .child(Icon::new(entry.icon).size(IconSize::XSmall))
+                                .child(
+                                    Label::new(entry.name.clone())
+                                        .single_line()
+                                        .size(LabelSize::Small),
+                                ),
+                        )
+                        .child(
+                            div().overflow_hidden().text_ellipsis().child(
+                                Label::new(entry.description.clone())
+                                    .size(LabelSize::Small)
+                                    .color(Color::Muted),
+                            ),
+                        ),
+                ),
+        )
+    }
+}
+
+impl<T: PopoverTrigger> RenderOnce for ContextPicker<T> {
+    fn render(self, cx: &mut WindowContext) -> impl IntoElement {
+        let entries = vec![
+            ContextPickerEntry {
+                name: "directory".into(),
+                description: "Insert any directory".into(),
+                icon: IconName::Folder,
+            },
+            ContextPickerEntry {
+                name: "file".into(),
+                description: "Insert any file".into(),
+                icon: IconName::File,
+            },
+            ContextPickerEntry {
+                name: "web".into(),
+                description: "Fetch content from URL".into(),
+                icon: IconName::Globe,
+            },
+        ];
+
+        let delegate = ContextPickerDelegate {
+            all_entries: entries.clone(),
+            message_editor: self.message_editor.clone(),
+            filtered_entries: entries,
+            selected_ix: 0,
+        };
+
+        let picker =
+            cx.new_view(|cx| Picker::uniform_list(delegate, cx).max_height(Some(rems(20.).into())));
+
+        let handle = self
+            .message_editor
+            .update(cx, |this, _| this.context_picker_handle.clone())
+            .ok();
+        PopoverMenu::new("context-picker")
+            .menu(move |_cx| Some(picker.clone()))
+            .trigger(self.trigger)
+            .attach(gpui::AnchorCorner::TopLeft)
+            .anchor(gpui::AnchorCorner::BottomLeft)
+            .offset(gpui::Point {
+                x: px(0.0),
+                y: px(-16.0),
+            })
+            .when_some(handle, |this, handle| this.with_handle(handle))
+    }
+}

crates/assistant2/src/message_editor.rs 🔗

@@ -1,16 +1,22 @@
 use editor::{Editor, EditorElement, EditorStyle};
 use gpui::{AppContext, FocusableView, Model, TextStyle, View};
 use language_model::{LanguageModelRegistry, LanguageModelRequestTool};
+use picker::Picker;
 use settings::Settings;
 use theme::ThemeSettings;
-use ui::{prelude::*, ButtonLike, CheckboxWithLabel, ElevationIndex, KeyBinding};
+use ui::{
+    prelude::*, ButtonLike, CheckboxWithLabel, ElevationIndex, IconButtonShape, KeyBinding,
+    PopoverMenuHandle,
+};
 
+use crate::context_picker::{ContextPicker, ContextPickerDelegate};
 use crate::thread::{RequestKind, Thread};
 use crate::Chat;
 
 pub struct MessageEditor {
     thread: Model<Thread>,
     editor: View<Editor>,
+    pub(crate) context_picker_handle: PopoverMenuHandle<Picker<ContextPickerDelegate>>,
     use_tools: bool,
 }
 
@@ -24,6 +30,7 @@ impl MessageEditor {
 
                 editor
             }),
+            context_picker_handle: PopoverMenuHandle::default(),
             use_tools: false,
         }
     }
@@ -98,6 +105,14 @@ impl Render for MessageEditor {
             .gap_2()
             .p_2()
             .bg(cx.theme().colors().editor_background)
+            .child(
+                h_flex().gap_2().child(ContextPicker::new(
+                    cx.view().downgrade(),
+                    IconButton::new("add-context", IconName::Plus)
+                        .shape(IconButtonShape::Square)
+                        .icon_size(IconSize::Small),
+                )),
+            )
             .child({
                 let settings = ThemeSettings::get_global(cx);
                 let text_style = TextStyle {
@@ -123,26 +138,17 @@ impl Render for MessageEditor {
             .child(
                 h_flex()
                     .justify_between()
-                    .child(
-                        h_flex()
-                            .child(
-                                Button::new("add-context", "Add Context")
-                                    .style(ButtonStyle::Filled)
-                                    .icon(IconName::Plus)
-                                    .icon_position(IconPosition::Start),
-                            )
-                            .child(CheckboxWithLabel::new(
-                                "use-tools",
-                                Label::new("Tools"),
-                                self.use_tools.into(),
-                                cx.listener(|this, selection, _cx| {
-                                    this.use_tools = match selection {
-                                        Selection::Selected => true,
-                                        Selection::Unselected | Selection::Indeterminate => false,
-                                    };
-                                }),
-                            )),
-                    )
+                    .child(h_flex().gap_2().child(CheckboxWithLabel::new(
+                        "use-tools",
+                        Label::new("Tools"),
+                        self.use_tools.into(),
+                        cx.listener(|this, selection, _cx| {
+                            this.use_tools = match selection {
+                                Selection::Selected => true,
+                                Selection::Unselected | Selection::Indeterminate => false,
+                            };
+                        }),
+                    )))
                     .child(
                         h_flex()
                             .gap_2()