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}