diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index a18f9f21e79..2c448d34307 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -299,10 +299,15 @@ impl ToolCall { let subagent_session_info = subagent_session_info_from_meta(&tool_call.meta); + let label = if tool_call.kind == acp::ToolKind::Execute { + cx.new(|cx| Markdown::new_text(title.into(), cx)) + } else { + cx.new(|cx| Markdown::new(title.into(), Some(language_registry.clone()), None, cx)) + }; + let result = Self { id: tool_call.tool_call_id, - label: cx - .new(|cx| Markdown::new(title.into(), Some(language_registry.clone()), None, cx)), + label, kind: tool_call.kind, content, locations: tool_call.locations, diff --git a/crates/agent_ui/src/conversation_view.rs b/crates/agent_ui/src/conversation_view.rs index 6ddac5f3f9f..c01d8d8c04c 100644 --- a/crates/agent_ui/src/conversation_view.rs +++ b/crates/agent_ui/src/conversation_view.rs @@ -81,7 +81,7 @@ use crate::agent_connection_store::{ }; use crate::agent_diff::AgentDiff; use crate::entry_view_state::{EntryViewEvent, ViewEvent}; -use crate::message_editor::{MessageEditor, MessageEditorEvent}; +use crate::message_editor::{InputAttempt, MessageEditor, MessageEditorEvent}; use crate::profile_selector::{ProfileProvider, ProfileSelector}; use crate::thread_metadata_store::{ThreadId, ThreadMetadataStore}; @@ -1387,7 +1387,7 @@ impl ConversationView { fn move_queued_message_to_main_editor( &mut self, index: usize, - inserted_text: Option<&str>, + attempt: Option, cursor_offset: Option, window: &mut Window, cx: &mut Context, @@ -1396,7 +1396,7 @@ impl ConversationView { active.update(cx, |active, cx| { active.move_queued_message_to_main_editor( index, - inserted_text, + attempt, cursor_offset, window, cx, @@ -2384,15 +2384,17 @@ impl ConversationView { window, move |this, _editor, event, window, cx| match event { MessageEditorEvent::InputAttempted { - text, + attempt, cursor_offset, - } => this.move_queued_message_to_main_editor( - index, - Some(text.as_ref()), - Some(*cursor_offset), - window, - cx, - ), + } => { + this.move_queued_message_to_main_editor( + index, + Some(attempt.clone()), + Some(*cursor_offset), + window, + cx, + ); + } MessageEditorEvent::LostFocus => { this.save_queued_message_at_index(index, cx); } @@ -2958,8 +2960,9 @@ pub(crate) mod tests { use agent::{AgentTool, EditFileTool, FetchTool, TerminalTool, ToolPermissionContext}; use agent_servers::FakeAcpAgentServer; use editor::MultiBufferOffset; + use editor::actions::Paste; use fs::FakeFs; - use gpui::{EventEmitter, TestAppContext, VisualTestContext}; + use gpui::{ClipboardItem, EventEmitter, TestAppContext, VisualTestContext}; use parking_lot::Mutex; use project::Project; use serde_json::json; @@ -7405,6 +7408,107 @@ pub(crate) mod tests { ); } + #[gpui::test] + async fn test_paste_text_into_queued_message_promotes_to_main_editor(cx: &mut TestAppContext) { + init_test(cx); + + let (conversation_view, cx) = + paste_into_queued_message(cx, ClipboardItem::new_string("PASTED".to_string())).await; + + let queue_len = active_thread(&conversation_view, cx) + .read_with(cx, |thread, _cx| thread.local_queued_messages.len()); + assert_eq!(queue_len, 0); + + let text = message_editor(&conversation_view, cx).update(cx, |editor, cx| editor.text(cx)); + assert_eq!(text, "queued PASTEDmessage"); + } + + #[gpui::test] + async fn test_paste_image_into_queued_message_promotes_to_main_editor(cx: &mut TestAppContext) { + init_test(cx); + + use base64::Engine as _; + use std::io::Write as _; + let png_bytes = base64::prelude::BASE64_STANDARD + .decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==") + .unwrap(); + let mut image_file = tempfile::Builder::new().suffix(".png").tempfile().unwrap(); + image_file.write_all(&png_bytes).unwrap(); + + let (conversation_view, cx) = paste_into_queued_message( + cx, + ClipboardItem { + entries: vec![gpui::ClipboardEntry::ExternalPaths(gpui::ExternalPaths( + vec![image_file.path().to_path_buf()].into(), + ))], + }, + ) + .await; + + let queue_len = active_thread(&conversation_view, cx) + .read_with(cx, |thread, _cx| thread.local_queued_messages.len()); + assert_eq!(queue_len, 0); + + let text = message_editor(&conversation_view, cx).update(cx, |editor, cx| editor.text(cx)); + let image_name = image_file.path().file_name().unwrap().to_string_lossy(); + let expected_uri = acp_thread::MentionUri::PastedImage { + name: image_name.to_string(), + } + .to_uri() + .to_string(); + assert_eq!( + text, + format!("queued [@{image_name}]({expected_uri}) message"), + ); + } + + async fn paste_into_queued_message( + cx: &mut TestAppContext, + clipboard: ClipboardItem, + ) -> (Entity, &mut VisualTestContext) { + let (conversation_view, cx) = + setup_conversation_view(StubAgentServer::default_response(), cx).await; + add_to_workspace(conversation_view.clone(), cx); + + active_thread(&conversation_view, cx).update_in(cx, |thread, _window, cx| { + thread + .session_capabilities + .write() + .set_prompt_capabilities(acp::PromptCapabilities::new().image(true)); + thread.add_to_queue( + vec![acp::ContentBlock::Text(acp::TextContent::new( + "queued message".to_string(), + ))], + vec![], + cx, + ); + }); + conversation_view.update(cx, |_, cx| cx.notify()); + cx.run_until_parked(); + + let queued_editor = active_thread(&conversation_view, cx).read_with(cx, |thread, _cx| { + thread + .queued_message_editors + .first() + .cloned() + .expect("queued message editor not synced") + }); + + cx.write_to_clipboard(clipboard); + + queued_editor.update_in(cx, |message_editor, window, cx| { + message_editor.editor().update(cx, |editor, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { + selections.select_ranges([MultiBufferOffset(7)..MultiBufferOffset(7)]); + }); + }); + message_editor.paste(&Paste, window, cx); + }); + cx.run_until_parked(); + + (conversation_view, cx) + } + #[gpui::test] async fn test_close_all_sessions_skips_when_unsupported(cx: &mut TestAppContext) { init_test(cx); diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index 44b51bb491e..0e0b3d04a8d 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -1539,7 +1539,7 @@ impl ThreadView { pub fn move_queued_message_to_main_editor( &mut self, index: usize, - inserted_text: Option<&str>, + attempt: Option, cursor_offset: Option, window: &mut Window, cx: &mut Context, @@ -1549,36 +1549,35 @@ impl ThreadView { }; let queued_content = queued_message.content; let message_editor = self.message_editor.clone(); - let inserted_text = inserted_text.map(ToOwned::to_owned); window.focus(&message_editor.focus_handle(cx), cx); - if message_editor.read(cx).is_empty(cx) { + let adjusted_cursor_offset = if message_editor.read(cx).is_empty(cx) { message_editor.update(cx, |editor, cx| { editor.set_message(queued_content, window, cx); - if let Some(offset) = cursor_offset { - editor.set_cursor_offset(offset, window, cx); - } - if let Some(inserted_text) = inserted_text.as_deref() { - editor.insert_text(inserted_text, window, cx); - } }); - cx.notify(); - return true; - } - - // Adjust cursor offset accounting for existing content - let existing_len = message_editor.read(cx).text(cx).len(); - let separator = "\n\n"; + cursor_offset + } else { + let existing_len = message_editor.read(cx).text(cx).len(); + let separator = "\n\n"; + message_editor.update(cx, |editor, cx| { + editor.append_message(queued_content, Some(separator), window, cx); + }); + cursor_offset.map(|offset| existing_len + separator.len() + offset) + }; message_editor.update(cx, |editor, cx| { - editor.append_message(queued_content, Some(separator), window, cx); - if let Some(offset) = cursor_offset { - let adjusted_offset = existing_len + separator.len() + offset; - editor.set_cursor_offset(adjusted_offset, window, cx); + if let Some(offset) = adjusted_cursor_offset { + editor.set_cursor_offset(offset, window, cx); } - if let Some(inserted_text) = inserted_text.as_deref() { - editor.insert_text(inserted_text, window, cx); + match attempt { + Some(InputAttempt::Text(text)) => { + editor.insert_text(&text, window, cx); + } + Some(InputAttempt::Paste(clipboard)) => { + editor.paste_item(&clipboard, window, cx); + } + None => {} } }); diff --git a/crates/agent_ui/src/message_editor.rs b/crates/agent_ui/src/message_editor.rs index 401c282201d..d839e87d98e 100644 --- a/crates/agent_ui/src/message_editor.rs +++ b/crates/agent_ui/src/message_editor.rs @@ -145,6 +145,12 @@ pub struct MessageEditor { _parse_slash_command_task: Task<()>, } +#[derive(Clone, Debug)] +pub enum InputAttempt { + Text(Arc), + Paste(ClipboardItem), +} + #[derive(Clone, Debug)] pub enum MessageEditorEvent { Send, @@ -153,7 +159,7 @@ pub enum MessageEditorEvent { Focus, LostFocus, InputAttempted { - text: Arc, + attempt: InputAttempt, cursor_offset: usize, }, } @@ -494,7 +500,7 @@ impl MessageEditor { .to_offset(&editor.buffer().read(cx).snapshot(cx)) .0; cx.emit(MessageEditorEvent::InputAttempted { - text: text.clone(), + attempt: InputAttempt::Text(text.clone()), cursor_offset, }); } @@ -954,18 +960,47 @@ impl MessageEditor { cx.emit(MessageEditorEvent::Cancel) } - fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context) { + pub fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context) { + let Some(clipboard) = cx.read_from_clipboard() else { + return; + }; + + if self.editor.read(cx).read_only(cx) { + let editor = self.editor.read(cx); + let cursor_offset = editor + .selections + .newest_anchor() + .head() + .to_offset(&editor.buffer().read(cx).snapshot(cx)) + .0; + cx.emit(MessageEditorEvent::InputAttempted { + attempt: InputAttempt::Paste(clipboard), + cursor_offset, + }); + cx.stop_propagation(); + return; + } + + cx.stop_propagation(); + self.paste_item(&clipboard, window, cx); + } + + pub fn paste_item( + &mut self, + clipboard: &ClipboardItem, + window: &mut Window, + cx: &mut Context, + ) { let Some(workspace) = self.workspace.upgrade() else { return; }; - let editor_clipboard_selections = cx.read_from_clipboard().and_then(|item| { - item.entries().iter().find_map(|entry| match entry { + let editor_clipboard_selections = + clipboard.entries().iter().find_map(|entry| match entry { ClipboardEntry::String(text) => { text.metadata_json::>() } _ => None, - }) - }); + }); // Insert creases for pasted clipboard selections that: // 1. Contain exactly one selection @@ -997,7 +1032,6 @@ impl MessageEditor { .unwrap_or(false); if should_insert_creases && let Some(selections) = editor_clipboard_selections { - cx.stop_propagation(); let snapshot = self.editor.read(cx).buffer().read(cx).snapshot(cx); let (insertion_target, _) = snapshot .anchor_to_buffer_anchor(self.editor.read(cx).selections.newest_anchor().start) @@ -1085,14 +1119,12 @@ impl MessageEditor { } // Handle text paste with potential markdown mention links before // clipboard context entries so markdown text still pastes as text. - if let Some(clipboard_text) = cx.read_from_clipboard().and_then(|item| { - item.entries().iter().find_map(|entry| match entry { - ClipboardEntry::String(text) => Some(text.text().to_string()), - _ => None, - }) - }) { + let clipboard_text = clipboard.entries().iter().find_map(|entry| match entry { + ClipboardEntry::String(text) => Some(text.text().to_string()), + _ => None, + }); + if let Some(clipboard_text) = clipboard_text.as_deref() { if clipboard_text.contains("[@") { - cx.stop_propagation(); let selections_before = self.editor.update(cx, |editor, cx| { let snapshot = editor.buffer().read(cx).snapshot(cx); editor @@ -1109,7 +1141,7 @@ impl MessageEditor { }); self.editor.update(cx, |editor, cx| { - editor.insert(&clipboard_text, window, cx); + editor.insert(clipboard_text, window, cx); }); let snapshot = self.editor.read(cx).buffer().read(cx).snapshot(cx); @@ -1180,12 +1212,13 @@ impl MessageEditor { } } - if self.handle_pasted_context(window, cx) { + if self.handle_pasted_context(clipboard, window, cx) { return; } - // Fall through to default editor paste - cx.propagate(); + self.editor.update(cx, |editor, cx| { + editor.paste_item(clipboard, window, cx); + }); } fn copy(&mut self, _: &Copy, _: &mut Window, cx: &mut Context) { @@ -1205,11 +1238,12 @@ impl MessageEditor { }); } - fn handle_pasted_context(&mut self, window: &mut Window, cx: &mut Context) -> bool { - let Some(clipboard) = cx.read_from_clipboard() else { - return false; - }; - + fn handle_pasted_context( + &mut self, + clipboard: &ClipboardItem, + window: &mut Window, + cx: &mut Context, + ) -> bool { if matches!( clipboard.entries().first(), Some(ClipboardEntry::String(_)) | None @@ -1229,9 +1263,7 @@ impl MessageEditor { let editor = self.editor.clone(); let mention_set = self.mention_set.clone(); let workspace = self.workspace.clone(); - let entries = clipboard.into_entries().collect::>(); - - cx.stop_propagation(); + let entries = clipboard.clone().into_entries().collect::>(); window .spawn(cx, async move |mut cx| { diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index d9dd6078c08..a57b7058560 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -14756,24 +14756,33 @@ impl Editor { } pub fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context) { + if let Some(item) = cx.read_from_clipboard() { + self.paste_item(&item, window, cx); + } + } + + pub fn paste_item( + &mut self, + item: &ClipboardItem, + window: &mut Window, + cx: &mut Context, + ) { if self.read_only(cx) { return; } - if let Some(item) = cx.read_from_clipboard() { - let clipboard_string = item.entries().iter().find_map(|entry| match entry { - ClipboardEntry::String(s) => Some(s), - _ => None, - }); - match clipboard_string { - Some(clipboard_string) => self.do_paste( - clipboard_string.text(), - clipboard_string.metadata_json::>(), - true, - window, - cx, - ), - _ => self.do_paste(&item.text().unwrap_or_default(), None, true, window, cx), - } + let clipboard_string = item.entries().iter().find_map(|entry| match entry { + ClipboardEntry::String(s) => Some(s), + _ => None, + }); + match clipboard_string { + Some(clipboard_string) => self.do_paste( + clipboard_string.text(), + clipboard_string.metadata_json::>(), + true, + window, + cx, + ), + _ => self.do_paste(&item.text().unwrap_or_default(), None, true, window, cx), } }