message_editor.rs

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