Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions assets/keymaps/default-linux.json
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,7 @@
"ctrl-i": "agent::ToggleProfileSelector",
"shift-tab": "agent::CycleModeSelector",
"alt-tab": "agent::CycleFavoriteModels",
"ctrl-;": "agent::OpenAddContextMenu",
},
},
{
Expand Down
1 change: 1 addition & 0 deletions assets/keymaps/default-macos.json
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,7 @@
"cmd-i": "agent::ToggleProfileSelector",
"shift-tab": "agent::CycleModeSelector",
"alt-tab": "agent::CycleFavoriteModels",
"ctrl-;": "agent::OpenAddContextMenu",
},
},
{
Expand Down
5 changes: 3 additions & 2 deletions assets/keymaps/default-windows.json
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,7 @@
"ctrl-i": "agent::ToggleProfileSelector",
"shift-tab": "agent::CycleModeSelector",
"alt-tab": "agent::CycleFavoriteModels",
"ctrl-;": "agent::OpenAddContextMenu",
},
},
{
Expand Down Expand Up @@ -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",
},
},
]
98 changes: 88 additions & 10 deletions crates/agent_ui/src/acp/message_editor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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();
Expand Down Expand Up @@ -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);
Expand Down
180 changes: 162 additions & 18 deletions crates/agent_ui/src/acp/thread_view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -640,6 +641,7 @@ impl AcpThreadView {
turn_generation: 0,
_turn_timer_task: None,
hovered_edited_file_buttons: None,
add_context_menu_handle: PopoverMenuHandle::default(),
}
}

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
}))
Expand Down
2 changes: 2 additions & 0 deletions crates/agent_ui/src/agent_ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading