1use crate::{
2 acp::completion_provider::{ContextPickerCompletionProvider, MentionImage, MentionSet},
3 context_picker::fetch_context_picker::fetch_url_content,
4};
5use acp_thread::{MentionUri, selection_name};
6use agent::{TextThreadStore, ThreadId, ThreadStore};
7use agent_client_protocol as acp;
8use anyhow::Result;
9use collections::HashSet;
10use editor::{
11 Anchor, AnchorRangeExt, ContextMenuOptions, ContextMenuPlacement, Editor, EditorElement,
12 EditorMode, EditorStyle, ExcerptId, FoldPlaceholder, MultiBuffer, ToOffset,
13 actions::Paste,
14 display_map::{Crease, CreaseId, FoldId},
15};
16use futures::{FutureExt as _, TryFutureExt as _};
17use gpui::{
18 AppContext, ClipboardEntry, Context, Entity, EventEmitter, FocusHandle, Focusable, Image,
19 ImageFormat, Img, Task, TextStyle, WeakEntity,
20};
21use language::{Buffer, Language};
22use language_model::LanguageModelImage;
23use project::{CompletionIntent, Project};
24use settings::Settings;
25use std::{
26 ffi::OsStr,
27 fmt::Write,
28 ops::Range,
29 path::{Path, PathBuf},
30 rc::Rc,
31 sync::Arc,
32};
33use text::OffsetRangeExt;
34use theme::ThemeSettings;
35use ui::{
36 ActiveTheme, AnyElement, App, ButtonCommon, ButtonLike, ButtonStyle, Color, Icon, IconName,
37 IconSize, InteractiveElement, IntoElement, Label, LabelCommon, LabelSize, ParentElement,
38 Render, SelectableButton, SharedString, Styled, TextSize, TintColor, Toggleable, Window, div,
39 h_flex,
40};
41use util::ResultExt;
42use workspace::{Workspace, notifications::NotifyResultExt as _};
43use zed_actions::agent::Chat;
44
45use super::completion_provider::Mention;
46
47pub struct MessageEditor {
48 mention_set: MentionSet,
49 editor: Entity<Editor>,
50 project: Entity<Project>,
51 workspace: WeakEntity<Workspace>,
52 thread_store: Entity<ThreadStore>,
53 text_thread_store: Entity<TextThreadStore>,
54}
55
56#[derive(Clone, Copy)]
57pub enum MessageEditorEvent {
58 Send,
59 Cancel,
60 Focus,
61}
62
63impl EventEmitter<MessageEditorEvent> for MessageEditor {}
64
65impl MessageEditor {
66 pub fn new(
67 workspace: WeakEntity<Workspace>,
68 project: Entity<Project>,
69 thread_store: Entity<ThreadStore>,
70 text_thread_store: Entity<TextThreadStore>,
71 mode: EditorMode,
72 window: &mut Window,
73 cx: &mut Context<Self>,
74 ) -> Self {
75 let language = Language::new(
76 language::LanguageConfig {
77 completion_query_characters: HashSet::from_iter(['.', '-', '_', '@']),
78 ..Default::default()
79 },
80 None,
81 );
82 let completion_provider = ContextPickerCompletionProvider::new(
83 workspace.clone(),
84 thread_store.downgrade(),
85 text_thread_store.downgrade(),
86 cx.weak_entity(),
87 );
88 let mention_set = MentionSet::default();
89 let editor = cx.new(|cx| {
90 let buffer = cx.new(|cx| Buffer::local("", cx).with_language(Arc::new(language), cx));
91 let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
92
93 let mut editor = Editor::new(mode, buffer, None, window, cx);
94 editor.set_placeholder_text("Message the agent - @ to include files", cx);
95 editor.set_show_indent_guides(false, cx);
96 editor.set_soft_wrap();
97 editor.set_use_modal_editing(true);
98 editor.set_completion_provider(Some(Rc::new(completion_provider)));
99 editor.set_context_menu_options(ContextMenuOptions {
100 min_entries_visible: 12,
101 max_entries_visible: 12,
102 placement: Some(ContextMenuPlacement::Above),
103 });
104 editor
105 });
106
107 cx.on_focus(&editor.focus_handle(cx), window, |_, _, cx| {
108 cx.emit(MessageEditorEvent::Focus)
109 })
110 .detach();
111
112 Self {
113 editor,
114 project,
115 mention_set,
116 thread_store,
117 text_thread_store,
118 workspace,
119 }
120 }
121
122 #[cfg(test)]
123 pub(crate) fn editor(&self) -> &Entity<Editor> {
124 &self.editor
125 }
126
127 #[cfg(test)]
128 pub(crate) fn mention_set(&mut self) -> &mut MentionSet {
129 &mut self.mention_set
130 }
131
132 pub fn is_empty(&self, cx: &App) -> bool {
133 self.editor.read(cx).is_empty(cx)
134 }
135
136 pub fn mentioned_path_and_threads(&self) -> (HashSet<PathBuf>, HashSet<ThreadId>) {
137 let mut excluded_paths = HashSet::default();
138 let mut excluded_threads = HashSet::default();
139
140 for uri in self.mention_set.uri_by_crease_id.values() {
141 match uri {
142 MentionUri::File { abs_path, .. } => {
143 excluded_paths.insert(abs_path.clone());
144 }
145 MentionUri::Thread { id, .. } => {
146 excluded_threads.insert(id.clone());
147 }
148 _ => {}
149 }
150 }
151
152 (excluded_paths, excluded_threads)
153 }
154
155 pub fn confirm_completion(
156 &mut self,
157 crease_text: SharedString,
158 start: text::Anchor,
159 content_len: usize,
160 mention_uri: MentionUri,
161 window: &mut Window,
162 cx: &mut Context<Self>,
163 ) {
164 let snapshot = self
165 .editor
166 .update(cx, |editor, cx| editor.snapshot(window, cx));
167 let Some((excerpt_id, _, _)) = snapshot.buffer_snapshot.as_singleton() else {
168 return;
169 };
170 let Some(anchor) = snapshot
171 .buffer_snapshot
172 .anchor_in_excerpt(*excerpt_id, start)
173 else {
174 return;
175 };
176
177 let Some(crease_id) = crate::context_picker::insert_crease_for_mention(
178 *excerpt_id,
179 start,
180 content_len,
181 crease_text.clone(),
182 mention_uri.icon_path(cx),
183 self.editor.clone(),
184 window,
185 cx,
186 ) else {
187 return;
188 };
189 self.mention_set.insert_uri(crease_id, mention_uri.clone());
190
191 match mention_uri {
192 MentionUri::Fetch { url } => {
193 self.confirm_mention_for_fetch(crease_id, anchor, url, window, cx);
194 }
195 MentionUri::File {
196 abs_path,
197 is_directory,
198 } => {
199 self.confirm_mention_for_file(
200 crease_id,
201 anchor,
202 abs_path,
203 is_directory,
204 window,
205 cx,
206 );
207 }
208 MentionUri::Symbol { .. }
209 | MentionUri::Thread { .. }
210 | MentionUri::TextThread { .. }
211 | MentionUri::Rule { .. }
212 | MentionUri::Selection { .. } => {}
213 }
214 }
215
216 fn confirm_mention_for_file(
217 &mut self,
218 crease_id: CreaseId,
219 anchor: Anchor,
220 abs_path: PathBuf,
221 _is_directory: bool,
222 window: &mut Window,
223 cx: &mut Context<Self>,
224 ) {
225 let extension = abs_path
226 .extension()
227 .and_then(OsStr::to_str)
228 .unwrap_or_default();
229 let project = self.project.clone();
230 let Some(project_path) = project
231 .read(cx)
232 .project_path_for_absolute_path(&abs_path, cx)
233 else {
234 return;
235 };
236
237 if Img::extensions().contains(&extension) && !extension.contains("svg") {
238 let image = cx.spawn(async move |_, cx| {
239 let image = project
240 .update(cx, |project, cx| project.open_image(project_path, cx))?
241 .await?;
242 image.read_with(cx, |image, _cx| image.image.clone())
243 });
244 self.confirm_mention_for_image(crease_id, anchor, Some(abs_path), image, window, cx);
245 }
246 }
247
248 fn confirm_mention_for_fetch(
249 &mut self,
250 crease_id: CreaseId,
251 anchor: Anchor,
252 url: url::Url,
253 window: &mut Window,
254 cx: &mut Context<Self>,
255 ) {
256 let Some(http_client) = self
257 .workspace
258 .update(cx, |workspace, _cx| workspace.client().http_client())
259 .ok()
260 else {
261 return;
262 };
263
264 let url_string = url.to_string();
265 let fetch = cx
266 .background_executor()
267 .spawn(async move {
268 fetch_url_content(http_client, url_string)
269 .map_err(|e| e.to_string())
270 .await
271 })
272 .shared();
273 self.mention_set
274 .add_fetch_result(url.clone(), fetch.clone());
275
276 cx.spawn_in(window, async move |this, cx| {
277 let fetch = fetch.await.notify_async_err(cx);
278 this.update(cx, |this, cx| {
279 let mention_uri = MentionUri::Fetch { url };
280 if fetch.is_some() {
281 this.mention_set.insert_uri(crease_id, mention_uri.clone());
282 } else {
283 // Remove crease if we failed to fetch
284 this.editor.update(cx, |editor, cx| {
285 editor.display_map.update(cx, |display_map, cx| {
286 display_map.unfold_intersecting(vec![anchor..anchor], true, cx);
287 });
288 editor.remove_creases([crease_id], cx);
289 });
290 }
291 })
292 .ok();
293 })
294 .detach();
295 }
296
297 pub fn confirm_mention_for_selection(
298 &mut self,
299 source_range: Range<text::Anchor>,
300 selections: Vec<(Entity<Buffer>, Range<text::Anchor>, Range<usize>)>,
301 window: &mut Window,
302 cx: &mut Context<Self>,
303 ) {
304 let snapshot = self.editor.read(cx).buffer().read(cx).snapshot(cx);
305 let Some((&excerpt_id, _, _)) = snapshot.as_singleton() else {
306 return;
307 };
308 let Some(start) = snapshot.anchor_in_excerpt(excerpt_id, source_range.start) else {
309 return;
310 };
311
312 let offset = start.to_offset(&snapshot);
313
314 for (buffer, selection_range, range_to_fold) in selections {
315 let range = snapshot.anchor_after(offset + range_to_fold.start)
316 ..snapshot.anchor_after(offset + range_to_fold.end);
317
318 let path = buffer
319 .read(cx)
320 .file()
321 .map_or(PathBuf::from("untitled"), |file| file.path().to_path_buf());
322 let snapshot = buffer.read(cx).snapshot();
323
324 let point_range = selection_range.to_point(&snapshot);
325 let line_range = point_range.start.row..point_range.end.row;
326
327 let uri = MentionUri::Selection {
328 path: path.clone(),
329 line_range: line_range.clone(),
330 };
331 let crease = crate::context_picker::crease_for_mention(
332 selection_name(&path, &line_range).into(),
333 uri.icon_path(cx),
334 range,
335 self.editor.downgrade(),
336 );
337
338 let crease_id = self.editor.update(cx, |editor, cx| {
339 let crease_ids = editor.insert_creases(vec![crease.clone()], cx);
340 editor.fold_creases(vec![crease], false, window, cx);
341 crease_ids.first().copied().unwrap()
342 });
343
344 self.mention_set
345 .insert_uri(crease_id, MentionUri::Selection { path, line_range });
346 }
347 }
348
349 pub fn contents(
350 &self,
351 window: &mut Window,
352 cx: &mut Context<Self>,
353 ) -> Task<Result<Vec<acp::ContentBlock>>> {
354 let contents = self.mention_set.contents(
355 self.project.clone(),
356 self.thread_store.clone(),
357 self.text_thread_store.clone(),
358 window,
359 cx,
360 );
361 let editor = self.editor.clone();
362
363 cx.spawn(async move |_, cx| {
364 let contents = contents.await?;
365
366 editor.update(cx, |editor, cx| {
367 let mut ix = 0;
368 let mut chunks: Vec<acp::ContentBlock> = Vec::new();
369 let text = editor.text(cx);
370 editor.display_map.update(cx, |map, cx| {
371 let snapshot = map.snapshot(cx);
372 for (crease_id, crease) in snapshot.crease_snapshot.creases() {
373 // Skip creases that have been edited out of the message buffer.
374 if !crease.range().start.is_valid(&snapshot.buffer_snapshot) {
375 continue;
376 }
377
378 let Some(mention) = contents.get(&crease_id) else {
379 continue;
380 };
381
382 let crease_range = crease.range().to_offset(&snapshot.buffer_snapshot);
383 if crease_range.start > ix {
384 chunks.push(text[ix..crease_range.start].into());
385 }
386 let chunk = match mention {
387 Mention::Text { uri, content } => {
388 acp::ContentBlock::Resource(acp::EmbeddedResource {
389 annotations: None,
390 resource: acp::EmbeddedResourceResource::TextResourceContents(
391 acp::TextResourceContents {
392 mime_type: None,
393 text: content.clone(),
394 uri: uri.to_uri().to_string(),
395 },
396 ),
397 })
398 }
399 Mention::Image(mention_image) => {
400 acp::ContentBlock::Image(acp::ImageContent {
401 annotations: None,
402 data: mention_image.data.to_string(),
403 mime_type: mention_image.format.mime_type().into(),
404 uri: mention_image
405 .abs_path
406 .as_ref()
407 .map(|path| format!("file://{}", path.display())),
408 })
409 }
410 };
411 chunks.push(chunk);
412 ix = crease_range.end;
413 }
414
415 if ix < text.len() {
416 let last_chunk = text[ix..].trim_end();
417 if !last_chunk.is_empty() {
418 chunks.push(last_chunk.into());
419 }
420 }
421 });
422
423 chunks
424 })
425 })
426 }
427
428 pub fn clear(&mut self, window: &mut Window, cx: &mut Context<Self>) {
429 self.editor.update(cx, |editor, cx| {
430 editor.clear(window, cx);
431 editor.remove_creases(self.mention_set.drain(), cx)
432 });
433 }
434
435 fn send(&mut self, _: &Chat, _: &mut Window, cx: &mut Context<Self>) {
436 cx.emit(MessageEditorEvent::Send)
437 }
438
439 fn cancel(&mut self, _: &editor::actions::Cancel, _: &mut Window, cx: &mut Context<Self>) {
440 cx.emit(MessageEditorEvent::Cancel)
441 }
442
443 fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context<Self>) {
444 let images = cx
445 .read_from_clipboard()
446 .map(|item| {
447 item.into_entries()
448 .filter_map(|entry| {
449 if let ClipboardEntry::Image(image) = entry {
450 Some(image)
451 } else {
452 None
453 }
454 })
455 .collect::<Vec<_>>()
456 })
457 .unwrap_or_default();
458
459 if images.is_empty() {
460 return;
461 }
462 cx.stop_propagation();
463
464 let replacement_text = "image";
465 for image in images {
466 let (excerpt_id, text_anchor, multibuffer_anchor) =
467 self.editor.update(cx, |message_editor, cx| {
468 let snapshot = message_editor.snapshot(window, cx);
469 let (excerpt_id, _, buffer_snapshot) =
470 snapshot.buffer_snapshot.as_singleton().unwrap();
471
472 let text_anchor = buffer_snapshot.anchor_before(buffer_snapshot.len());
473 let multibuffer_anchor = snapshot
474 .buffer_snapshot
475 .anchor_in_excerpt(*excerpt_id, text_anchor);
476 message_editor.edit(
477 [(
478 multi_buffer::Anchor::max()..multi_buffer::Anchor::max(),
479 format!("{replacement_text} "),
480 )],
481 cx,
482 );
483 (*excerpt_id, text_anchor, multibuffer_anchor)
484 });
485
486 let content_len = replacement_text.len();
487 let Some(anchor) = multibuffer_anchor else {
488 return;
489 };
490 let Some(crease_id) = insert_crease_for_image(
491 excerpt_id,
492 text_anchor,
493 content_len,
494 None.clone(),
495 self.editor.clone(),
496 window,
497 cx,
498 ) else {
499 return;
500 };
501 self.confirm_mention_for_image(
502 crease_id,
503 anchor,
504 None,
505 Task::ready(Ok(Arc::new(image))),
506 window,
507 cx,
508 );
509 }
510 }
511
512 pub fn insert_dragged_files(
513 &self,
514 paths: Vec<project::ProjectPath>,
515 window: &mut Window,
516 cx: &mut Context<Self>,
517 ) {
518 let buffer = self.editor.read(cx).buffer().clone();
519 let Some(buffer) = buffer.read(cx).as_singleton() else {
520 return;
521 };
522 for path in paths {
523 let Some(entry) = self.project.read(cx).entry_for_path(&path, cx) else {
524 continue;
525 };
526 let Some(abs_path) = self.project.read(cx).absolute_path(&path, cx) else {
527 continue;
528 };
529
530 let anchor = buffer.update(cx, |buffer, _cx| buffer.anchor_before(buffer.len()));
531 let path_prefix = abs_path
532 .file_name()
533 .unwrap_or(path.path.as_os_str())
534 .display()
535 .to_string();
536 let Some(completion) = ContextPickerCompletionProvider::completion_for_path(
537 path,
538 &path_prefix,
539 false,
540 entry.is_dir(),
541 anchor..anchor,
542 cx.weak_entity(),
543 self.project.clone(),
544 cx,
545 ) else {
546 continue;
547 };
548
549 self.editor.update(cx, |message_editor, cx| {
550 message_editor.edit(
551 [(
552 multi_buffer::Anchor::max()..multi_buffer::Anchor::max(),
553 completion.new_text,
554 )],
555 cx,
556 );
557 });
558 if let Some(confirm) = completion.confirm.clone() {
559 confirm(CompletionIntent::Complete, window, cx);
560 }
561 }
562 }
563
564 pub fn set_read_only(&mut self, read_only: bool, cx: &mut Context<Self>) {
565 self.editor.update(cx, |message_editor, cx| {
566 message_editor.set_read_only(read_only);
567 cx.notify()
568 })
569 }
570
571 fn confirm_mention_for_image(
572 &mut self,
573 crease_id: CreaseId,
574 anchor: Anchor,
575 abs_path: Option<PathBuf>,
576 image: Task<Result<Arc<Image>>>,
577 window: &mut Window,
578 cx: &mut Context<Self>,
579 ) {
580 self.editor.update(cx, |_editor, cx| {
581 let task = cx
582 .spawn_in(window, async move |editor, cx| {
583 let image = image.await.map_err(|e| e.to_string())?;
584 let format = image.format;
585 let image = cx
586 .update(|_, cx| LanguageModelImage::from_image(image, cx))
587 .map_err(|e| e.to_string())?
588 .await;
589 if let Some(image) = image {
590 Ok(MentionImage {
591 abs_path,
592 data: image.source,
593 format,
594 })
595 } else {
596 editor
597 .update(cx, |editor, cx| {
598 editor.display_map.update(cx, |display_map, cx| {
599 display_map.unfold_intersecting(vec![anchor..anchor], true, cx);
600 });
601 editor.remove_creases([crease_id], cx);
602 })
603 .ok();
604 Err("Failed to convert image".to_string())
605 }
606 })
607 .shared();
608
609 cx.spawn_in(window, {
610 let task = task.clone();
611 async move |_, cx| task.clone().await.notify_async_err(cx)
612 })
613 .detach();
614
615 self.mention_set.insert_image(crease_id, task);
616 });
617 }
618
619 pub fn set_mode(&mut self, mode: EditorMode, cx: &mut Context<Self>) {
620 self.editor.update(cx, |editor, cx| {
621 editor.set_mode(mode);
622 cx.notify()
623 });
624 }
625
626 pub fn set_message(
627 &mut self,
628 message: Vec<acp::ContentBlock>,
629 window: &mut Window,
630 cx: &mut Context<Self>,
631 ) {
632 self.clear(window, cx);
633
634 let mut text = String::new();
635 let mut mentions = Vec::new();
636 let mut images = Vec::new();
637
638 for chunk in message {
639 match chunk {
640 acp::ContentBlock::Text(text_content) => {
641 text.push_str(&text_content.text);
642 }
643 acp::ContentBlock::Resource(acp::EmbeddedResource {
644 resource: acp::EmbeddedResourceResource::TextResourceContents(resource),
645 ..
646 }) => {
647 if let Some(mention_uri) = MentionUri::parse(&resource.uri).log_err() {
648 let start = text.len();
649 write!(&mut text, "{}", mention_uri.as_link()).ok();
650 let end = text.len();
651 mentions.push((start..end, mention_uri));
652 }
653 }
654 acp::ContentBlock::Image(content) => {
655 let start = text.len();
656 text.push_str("image");
657 let end = text.len();
658 images.push((start..end, content));
659 }
660 acp::ContentBlock::Audio(_)
661 | acp::ContentBlock::Resource(_)
662 | acp::ContentBlock::ResourceLink(_) => {}
663 }
664 }
665
666 let snapshot = self.editor.update(cx, |editor, cx| {
667 editor.set_text(text, window, cx);
668 editor.buffer().read(cx).snapshot(cx)
669 });
670
671 for (range, mention_uri) in mentions {
672 let anchor = snapshot.anchor_before(range.start);
673 let crease_id = crate::context_picker::insert_crease_for_mention(
674 anchor.excerpt_id,
675 anchor.text_anchor,
676 range.end - range.start,
677 mention_uri.name().into(),
678 mention_uri.icon_path(cx),
679 self.editor.clone(),
680 window,
681 cx,
682 );
683
684 if let Some(crease_id) = crease_id {
685 self.mention_set.insert_uri(crease_id, mention_uri);
686 }
687 }
688 for (range, content) in images {
689 let Some(format) = ImageFormat::from_mime_type(&content.mime_type) else {
690 continue;
691 };
692 let anchor = snapshot.anchor_before(range.start);
693 let abs_path = content
694 .uri
695 .as_ref()
696 .and_then(|uri| uri.strip_prefix("file://").map(|s| Path::new(s).into()));
697
698 let name = content
699 .uri
700 .as_ref()
701 .and_then(|uri| {
702 uri.strip_prefix("file://")
703 .and_then(|path| Path::new(path).file_name())
704 })
705 .map(|name| name.to_string_lossy().to_string())
706 .unwrap_or("Image".to_owned());
707 let crease_id = crate::context_picker::insert_crease_for_mention(
708 anchor.excerpt_id,
709 anchor.text_anchor,
710 range.end - range.start,
711 name.into(),
712 IconName::Image.path().into(),
713 self.editor.clone(),
714 window,
715 cx,
716 );
717 let data: SharedString = content.data.to_string().into();
718
719 if let Some(crease_id) = crease_id {
720 self.mention_set.insert_image(
721 crease_id,
722 Task::ready(Ok(MentionImage {
723 abs_path,
724 data,
725 format,
726 }))
727 .shared(),
728 );
729 }
730 }
731 cx.notify();
732 }
733
734 #[cfg(test)]
735 pub fn set_text(&mut self, text: &str, window: &mut Window, cx: &mut Context<Self>) {
736 self.editor.update(cx, |editor, cx| {
737 editor.set_text(text, window, cx);
738 });
739 }
740
741 #[cfg(test)]
742 pub fn text(&self, cx: &App) -> String {
743 self.editor.read(cx).text(cx)
744 }
745}
746
747impl Focusable for MessageEditor {
748 fn focus_handle(&self, cx: &App) -> FocusHandle {
749 self.editor.focus_handle(cx)
750 }
751}
752
753impl Render for MessageEditor {
754 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
755 div()
756 .key_context("MessageEditor")
757 .on_action(cx.listener(Self::send))
758 .on_action(cx.listener(Self::cancel))
759 .capture_action(cx.listener(Self::paste))
760 .flex_1()
761 .child({
762 let settings = ThemeSettings::get_global(cx);
763 let font_size = TextSize::Small
764 .rems(cx)
765 .to_pixels(settings.agent_font_size(cx));
766 let line_height = settings.buffer_line_height.value() * font_size;
767
768 let text_style = TextStyle {
769 color: cx.theme().colors().text,
770 font_family: settings.buffer_font.family.clone(),
771 font_fallbacks: settings.buffer_font.fallbacks.clone(),
772 font_features: settings.buffer_font.features.clone(),
773 font_size: font_size.into(),
774 line_height: line_height.into(),
775 ..Default::default()
776 };
777
778 EditorElement::new(
779 &self.editor,
780 EditorStyle {
781 background: cx.theme().colors().editor_background,
782 local_player: cx.theme().players().local(),
783 text: text_style,
784 syntax: cx.theme().syntax().clone(),
785 ..Default::default()
786 },
787 )
788 })
789 }
790}
791
792pub(crate) fn insert_crease_for_image(
793 excerpt_id: ExcerptId,
794 anchor: text::Anchor,
795 content_len: usize,
796 abs_path: Option<Arc<Path>>,
797 editor: Entity<Editor>,
798 window: &mut Window,
799 cx: &mut App,
800) -> Option<CreaseId> {
801 let crease_label = abs_path
802 .as_ref()
803 .and_then(|path| path.file_name())
804 .map(|name| name.to_string_lossy().to_string().into())
805 .unwrap_or(SharedString::from("Image"));
806
807 editor.update(cx, |editor, cx| {
808 let snapshot = editor.buffer().read(cx).snapshot(cx);
809
810 let start = snapshot.anchor_in_excerpt(excerpt_id, anchor)?;
811
812 let start = start.bias_right(&snapshot);
813 let end = snapshot.anchor_before(start.to_offset(&snapshot) + content_len);
814
815 let placeholder = FoldPlaceholder {
816 render: render_image_fold_icon_button(crease_label, cx.weak_entity()),
817 merge_adjacent: false,
818 ..Default::default()
819 };
820
821 let crease = Crease::Inline {
822 range: start..end,
823 placeholder,
824 render_toggle: None,
825 render_trailer: None,
826 metadata: None,
827 };
828
829 let ids = editor.insert_creases(vec![crease.clone()], cx);
830 editor.fold_creases(vec![crease], false, window, cx);
831
832 Some(ids[0])
833 })
834}
835
836fn render_image_fold_icon_button(
837 label: SharedString,
838 editor: WeakEntity<Editor>,
839) -> Arc<dyn Send + Sync + Fn(FoldId, Range<Anchor>, &mut App) -> AnyElement> {
840 Arc::new({
841 move |fold_id, fold_range, cx| {
842 let is_in_text_selection = editor
843 .update(cx, |editor, cx| editor.is_range_selected(&fold_range, cx))
844 .unwrap_or_default();
845
846 ButtonLike::new(fold_id)
847 .style(ButtonStyle::Filled)
848 .selected_style(ButtonStyle::Tinted(TintColor::Accent))
849 .toggle_state(is_in_text_selection)
850 .child(
851 h_flex()
852 .gap_1()
853 .child(
854 Icon::new(IconName::Image)
855 .size(IconSize::XSmall)
856 .color(Color::Muted),
857 )
858 .child(
859 Label::new(label.clone())
860 .size(LabelSize::Small)
861 .buffer_font(cx)
862 .single_line(),
863 ),
864 )
865 .into_any_element()
866 }
867 })
868}
869
870#[cfg(test)]
871mod tests {
872 use std::path::Path;
873
874 use agent::{TextThreadStore, ThreadStore};
875 use agent_client_protocol as acp;
876 use editor::EditorMode;
877 use fs::FakeFs;
878 use gpui::{AppContext, TestAppContext};
879 use lsp::{CompletionContext, CompletionTriggerKind};
880 use project::{CompletionIntent, Project};
881 use serde_json::json;
882 use util::path;
883 use workspace::Workspace;
884
885 use crate::acp::{message_editor::MessageEditor, thread_view::tests::init_test};
886
887 #[gpui::test]
888 async fn test_at_mention_removal(cx: &mut TestAppContext) {
889 init_test(cx);
890
891 let fs = FakeFs::new(cx.executor());
892 fs.insert_tree("/project", json!({"file": ""})).await;
893 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
894
895 let (workspace, cx) =
896 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
897
898 let thread_store = cx.new(|cx| ThreadStore::fake(project.clone(), cx));
899 let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx));
900
901 let message_editor = cx.update(|window, cx| {
902 cx.new(|cx| {
903 MessageEditor::new(
904 workspace.downgrade(),
905 project.clone(),
906 thread_store.clone(),
907 text_thread_store.clone(),
908 EditorMode::AutoHeight {
909 min_lines: 1,
910 max_lines: None,
911 },
912 window,
913 cx,
914 )
915 })
916 });
917 let editor = message_editor.update(cx, |message_editor, _| message_editor.editor.clone());
918
919 cx.run_until_parked();
920
921 let excerpt_id = editor.update(cx, |editor, cx| {
922 editor
923 .buffer()
924 .read(cx)
925 .excerpt_ids()
926 .into_iter()
927 .next()
928 .unwrap()
929 });
930 let completions = editor.update_in(cx, |editor, window, cx| {
931 editor.set_text("Hello @file ", window, cx);
932 let buffer = editor.buffer().read(cx).as_singleton().unwrap();
933 let completion_provider = editor.completion_provider().unwrap();
934 completion_provider.completions(
935 excerpt_id,
936 &buffer,
937 text::Anchor::MAX,
938 CompletionContext {
939 trigger_kind: CompletionTriggerKind::TRIGGER_CHARACTER,
940 trigger_character: Some("@".into()),
941 },
942 window,
943 cx,
944 )
945 });
946 let [_, completion]: [_; 2] = completions
947 .await
948 .unwrap()
949 .into_iter()
950 .flat_map(|response| response.completions)
951 .collect::<Vec<_>>()
952 .try_into()
953 .unwrap();
954
955 editor.update_in(cx, |editor, window, cx| {
956 let snapshot = editor.buffer().read(cx).snapshot(cx);
957 let start = snapshot
958 .anchor_in_excerpt(excerpt_id, completion.replace_range.start)
959 .unwrap();
960 let end = snapshot
961 .anchor_in_excerpt(excerpt_id, completion.replace_range.end)
962 .unwrap();
963 editor.edit([(start..end, completion.new_text)], cx);
964 (completion.confirm.unwrap())(CompletionIntent::Complete, window, cx);
965 });
966
967 cx.run_until_parked();
968
969 // Backspace over the inserted crease (and the following space).
970 editor.update_in(cx, |editor, window, cx| {
971 editor.backspace(&Default::default(), window, cx);
972 editor.backspace(&Default::default(), window, cx);
973 });
974
975 let content = message_editor
976 .update_in(cx, |message_editor, window, cx| {
977 message_editor.contents(window, cx)
978 })
979 .await
980 .unwrap();
981
982 // We don't send a resource link for the deleted crease.
983 pretty_assertions::assert_matches!(content.as_slice(), [acp::ContentBlock::Text { .. }]);
984 }
985}