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; })) }