Detailed changes
@@ -316,6 +316,7 @@
"ctrl-i": "agent::ToggleProfileSelector",
"shift-tab": "agent::CycleModeSelector",
"alt-tab": "agent::CycleFavoriteModels",
+ "ctrl-;": "agent::OpenAddContextMenu",
},
},
{
@@ -364,6 +364,7 @@
"cmd-i": "agent::ToggleProfileSelector",
"shift-tab": "agent::CycleModeSelector",
"alt-tab": "agent::CycleFavoriteModels",
+ "ctrl-;": "agent::OpenAddContextMenu",
},
},
{
@@ -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",
+ },
},
]
@@ -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);
@@ -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);
}))
@@ -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.
@@ -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;
}))
}