agent_ui: Add a menu for inserting context (#47768)

Danilo Leal created

This PR swaps the @ icon button in the message editor for a + one, which
opens a dropdown that displays context options you can add to the agent.
Aside from removing one step if you're wanting to add context first with
the mouse (in comparison to just inserting @ in the message editor),
this menu will also house skills you've created, whenever we get to
support to that. It also works to surface images and selections in a bit
more visible way as context options. So, effectivelly, this is a bit of
foundation work for further features to come. Here's what it looks like:

<img width="500" height="586" alt="Screenshot 2026-01-27 at 11  38@2x"
src="https://github.com/user-attachments/assets/551686ba-4629-4317-9177-1e942512a23c"
/>

Note that all the options you see in the menu should also be available
through simply typing @ in the message editor.

Release Notes:

- Agent: Added a menu for inserting context more easily with the mouse
in the agent panel.

Change summary

assets/keymaps/default-linux.json         |   1 
assets/keymaps/default-macos.json         |   1 
assets/keymaps/default-windows.json       |   5 
crates/agent_ui/src/acp/message_editor.rs |  98 ++++++++++++-
crates/agent_ui/src/acp/thread_view.rs    | 180 ++++++++++++++++++++++--
crates/agent_ui/src/agent_ui.rs           |   2 
crates/agent_ui/src/mention_set.rs        | 181 +++++++++++++-----------
7 files changed, 354 insertions(+), 114 deletions(-)

Detailed changes

assets/keymaps/default-linux.json 🔗

@@ -316,6 +316,7 @@
       "ctrl-i": "agent::ToggleProfileSelector",
       "shift-tab": "agent::CycleModeSelector",
       "alt-tab": "agent::CycleFavoriteModels",
+      "ctrl-;": "agent::OpenAddContextMenu",
     },
   },
   {

assets/keymaps/default-macos.json 🔗

@@ -364,6 +364,7 @@
       "cmd-i": "agent::ToggleProfileSelector",
       "shift-tab": "agent::CycleModeSelector",
       "alt-tab": "agent::CycleFavoriteModels",
+      "ctrl-;": "agent::OpenAddContextMenu",
     },
   },
   {

assets/keymaps/default-windows.json 🔗

@@ -318,6 +318,7 @@
       "ctrl-i": "agent::ToggleProfileSelector",
       "shift-tab": "agent::CycleModeSelector",
       "alt-tab": "agent::CycleFavoriteModels",
+      "ctrl-;": "agent::OpenAddContextMenu",
     },
   },
   {
@@ -1410,7 +1411,7 @@
       "ctrl-m": "notebook::AddCodeBlock",
       "ctrl-shift-m": "notebook::AddMarkdownBlock",
       "ctrl-shift-r": "notebook::RestartKernel",
-      "ctrl-c": "notebook::InterruptKernel"
-    }
+      "ctrl-c": "notebook::InterruptKernel",
+    },
   },
 ]

crates/agent_ui/src/acp/message_editor.rs 🔗

