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}