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