Support images in agent2 threads (#36152)

- Support adding ImageContent to messages through copy/paste and through
path completions
- Ensure images are fully converted to LanguageModelImageContent before
sending them to the model
- Update ACP crate to v0.0.24 to enable passing image paths through the
protocol

Release Notes:

- N/A

---------

Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>
This commit is contained in:
Cole Miller 2025-08-14 17:31:14 -04:00 committed by GitHub
parent e2ce787c05
commit b1e806442a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 415 additions and 92 deletions

4
Cargo.lock generated
View file

@ -172,9 +172,9 @@ dependencies = [
[[package]] [[package]]
name = "agent-client-protocol" name = "agent-client-protocol"
version = "0.0.23" version = "0.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fad72b7b8ee4331b3a4c8d43c107e982a4725564b4ee658ae5c4e79d2b486e8" checksum = "8fd68bbbef8e424fb8a605c5f0b00c360f682c4528b0a5feb5ec928aaf5ce28e"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"futures 0.3.31", "futures 0.3.31",

View file

@ -425,7 +425,7 @@ zlog_settings = { path = "crates/zlog_settings" }
# #
agentic-coding-protocol = "0.0.10" agentic-coding-protocol = "0.0.10"
agent-client-protocol = "0.0.23" agent-client-protocol = "0.0.24"
aho-corasick = "1.1" aho-corasick = "1.1"
alacritty_terminal = { git = "https://github.com/zed-industries/alacritty.git", branch = "add-hush-login-flag" } alacritty_terminal = { git = "https://github.com/zed-industries/alacritty.git", branch = "add-hush-login-flag" }
any_vec = "0.14" any_vec = "0.14"

View file

@ -443,9 +443,8 @@ impl ContentBlock {
}), }),
.. ..
}) => Self::resource_link_md(&uri), }) => Self::resource_link_md(&uri),
acp::ContentBlock::Image(_) acp::ContentBlock::Image(image) => Self::image_md(&image),
| acp::ContentBlock::Audio(_) acp::ContentBlock::Audio(_) | acp::ContentBlock::Resource(_) => String::new(),
| acp::ContentBlock::Resource(_) => String::new(),
} }
} }
@ -457,6 +456,10 @@ impl ContentBlock {
} }
} }
fn image_md(_image: &acp::ImageContent) -> String {
"`Image`".into()
}
fn to_markdown<'a>(&'a self, cx: &'a App) -> &'a str { fn to_markdown<'a>(&'a self, cx: &'a App) -> &'a str {
match self { match self {
ContentBlock::Empty => "", ContentBlock::Empty => "",

View file

@ -6,6 +6,7 @@ use std::{
fmt, fmt,
ops::Range, ops::Range,
path::{Path, PathBuf}, path::{Path, PathBuf},
str::FromStr,
}; };
use ui::{App, IconName, SharedString}; use ui::{App, IconName, SharedString};
use url::Url; use url::Url;
@ -224,6 +225,14 @@ impl MentionUri {
} }
} }
impl FromStr for MentionUri {
type Err = anyhow::Error;
fn from_str(s: &str) -> anyhow::Result<Self> {
Self::parse(s)
}
}
pub struct MentionLink<'a>(&'a MentionUri); pub struct MentionLink<'a>(&'a MentionUri);
impl fmt::Display for MentionLink<'_> { impl fmt::Display for MentionLink<'_> {

View file

@ -1,5 +1,6 @@
use std::ffi::OsStr;
use std::ops::Range; use std::ops::Range;
use std::path::PathBuf; use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::AtomicBool; use std::sync::atomic::AtomicBool;
@ -8,13 +9,14 @@ use anyhow::{Context as _, Result, anyhow};
use collections::{HashMap, HashSet}; use collections::{HashMap, HashSet};
use editor::display_map::CreaseId; use editor::display_map::CreaseId;
use editor::{CompletionProvider, Editor, ExcerptId, ToOffset as _}; use editor::{CompletionProvider, Editor, ExcerptId, ToOffset as _};
use futures::future::{Shared, try_join_all};
use futures::future::try_join_all; use futures::{FutureExt, TryFutureExt};
use fuzzy::{StringMatch, StringMatchCandidate}; use fuzzy::{StringMatch, StringMatchCandidate};
use gpui::{App, Entity, Task, WeakEntity}; use gpui::{App, Entity, ImageFormat, Img, Task, WeakEntity};
use http_client::HttpClientWithUrl; use http_client::HttpClientWithUrl;
use itertools::Itertools as _; use itertools::Itertools as _;
use language::{Buffer, CodeLabel, HighlightId}; use language::{Buffer, CodeLabel, HighlightId};
use language_model::LanguageModelImage;
use lsp::CompletionContext; use lsp::CompletionContext;
use parking_lot::Mutex; use parking_lot::Mutex;
use project::{ use project::{
@ -43,24 +45,43 @@ use crate::context_picker::{
available_context_picker_entries, recent_context_picker_entries, selection_ranges, available_context_picker_entries, recent_context_picker_entries, selection_ranges,
}; };
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MentionImage {
pub abs_path: Option<Arc<Path>>,
pub data: SharedString,
pub format: ImageFormat,
}
#[derive(Default)] #[derive(Default)]
pub struct MentionSet { pub struct MentionSet {
uri_by_crease_id: HashMap<CreaseId, MentionUri>, uri_by_crease_id: HashMap<CreaseId, MentionUri>,
fetch_results: HashMap<Url, String>, fetch_results: HashMap<Url, Shared<Task<Result<String, String>>>>,
images: HashMap<CreaseId, Shared<Task<Result<MentionImage, String>>>>,
} }
impl MentionSet { impl MentionSet {
pub fn insert(&mut self, crease_id: CreaseId, uri: MentionUri) { pub fn insert_uri(&mut self, crease_id: CreaseId, uri: MentionUri) {
self.uri_by_crease_id.insert(crease_id, uri); self.uri_by_crease_id.insert(crease_id, uri);
} }
pub fn add_fetch_result(&mut self, url: Url, content: String) { pub fn add_fetch_result(&mut self, url: Url, content: Shared<Task<Result<String, String>>>) {
self.fetch_results.insert(url, content); self.fetch_results.insert(url, content);
} }
pub fn insert_image(
&mut self,
crease_id: CreaseId,
task: Shared<Task<Result<MentionImage, String>>>,
) {
self.images.insert(crease_id, task);
}
pub fn drain(&mut self) -> impl Iterator<Item = CreaseId> { pub fn drain(&mut self) -> impl Iterator<Item = CreaseId> {
self.fetch_results.clear(); self.fetch_results.clear();
self.uri_by_crease_id.drain().map(|(id, _)| id) self.uri_by_crease_id
.drain()
.map(|(id, _)| id)
.chain(self.images.drain().map(|(id, _)| id))
} }
pub fn clear(&mut self) { pub fn clear(&mut self) {
@ -76,7 +97,7 @@ impl MentionSet {
window: &mut Window, window: &mut Window,
cx: &mut App, cx: &mut App,
) -> Task<Result<HashMap<CreaseId, Mention>>> { ) -> Task<Result<HashMap<CreaseId, Mention>>> {
let contents = self let mut contents = self
.uri_by_crease_id .uri_by_crease_id
.iter() .iter()
.map(|(&crease_id, uri)| { .map(|(&crease_id, uri)| {
@ -85,19 +106,59 @@ impl MentionSet {
// TODO directories // TODO directories
let uri = uri.clone(); let uri = uri.clone();
let abs_path = abs_path.to_path_buf(); let abs_path = abs_path.to_path_buf();
let buffer_task = project.update(cx, |project, cx| { let extension = abs_path.extension().and_then(OsStr::to_str).unwrap_or("");
let path = project
.find_project_path(abs_path, cx)
.context("Failed to find project path")?;
anyhow::Ok(project.open_buffer(path, cx))
});
cx.spawn(async move |cx| { if Img::extensions().contains(&extension) && !extension.contains("svg") {
let buffer = buffer_task?.await?; let open_image_task = project.update(cx, |project, cx| {
let content = buffer.read_with(cx, |buffer, _cx| buffer.text())?; let path = project
.find_project_path(&abs_path, cx)
.context("Failed to find project path")?;
anyhow::Ok(project.open_image(path, cx))
});
anyhow::Ok((crease_id, Mention { uri, content })) cx.spawn(async move |cx| {
}) let image_item = open_image_task?.await?;
let (data, format) = image_item.update(cx, |image_item, cx| {
let format = image_item.image.format;
(
LanguageModelImage::from_image(
image_item.image.clone(),
cx,
),
format,
)
})?;
let data = cx.spawn(async move |_| {
if let Some(data) = data.await {
Ok(data.source)
} else {
anyhow::bail!("Failed to convert image")
}
});
anyhow::Ok((
crease_id,
Mention::Image(MentionImage {
abs_path: Some(abs_path.as_path().into()),
data: data.await?,
format,
}),
))
})
} else {
let buffer_task = project.update(cx, |project, cx| {
let path = project
.find_project_path(abs_path, cx)
.context("Failed to find project path")?;
anyhow::Ok(project.open_buffer(path, cx))
});
cx.spawn(async move |cx| {
let buffer = buffer_task?.await?;
let content = buffer.read_with(cx, |buffer, _cx| buffer.text())?;
anyhow::Ok((crease_id, Mention::Text { uri, content }))
})
}
} }
MentionUri::Symbol { MentionUri::Symbol {
path, line_range, .. path, line_range, ..
@ -130,7 +191,7 @@ impl MentionSet {
.collect() .collect()
})?; })?;
anyhow::Ok((crease_id, Mention { uri, content })) anyhow::Ok((crease_id, Mention::Text { uri, content }))
}) })
} }
MentionUri::Thread { id: thread_id, .. } => { MentionUri::Thread { id: thread_id, .. } => {
@ -145,7 +206,7 @@ impl MentionSet {
thread.latest_detailed_summary_or_text().to_string() thread.latest_detailed_summary_or_text().to_string()
})?; })?;
anyhow::Ok((crease_id, Mention { uri, content })) anyhow::Ok((crease_id, Mention::Text { uri, content }))
}) })
} }
MentionUri::TextThread { path, .. } => { MentionUri::TextThread { path, .. } => {
@ -156,7 +217,7 @@ impl MentionSet {
cx.spawn(async move |cx| { cx.spawn(async move |cx| {
let context = context.await?; let context = context.await?;
let xml = context.update(cx, |context, cx| context.to_xml(cx))?; let xml = context.update(cx, |context, cx| context.to_xml(cx))?;
anyhow::Ok((crease_id, Mention { uri, content: xml })) anyhow::Ok((crease_id, Mention::Text { uri, content: xml }))
}) })
} }
MentionUri::Rule { id: prompt_id, .. } => { MentionUri::Rule { id: prompt_id, .. } => {
@ -169,25 +230,39 @@ impl MentionSet {
cx.spawn(async move |_| { cx.spawn(async move |_| {
// TODO: report load errors instead of just logging // TODO: report load errors instead of just logging
let text = text_task.await?; let text = text_task.await?;
anyhow::Ok((crease_id, Mention { uri, content: text })) anyhow::Ok((crease_id, Mention::Text { uri, content: text }))
}) })
} }
MentionUri::Fetch { url } => { MentionUri::Fetch { url } => {
let Some(content) = self.fetch_results.get(&url) else { let Some(content) = self.fetch_results.get(&url).cloned() else {
return Task::ready(Err(anyhow!("missing fetch result"))); return Task::ready(Err(anyhow!("missing fetch result")));
}; };
Task::ready(Ok(( let uri = uri.clone();
crease_id, cx.spawn(async move |_| {
Mention { Ok((
uri: uri.clone(), crease_id,
content: content.clone(), Mention::Text {
}, uri,
))) content: content.await.map_err(|e| anyhow::anyhow!("{e}"))?,
},
))
})
} }
} }
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
contents.extend(self.images.iter().map(|(crease_id, image)| {
let crease_id = *crease_id;
let image = image.clone();
cx.spawn(async move |_| {
Ok((
crease_id,
Mention::Image(image.await.map_err(|e| anyhow::anyhow!("{e}"))?),
))
})
}));
cx.spawn(async move |_cx| { cx.spawn(async move |_cx| {
let contents = try_join_all(contents).await?.into_iter().collect(); let contents = try_join_all(contents).await?.into_iter().collect();
anyhow::Ok(contents) anyhow::Ok(contents)
@ -195,10 +270,10 @@ impl MentionSet {
} }
} }
#[derive(Debug)] #[derive(Debug, Eq, PartialEq)]
pub struct Mention { pub enum Mention {
pub uri: MentionUri, Text { uri: MentionUri, content: String },
pub content: String, Image(MentionImage),
} }
pub(crate) enum Match { pub(crate) enum Match {
@ -536,7 +611,10 @@ impl ContextPickerCompletionProvider {
crease_ids.try_into().unwrap() crease_ids.try_into().unwrap()
}); });
mention_set.lock().insert(crease_id, uri); mention_set.lock().insert_uri(
crease_id,
MentionUri::Selection { path, line_range },
);
current_offset += text_len + 1; current_offset += text_len + 1;
} }
@ -786,6 +864,7 @@ impl ContextPickerCompletionProvider {
let url_to_fetch = url_to_fetch.clone(); let url_to_fetch = url_to_fetch.clone();
let source_range = source_range.clone(); let source_range = source_range.clone();
let icon_path = icon_path.clone(); let icon_path = icon_path.clone();
let mention_uri = mention_uri.clone();
Arc::new(move |_, window, cx| { Arc::new(move |_, window, cx| {
let Some(url) = url::Url::parse(url_to_fetch.as_ref()) let Some(url) = url::Url::parse(url_to_fetch.as_ref())
.or_else(|_| url::Url::parse(&format!("https://{url_to_fetch}"))) .or_else(|_| url::Url::parse(&format!("https://{url_to_fetch}")))
@ -799,6 +878,7 @@ impl ContextPickerCompletionProvider {
let http_client = http_client.clone(); let http_client = http_client.clone();
let source_range = source_range.clone(); let source_range = source_range.clone();
let icon_path = icon_path.clone(); let icon_path = icon_path.clone();
let mention_uri = mention_uri.clone();
window.defer(cx, move |window, cx| { window.defer(cx, move |window, cx| {
let url = url.clone(); let url = url.clone();
@ -819,17 +899,24 @@ impl ContextPickerCompletionProvider {
let mention_set = mention_set.clone(); let mention_set = mention_set.clone();
let http_client = http_client.clone(); let http_client = http_client.clone();
let source_range = source_range.clone(); let source_range = source_range.clone();
let url_string = url.to_string();
let fetch = cx
.background_executor()
.spawn(async move {
fetch_url_content(http_client, url_string)
.map_err(|e| e.to_string())
.await
})
.shared();
mention_set.lock().add_fetch_result(url, fetch.clone());
window window
.spawn(cx, async move |cx| { .spawn(cx, async move |cx| {
if let Some(content) = if fetch.await.notify_async_err(cx).is_some() {
fetch_url_content(http_client, url.to_string())
.await
.notify_async_err(cx)
{
mention_set.lock().add_fetch_result(url.clone(), content);
mention_set mention_set
.lock() .lock()
.insert(crease_id, MentionUri::Fetch { url }); .insert_uri(crease_id, mention_uri.clone());
} else { } else {
// Remove crease if we failed to fetch // Remove crease if we failed to fetch
editor editor
@ -1121,7 +1208,9 @@ fn confirm_completion_callback(
window, window,
cx, cx,
) { ) {
mention_set.lock().insert(crease_id, mention_uri.clone()); mention_set
.lock()
.insert_uri(crease_id, mention_uri.clone());
} }
}); });
false false
@ -1499,11 +1588,12 @@ mod tests {
.into_values() .into_values()
.collect::<Vec<_>>(); .collect::<Vec<_>>();
assert_eq!(contents.len(), 1); pretty_assertions::assert_eq!(
assert_eq!(contents[0].content, "1"); contents,
assert_eq!( [Mention::Text {
contents[0].uri.to_uri().to_string(), content: "1".into(),
"file:///dir/a/one.txt" uri: "file:///dir/a/one.txt".parse().unwrap()
}]
); );
cx.simulate_input(" "); cx.simulate_input(" ");
@ -1567,11 +1657,13 @@ mod tests {
.collect::<Vec<_>>(); .collect::<Vec<_>>();
assert_eq!(contents.len(), 2); assert_eq!(contents.len(), 2);
let new_mention = contents pretty_assertions::assert_eq!(
.iter() contents[1],
.find(|mention| mention.uri.to_uri().to_string() == "file:///dir/b/eight.txt") Mention::Text {
.unwrap(); content: "8".to_string(),
assert_eq!(new_mention.content, "8"); uri: "file:///dir/b/eight.txt".parse().unwrap(),
}
);
editor.update(&mut cx, |editor, cx| { editor.update(&mut cx, |editor, cx| {
assert_eq!( assert_eq!(
@ -1689,13 +1781,15 @@ mod tests {
.collect::<Vec<_>>(); .collect::<Vec<_>>();
assert_eq!(contents.len(), 3); assert_eq!(contents.len(), 3);
let new_mention = contents pretty_assertions::assert_eq!(
.iter() contents[2],
.find(|mention| { Mention::Text {
mention.uri.to_uri().to_string() == "file:///dir/a/one.txt?symbol=MySymbol#L1:1" content: "1".into(),
}) uri: "file:///dir/a/one.txt?symbol=MySymbol#L1:1"
.unwrap(); .parse()
assert_eq!(new_mention.content, "1"); .unwrap(),
}
);
cx.run_until_parked(); cx.run_until_parked();

View file

@ -1,4 +1,5 @@
use crate::acp::completion_provider::ContextPickerCompletionProvider; use crate::acp::completion_provider::ContextPickerCompletionProvider;
use crate::acp::completion_provider::MentionImage;
use crate::acp::completion_provider::MentionSet; use crate::acp::completion_provider::MentionSet;
use acp_thread::MentionUri; use acp_thread::MentionUri;
use agent::TextThreadStore; use agent::TextThreadStore;
@ -6,30 +7,44 @@ use agent::ThreadStore;
use agent_client_protocol as acp; use agent_client_protocol as acp;
use anyhow::Result; use anyhow::Result;
use collections::HashSet; use collections::HashSet;
use editor::ExcerptId;
use editor::actions::Paste;
use editor::display_map::CreaseId;
use editor::{ use editor::{
AnchorRangeExt, ContextMenuOptions, ContextMenuPlacement, Editor, EditorElement, EditorMode, AnchorRangeExt, ContextMenuOptions, ContextMenuPlacement, Editor, EditorElement, EditorMode,
EditorStyle, MultiBuffer, EditorStyle, MultiBuffer,
}; };
use futures::FutureExt as _;
use gpui::ClipboardEntry;
use gpui::Image;
use gpui::ImageFormat;
use gpui::{ use gpui::{
AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, Task, TextStyle, WeakEntity, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, Task, TextStyle, WeakEntity,
}; };
use language::Buffer; use language::Buffer;
use language::Language; use language::Language;
use language_model::LanguageModelImage;
use parking_lot::Mutex; use parking_lot::Mutex;
use project::{CompletionIntent, Project}; use project::{CompletionIntent, Project};
use settings::Settings; use settings::Settings;
use std::fmt::Write; use std::fmt::Write;
use std::path::Path;
use std::rc::Rc; use std::rc::Rc;
use std::sync::Arc; use std::sync::Arc;
use theme::ThemeSettings; use theme::ThemeSettings;
use ui::IconName;
use ui::SharedString;
use ui::{ use ui::{
ActiveTheme, App, InteractiveElement, IntoElement, ParentElement, Render, Styled, TextSize, ActiveTheme, App, InteractiveElement, IntoElement, ParentElement, Render, Styled, TextSize,
Window, div, Window, div,
}; };
use util::ResultExt; use util::ResultExt;
use workspace::Workspace; use workspace::Workspace;
use workspace::notifications::NotifyResultExt as _;
use zed_actions::agent::Chat; use zed_actions::agent::Chat;
use super::completion_provider::Mention;
pub struct MessageEditor { pub struct MessageEditor {
editor: Entity<Editor>, editor: Entity<Editor>,
project: Entity<Project>, project: Entity<Project>,
@ -130,23 +145,41 @@ impl MessageEditor {
continue; continue;
} }
if let Some(mention) = contents.get(&crease_id) { let Some(mention) = contents.get(&crease_id) else {
let crease_range = crease.range().to_offset(&snapshot.buffer_snapshot); continue;
if crease_range.start > ix { };
chunks.push(text[ix..crease_range.start].into());
} let crease_range = crease.range().to_offset(&snapshot.buffer_snapshot);
chunks.push(acp::ContentBlock::Resource(acp::EmbeddedResource { if crease_range.start > ix {
annotations: None, chunks.push(text[ix..crease_range.start].into());
resource: acp::EmbeddedResourceResource::TextResourceContents(
acp::TextResourceContents {
mime_type: None,
text: mention.content.clone(),
uri: mention.uri.to_uri().to_string(),
},
),
}));
ix = crease_range.end;
} }
let chunk = match mention {
Mention::Text { uri, content } => {
acp::ContentBlock::Resource(acp::EmbeddedResource {
annotations: None,
resource: acp::EmbeddedResourceResource::TextResourceContents(
acp::TextResourceContents {
mime_type: None,
text: content.clone(),
uri: uri.to_uri().to_string(),
},
),
})
}
Mention::Image(mention_image) => {
acp::ContentBlock::Image(acp::ImageContent {
annotations: None,
data: mention_image.data.to_string(),
mime_type: mention_image.format.mime_type().into(),
uri: mention_image
.abs_path
.as_ref()
.map(|path| format!("file://{}", path.display())),
})
}
};
chunks.push(chunk);
ix = crease_range.end;
} }
if ix < text.len() { if ix < text.len() {
@ -177,6 +210,56 @@ impl MessageEditor {
cx.emit(MessageEditorEvent::Cancel) cx.emit(MessageEditorEvent::Cancel)
} }
fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context<Self>) {
let images = cx
.read_from_clipboard()
.map(|item| {
item.into_entries()
.filter_map(|entry| {
if let ClipboardEntry::Image(image) = entry {
Some(image)
} else {
None
}
})
.collect::<Vec<_>>()
})
.unwrap_or_default();
if images.is_empty() {
return;
}
cx.stop_propagation();
let replacement_text = "image";
for image in images {
let (excerpt_id, anchor) = self.editor.update(cx, |message_editor, cx| {
let snapshot = message_editor.snapshot(window, cx);
let (excerpt_id, _, snapshot) = snapshot.buffer_snapshot.as_singleton().unwrap();
let anchor = snapshot.anchor_before(snapshot.len());
message_editor.edit(
[(
multi_buffer::Anchor::max()..multi_buffer::Anchor::max(),
format!("{replacement_text} "),
)],
cx,
);
(*excerpt_id, anchor)
});
self.insert_image(
excerpt_id,
anchor,
replacement_text.len(),
Arc::new(image),
None,
window,
cx,
);
}
}
pub fn insert_dragged_files( pub fn insert_dragged_files(
&self, &self,
paths: Vec<project::ProjectPath>, paths: Vec<project::ProjectPath>,
@ -234,6 +317,68 @@ impl MessageEditor {
} }
} }
fn insert_image(
&mut self,
excerpt_id: ExcerptId,
crease_start: text::Anchor,
content_len: usize,
image: Arc<Image>,
abs_path: Option<Arc<Path>>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(crease_id) = insert_crease_for_image(
excerpt_id,
crease_start,
content_len,
self.editor.clone(),
window,
cx,
) else {
return;
};
self.editor.update(cx, |_editor, cx| {
let format = image.format;
let convert = LanguageModelImage::from_image(image, cx);
let task = cx
.spawn_in(window, async move |editor, cx| {
if let Some(image) = convert.await {
Ok(MentionImage {
abs_path,
data: image.source,
format,
})
} else {
editor
.update(cx, |editor, cx| {
let snapshot = editor.buffer().read(cx).snapshot(cx);
let Some(anchor) =
snapshot.anchor_in_excerpt(excerpt_id, crease_start)
else {
return;
};
editor.display_map.update(cx, |display_map, cx| {
display_map.unfold_intersecting(vec![anchor..anchor], true, cx);
});
editor.remove_creases([crease_id], cx);
})
.ok();
Err("Failed to convert image".to_string())
}
})
.shared();
cx.spawn_in(window, {
let task = task.clone();
async move |_, cx| task.clone().await.notify_async_err(cx)
})
.detach();
self.mention_set.lock().insert_image(crease_id, task);
});
}
pub fn set_mode(&mut self, mode: EditorMode, cx: &mut Context<Self>) { pub fn set_mode(&mut self, mode: EditorMode, cx: &mut Context<Self>) {
self.editor.update(cx, |editor, cx| { self.editor.update(cx, |editor, cx| {
editor.set_mode(mode); editor.set_mode(mode);
@ -243,12 +388,13 @@ impl MessageEditor {
pub fn set_message( pub fn set_message(
&mut self, &mut self,
message: &[acp::ContentBlock], message: Vec<acp::ContentBlock>,
window: &mut Window, window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) { ) {
let mut text = String::new(); let mut text = String::new();
let mut mentions = Vec::new(); let mut mentions = Vec::new();
let mut images = Vec::new();
for chunk in message { for chunk in message {
match chunk { match chunk {
@ -266,8 +412,13 @@ impl MessageEditor {
mentions.push((start..end, mention_uri)); mentions.push((start..end, mention_uri));
} }
} }
acp::ContentBlock::Image(_) acp::ContentBlock::Image(content) => {
| acp::ContentBlock::Audio(_) let start = text.len();
text.push_str("image");
let end = text.len();
images.push((start..end, content));
}
acp::ContentBlock::Audio(_)
| acp::ContentBlock::Resource(_) | acp::ContentBlock::Resource(_)
| acp::ContentBlock::ResourceLink(_) => {} | acp::ContentBlock::ResourceLink(_) => {}
} }
@ -293,7 +444,50 @@ impl MessageEditor {
); );
if let Some(crease_id) = crease_id { if let Some(crease_id) = crease_id {
self.mention_set.lock().insert(crease_id, mention_uri); self.mention_set.lock().insert_uri(crease_id, mention_uri);
}
}
for (range, content) in images {
let Some(format) = ImageFormat::from_mime_type(&content.mime_type) else {
continue;
};
let anchor = snapshot.anchor_before(range.start);
let abs_path = content
.uri
.as_ref()
.and_then(|uri| uri.strip_prefix("file://").map(|s| Path::new(s).into()));
let name = content
.uri
.as_ref()
.and_then(|uri| {
uri.strip_prefix("file://")
.and_then(|path| Path::new(path).file_name())
})
.map(|name| name.to_string_lossy().to_string())
.unwrap_or("Image".to_owned());
let crease_id = crate::context_picker::insert_crease_for_mention(
anchor.excerpt_id,
anchor.text_anchor,
range.end - range.start,
name.into(),
IconName::Image.path().into(),
self.editor.clone(),
window,
cx,
);
let data: SharedString = content.data.to_string().into();
if let Some(crease_id) = crease_id {
self.mention_set.lock().insert_image(
crease_id,
Task::ready(Ok(MentionImage {
abs_path,
data,
format,
}))
.shared(),
);
} }
} }
cx.notify(); cx.notify();
@ -319,6 +513,7 @@ impl Render for MessageEditor {
.key_context("MessageEditor") .key_context("MessageEditor")
.on_action(cx.listener(Self::chat)) .on_action(cx.listener(Self::chat))
.on_action(cx.listener(Self::cancel)) .on_action(cx.listener(Self::cancel))
.capture_action(cx.listener(Self::paste))
.flex_1() .flex_1()
.child({ .child({
let settings = ThemeSettings::get_global(cx); let settings = ThemeSettings::get_global(cx);
@ -351,6 +546,26 @@ impl Render for MessageEditor {
} }
} }
pub(crate) fn insert_crease_for_image(
excerpt_id: ExcerptId,
anchor: text::Anchor,
content_len: usize,
editor: Entity<Editor>,
window: &mut Window,
cx: &mut App,
) -> Option<CreaseId> {
crate::context_picker::insert_crease_for_mention(
excerpt_id,
anchor,
content_len,
"Image".into(),
IconName::Image.path().into(),
editor,
window,
cx,
)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::path::Path; use std::path::Path;

View file

@ -5,9 +5,10 @@ use acp_thread::{
use acp_thread::{AgentConnection, Plan}; use acp_thread::{AgentConnection, Plan};
use action_log::ActionLog; use action_log::ActionLog;
use agent::{TextThreadStore, ThreadStore}; use agent::{TextThreadStore, ThreadStore};
use agent_client_protocol as acp; use agent_client_protocol::{self as acp};
use agent_servers::AgentServer; use agent_servers::AgentServer;
use agent_settings::{AgentSettings, NotifyWhenAgentWaiting}; use agent_settings::{AgentSettings, NotifyWhenAgentWaiting};
use anyhow::bail;
use audio::{Audio, Sound}; use audio::{Audio, Sound};
use buffer_diff::BufferDiff; use buffer_diff::BufferDiff;
use collections::{HashMap, HashSet}; use collections::{HashMap, HashSet};
@ -2360,7 +2361,7 @@ impl AcpThreadView {
window, window,
cx, cx,
); );
editor.set_message(&chunks, window, cx); editor.set_message(chunks, window, cx);
editor editor
}); });
let subscription = let subscription =
@ -2725,7 +2726,7 @@ impl AcpThreadView {
let project = workspace.project().clone(); let project = workspace.project().clone();
if !project.read(cx).is_local() { if !project.read(cx).is_local() {
anyhow::bail!("failed to open active thread as markdown in remote project"); bail!("failed to open active thread as markdown in remote project");
} }
let buffer = project.update(cx, |project, cx| { let buffer = project.update(cx, |project, cx| {
@ -2990,12 +2991,13 @@ impl AcpThreadView {
pub(crate) fn insert_dragged_files( pub(crate) fn insert_dragged_files(
&self, &self,
paths: Vec<project::ProjectPath>, paths: Vec<project::ProjectPath>,
_added_worktrees: Vec<Entity<project::Worktree>>, added_worktrees: Vec<Entity<project::Worktree>>,
window: &mut Window, window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) { ) {
self.message_editor.update(cx, |message_editor, cx| { self.message_editor.update(cx, |message_editor, cx| {
message_editor.insert_dragged_files(paths, window, cx); message_editor.insert_dragged_files(paths, window, cx);
drop(added_worktrees);
}) })
} }
} }