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
41 changes: 18 additions & 23 deletions crates/agent_ui/src/agent_panel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2598,31 +2598,26 @@ impl AgentPanel {
}

fn active_initial_content(&self, cx: &App) -> Option<AgentInitialContent> {
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,
})
}

Expand Down
10 changes: 10 additions & 0 deletions crates/agent_ui/src/mention_set.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Mention>)> {
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<CreaseId, (MentionUri, MentionTask)>) {
self.mentions = mentions;
}
Expand Down
253 changes: 173 additions & 80 deletions crates/agent_ui/src/message_editor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -761,90 +762,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<acp::ContentBlock> = 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<acp::ContentBlock> {
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>) {
self.editor.update(cx, |editor, cx| {
editor.clear(window, cx);
Expand Down Expand Up @@ -1841,6 +1798,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<Mention>)>,
) -> (Vec<acp::ContentBlock>, Vec<Entity<Buffer>>) {
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<Entity<Buffer>>,
) -> 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<usize>, MentionUri)> {
Expand Down Expand Up @@ -4164,6 +4207,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,
Expand Down
4 changes: 4 additions & 0 deletions crates/editor/src/display_map.rs
Original file line number Diff line number Diff line change
Expand Up @@ -688,6 +688,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>) {
self.fold(
Expand Down
Loading