diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 921d1347ffb6c344d11f45a467a60d047e977b78..7a2ee6d00c09ddae77988ac87b9343750fed3472 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -2597,31 +2597,26 @@ impl AgentPanel { } fn active_initial_content(&self, cx: &App) -> Option { - self.active_thread_view(cx).and_then(|thread_view| { + let thread_view = self.active_thread_view(cx)?; + let thread_view = thread_view.read(cx); + let saved = thread_view + .thread + .read(cx) + .draft_prompt() + .map(|blocks| blocks.to_vec()) + .filter(|blocks| !blocks.is_empty()); + let blocks = saved.unwrap_or_else(|| { thread_view + .message_editor .read(cx) - .thread - .read(cx) - .draft_prompt() - .map(|draft| AgentInitialContent::ContentBlock { - blocks: draft.to_vec(), - auto_submit: false, - }) - .filter(|initial_content| match initial_content { - AgentInitialContent::ContentBlock { blocks, .. } => !blocks.is_empty(), - _ => true, - }) - .or_else(|| { - let text = thread_view.read(cx).message_editor.read(cx).text(cx); - if text.trim().is_empty() { - None - } else { - Some(AgentInitialContent::ContentBlock { - blocks: vec![acp::ContentBlock::Text(acp::TextContent::new(text))], - auto_submit: false, - }) - } - }) + .draft_content_blocks_snapshot(cx) + }); + if blocks.is_empty() { + return None; + } + Some(AgentInitialContent::ContentBlock { + blocks, + auto_submit: false, }) } diff --git a/crates/agent_ui/src/mention_set.rs b/crates/agent_ui/src/mention_set.rs index 8c98b9458bbce89133bd0cafdc4ef3ff00e6ffeb..fc2cc6523c8d3ebd0cefcd631cadc3a3989fdbb2 100644 --- a/crates/agent_ui/src/mention_set.rs +++ b/crates/agent_ui/src/mention_set.rs @@ -178,6 +178,16 @@ impl MentionSet { self.mentions.get(crease_id).map(|(uri, _)| uri.clone()) } + /// Returns the resolved mention for a crease, if any. + pub fn resolved_mention_for_crease( + &self, + crease_id: &CreaseId, + ) -> Option<(MentionUri, Option)> { + let (uri, task) = self.mentions.get(crease_id)?; + let mention = task.clone().now_or_never().and_then(|result| result.ok()); + Some((uri.clone(), mention)) + } + pub fn set_mentions(&mut self, mentions: HashMap) { self.mentions = mentions; } diff --git a/crates/agent_ui/src/message_editor.rs b/crates/agent_ui/src/message_editor.rs index 66887019d3129494d039cddec97279473f7d8354..c6fd040f7e91e9e60d4f45a46c001c18ee012c37 100644 --- a/crates/agent_ui/src/message_editor.rs +++ b/crates/agent_ui/src/message_editor.rs @@ -17,6 +17,7 @@ use editor::{ EditorStyle, Inlay, MultiBuffer, MultiBufferOffset, MultiBufferSnapshot, ToOffset, actions::{Copy, Paste}, code_context_menus::CodeContextMenu, + display_map::{CreaseId, CreaseSnapshot}, scroll::Autoscroll, }; use futures::{FutureExt as _, future::join_all}; @@ -768,90 +769,46 @@ impl MessageEditor { self.session_capabilities.read().supports_embedded_context(); cx.spawn(async move |_, cx| { - let contents = contents.await?; - let mut all_tracked_buffers = Vec::new(); - - let result = editor.update(cx, |editor, cx| { + let mut contents = contents.await?; + Ok(editor.update(cx, |editor, cx| { + let crease_snapshot = editor.display_map.read(cx).crease_snapshot(); + let buffer_snapshot = editor.buffer().read(cx).snapshot(cx); let text = editor.text(cx); - let (mut ix, _) = text - .char_indices() - .find(|(_, c)| !c.is_whitespace()) - .unwrap_or((0, '\0')); - let mut chunks: Vec = Vec::new(); - editor.display_map.update(cx, |map, cx| { - let snapshot = map.snapshot(cx); - for (crease_id, crease) in snapshot.crease_snapshot.creases() { - let Some((uri, mention)) = contents.get(&crease_id) else { - continue; - }; - - let crease_range = crease.range().to_offset(&snapshot.buffer_snapshot()); - if crease_range.start.0 > ix { - let chunk = text[ix..crease_range.start.0].into(); - chunks.push(chunk); - } - let chunk = match mention { - Mention::Text { - content, - tracked_buffers, - } => { - all_tracked_buffers.extend(tracked_buffers.iter().cloned()); - if supports_embedded_context { - acp::ContentBlock::Resource(acp::EmbeddedResource::new( - acp::EmbeddedResourceResource::TextResourceContents( - acp::TextResourceContents::new( - content.clone(), - uri.to_uri().to_string(), - ), - ), - )) - } else { - acp::ContentBlock::ResourceLink(acp::ResourceLink::new( - uri.name(), - uri.to_uri().to_string(), - )) - } - } - Mention::Image(mention_image) => acp::ContentBlock::Image( - acp::ImageContent::new( - mention_image.data.clone(), - mention_image.format.mime_type(), - ) - .uri(match uri { - MentionUri::File { .. } => Some(uri.to_uri().to_string()), - MentionUri::PastedImage { .. } => { - Some(uri.to_uri().to_string()) - } - other => { - debug_panic!( - "unexpected mention uri for image: {:?}", - other - ); - None - } - }), - ), - Mention::Link => acp::ContentBlock::ResourceLink( - acp::ResourceLink::new(uri.name(), uri.to_uri().to_string()), - ), - }; - chunks.push(chunk); - ix = crease_range.end.0; - } - - if ix < text.len() { - let last_chunk = text[ix..].trim_end().to_owned(); - if !last_chunk.is_empty() { - chunks.push(last_chunk.into()); - } - } - }); - anyhow::Ok((chunks, all_tracked_buffers)) - })?; - Ok(result) + build_chunks_from_creases( + &text, + &crease_snapshot, + &buffer_snapshot, + supports_embedded_context, + |crease_id| { + contents + .remove(crease_id) + .map(|(uri, mention)| (uri, Some(mention))) + }, + ) + })) }) } + /// Snapshots the editor's current draft into a list of `ContentBlock`s + /// without awaiting any pending mention resolution. + pub fn draft_content_blocks_snapshot(&self, cx: &App) -> Vec { + let editor = self.editor.read(cx); + let crease_snapshot = editor.display_map.read(cx).crease_snapshot(); + let buffer_snapshot = editor.buffer().read(cx).snapshot(cx); + let text = editor.text(cx); + let mention_set = self.mention_set.read(cx); + let supports_embedded_context = + self.session_capabilities.read().supports_embedded_context(); + let (chunks, _tracked_buffers) = build_chunks_from_creases( + &text, + &crease_snapshot, + &buffer_snapshot, + supports_embedded_context, + |crease_id| mention_set.resolved_mention_for_crease(crease_id), + ); + chunks + } + pub fn clear(&mut self, window: &mut Window, cx: &mut Context) { self.editor.update(cx, |editor, cx| { editor.clear(window, cx); @@ -1874,6 +1831,92 @@ impl Addon for MessageEditorAddon { } } +/// Walks the editor's creases in order, interleaving plain-text chunks from +/// `text` with mention blocks produced from `resolve`. +fn build_chunks_from_creases( + text: &str, + crease_snapshot: &CreaseSnapshot, + buffer_snapshot: &MultiBufferSnapshot, + supports_embedded_context: bool, + mut resolve: impl FnMut(&CreaseId) -> Option<(MentionUri, Option)>, +) -> (Vec, Vec>) { + let mut ix = text + .char_indices() + .find(|(_, c)| !c.is_whitespace()) + .map_or(text.len(), |(i, _)| i); + let mut chunks = Vec::new(); + let mut tracked_buffers = Vec::new(); + + for (crease_id, crease) in crease_snapshot.creases() { + let Some((uri, mention)) = resolve(&crease_id) else { + continue; + }; + let crease_range = crease.range().to_offset(buffer_snapshot); + if crease_range.start.0 > ix { + chunks.push(text[ix..crease_range.start.0].into()); + } + chunks.push(mention_to_content_block( + &uri, + mention.as_ref(), + supports_embedded_context, + &mut tracked_buffers, + )); + ix = crease_range.end.0; + } + + if ix < text.len() { + let last_chunk = text[ix..].trim_end().to_owned(); + if !last_chunk.is_empty() { + chunks.push(last_chunk.into()); + } + } + (chunks, tracked_buffers) +} + +fn mention_to_content_block( + uri: &MentionUri, + mention: Option<&Mention>, + supports_embedded_context: bool, + tracked_buffers: &mut Vec>, +) -> acp::ContentBlock { + match mention { + Some(Mention::Text { + content, + tracked_buffers: mention_tracked_buffers, + }) => { + tracked_buffers.extend(mention_tracked_buffers.iter().cloned()); + if supports_embedded_context { + acp::ContentBlock::Resource(acp::EmbeddedResource::new( + acp::EmbeddedResourceResource::TextResourceContents( + acp::TextResourceContents::new(content.clone(), uri.to_uri().to_string()), + ), + )) + } else { + acp::ContentBlock::ResourceLink(acp::ResourceLink::new( + uri.name(), + uri.to_uri().to_string(), + )) + } + } + Some(Mention::Image(mention_image)) => acp::ContentBlock::Image( + acp::ImageContent::new(mention_image.data.clone(), mention_image.format.mime_type()) + .uri(match uri { + MentionUri::File { .. } | MentionUri::PastedImage { .. } => { + Some(uri.to_uri().to_string()) + } + other => { + debug_panic!("unexpected mention uri for image: {:?}", other); + None + } + }), + ), + _ => acp::ContentBlock::ResourceLink(acp::ResourceLink::new( + uri.name(), + uri.to_uri().to_string(), + )), + } +} + /// Parses markdown mention links in the format `[@name](uri)` from text. /// Returns a vector of (range, MentionUri) pairs where range is the byte range in the text. fn parse_mention_links(text: &str, path_style: PathStyle) -> Vec<(Range, MentionUri)> { @@ -4197,6 +4240,56 @@ mod tests { assert_eq!(copied, None); } + #[gpui::test] + async fn test_draft_content_blocks_snapshot_preserves_selection_mentions( + cx: &mut TestAppContext, + ) { + init_test(cx); + + let (fixture, mut cx) = setup_selection_mention_fixture(cx).await; + + let blocks = fixture.message_editor.update(&mut cx, |editor, cx| { + editor + .session_capabilities + .write() + .set_prompt_capabilities(acp::PromptCapabilities::new().embedded_context(true)); + editor.draft_content_blocks_snapshot(cx) + }); + + // Each selection mention must round-trip as a `Resource` block carrying + // its URI and content, not as a `Text` block containing the fold + // placeholder string. + let resource_uris: Vec<&str> = + blocks + .iter() + .filter_map(|block| match block { + acp::ContentBlock::Resource(acp::EmbeddedResource { + resource: + acp::EmbeddedResourceResource::TextResourceContents( + acp::TextResourceContents { uri, .. }, + ), + .. + }) => Some(uri.as_str()), + _ => None, + }) + .collect(); + assert_eq!( + resource_uris.len(), + 2, + "snapshot should emit one Resource block per selection mention; got {blocks:#?}" + ); + assert!(resource_uris.contains(&fixture.first_uri.to_uri().to_string().as_str())); + for block in &blocks { + if let acp::ContentBlock::Text(text) = block { + assert!( + !text.text.split_whitespace().any(|word| word == "selection"), + "text block must not contain bare fold placeholder: {:?}", + text.text + ); + } + } + } + #[gpui::test] async fn test_paste_mention_link_with_completion_trigger_does_not_panic( cx: &mut TestAppContext, diff --git a/crates/editor/src/display_map.rs b/crates/editor/src/display_map.rs index db01bbb178694f4c13f7cac4f933629682598331..f7433c96448d298505f3a891b9392781ed3fa711 100644 --- a/crates/editor/src/display_map.rs +++ b/crates/editor/src/display_map.rs @@ -689,6 +689,10 @@ impl DisplayMap { } } + pub fn crease_snapshot(&self) -> CreaseSnapshot { + self.crease_map.snapshot() + } + #[instrument(skip_all)] pub fn set_state(&mut self, other: &DisplaySnapshot, cx: &mut Context) { self.fold(