From 37b8f66df5d0d01ca59ccead0c1ef6d29563860e Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Tue, 27 Jan 2026 15:37:32 -0300 Subject: [PATCH] agent_ui: Add a menu for inserting context (#47768) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: Screenshot 2026-01-27 at 11  38@2x 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. --- 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(-) diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index f612bbdd0831463ad5652375989871db8556b792..93ef34228c3859a14ffaa240c93bbed652f5b20c 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -316,6 +316,7 @@ "ctrl-i": "agent::ToggleProfileSelector", "shift-tab": "agent::CycleModeSelector", "alt-tab": "agent::CycleFavoriteModels", + "ctrl-;": "agent::OpenAddContextMenu", }, }, { diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index a3d8738c15ba7e42d9d1fdaf1597b859c6bcd442..cfbe58ba7ef3b6939e28ab51e51fa3ed4ef33b73 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -364,6 +364,7 @@ "cmd-i": "agent::ToggleProfileSelector", "shift-tab": "agent::CycleModeSelector", "alt-tab": "agent::CycleFavoriteModels", + "ctrl-;": "agent::OpenAddContextMenu", }, }, { diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index 52227e82db1dcaf0bd5e5ba98a8f8ff3d5c00372..9f134b8a8620c64495ec2869a23763e76be658e0 100644 --- a/assets/keymaps/default-windows.json +++ b/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", + }, }, ] diff --git a/crates/agent_ui/src/acp/message_editor.rs b/crates/agent_ui/src/acp/message_editor.rs index 3c919a50f6f2de307b3d958ea0a214fdf7de54ef..ecaf947963fb589ffb3013898ff9299d89856395 100644 --- a/crates/agent_ui/src/acp/message_editor.rs +++ b/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.insert_context_prefix("@", window, cx); + } + + pub fn insert_context_type( + &mut self, + context_keyword: &str, + window: &mut Window, + cx: &mut Context, + ) { + 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) { 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::(&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::(); + 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) { + 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.editor.update(cx, |message_editor, cx| { message_editor.set_read_only(read_only); diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 17b0eeb76aa9c7e0cf69ea075f003a57a5e77951..8ef92266ea28a60426df2a200c5542b415aadf0c 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/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>, hovered_edited_file_buttons: Option, + add_context_menu_handle: PopoverMenuHandle, } 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) -> impl IntoElement { + fn render_add_context_button(&mut self, cx: &mut Context) -> 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, + ) -> Entity { 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::()) }) - .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, + ) { + 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, 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); })) diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index cb6c0342369289b13dfc2c12d9b83f87022f394d..bb39ce96c343d96b81331957d478ed5fccedd026 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/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. diff --git a/crates/agent_ui/src/mention_set.rs b/crates/agent_ui/src/mention_set.rs index cf3c412e250a49625c527304dbc9ccb209bb9a98..0d4ddd6f7a498f0b6624fd75a15b2577e06bbadf 100644 --- a/crates/agent_ui/src/mention_set.rs +++ b/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, + editor: Entity, + mention_set: Entity, + 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, mention_set: Entity, @@ -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; })) }