@@ -672,7 +672,22 @@ impl MessageEditor {
     }
 
     pub fn trigger_completion_menu(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+        self.insert_context_prefix("@", window, cx);
+    }
+
+    pub fn insert_context_type(
+        &mut self,
+        context_keyword: &str,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let prefix = format!("@{}", context_keyword);
+        self.insert_context_prefix(&prefix, window, cx);
+    }
+
+    fn insert_context_prefix(&mut self, prefix: &str, window: &mut Window, cx: &mut Context<Self>) {
         let editor = self.editor.clone();
+        let prefix = prefix.to_string();
 
         cx.spawn_in(window, async move |_, cx| {
             editor
@@ -682,27 +697,27 @@ impl MessageEditor {
                             matches!(menu, CodeContextMenu::Completions(_)) && menu.visible()
                         });
 
-                    let has_at_sign = {
+                    let has_prefix = {
                         let snapshot = editor.display_snapshot(cx);
                         let cursor = editor.selections.newest::<text::Point>(&snapshot).head();
                         let offset = cursor.to_offset(&snapshot);
-                        if offset.0 > 0 {
-                            snapshot
-                                .buffer_snapshot()
-                                .reversed_chars_at(offset)
-                                .next()
-                                .map(|sign| sign == '@')
-                                .unwrap_or(false)
+                        if offset.0 >= prefix.len() {
+                            let start_offset = MultiBufferOffset(offset.0 - prefix.len());
+                            let buffer_snapshot = snapshot.buffer_snapshot();
+                            let text = buffer_snapshot
+                                .text_for_range(start_offset..offset)
+                                .collect::<String>();
+                            text == prefix
                         } else {
                             false
                         }
                     };
 
-                    if menu_is_open && has_at_sign {
+                    if menu_is_open && has_prefix {
                         return;
                     }
 
-                    editor.insert("@", window, cx);
+                    editor.insert(&prefix, window, cx);
                     editor.show_completions(&editor::actions::ShowCompletions, window, cx);
                 })
                 .log_err();
@@ -1068,6 +1083,69 @@ impl MessageEditor {
         }
     }
 
+    pub fn add_images_from_picker(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+        if !self.prompt_capabilities.borrow().image {
+            return;
+        }
+
+        let editor = self.editor.clone();
+        let mention_set = self.mention_set.clone();
+
+        let paths_receiver = cx.prompt_for_paths(gpui::PathPromptOptions {
+            files: true,
+            directories: false,
+            multiple: true,
+            prompt: Some("Select Images".into()),
+        });
+
+        window
+            .spawn(cx, async move |cx| {
+                let paths = match paths_receiver.await {
+                    Ok(Ok(Some(paths))) => paths,
+                    _ => return Ok::<(), anyhow::Error>(()),
+                };
+
+                let supported_formats = [
+                    ("png", gpui::ImageFormat::Png),
+                    ("jpg", gpui::ImageFormat::Jpeg),
+                    ("jpeg", gpui::ImageFormat::Jpeg),
+                    ("webp", gpui::ImageFormat::Webp),
+                    ("gif", gpui::ImageFormat::Gif),
+                    ("bmp", gpui::ImageFormat::Bmp),
+                    ("tiff", gpui::ImageFormat::Tiff),
+                    ("tif", gpui::ImageFormat::Tiff),
+                    ("ico", gpui::ImageFormat::Ico),
+                ];
+
+                let mut images = Vec::new();
+                for path in paths {
+                    let extension = path
+                        .extension()
+                        .and_then(|ext| ext.to_str())
+                        .map(|s| s.to_lowercase());
+
+                    let Some(format) = extension.and_then(|ext| {
+                        supported_formats
+                            .iter()
+                            .find(|(e, _)| *e == ext)
+                            .map(|(_, f)| *f)
+                    }) else {
+                        continue;
+                    };
+
+                    let Ok(content) = async_fs::read(&path).await else {
+                        continue;
+                    };
+
+                    images.push(gpui::Image::from_bytes(format, content));
+                }
+
+                crate::mention_set::insert_images_as_context(images, editor, mention_set, cx).await;
+                Ok(())
+            })
+            .detach_and_log_err(cx);
+    }
+
     pub fn set_read_only(&mut self, read_only: bool, cx: &mut Context<Self>) {
         self.editor.update(cx, |message_editor, cx| {
             message_editor.set_read_only(read_only);

crates/agent_ui/src/acp/thread_view.rs 🔗

@@ -80,7 +80,7 @@ use crate::user_slash_command::{
 use crate::{
     AgentDiffPane, AgentPanel, AllowAlways, AllowOnce, AuthorizeToolCall, ClearMessageQueue,
     CycleFavoriteModels, CycleModeSelector, EditFirstQueuedMessage, ExpandMessageEditor, Follow,
-    KeepAll, NewThread, OpenAgentDiff, OpenHistory, RejectAll, RejectOnce,
+    KeepAll, NewThread, OpenAddContextMenu, OpenAgentDiff, OpenHistory, RejectAll, RejectOnce,
     RemoveFirstQueuedMessage, SelectPermissionGranularity, SendImmediately, SendNextQueuedMessage,
     ToggleProfileSelector,
 };
@@ -396,6 +396,7 @@ pub struct AcpThreadView {
     turn_generation: usize,
     _turn_timer_task: Option<Task<()>>,
     hovered_edited_file_buttons: Option<usize>,
+    add_context_menu_handle: PopoverMenuHandle<ContextMenu>,
 }
 
 enum ThreadState {
@@ -640,6 +641,7 @@ impl AcpThreadView {
             turn_generation: 0,
             _turn_timer_task: None,
             hovered_edited_file_buttons: None,
+            add_context_menu_handle: PopoverMenuHandle::default(),
         }
     }
 
@@ -6861,27 +6863,168 @@ impl AcpThreadView {
             }))
     }
 
-    fn render_add_context_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
+    fn render_add_context_button(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
+        let focus_handle = self.message_editor.focus_handle(cx);
+        let weak_self = cx.weak_entity();
+
+        PopoverMenu::new("add-context-menu")
+            .trigger_with_tooltip(
+                IconButton::new("add-context", IconName::Plus)
+                    .icon_size(IconSize::Small)
+                    .icon_color(Color::Muted),
+                {
+                    move |_window, cx| {
+                        Tooltip::for_action_in(
+                            "Add Context",
+                            &OpenAddContextMenu,
+                            &focus_handle,
+                            cx,
+                        )
+                    }
+                },
+            )
+            .anchor(gpui::Corner::BottomLeft)
+            .with_handle(self.add_context_menu_handle.clone())
+            .offset(gpui::Point {
+                x: px(0.0),
+                y: px(-2.0),
+            })
+            .menu(move |window, cx| {
+                weak_self
+                    .update(cx, |this, cx| this.build_add_context_menu(window, cx))
+                    .ok()
+            })
+    }
+
+    fn build_add_context_menu(
+        &self,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Entity<ContextMenu> {
         let message_editor = self.message_editor.clone();
-        let menu_visible = message_editor.read(cx).is_completions_menu_visible(cx);
+        let workspace = self.workspace.clone();
+        let supports_images = self.prompt_capabilities.borrow().image;
 
-        IconButton::new("add-context", IconName::AtSign)
-            .icon_size(IconSize::Small)
-            .icon_color(Color::Muted)
-            .when(!menu_visible, |this| {
-                this.tooltip(move |_window, cx| {
-                    Tooltip::with_meta("Add Context", None, "Or type @ to include context", cx)
-                })
+        let has_selection = workspace
+            .upgrade()
+            .and_then(|ws| {
+                ws.read(cx)
+                    .active_item(cx)
+                    .and_then(|item| item.downcast::<Editor>())
             })
-            .on_click(cx.listener(move |_this, _, window, cx| {
-                let message_editor_clone = message_editor.clone();
+            .is_some_and(|editor| {
+                editor.update(cx, |editor, cx| {
+                    editor.has_non_empty_selection(&editor.display_snapshot(cx))
+                })
+            });
 
-                window.defer(cx, move |window, cx| {
-                    message_editor_clone.update(cx, |message_editor, cx| {
-                        message_editor.trigger_completion_menu(window, cx);
-                    });
-                });
-            }))
+        ContextMenu::build(window, cx, move |menu, _window, _cx| {
+            menu.key_context("AddContextMenu")
+                .header("Context")
+                .item(
+                    ContextMenuEntry::new("Files & Directories")
+                        .icon(IconName::File)
+                        .icon_color(Color::Muted)
+                        .icon_size(IconSize::XSmall)
+                        .handler({
+                            let message_editor = message_editor.clone();
+                            move |window, cx| {
+                                message_editor.focus_handle(cx).focus(window, cx);
+                                message_editor.update(cx, |editor, cx| {
+                                    editor.insert_context_type("file", window, cx);
+                                });
+                            }
+                        }),
+                )
+                .item(
+                    ContextMenuEntry::new("Symbols")
+                        .icon(IconName::Code)
+                        .icon_color(Color::Muted)
+                        .icon_size(IconSize::XSmall)
+                        .handler({
+                            let message_editor = message_editor.clone();
+                            move |window, cx| {
+                                message_editor.focus_handle(cx).focus(window, cx);
+                                message_editor.update(cx, |editor, cx| {
+                                    editor.insert_context_type("symbol", window, cx);
+                                });
+                            }
+                        }),
+                )
+                .item(
+                    ContextMenuEntry::new("Threads")
+                        .icon(IconName::Thread)
+                        .icon_color(Color::Muted)
+                        .icon_size(IconSize::XSmall)
+                        .handler({
+                            let message_editor = message_editor.clone();
+                            move |window, cx| {
+                                message_editor.focus_handle(cx).focus(window, cx);
+                                message_editor.update(cx, |editor, cx| {
+                                    editor.insert_context_type("thread", window, cx);
+                                });
+                            }
+                        }),
+                )
+                .item(
+                    ContextMenuEntry::new("Rules")
+                        .icon(IconName::Reader)
+                        .icon_color(Color::Muted)
+                        .icon_size(IconSize::XSmall)
+                        .handler({
+                            let message_editor = message_editor.clone();
+                            move |window, cx| {
+                                message_editor.focus_handle(cx).focus(window, cx);
+                                message_editor.update(cx, |editor, cx| {
+                                    editor.insert_context_type("rule", window, cx);
+                                });
+                            }
+                        }),
+                )
+                .item(
+                    ContextMenuEntry::new("Image")
+                        .icon(IconName::Image)
+                        .icon_color(Color::Muted)
+                        .icon_size(IconSize::XSmall)
+                        .disabled(!supports_images)
+                        .handler({
+                            let message_editor = message_editor.clone();
+                            move |window, cx| {
+                                message_editor.focus_handle(cx).focus(window, cx);
+                                message_editor.update(cx, |editor, cx| {
+                                    editor.add_images_from_picker(window, cx);
+                                });
+                            }
+                        }),
+                )
+                .item(
+                    ContextMenuEntry::new("Selection")
+                        .icon(IconName::CursorIBeam)
+                        .icon_color(Color::Muted)
+                        .icon_size(IconSize::XSmall)
+                        .disabled(!has_selection)
+                        .handler({
+                            move |window, cx| {
+                                message_editor.focus_handle(cx).focus(window, cx);
+                                message_editor.update(cx, |editor, cx| {
+                                    editor.insert_selections(window, cx);
+                                });
+                            }
+                        }),
+                )
+        })
+    }
+
+    fn open_add_context_menu(
+        &mut self,
+        _action: &OpenAddContextMenu,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let menu_handle = self.add_context_menu_handle.clone();
+        window.defer(cx, move |window, cx| {
+            menu_handle.toggle(window, cx);
+        });
     }
 
     fn render_markdown(&self, markdown: Entity<Markdown>, style: MarkdownStyle) -> MarkdownElement {
@@ -8386,6 +8529,7 @@ impl Render for AcpThreadView {
             .on_action(cx.listener(Self::handle_authorize_tool_call))
             .on_action(cx.listener(Self::handle_select_permission_granularity))
             .on_action(cx.listener(Self::open_permission_dropdown))
+            .on_action(cx.listener(Self::open_add_context_menu))
             .on_action(cx.listener(|this, _: &SendNextQueuedMessage, window, cx| {
                 this.send_queued_message_at_index(0, true, window, cx);
             }))

crates/agent_ui/src/agent_ui.rs 🔗

@@ -121,6 +121,8 @@ actions!(
         ResetTrialUpsell,
         /// Resets the trial end upsell notification.
         ResetTrialEndUpsell,
+        /// Opens the "Add Context" menu in the message editor.
+        OpenAddContextMenu,
         /// Continues the current thread.
         ContinueThread,
         /// Interrupts the current generation and sends the message immediately.

crates/agent_ui/src/mention_set.rs 🔗

@@ -597,6 +597,102 @@ mod tests {
     }
 }
 
+/// Inserts a list of images into the editor as context mentions.
+/// This is the shared implementation used by both paste and file picker operations.
+pub(crate) async fn insert_images_as_context(
+    images: Vec<gpui::Image>,
+    editor: Entity<Editor>,
+    mention_set: Entity<MentionSet>,
+    cx: &mut gpui::AsyncWindowContext,
+) {
+    if images.is_empty() {
+        return;
+    }
+
+    let replacement_text = MentionUri::PastedImage.as_link().to_string();
+
+    for image in images {
+        let Some((excerpt_id, text_anchor, multibuffer_anchor)) = editor
+            .update_in(cx, |editor, window, cx| {
+                let snapshot = editor.snapshot(window, cx);
+                let (excerpt_id, _, buffer_snapshot) =
+                    snapshot.buffer_snapshot().as_singleton().unwrap();
+
+                let text_anchor = buffer_snapshot.anchor_before(buffer_snapshot.len());
+                let multibuffer_anchor = snapshot
+                    .buffer_snapshot()
+                    .anchor_in_excerpt(*excerpt_id, text_anchor);
+                editor.edit(
+                    [(
+                        multi_buffer::Anchor::max()..multi_buffer::Anchor::max(),
+                        format!("{replacement_text} "),
+                    )],
+                    cx,
+                );
+                (*excerpt_id, text_anchor, multibuffer_anchor)
+            })
+            .ok()
+        else {
+            break;
+        };
+
+        let content_len = replacement_text.len();
+        let Some(start_anchor) = multibuffer_anchor else {
+            continue;
+        };
+        let end_anchor = editor.update(cx, |editor, cx| {
+            let snapshot = editor.buffer().read(cx).snapshot(cx);
+            snapshot.anchor_before(start_anchor.to_offset(&snapshot) + content_len)
+        });
+        let image = Arc::new(image);
+        let Ok(Some((crease_id, tx))) = cx.update(|window, cx| {
+            insert_crease_for_mention(
+                excerpt_id,
+                text_anchor,
+                content_len,
+                MentionUri::PastedImage.name().into(),
+                IconName::Image.path().into(),
+                Some(Task::ready(Ok(image.clone())).shared()),
+                editor.clone(),
+                window,
+                cx,
+            )
+        }) else {
+            continue;
+        };
+        let task = cx
+            .spawn(async move |cx| {
+                let image = cx
+                    .update(|_, cx| LanguageModelImage::from_image(image, cx))
+                    .map_err(|e| e.to_string())?
+                    .await;
+                drop(tx);
+                if let Some(image) = image {
+                    Ok(Mention::Image(MentionImage {
+                        data: image.source,
+                        format: LanguageModelImage::FORMAT,
+                    }))
+                } else {
+                    Err("Failed to convert image".into())
+                }
+            })
+            .shared();
+
+        mention_set.update(cx, |mention_set, _cx| {
+            mention_set.insert_mention(crease_id, MentionUri::PastedImage, task.clone())
+        });
+
+        if task.await.notify_async_err(cx).is_none() {
+            editor.update(cx, |editor, cx| {
+                editor.edit([(start_anchor..end_anchor, "")], cx);
+            });
+            mention_set.update(cx, |mention_set, _cx| {
+                mention_set.remove_mention(&crease_id)
+            });
+        }
+    }
+}
+
 pub(crate) fn paste_images_as_context(
     editor: Entity<Editor>,
     mention_set: Entity<MentionSet>,
@@ -646,95 +742,12 @@ pub(crate) fn paste_images_as_context(
             );
         }
 
-        if images.is_empty() {
-            return;
-        }
-
-        let replacement_text = MentionUri::PastedImage.as_link().to_string();
         cx.update(|_window, cx| {
             cx.stop_propagation();
         })
         .ok();
-        for image in images {
-            let Some((excerpt_id, text_anchor, multibuffer_anchor)) = editor
-                .update_in(cx, |message_editor, window, cx| {
-                    let snapshot = message_editor.snapshot(window, cx);
-                    let (excerpt_id, _, buffer_snapshot) =
-                        snapshot.buffer_snapshot().as_singleton().unwrap();
-
-                    let text_anchor = buffer_snapshot.anchor_before(buffer_snapshot.len());
-                    let multibuffer_anchor = snapshot
-                        .buffer_snapshot()
-                        .anchor_in_excerpt(*excerpt_id, text_anchor);
-                    message_editor.edit(
-                        [(
-                            multi_buffer::Anchor::max()..multi_buffer::Anchor::max(),
-                            format!("{replacement_text} "),
-                        )],
-                        cx,
-                    );
-                    (*excerpt_id, text_anchor, multibuffer_anchor)
-                })
-                .ok()
-            else {
-                break;
-            };
 
-            let content_len = replacement_text.len();
-            let Some(start_anchor) = multibuffer_anchor else {
-                continue;
-            };
-            let end_anchor = editor.update(cx, |editor, cx| {
-                let snapshot = editor.buffer().read(cx).snapshot(cx);
-                snapshot.anchor_before(start_anchor.to_offset(&snapshot) + content_len)
-            });
-            let image = Arc::new(image);
-            let Ok(Some((crease_id, tx))) = cx.update(|window, cx| {
-                insert_crease_for_mention(
-                    excerpt_id,
-                    text_anchor,
-                    content_len,
-                    MentionUri::PastedImage.name().into(),
-                    IconName::Image.path().into(),
-                    Some(Task::ready(Ok(image.clone())).shared()),
-                    editor.clone(),
-                    window,
-                    cx,
-                )
-            }) else {
-                continue;
-            };
-            let task = cx
-                .spawn(async move |cx| {
-                    let image = cx
-                        .update(|_, cx| LanguageModelImage::from_image(image, cx))
-                        .map_err(|e| e.to_string())?
-                        .await;
-                    drop(tx);
-                    if let Some(image) = image {
-                        Ok(Mention::Image(MentionImage {
-                            data: image.source,
-                            format: LanguageModelImage::FORMAT,
-                        }))
-                    } else {
-                        Err("Failed to convert image".into())
-                    }
-                })
-                .shared();
-
-            mention_set.update(cx, |mention_set, _cx| {
-                mention_set.insert_mention(crease_id, MentionUri::PastedImage, task.clone())
-            });
-
-            if task.await.notify_async_err(cx).is_none() {
-                editor.update(cx, |editor, cx| {
-                    editor.edit([(start_anchor..end_anchor, "")], cx);
-                });
-                mention_set.update(cx, |mention_set, _cx| {
-                    mention_set.remove_mention(&crease_id)
-                });
-            }
-        }
+        insert_images_as_context(images, editor, mention_set, cx).await;
     }))
 }