1use crate::{
2 ChatWithFollow,
3 completion_provider::{
4 PromptCompletionProvider, PromptCompletionProviderDelegate, PromptContextAction,
5 PromptContextType, SlashCommandCompletion,
6 },
7 mention_set::{
8 Mention, MentionImage, MentionSet, insert_crease_for_mention, paste_images_as_context,
9 },
10};
11use acp_thread::MentionUri;
12use agent::HistoryStore;
13use agent_client_protocol as acp;
14use anyhow::{Result, anyhow};
15use collections::HashSet;
16use editor::{
17 Addon, AnchorRangeExt, ContextMenuOptions, ContextMenuPlacement, Editor, EditorElement,
18 EditorEvent, EditorMode, EditorStyle, Inlay, MultiBuffer, MultiBufferOffset,
19 MultiBufferSnapshot, ToOffset, actions::Paste, code_context_menus::CodeContextMenu,
20 scroll::Autoscroll,
21};
22use futures::{FutureExt as _, future::join_all};
23use gpui::{
24 AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, ImageFormat, KeyContext,
25 SharedString, Subscription, Task, TextStyle, WeakEntity,
26};
27use language::{Buffer, Language, language_settings::InlayHintKind};
28use project::{CompletionIntent, InlayHint, InlayHintLabel, InlayId, Project, Worktree};
29use prompt_store::PromptStore;
30use rope::Point;
31use settings::Settings;
32use std::{cell::RefCell, fmt::Write, rc::Rc, sync::Arc};
33use theme::ThemeSettings;
34use ui::prelude::*;
35use util::{ResultExt, debug_panic};
36use workspace::{CollaboratorId, Workspace};
37use zed_actions::agent::Chat;
38
39pub struct MessageEditor {
40 mention_set: Entity<MentionSet>,
41 editor: Entity<Editor>,
42 project: Entity<Project>,
43 workspace: WeakEntity<Workspace>,
44 prompt_capabilities: Rc<RefCell<acp::PromptCapabilities>>,
45 available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
46 agent_name: SharedString,
47 _subscriptions: Vec<Subscription>,
48 _parse_slash_command_task: Task<()>,
49}
50
51#[derive(Clone, Copy, Debug)]
52pub enum MessageEditorEvent {
53 Send,
54 Cancel,
55 Focus,
56 LostFocus,
57}
58
59impl EventEmitter<MessageEditorEvent> for MessageEditor {}
60
61const COMMAND_HINT_INLAY_ID: InlayId = InlayId::Hint(0);
62
63impl PromptCompletionProviderDelegate for Entity<MessageEditor> {
64 fn supports_images(&self, cx: &App) -> bool {
65 self.read(cx).prompt_capabilities.borrow().image
66 }
67
68 fn supported_modes(&self, cx: &App) -> Vec<PromptContextType> {
69 let mut supported = vec![PromptContextType::File, PromptContextType::Symbol];
70 if self.read(cx).prompt_capabilities.borrow().embedded_context {
71 supported.extend(&[
72 PromptContextType::Thread,
73 PromptContextType::Fetch,
74 PromptContextType::Rules,
75 ]);
76 }
77 supported
78 }
79
80 fn available_commands(&self, cx: &App) -> Vec<crate::completion_provider::AvailableCommand> {
81 self.read(cx)
82 .available_commands
83 .borrow()
84 .iter()
85 .map(|cmd| crate::completion_provider::AvailableCommand {
86 name: cmd.name.clone().into(),
87 description: cmd.description.clone().into(),
88 requires_argument: cmd.input.is_some(),
89 })
90 .collect()
91 }
92
93 fn confirm_command(&self, cx: &mut App) {
94 self.update(cx, |this, cx| this.send(cx));
95 }
96}
97
98impl MessageEditor {
99 pub fn new(
100 workspace: WeakEntity<Workspace>,
101 project: Entity<Project>,
102 history_store: Entity<HistoryStore>,
103 prompt_store: Option<Entity<PromptStore>>,
104 prompt_capabilities: Rc<RefCell<acp::PromptCapabilities>>,
105 available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
106 agent_name: SharedString,
107 placeholder: &str,
108 mode: EditorMode,
109 window: &mut Window,
110 cx: &mut Context<Self>,
111 ) -> Self {
112 let language = Language::new(
113 language::LanguageConfig {
114 completion_query_characters: HashSet::from_iter(['.', '-', '_', '@']),
115 ..Default::default()
116 },
117 None,
118 );
119
120 let editor = cx.new(|cx| {
121 let buffer = cx.new(|cx| Buffer::local("", cx).with_language(Arc::new(language), cx));
122 let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
123
124 let mut editor = Editor::new(mode, buffer, None, window, cx);
125 editor.set_placeholder_text(placeholder, window, cx);
126 editor.set_show_indent_guides(false, cx);
127 editor.set_soft_wrap();
128 editor.set_use_modal_editing(true);
129 editor.set_context_menu_options(ContextMenuOptions {
130 min_entries_visible: 12,
131 max_entries_visible: 12,
132 placement: Some(ContextMenuPlacement::Above),
133 });
134 editor.register_addon(MessageEditorAddon::new());
135 editor
136 });
137 let mention_set = cx.new(|_cx| {
138 MentionSet::new(
139 project.downgrade(),
140 history_store.clone(),
141 prompt_store.clone(),
142 )
143 });
144 let completion_provider = Rc::new(PromptCompletionProvider::new(
145 cx.entity(),
146 editor.downgrade(),
147 mention_set.clone(),
148 history_store.clone(),
149 prompt_store.clone(),
150 workspace.clone(),
151 ));
152 editor.update(cx, |editor, _cx| {
153 editor.set_completion_provider(Some(completion_provider.clone()))
154 });
155
156 cx.on_focus_in(&editor.focus_handle(cx), window, |_, _, cx| {
157 cx.emit(MessageEditorEvent::Focus)
158 })
159 .detach();
160 cx.on_focus_out(&editor.focus_handle(cx), window, |_, _, _, cx| {
161 cx.emit(MessageEditorEvent::LostFocus)
162 })
163 .detach();
164
165 let mut has_hint = false;
166 let mut subscriptions = Vec::new();
167
168 subscriptions.push(cx.subscribe_in(&editor, window, {
169 move |this, editor, event, window, cx| {
170 if let EditorEvent::Edited { .. } = event
171 && !editor.read(cx).read_only(cx)
172 {
173 editor.update(cx, |editor, cx| {
174 let snapshot = editor.snapshot(window, cx);
175 this.mention_set
176 .update(cx, |mention_set, _cx| mention_set.remove_invalid(&snapshot));
177
178 let new_hints = this
179 .command_hint(snapshot.buffer())
180 .into_iter()
181 .collect::<Vec<_>>();
182 let has_new_hint = !new_hints.is_empty();
183 editor.splice_inlays(
184 if has_hint {
185 &[COMMAND_HINT_INLAY_ID]
186 } else {
187 &[]
188 },
189 new_hints,
190 cx,
191 );
192 has_hint = has_new_hint;
193 });
194 cx.notify();
195 }
196 }
197 }));
198
199 Self {
200 editor,
201 project,
202 mention_set,
203 workspace,
204 prompt_capabilities,
205 available_commands,
206 agent_name,
207 _subscriptions: subscriptions,
208 _parse_slash_command_task: Task::ready(()),
209 }
210 }
211
212 fn command_hint(&self, snapshot: &MultiBufferSnapshot) -> Option<Inlay> {
213 let available_commands = self.available_commands.borrow();
214 if available_commands.is_empty() {
215 return None;
216 }
217
218 let parsed_command = SlashCommandCompletion::try_parse(&snapshot.text(), 0)?;
219 if parsed_command.argument.is_some() {
220 return None;
221 }
222
223 let command_name = parsed_command.command?;
224 let available_command = available_commands
225 .iter()
226 .find(|command| command.name == command_name)?;
227
228 let acp::AvailableCommandInput::Unstructured(acp::UnstructuredCommandInput {
229 mut hint,
230 ..
231 }) = available_command.input.clone()?
232 else {
233 return None;
234 };
235
236 let mut hint_pos = MultiBufferOffset(parsed_command.source_range.end) + 1usize;
237 if hint_pos > snapshot.len() {
238 hint_pos = snapshot.len();
239 hint.insert(0, ' ');
240 }
241
242 let hint_pos = snapshot.anchor_after(hint_pos);
243
244 Some(Inlay::hint(
245 COMMAND_HINT_INLAY_ID,
246 hint_pos,
247 &InlayHint {
248 position: hint_pos.text_anchor,
249 label: InlayHintLabel::String(hint),
250 kind: Some(InlayHintKind::Parameter),
251 padding_left: false,
252 padding_right: false,
253 tooltip: None,
254 resolve_state: project::ResolveState::Resolved,
255 },
256 ))
257 }
258
259 pub fn insert_thread_summary(
260 &mut self,
261 thread: agent::DbThreadMetadata,
262 window: &mut Window,
263 cx: &mut Context<Self>,
264 ) {
265 let Some(workspace) = self.workspace.upgrade() else {
266 return;
267 };
268 let uri = MentionUri::Thread {
269 id: thread.id.clone(),
270 name: thread.title.to_string(),
271 };
272 let content = format!("{}\n", uri.as_link());
273
274 let content_len = content.len() - 1;
275
276 let start = self.editor.update(cx, |editor, cx| {
277 editor.set_text(content, window, cx);
278 editor
279 .buffer()
280 .read(cx)
281 .snapshot(cx)
282 .anchor_before(Point::zero())
283 .text_anchor
284 });
285
286 let supports_images = self.prompt_capabilities.borrow().image;
287
288 self.mention_set
289 .update(cx, |mention_set, cx| {
290 mention_set.confirm_mention_completion(
291 thread.title,
292 start,
293 content_len,
294 uri,
295 supports_images,
296 self.editor.clone(),
297 &workspace,
298 window,
299 cx,
300 )
301 })
302 .detach();
303 }
304
305 #[cfg(test)]
306 pub(crate) fn editor(&self) -> &Entity<Editor> {
307 &self.editor
308 }
309
310 pub fn is_empty(&self, cx: &App) -> bool {
311 self.editor.read(cx).is_empty(cx)
312 }
313
314 pub fn is_completions_menu_visible(&self, cx: &App) -> bool {
315 self.editor
316 .read(cx)
317 .context_menu()
318 .borrow()
319 .as_ref()
320 .is_some_and(|menu| matches!(menu, CodeContextMenu::Completions(_)) && menu.visible())
321 }
322
323 #[cfg(test)]
324 pub fn mention_set(&self) -> &Entity<MentionSet> {
325 &self.mention_set
326 }
327
328 fn validate_slash_commands(
329 text: &str,
330 available_commands: &[acp::AvailableCommand],
331 agent_name: &str,
332 ) -> Result<()> {
333 if let Some(parsed_command) = SlashCommandCompletion::try_parse(text, 0) {
334 if let Some(command_name) = parsed_command.command {
335 // Check if this command is in the list of available commands from the server
336 let is_supported = available_commands
337 .iter()
338 .any(|cmd| cmd.name == command_name);
339
340 if !is_supported {
341 return Err(anyhow!(
342 "The /{} command is not supported by {}.\n\nAvailable commands: {}",
343 command_name,
344 agent_name,
345 if available_commands.is_empty() {
346 "none".to_string()
347 } else {
348 available_commands
349 .iter()
350 .map(|cmd| format!("/{}", cmd.name))
351 .collect::<Vec<_>>()
352 .join(", ")
353 }
354 ));
355 }
356 }
357 }
358 Ok(())
359 }
360
361 pub fn contents(
362 &self,
363 full_mention_content: bool,
364 cx: &mut Context<Self>,
365 ) -> Task<Result<(Vec<acp::ContentBlock>, Vec<Entity<Buffer>>)>> {
366 // Check for unsupported slash commands before spawning async task
367 let text = self.editor.read(cx).text(cx);
368 let available_commands = self.available_commands.borrow().clone();
369 if let Err(err) =
370 Self::validate_slash_commands(&text, &available_commands, &self.agent_name)
371 {
372 return Task::ready(Err(err));
373 }
374
375 let contents = self
376 .mention_set
377 .update(cx, |store, cx| store.contents(full_mention_content, cx));
378 let editor = self.editor.clone();
379 let supports_embedded_context = self.prompt_capabilities.borrow().embedded_context;
380
381 cx.spawn(async move |_, cx| {
382 let contents = contents.await?;
383 let mut all_tracked_buffers = Vec::new();
384
385 let result = editor.update(cx, |editor, cx| {
386 let (mut ix, _) = text
387 .char_indices()
388 .find(|(_, c)| !c.is_whitespace())
389 .unwrap_or((0, '\0'));
390 let mut chunks: Vec<acp::ContentBlock> = Vec::new();
391 let text = editor.text(cx);
392 editor.display_map.update(cx, |map, cx| {
393 let snapshot = map.snapshot(cx);
394 for (crease_id, crease) in snapshot.crease_snapshot.creases() {
395 let Some((uri, mention)) = contents.get(&crease_id) else {
396 continue;
397 };
398
399 let crease_range = crease.range().to_offset(&snapshot.buffer_snapshot());
400 if crease_range.start.0 > ix {
401 let chunk = text[ix..crease_range.start.0].into();
402 chunks.push(chunk);
403 }
404 let chunk = match mention {
405 Mention::Text {
406 content,
407 tracked_buffers,
408 } => {
409 all_tracked_buffers.extend(tracked_buffers.iter().cloned());
410 if supports_embedded_context {
411 acp::ContentBlock::Resource(acp::EmbeddedResource::new(
412 acp::EmbeddedResourceResource::TextResourceContents(
413 acp::TextResourceContents::new(
414 content.clone(),
415 uri.to_uri().to_string(),
416 ),
417 ),
418 ))
419 } else {
420 acp::ContentBlock::ResourceLink(acp::ResourceLink::new(
421 uri.name(),
422 uri.to_uri().to_string(),
423 ))
424 }
425 }
426 Mention::Image(mention_image) => {
427 let mut image = acp::ImageContent::new(
428 mention_image.data.clone(),
429 mention_image.format.mime_type(),
430 );
431
432 if let Some(uri) = match uri {
433 MentionUri::File { .. } => Some(uri.to_uri().to_string()),
434 MentionUri::PastedImage => None,
435 other => {
436 debug_panic!(
437 "unexpected mention uri for image: {:?}",
438 other
439 );
440 None
441 }
442 } {
443 image = image.uri(uri)
444 };
445 acp::ContentBlock::Image(image)
446 }
447 Mention::Link => acp::ContentBlock::ResourceLink(
448 acp::ResourceLink::new(uri.name(), uri.to_uri().to_string()),
449 ),
450 };
451 chunks.push(chunk);
452 ix = crease_range.end.0;
453 }
454
455 if ix < text.len() {
456 let last_chunk = text[ix..].trim_end().to_owned();
457 if !last_chunk.is_empty() {
458 chunks.push(last_chunk.into());
459 }
460 }
461 });
462 Ok((chunks, all_tracked_buffers))
463 })?;
464 result
465 })
466 }
467
468 pub fn clear(&mut self, window: &mut Window, cx: &mut Context<Self>) {
469 self.editor.update(cx, |editor, cx| {
470 editor.clear(window, cx);
471 editor.remove_creases(
472 self.mention_set.update(cx, |mention_set, _cx| {
473 mention_set
474 .clear()
475 .map(|(crease_id, _)| crease_id)
476 .collect::<Vec<_>>()
477 }),
478 cx,
479 )
480 });
481 }
482
483 pub fn send(&mut self, cx: &mut Context<Self>) {
484 if self.is_empty(cx) {
485 return;
486 }
487 self.editor.update(cx, |editor, cx| {
488 editor.clear_inlay_hints(cx);
489 });
490 cx.emit(MessageEditorEvent::Send)
491 }
492
493 pub fn trigger_completion_menu(&mut self, window: &mut Window, cx: &mut Context<Self>) {
494 let editor = self.editor.clone();
495
496 cx.spawn_in(window, async move |_, cx| {
497 editor
498 .update_in(cx, |editor, window, cx| {
499 let menu_is_open =
500 editor.context_menu().borrow().as_ref().is_some_and(|menu| {
501 matches!(menu, CodeContextMenu::Completions(_)) && menu.visible()
502 });
503
504 let has_at_sign = {
505 let snapshot = editor.display_snapshot(cx);
506 let cursor = editor.selections.newest::<text::Point>(&snapshot).head();
507 let offset = cursor.to_offset(&snapshot);
508 if offset.0 > 0 {
509 snapshot
510 .buffer_snapshot()
511 .reversed_chars_at(offset)
512 .next()
513 .map(|sign| sign == '@')
514 .unwrap_or(false)
515 } else {
516 false
517 }
518 };
519
520 if menu_is_open && has_at_sign {
521 return;
522 }
523
524 editor.insert("@", window, cx);
525 editor.show_completions(&editor::actions::ShowCompletions, window, cx);
526 })
527 .log_err();
528 })
529 .detach();
530 }
531
532 fn chat(&mut self, _: &Chat, _: &mut Window, cx: &mut Context<Self>) {
533 self.send(cx);
534 }
535
536 fn chat_with_follow(
537 &mut self,
538 _: &ChatWithFollow,
539 window: &mut Window,
540 cx: &mut Context<Self>,
541 ) {
542 self.workspace
543 .update(cx, |this, cx| {
544 this.follow(CollaboratorId::Agent, window, cx)
545 })
546 .log_err();
547
548 self.send(cx);
549 }
550
551 fn cancel(&mut self, _: &editor::actions::Cancel, _: &mut Window, cx: &mut Context<Self>) {
552 cx.emit(MessageEditorEvent::Cancel)
553 }
554
555 fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context<Self>) {
556 if self.prompt_capabilities.borrow().image
557 && let Some(task) =
558 paste_images_as_context(self.editor.clone(), self.mention_set.clone(), window, cx)
559 {
560 task.detach();
561 }
562 }
563
564 pub fn insert_dragged_files(
565 &mut self,
566 paths: Vec<project::ProjectPath>,
567 added_worktrees: Vec<Entity<Worktree>>,
568 window: &mut Window,
569 cx: &mut Context<Self>,
570 ) {
571 let Some(workspace) = self.workspace.upgrade() else {
572 return;
573 };
574 let path_style = self.project.read(cx).path_style(cx);
575 let buffer = self.editor.read(cx).buffer().clone();
576 let Some(buffer) = buffer.read(cx).as_singleton() else {
577 return;
578 };
579 let mut tasks = Vec::new();
580 for path in paths {
581 let Some(entry) = self.project.read(cx).entry_for_path(&path, cx) else {
582 continue;
583 };
584 let Some(worktree) = self.project.read(cx).worktree_for_id(path.worktree_id, cx) else {
585 continue;
586 };
587 let abs_path = worktree.read(cx).absolutize(&path.path);
588 let (file_name, _) = crate::completion_provider::extract_file_name_and_directory(
589 &path.path,
590 worktree.read(cx).root_name(),
591 path_style,
592 );
593
594 let uri = if entry.is_dir() {
595 MentionUri::Directory { abs_path }
596 } else {
597 MentionUri::File { abs_path }
598 };
599
600 let new_text = format!("{} ", uri.as_link());
601 let content_len = new_text.len() - 1;
602
603 let anchor = buffer.update(cx, |buffer, _cx| buffer.anchor_before(buffer.len()));
604
605 self.editor.update(cx, |message_editor, cx| {
606 message_editor.edit(
607 [(
608 multi_buffer::Anchor::max()..multi_buffer::Anchor::max(),
609 new_text,
610 )],
611 cx,
612 );
613 });
614 let supports_images = self.prompt_capabilities.borrow().image;
615 tasks.push(self.mention_set.update(cx, |mention_set, cx| {
616 mention_set.confirm_mention_completion(
617 file_name,
618 anchor,
619 content_len,
620 uri,
621 supports_images,
622 self.editor.clone(),
623 &workspace,
624 window,
625 cx,
626 )
627 }));
628 }
629 cx.spawn(async move |_, _| {
630 join_all(tasks).await;
631 drop(added_worktrees);
632 })
633 .detach();
634 }
635
636 pub fn insert_selections(&mut self, window: &mut Window, cx: &mut Context<Self>) {
637 let editor = self.editor.read(cx);
638 let editor_buffer = editor.buffer().read(cx);
639 let Some(buffer) = editor_buffer.as_singleton() else {
640 return;
641 };
642 let cursor_anchor = editor.selections.newest_anchor().head();
643 let cursor_offset = cursor_anchor.to_offset(&editor_buffer.snapshot(cx));
644 let anchor = buffer.update(cx, |buffer, _cx| {
645 buffer.anchor_before(cursor_offset.0.min(buffer.len()))
646 });
647 let Some(workspace) = self.workspace.upgrade() else {
648 return;
649 };
650 let Some(completion) =
651 PromptCompletionProvider::<Entity<MessageEditor>>::completion_for_action(
652 PromptContextAction::AddSelections,
653 anchor..anchor,
654 self.editor.downgrade(),
655 self.mention_set.downgrade(),
656 &workspace,
657 cx,
658 )
659 else {
660 return;
661 };
662
663 self.editor.update(cx, |message_editor, cx| {
664 message_editor.edit([(cursor_anchor..cursor_anchor, completion.new_text)], cx);
665 message_editor.request_autoscroll(Autoscroll::fit(), cx);
666 });
667 if let Some(confirm) = completion.confirm {
668 confirm(CompletionIntent::Complete, window, cx);
669 }
670 }
671
672 pub fn set_read_only(&mut self, read_only: bool, cx: &mut Context<Self>) {
673 self.editor.update(cx, |message_editor, cx| {
674 message_editor.set_read_only(read_only);
675 cx.notify()
676 })
677 }
678
679 pub fn set_mode(&mut self, mode: EditorMode, cx: &mut Context<Self>) {
680 self.editor.update(cx, |editor, cx| {
681 editor.set_mode(mode);
682 cx.notify()
683 });
684 }
685
686 pub fn set_message(
687 &mut self,
688 message: Vec<acp::ContentBlock>,
689 window: &mut Window,
690 cx: &mut Context<Self>,
691 ) {
692 self.clear(window, cx);
693
694 let path_style = self.project.read(cx).path_style(cx);
695 let mut text = String::new();
696 let mut mentions = Vec::new();
697
698 for chunk in message {
699 match chunk {
700 acp::ContentBlock::Text(text_content) => {
701 text.push_str(&text_content.text);
702 }
703 acp::ContentBlock::Resource(acp::EmbeddedResource {
704 resource: acp::EmbeddedResourceResource::TextResourceContents(resource),
705 ..
706 }) => {
707 let Some(mention_uri) = MentionUri::parse(&resource.uri, path_style).log_err()
708 else {
709 continue;
710 };
711 let start = text.len();
712 write!(&mut text, "{}", mention_uri.as_link()).ok();
713 let end = text.len();
714 mentions.push((
715 start..end,
716 mention_uri,
717 Mention::Text {
718 content: resource.text,
719 tracked_buffers: Vec::new(),
720 },
721 ));
722 }
723 acp::ContentBlock::ResourceLink(resource) => {
724 if let Some(mention_uri) =
725 MentionUri::parse(&resource.uri, path_style).log_err()
726 {
727 let start = text.len();
728 write!(&mut text, "{}", mention_uri.as_link()).ok();
729 let end = text.len();
730 mentions.push((start..end, mention_uri, Mention::Link));
731 }
732 }
733 acp::ContentBlock::Image(acp::ImageContent {
734 uri,
735 data,
736 mime_type,
737 ..
738 }) => {
739 let mention_uri = if let Some(uri) = uri {
740 MentionUri::parse(&uri, path_style)
741 } else {
742 Ok(MentionUri::PastedImage)
743 };
744 let Some(mention_uri) = mention_uri.log_err() else {
745 continue;
746 };
747 let Some(format) = ImageFormat::from_mime_type(&mime_type) else {
748 log::error!("failed to parse MIME type for image: {mime_type:?}");
749 continue;
750 };
751 let start = text.len();
752 write!(&mut text, "{}", mention_uri.as_link()).ok();
753 let end = text.len();
754 mentions.push((
755 start..end,
756 mention_uri,
757 Mention::Image(MentionImage {
758 data: data.into(),
759 format,
760 }),
761 ));
762 }
763 _ => {}
764 }
765 }
766
767 let snapshot = self.editor.update(cx, |editor, cx| {
768 editor.set_text(text, window, cx);
769 editor.buffer().read(cx).snapshot(cx)
770 });
771
772 for (range, mention_uri, mention) in mentions {
773 let anchor = snapshot.anchor_before(MultiBufferOffset(range.start));
774 let Some((crease_id, tx)) = insert_crease_for_mention(
775 anchor.excerpt_id,
776 anchor.text_anchor,
777 range.end - range.start,
778 mention_uri.name().into(),
779 mention_uri.icon_path(cx),
780 None,
781 self.editor.clone(),
782 window,
783 cx,
784 ) else {
785 continue;
786 };
787 drop(tx);
788
789 self.mention_set.update(cx, |mention_set, _cx| {
790 mention_set.insert_mention(
791 crease_id,
792 mention_uri.clone(),
793 Task::ready(Ok(mention)).shared(),
794 )
795 });
796 }
797 cx.notify();
798 }
799
800 pub fn text(&self, cx: &App) -> String {
801 self.editor.read(cx).text(cx)
802 }
803
804 pub fn set_placeholder_text(
805 &mut self,
806 placeholder: &str,
807 window: &mut Window,
808 cx: &mut Context<Self>,
809 ) {
810 self.editor.update(cx, |editor, cx| {
811 editor.set_placeholder_text(placeholder, window, cx);
812 });
813 }
814
815 #[cfg(test)]
816 pub fn set_text(&mut self, text: &str, window: &mut Window, cx: &mut Context<Self>) {
817 self.editor.update(cx, |editor, cx| {
818 editor.set_text(text, window, cx);
819 });
820 }
821}
822
823impl Focusable for MessageEditor {
824 fn focus_handle(&self, cx: &App) -> FocusHandle {
825 self.editor.focus_handle(cx)
826 }
827}
828
829impl Render for MessageEditor {
830 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
831 div()
832 .key_context("MessageEditor")
833 .on_action(cx.listener(Self::chat))
834 .on_action(cx.listener(Self::chat_with_follow))
835 .on_action(cx.listener(Self::cancel))
836 .capture_action(cx.listener(Self::paste))
837 .flex_1()
838 .child({
839 let settings = ThemeSettings::get_global(cx);
840
841 let text_style = TextStyle {
842 color: cx.theme().colors().text,
843 font_family: settings.buffer_font.family.clone(),
844 font_fallbacks: settings.buffer_font.fallbacks.clone(),
845 font_features: settings.buffer_font.features.clone(),
846 font_size: settings.agent_buffer_font_size(cx).into(),
847 line_height: relative(settings.buffer_line_height.value()),
848 ..Default::default()
849 };
850
851 EditorElement::new(
852 &self.editor,
853 EditorStyle {
854 background: cx.theme().colors().editor_background,
855 local_player: cx.theme().players().local(),
856 text: text_style,
857 syntax: cx.theme().syntax().clone(),
858 inlay_hints_style: editor::make_inlay_hints_style(cx),
859 ..Default::default()
860 },
861 )
862 })
863 }
864}
865
866pub struct MessageEditorAddon {}
867
868impl MessageEditorAddon {
869 pub fn new() -> Self {
870 Self {}
871 }
872}
873
874impl Addon for MessageEditorAddon {
875 fn to_any(&self) -> &dyn std::any::Any {
876 self
877 }
878
879 fn to_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
880 Some(self)
881 }
882
883 fn extend_key_context(&self, key_context: &mut KeyContext, cx: &App) {
884 let settings = agent_settings::AgentSettings::get_global(cx);
885 if settings.use_modifier_to_send {
886 key_context.add("use_modifier_to_send");
887 }
888 }
889}
890
891#[cfg(test)]
892mod tests {
893 use std::{cell::RefCell, ops::Range, path::Path, rc::Rc, sync::Arc};
894
895 use acp_thread::MentionUri;
896 use agent::{HistoryStore, outline};
897 use agent_client_protocol as acp;
898 use assistant_text_thread::TextThreadStore;
899 use editor::{AnchorRangeExt as _, Editor, EditorMode, MultiBufferOffset};
900 use fs::FakeFs;
901 use futures::StreamExt as _;
902 use gpui::{
903 AppContext, Entity, EventEmitter, FocusHandle, Focusable, TestAppContext, VisualTestContext,
904 };
905 use language_model::LanguageModelRegistry;
906 use lsp::{CompletionContext, CompletionTriggerKind};
907 use project::{CompletionIntent, Project, ProjectPath};
908 use serde_json::json;
909 use text::Point;
910 use ui::{App, Context, IntoElement, Render, SharedString, Window};
911 use util::{path, paths::PathStyle, rel_path::rel_path};
912 use workspace::{AppState, Item, Workspace};
913
914 use crate::acp::{
915 message_editor::{Mention, MessageEditor},
916 thread_view::tests::init_test,
917 };
918
919 #[gpui::test]
920 async fn test_at_mention_removal(cx: &mut TestAppContext) {
921 init_test(cx);
922
923 let fs = FakeFs::new(cx.executor());
924 fs.insert_tree("/project", json!({"file": ""})).await;
925 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
926
927 let (workspace, cx) =
928 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
929
930 let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx));
931 let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx));
932
933 let message_editor = cx.update(|window, cx| {
934 cx.new(|cx| {
935 MessageEditor::new(
936 workspace.downgrade(),
937 project.clone(),
938 history_store.clone(),
939 None,
940 Default::default(),
941 Default::default(),
942 "Test Agent".into(),
943 "Test",
944 EditorMode::AutoHeight {
945 min_lines: 1,
946 max_lines: None,
947 },
948 window,
949 cx,
950 )
951 })
952 });
953 let editor = message_editor.update(cx, |message_editor, _| message_editor.editor.clone());
954
955 cx.run_until_parked();
956
957 let excerpt_id = editor.update(cx, |editor, cx| {
958 editor
959 .buffer()
960 .read(cx)
961 .excerpt_ids()
962 .into_iter()
963 .next()
964 .unwrap()
965 });
966 let completions = editor.update_in(cx, |editor, window, cx| {
967 editor.set_text("Hello @file ", window, cx);
968 let buffer = editor.buffer().read(cx).as_singleton().unwrap();
969 let completion_provider = editor.completion_provider().unwrap();
970 completion_provider.completions(
971 excerpt_id,
972 &buffer,
973 text::Anchor::MAX,
974 CompletionContext {
975 trigger_kind: CompletionTriggerKind::TRIGGER_CHARACTER,
976 trigger_character: Some("@".into()),
977 },
978 window,
979 cx,
980 )
981 });
982 let [_, completion]: [_; 2] = completions
983 .await
984 .unwrap()
985 .into_iter()
986 .flat_map(|response| response.completions)
987 .collect::<Vec<_>>()
988 .try_into()
989 .unwrap();
990
991 editor.update_in(cx, |editor, window, cx| {
992 let snapshot = editor.buffer().read(cx).snapshot(cx);
993 let range = snapshot
994 .anchor_range_in_excerpt(excerpt_id, completion.replace_range)
995 .unwrap();
996 editor.edit([(range, completion.new_text)], cx);
997 (completion.confirm.unwrap())(CompletionIntent::Complete, window, cx);
998 });
999
1000 cx.run_until_parked();
1001
1002 // Backspace over the inserted crease (and the following space).
1003 editor.update_in(cx, |editor, window, cx| {
1004 editor.backspace(&Default::default(), window, cx);
1005 editor.backspace(&Default::default(), window, cx);
1006 });
1007
1008 let (content, _) = message_editor
1009 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
1010 .await
1011 .unwrap();
1012
1013 // We don't send a resource link for the deleted crease.
1014 pretty_assertions::assert_matches!(content.as_slice(), [acp::ContentBlock::Text { .. }]);
1015 }
1016
1017 #[gpui::test]
1018 async fn test_slash_command_validation(cx: &mut gpui::TestAppContext) {
1019 init_test(cx);
1020 let fs = FakeFs::new(cx.executor());
1021 fs.insert_tree(
1022 "/test",
1023 json!({
1024 ".zed": {
1025 "tasks.json": r#"[{"label": "test", "command": "echo"}]"#
1026 },
1027 "src": {
1028 "main.rs": "fn main() {}",
1029 },
1030 }),
1031 )
1032 .await;
1033
1034 let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
1035 let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx));
1036 let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx));
1037 let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default()));
1038 // Start with no available commands - simulating Claude which doesn't support slash commands
1039 let available_commands = Rc::new(RefCell::new(vec![]));
1040
1041 let (workspace, cx) =
1042 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
1043 let workspace_handle = workspace.downgrade();
1044 let message_editor = workspace.update_in(cx, |_, window, cx| {
1045 cx.new(|cx| {
1046 MessageEditor::new(
1047 workspace_handle.clone(),
1048 project.clone(),
1049 history_store.clone(),
1050 None,
1051 prompt_capabilities.clone(),
1052 available_commands.clone(),
1053 "Claude Code".into(),
1054 "Test",
1055 EditorMode::AutoHeight {
1056 min_lines: 1,
1057 max_lines: None,
1058 },
1059 window,
1060 cx,
1061 )
1062 })
1063 });
1064 let editor = message_editor.update(cx, |message_editor, _| message_editor.editor.clone());
1065
1066 // Test that slash commands fail when no available_commands are set (empty list means no commands supported)
1067 editor.update_in(cx, |editor, window, cx| {
1068 editor.set_text("/file test.txt", window, cx);
1069 });
1070
1071 let contents_result = message_editor
1072 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
1073 .await;
1074
1075 // Should fail because available_commands is empty (no commands supported)
1076 assert!(contents_result.is_err());
1077 let error_message = contents_result.unwrap_err().to_string();
1078 assert!(error_message.contains("not supported by Claude Code"));
1079 assert!(error_message.contains("Available commands: none"));
1080
1081 // Now simulate Claude providing its list of available commands (which doesn't include file)
1082 available_commands.replace(vec![acp::AvailableCommand::new("help", "Get help")]);
1083
1084 // Test that unsupported slash commands trigger an error when we have a list of available commands
1085 editor.update_in(cx, |editor, window, cx| {
1086 editor.set_text("/file test.txt", window, cx);
1087 });
1088
1089 let contents_result = message_editor
1090 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
1091 .await;
1092
1093 assert!(contents_result.is_err());
1094 let error_message = contents_result.unwrap_err().to_string();
1095 assert!(error_message.contains("not supported by Claude Code"));
1096 assert!(error_message.contains("/file"));
1097 assert!(error_message.contains("Available commands: /help"));
1098
1099 // Test that supported commands work fine
1100 editor.update_in(cx, |editor, window, cx| {
1101 editor.set_text("/help", window, cx);
1102 });
1103
1104 let contents_result = message_editor
1105 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
1106 .await;
1107
1108 // Should succeed because /help is in available_commands
1109 assert!(contents_result.is_ok());
1110
1111 // Test that regular text works fine
1112 editor.update_in(cx, |editor, window, cx| {
1113 editor.set_text("Hello Claude!", window, cx);
1114 });
1115
1116 let (content, _) = message_editor
1117 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
1118 .await
1119 .unwrap();
1120
1121 assert_eq!(content.len(), 1);
1122 if let acp::ContentBlock::Text(text) = &content[0] {
1123 assert_eq!(text.text, "Hello Claude!");
1124 } else {
1125 panic!("Expected ContentBlock::Text");
1126 }
1127
1128 // Test that @ mentions still work
1129 editor.update_in(cx, |editor, window, cx| {
1130 editor.set_text("Check this @", window, cx);
1131 });
1132
1133 // The @ mention functionality should not be affected
1134 let (content, _) = message_editor
1135 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
1136 .await
1137 .unwrap();
1138
1139 assert_eq!(content.len(), 1);
1140 if let acp::ContentBlock::Text(text) = &content[0] {
1141 assert_eq!(text.text, "Check this @");
1142 } else {
1143 panic!("Expected ContentBlock::Text");
1144 }
1145 }
1146
1147 struct MessageEditorItem(Entity<MessageEditor>);
1148
1149 impl Item for MessageEditorItem {
1150 type Event = ();
1151
1152 fn include_in_nav_history() -> bool {
1153 false
1154 }
1155
1156 fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
1157 "Test".into()
1158 }
1159 }
1160
1161 impl EventEmitter<()> for MessageEditorItem {}
1162
1163 impl Focusable for MessageEditorItem {
1164 fn focus_handle(&self, cx: &App) -> FocusHandle {
1165 self.0.read(cx).focus_handle(cx)
1166 }
1167 }
1168
1169 impl Render for MessageEditorItem {
1170 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
1171 self.0.clone().into_any_element()
1172 }
1173 }
1174
1175 #[gpui::test]
1176 async fn test_completion_provider_commands(cx: &mut TestAppContext) {
1177 init_test(cx);
1178
1179 let app_state = cx.update(AppState::test);
1180
1181 cx.update(|cx| {
1182 editor::init(cx);
1183 workspace::init(app_state.clone(), cx);
1184 });
1185
1186 let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
1187 let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
1188 let workspace = window.root(cx).unwrap();
1189
1190 let mut cx = VisualTestContext::from_window(*window, cx);
1191
1192 let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx));
1193 let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx));
1194 let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default()));
1195 let available_commands = Rc::new(RefCell::new(vec![
1196 acp::AvailableCommand::new("quick-math", "2 + 2 = 4 - 1 = 3"),
1197 acp::AvailableCommand::new("say-hello", "Say hello to whoever you want").input(
1198 acp::AvailableCommandInput::Unstructured(acp::UnstructuredCommandInput::new(
1199 "<name>",
1200 )),
1201 ),
1202 ]));
1203
1204 let editor = workspace.update_in(&mut cx, |workspace, window, cx| {
1205 let workspace_handle = cx.weak_entity();
1206 let message_editor = cx.new(|cx| {
1207 MessageEditor::new(
1208 workspace_handle,
1209 project.clone(),
1210 history_store.clone(),
1211 None,
1212 prompt_capabilities.clone(),
1213 available_commands.clone(),
1214 "Test Agent".into(),
1215 "Test",
1216 EditorMode::AutoHeight {
1217 max_lines: None,
1218 min_lines: 1,
1219 },
1220 window,
1221 cx,
1222 )
1223 });
1224 workspace.active_pane().update(cx, |pane, cx| {
1225 pane.add_item(
1226 Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
1227 true,
1228 true,
1229 None,
1230 window,
1231 cx,
1232 );
1233 });
1234 message_editor.read(cx).focus_handle(cx).focus(window);
1235 message_editor.read(cx).editor().clone()
1236 });
1237
1238 cx.simulate_input("/");
1239
1240 editor.update_in(&mut cx, |editor, window, cx| {
1241 assert_eq!(editor.text(cx), "/");
1242 assert!(editor.has_visible_completions_menu());
1243
1244 assert_eq!(
1245 current_completion_labels_with_documentation(editor),
1246 &[
1247 ("quick-math".into(), "2 + 2 = 4 - 1 = 3".into()),
1248 ("say-hello".into(), "Say hello to whoever you want".into())
1249 ]
1250 );
1251 editor.set_text("", window, cx);
1252 });
1253
1254 cx.simulate_input("/qui");
1255
1256 editor.update_in(&mut cx, |editor, window, cx| {
1257 assert_eq!(editor.text(cx), "/qui");
1258 assert!(editor.has_visible_completions_menu());
1259
1260 assert_eq!(
1261 current_completion_labels_with_documentation(editor),
1262 &[("quick-math".into(), "2 + 2 = 4 - 1 = 3".into())]
1263 );
1264 editor.set_text("", window, cx);
1265 });
1266
1267 editor.update_in(&mut cx, |editor, window, cx| {
1268 assert!(editor.has_visible_completions_menu());
1269 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
1270 });
1271
1272 cx.run_until_parked();
1273
1274 editor.update_in(&mut cx, |editor, window, cx| {
1275 assert_eq!(editor.display_text(cx), "/quick-math ");
1276 assert!(!editor.has_visible_completions_menu());
1277 editor.set_text("", window, cx);
1278 });
1279
1280 cx.simulate_input("/say");
1281
1282 editor.update_in(&mut cx, |editor, _window, cx| {
1283 assert_eq!(editor.display_text(cx), "/say");
1284 assert!(editor.has_visible_completions_menu());
1285
1286 assert_eq!(
1287 current_completion_labels_with_documentation(editor),
1288 &[("say-hello".into(), "Say hello to whoever you want".into())]
1289 );
1290 });
1291
1292 editor.update_in(&mut cx, |editor, window, cx| {
1293 assert!(editor.has_visible_completions_menu());
1294 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
1295 });
1296
1297 cx.run_until_parked();
1298
1299 editor.update_in(&mut cx, |editor, _window, cx| {
1300 assert_eq!(editor.text(cx), "/say-hello ");
1301 assert_eq!(editor.display_text(cx), "/say-hello <name>");
1302 assert!(!editor.has_visible_completions_menu());
1303 });
1304
1305 cx.simulate_input("GPT5");
1306
1307 cx.run_until_parked();
1308
1309 editor.update_in(&mut cx, |editor, window, cx| {
1310 assert_eq!(editor.text(cx), "/say-hello GPT5");
1311 assert_eq!(editor.display_text(cx), "/say-hello GPT5");
1312 assert!(!editor.has_visible_completions_menu());
1313
1314 // Delete argument
1315 for _ in 0..5 {
1316 editor.backspace(&editor::actions::Backspace, window, cx);
1317 }
1318 });
1319
1320 cx.run_until_parked();
1321
1322 editor.update_in(&mut cx, |editor, window, cx| {
1323 assert_eq!(editor.text(cx), "/say-hello");
1324 // Hint is visible because argument was deleted
1325 assert_eq!(editor.display_text(cx), "/say-hello <name>");
1326
1327 // Delete last command letter
1328 editor.backspace(&editor::actions::Backspace, window, cx);
1329 });
1330
1331 cx.run_until_parked();
1332
1333 editor.update_in(&mut cx, |editor, _window, cx| {
1334 // Hint goes away once command no longer matches an available one
1335 assert_eq!(editor.text(cx), "/say-hell");
1336 assert_eq!(editor.display_text(cx), "/say-hell");
1337 assert!(!editor.has_visible_completions_menu());
1338 });
1339 }
1340
1341 #[gpui::test]
1342 async fn test_context_completion_provider_mentions(cx: &mut TestAppContext) {
1343 init_test(cx);
1344
1345 let app_state = cx.update(AppState::test);
1346
1347 cx.update(|cx| {
1348 editor::init(cx);
1349 workspace::init(app_state.clone(), cx);
1350 });
1351
1352 app_state
1353 .fs
1354 .as_fake()
1355 .insert_tree(
1356 path!("/dir"),
1357 json!({
1358 "editor": "",
1359 "a": {
1360 "one.txt": "1",
1361 "two.txt": "2",
1362 "three.txt": "3",
1363 "four.txt": "4"
1364 },
1365 "b": {
1366 "five.txt": "5",
1367 "six.txt": "6",
1368 "seven.txt": "7",
1369 "eight.txt": "8",
1370 },
1371 "x.png": "",
1372 }),
1373 )
1374 .await;
1375
1376 let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
1377 let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
1378 let workspace = window.root(cx).unwrap();
1379
1380 let worktree = project.update(cx, |project, cx| {
1381 let mut worktrees = project.worktrees(cx).collect::<Vec<_>>();
1382 assert_eq!(worktrees.len(), 1);
1383 worktrees.pop().unwrap()
1384 });
1385 let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id());
1386
1387 let mut cx = VisualTestContext::from_window(*window, cx);
1388
1389 let paths = vec![
1390 rel_path("a/one.txt"),
1391 rel_path("a/two.txt"),
1392 rel_path("a/three.txt"),
1393 rel_path("a/four.txt"),
1394 rel_path("b/five.txt"),
1395 rel_path("b/six.txt"),
1396 rel_path("b/seven.txt"),
1397 rel_path("b/eight.txt"),
1398 ];
1399
1400 let slash = PathStyle::local().primary_separator();
1401
1402 let mut opened_editors = Vec::new();
1403 for path in paths {
1404 let buffer = workspace
1405 .update_in(&mut cx, |workspace, window, cx| {
1406 workspace.open_path(
1407 ProjectPath {
1408 worktree_id,
1409 path: path.into(),
1410 },
1411 None,
1412 false,
1413 window,
1414 cx,
1415 )
1416 })
1417 .await
1418 .unwrap();
1419 opened_editors.push(buffer);
1420 }
1421
1422 let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx));
1423 let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx));
1424 let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default()));
1425
1426 let (message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| {
1427 let workspace_handle = cx.weak_entity();
1428 let message_editor = cx.new(|cx| {
1429 MessageEditor::new(
1430 workspace_handle,
1431 project.clone(),
1432 history_store.clone(),
1433 None,
1434 prompt_capabilities.clone(),
1435 Default::default(),
1436 "Test Agent".into(),
1437 "Test",
1438 EditorMode::AutoHeight {
1439 max_lines: None,
1440 min_lines: 1,
1441 },
1442 window,
1443 cx,
1444 )
1445 });
1446 workspace.active_pane().update(cx, |pane, cx| {
1447 pane.add_item(
1448 Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
1449 true,
1450 true,
1451 None,
1452 window,
1453 cx,
1454 );
1455 });
1456 message_editor.read(cx).focus_handle(cx).focus(window);
1457 let editor = message_editor.read(cx).editor().clone();
1458 (message_editor, editor)
1459 });
1460
1461 cx.simulate_input("Lorem @");
1462
1463 editor.update_in(&mut cx, |editor, window, cx| {
1464 assert_eq!(editor.text(cx), "Lorem @");
1465 assert!(editor.has_visible_completions_menu());
1466
1467 assert_eq!(
1468 current_completion_labels(editor),
1469 &[
1470 format!("eight.txt b{slash}"),
1471 format!("seven.txt b{slash}"),
1472 format!("six.txt b{slash}"),
1473 format!("five.txt b{slash}"),
1474 "Files & Directories".into(),
1475 "Symbols".into()
1476 ]
1477 );
1478 editor.set_text("", window, cx);
1479 });
1480
1481 prompt_capabilities.replace(
1482 acp::PromptCapabilities::new()
1483 .image(true)
1484 .audio(true)
1485 .embedded_context(true),
1486 );
1487
1488 cx.simulate_input("Lorem ");
1489
1490 editor.update(&mut cx, |editor, cx| {
1491 assert_eq!(editor.text(cx), "Lorem ");
1492 assert!(!editor.has_visible_completions_menu());
1493 });
1494
1495 cx.simulate_input("@");
1496
1497 editor.update(&mut cx, |editor, cx| {
1498 assert_eq!(editor.text(cx), "Lorem @");
1499 assert!(editor.has_visible_completions_menu());
1500 assert_eq!(
1501 current_completion_labels(editor),
1502 &[
1503 format!("eight.txt b{slash}"),
1504 format!("seven.txt b{slash}"),
1505 format!("six.txt b{slash}"),
1506 format!("five.txt b{slash}"),
1507 "Files & Directories".into(),
1508 "Symbols".into(),
1509 "Threads".into(),
1510 "Fetch".into()
1511 ]
1512 );
1513 });
1514
1515 // Select and confirm "File"
1516 editor.update_in(&mut cx, |editor, window, cx| {
1517 assert!(editor.has_visible_completions_menu());
1518 editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
1519 editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
1520 editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
1521 editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
1522 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
1523 });
1524
1525 cx.run_until_parked();
1526
1527 editor.update(&mut cx, |editor, cx| {
1528 assert_eq!(editor.text(cx), "Lorem @file ");
1529 assert!(editor.has_visible_completions_menu());
1530 });
1531
1532 cx.simulate_input("one");
1533
1534 editor.update(&mut cx, |editor, cx| {
1535 assert_eq!(editor.text(cx), "Lorem @file one");
1536 assert!(editor.has_visible_completions_menu());
1537 assert_eq!(
1538 current_completion_labels(editor),
1539 vec![format!("one.txt a{slash}")]
1540 );
1541 });
1542
1543 editor.update_in(&mut cx, |editor, window, cx| {
1544 assert!(editor.has_visible_completions_menu());
1545 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
1546 });
1547
1548 let url_one = MentionUri::File {
1549 abs_path: path!("/dir/a/one.txt").into(),
1550 }
1551 .to_uri()
1552 .to_string();
1553 editor.update(&mut cx, |editor, cx| {
1554 let text = editor.text(cx);
1555 assert_eq!(text, format!("Lorem [@one.txt]({url_one}) "));
1556 assert!(!editor.has_visible_completions_menu());
1557 assert_eq!(fold_ranges(editor, cx).len(), 1);
1558 });
1559
1560 let contents = message_editor
1561 .update(&mut cx, |message_editor, cx| {
1562 message_editor
1563 .mention_set()
1564 .update(cx, |mention_set, cx| mention_set.contents(false, cx))
1565 })
1566 .await
1567 .unwrap()
1568 .into_values()
1569 .collect::<Vec<_>>();
1570
1571 {
1572 let [(uri, Mention::Text { content, .. })] = contents.as_slice() else {
1573 panic!("Unexpected mentions");
1574 };
1575 pretty_assertions::assert_eq!(content, "1");
1576 pretty_assertions::assert_eq!(
1577 uri,
1578 &MentionUri::parse(&url_one, PathStyle::local()).unwrap()
1579 );
1580 }
1581
1582 cx.simulate_input(" ");
1583
1584 editor.update(&mut cx, |editor, cx| {
1585 let text = editor.text(cx);
1586 assert_eq!(text, format!("Lorem [@one.txt]({url_one}) "));
1587 assert!(!editor.has_visible_completions_menu());
1588 assert_eq!(fold_ranges(editor, cx).len(), 1);
1589 });
1590
1591 cx.simulate_input("Ipsum ");
1592
1593 editor.update(&mut cx, |editor, cx| {
1594 let text = editor.text(cx);
1595 assert_eq!(text, format!("Lorem [@one.txt]({url_one}) Ipsum "),);
1596 assert!(!editor.has_visible_completions_menu());
1597 assert_eq!(fold_ranges(editor, cx).len(), 1);
1598 });
1599
1600 cx.simulate_input("@file ");
1601
1602 editor.update(&mut cx, |editor, cx| {
1603 let text = editor.text(cx);
1604 assert_eq!(text, format!("Lorem [@one.txt]({url_one}) Ipsum @file "),);
1605 assert!(editor.has_visible_completions_menu());
1606 assert_eq!(fold_ranges(editor, cx).len(), 1);
1607 });
1608
1609 editor.update_in(&mut cx, |editor, window, cx| {
1610 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
1611 });
1612
1613 cx.run_until_parked();
1614
1615 let contents = message_editor
1616 .update(&mut cx, |message_editor, cx| {
1617 message_editor
1618 .mention_set()
1619 .update(cx, |mention_set, cx| mention_set.contents(false, cx))
1620 })
1621 .await
1622 .unwrap()
1623 .into_values()
1624 .collect::<Vec<_>>();
1625
1626 let url_eight = MentionUri::File {
1627 abs_path: path!("/dir/b/eight.txt").into(),
1628 }
1629 .to_uri()
1630 .to_string();
1631
1632 {
1633 let [_, (uri, Mention::Text { content, .. })] = contents.as_slice() else {
1634 panic!("Unexpected mentions");
1635 };
1636 pretty_assertions::assert_eq!(content, "8");
1637 pretty_assertions::assert_eq!(
1638 uri,
1639 &MentionUri::parse(&url_eight, PathStyle::local()).unwrap()
1640 );
1641 }
1642
1643 editor.update(&mut cx, |editor, cx| {
1644 assert_eq!(
1645 editor.text(cx),
1646 format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) ")
1647 );
1648 assert!(!editor.has_visible_completions_menu());
1649 assert_eq!(fold_ranges(editor, cx).len(), 2);
1650 });
1651
1652 let plain_text_language = Arc::new(language::Language::new(
1653 language::LanguageConfig {
1654 name: "Plain Text".into(),
1655 matcher: language::LanguageMatcher {
1656 path_suffixes: vec!["txt".to_string()],
1657 ..Default::default()
1658 },
1659 ..Default::default()
1660 },
1661 None,
1662 ));
1663
1664 // Register the language and fake LSP
1665 let language_registry = project.read_with(&cx, |project, _| project.languages().clone());
1666 language_registry.add(plain_text_language);
1667
1668 let mut fake_language_servers = language_registry.register_fake_lsp(
1669 "Plain Text",
1670 language::FakeLspAdapter {
1671 capabilities: lsp::ServerCapabilities {
1672 workspace_symbol_provider: Some(lsp::OneOf::Left(true)),
1673 ..Default::default()
1674 },
1675 ..Default::default()
1676 },
1677 );
1678
1679 // Open the buffer to trigger LSP initialization
1680 let buffer = project
1681 .update(&mut cx, |project, cx| {
1682 project.open_local_buffer(path!("/dir/a/one.txt"), cx)
1683 })
1684 .await
1685 .unwrap();
1686
1687 // Register the buffer with language servers
1688 let _handle = project.update(&mut cx, |project, cx| {
1689 project.register_buffer_with_language_servers(&buffer, cx)
1690 });
1691
1692 cx.run_until_parked();
1693
1694 let fake_language_server = fake_language_servers.next().await.unwrap();
1695 fake_language_server.set_request_handler::<lsp::WorkspaceSymbolRequest, _, _>(
1696 move |_, _| async move {
1697 Ok(Some(lsp::WorkspaceSymbolResponse::Flat(vec![
1698 #[allow(deprecated)]
1699 lsp::SymbolInformation {
1700 name: "MySymbol".into(),
1701 location: lsp::Location {
1702 uri: lsp::Uri::from_file_path(path!("/dir/a/one.txt")).unwrap(),
1703 range: lsp::Range::new(
1704 lsp::Position::new(0, 0),
1705 lsp::Position::new(0, 1),
1706 ),
1707 },
1708 kind: lsp::SymbolKind::CONSTANT,
1709 tags: None,
1710 container_name: None,
1711 deprecated: None,
1712 },
1713 ])))
1714 },
1715 );
1716
1717 cx.simulate_input("@symbol ");
1718
1719 editor.update(&mut cx, |editor, cx| {
1720 assert_eq!(
1721 editor.text(cx),
1722 format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) @symbol ")
1723 );
1724 assert!(editor.has_visible_completions_menu());
1725 assert_eq!(current_completion_labels(editor), &["MySymbol one.txt L1"]);
1726 });
1727
1728 editor.update_in(&mut cx, |editor, window, cx| {
1729 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
1730 });
1731
1732 let symbol = MentionUri::Symbol {
1733 abs_path: path!("/dir/a/one.txt").into(),
1734 name: "MySymbol".into(),
1735 line_range: 0..=0,
1736 };
1737
1738 let contents = message_editor
1739 .update(&mut cx, |message_editor, cx| {
1740 message_editor
1741 .mention_set()
1742 .update(cx, |mention_set, cx| mention_set.contents(false, cx))
1743 })
1744 .await
1745 .unwrap()
1746 .into_values()
1747 .collect::<Vec<_>>();
1748
1749 {
1750 let [_, _, (uri, Mention::Text { content, .. })] = contents.as_slice() else {
1751 panic!("Unexpected mentions");
1752 };
1753 pretty_assertions::assert_eq!(content, "1");
1754 pretty_assertions::assert_eq!(uri, &symbol);
1755 }
1756
1757 cx.run_until_parked();
1758
1759 editor.read_with(&cx, |editor, cx| {
1760 assert_eq!(
1761 editor.text(cx),
1762 format!(
1763 "Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) ",
1764 symbol.to_uri(),
1765 )
1766 );
1767 });
1768
1769 // Try to mention an "image" file that will fail to load
1770 cx.simulate_input("@file x.png");
1771
1772 editor.update(&mut cx, |editor, cx| {
1773 assert_eq!(
1774 editor.text(cx),
1775 format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) @file x.png", symbol.to_uri())
1776 );
1777 assert!(editor.has_visible_completions_menu());
1778 assert_eq!(current_completion_labels(editor), &["x.png "]);
1779 });
1780
1781 editor.update_in(&mut cx, |editor, window, cx| {
1782 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
1783 });
1784
1785 // Getting the message contents fails
1786 message_editor
1787 .update(&mut cx, |message_editor, cx| {
1788 message_editor
1789 .mention_set()
1790 .update(cx, |mention_set, cx| mention_set.contents(false, cx))
1791 })
1792 .await
1793 .expect_err("Should fail to load x.png");
1794
1795 cx.run_until_parked();
1796
1797 // Mention was removed
1798 editor.read_with(&cx, |editor, cx| {
1799 assert_eq!(
1800 editor.text(cx),
1801 format!(
1802 "Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) ",
1803 symbol.to_uri()
1804 )
1805 );
1806 });
1807
1808 // Once more
1809 cx.simulate_input("@file x.png");
1810
1811 editor.update(&mut cx, |editor, cx| {
1812 assert_eq!(
1813 editor.text(cx),
1814 format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) @file x.png", symbol.to_uri())
1815 );
1816 assert!(editor.has_visible_completions_menu());
1817 assert_eq!(current_completion_labels(editor), &["x.png "]);
1818 });
1819
1820 editor.update_in(&mut cx, |editor, window, cx| {
1821 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
1822 });
1823
1824 // This time don't immediately get the contents, just let the confirmed completion settle
1825 cx.run_until_parked();
1826
1827 // Mention was removed
1828 editor.read_with(&cx, |editor, cx| {
1829 assert_eq!(
1830 editor.text(cx),
1831 format!(
1832 "Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) ",
1833 symbol.to_uri()
1834 )
1835 );
1836 });
1837
1838 // Now getting the contents succeeds, because the invalid mention was removed
1839 let contents = message_editor
1840 .update(&mut cx, |message_editor, cx| {
1841 message_editor
1842 .mention_set()
1843 .update(cx, |mention_set, cx| mention_set.contents(false, cx))
1844 })
1845 .await
1846 .unwrap();
1847 assert_eq!(contents.len(), 3);
1848 }
1849
1850 fn fold_ranges(editor: &Editor, cx: &mut App) -> Vec<Range<Point>> {
1851 let snapshot = editor.buffer().read(cx).snapshot(cx);
1852 editor.display_map.update(cx, |display_map, cx| {
1853 display_map
1854 .snapshot(cx)
1855 .folds_in_range(MultiBufferOffset(0)..snapshot.len())
1856 .map(|fold| fold.range.to_point(&snapshot))
1857 .collect()
1858 })
1859 }
1860
1861 fn current_completion_labels(editor: &Editor) -> Vec<String> {
1862 let completions = editor.current_completions().expect("Missing completions");
1863 completions
1864 .into_iter()
1865 .map(|completion| completion.label.text)
1866 .collect::<Vec<_>>()
1867 }
1868
1869 fn current_completion_labels_with_documentation(editor: &Editor) -> Vec<(String, String)> {
1870 let completions = editor.current_completions().expect("Missing completions");
1871 completions
1872 .into_iter()
1873 .map(|completion| {
1874 (
1875 completion.label.text,
1876 completion
1877 .documentation
1878 .map(|d| d.text().to_string())
1879 .unwrap_or_default(),
1880 )
1881 })
1882 .collect::<Vec<_>>()
1883 }
1884
1885 #[gpui::test]
1886 async fn test_large_file_mention_fallback(cx: &mut TestAppContext) {
1887 init_test(cx);
1888
1889 let fs = FakeFs::new(cx.executor());
1890
1891 // Create a large file that exceeds AUTO_OUTLINE_SIZE
1892 // Using plain text without a configured language, so no outline is available
1893 const LINE: &str = "This is a line of text in the file\n";
1894 let large_content = LINE.repeat(2 * (outline::AUTO_OUTLINE_SIZE / LINE.len()));
1895 assert!(large_content.len() > outline::AUTO_OUTLINE_SIZE);
1896
1897 // Create a small file that doesn't exceed AUTO_OUTLINE_SIZE
1898 let small_content = "fn small_function() { /* small */ }\n";
1899 assert!(small_content.len() < outline::AUTO_OUTLINE_SIZE);
1900
1901 fs.insert_tree(
1902 "/project",
1903 json!({
1904 "large_file.txt": large_content.clone(),
1905 "small_file.txt": small_content,
1906 }),
1907 )
1908 .await;
1909
1910 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
1911
1912 let (workspace, cx) =
1913 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
1914
1915 let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx));
1916 let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx));
1917
1918 let message_editor = cx.update(|window, cx| {
1919 cx.new(|cx| {
1920 let editor = MessageEditor::new(
1921 workspace.downgrade(),
1922 project.clone(),
1923 history_store.clone(),
1924 None,
1925 Default::default(),
1926 Default::default(),
1927 "Test Agent".into(),
1928 "Test",
1929 EditorMode::AutoHeight {
1930 min_lines: 1,
1931 max_lines: None,
1932 },
1933 window,
1934 cx,
1935 );
1936 // Enable embedded context so files are actually included
1937 editor
1938 .prompt_capabilities
1939 .replace(acp::PromptCapabilities::new().embedded_context(true));
1940 editor
1941 })
1942 });
1943
1944 // Test large file mention
1945 // Get the absolute path using the project's worktree
1946 let large_file_abs_path = project.read_with(cx, |project, cx| {
1947 let worktree = project.worktrees(cx).next().unwrap();
1948 let worktree_root = worktree.read(cx).abs_path();
1949 worktree_root.join("large_file.txt")
1950 });
1951 let large_file_task = message_editor.update(cx, |editor, cx| {
1952 editor.mention_set().update(cx, |set, cx| {
1953 set.confirm_mention_for_file(large_file_abs_path, true, cx)
1954 })
1955 });
1956
1957 let large_file_mention = large_file_task.await.unwrap();
1958 match large_file_mention {
1959 Mention::Text { content, .. } => {
1960 // Should contain some of the content but not all of it
1961 assert!(
1962 content.contains(LINE),
1963 "Should contain some of the file content"
1964 );
1965 assert!(
1966 !content.contains(&LINE.repeat(100)),
1967 "Should not contain the full file"
1968 );
1969 // Should be much smaller than original
1970 assert!(
1971 content.len() < large_content.len() / 10,
1972 "Should be significantly truncated"
1973 );
1974 }
1975 _ => panic!("Expected Text mention for large file"),
1976 }
1977
1978 // Test small file mention
1979 // Get the absolute path using the project's worktree
1980 let small_file_abs_path = project.read_with(cx, |project, cx| {
1981 let worktree = project.worktrees(cx).next().unwrap();
1982 let worktree_root = worktree.read(cx).abs_path();
1983 worktree_root.join("small_file.txt")
1984 });
1985 let small_file_task = message_editor.update(cx, |editor, cx| {
1986 editor.mention_set().update(cx, |set, cx| {
1987 set.confirm_mention_for_file(small_file_abs_path, true, cx)
1988 })
1989 });
1990
1991 let small_file_mention = small_file_task.await.unwrap();
1992 match small_file_mention {
1993 Mention::Text { content, .. } => {
1994 // Should contain the full actual content
1995 assert_eq!(content, small_content);
1996 }
1997 _ => panic!("Expected Text mention for small file"),
1998 }
1999 }
2000
2001 #[gpui::test]
2002 async fn test_insert_thread_summary(cx: &mut TestAppContext) {
2003 init_test(cx);
2004 cx.update(LanguageModelRegistry::test);
2005
2006 let fs = FakeFs::new(cx.executor());
2007 fs.insert_tree("/project", json!({"file": ""})).await;
2008 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
2009
2010 let (workspace, cx) =
2011 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
2012
2013 let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx));
2014 let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx));
2015
2016 // Create a thread metadata to insert as summary
2017 let thread_metadata = agent::DbThreadMetadata {
2018 id: acp::SessionId::new("thread-123"),
2019 title: "Previous Conversation".into(),
2020 updated_at: chrono::Utc::now(),
2021 };
2022
2023 let message_editor = cx.update(|window, cx| {
2024 cx.new(|cx| {
2025 let mut editor = MessageEditor::new(
2026 workspace.downgrade(),
2027 project.clone(),
2028 history_store.clone(),
2029 None,
2030 Default::default(),
2031 Default::default(),
2032 "Test Agent".into(),
2033 "Test",
2034 EditorMode::AutoHeight {
2035 min_lines: 1,
2036 max_lines: None,
2037 },
2038 window,
2039 cx,
2040 );
2041 editor.insert_thread_summary(thread_metadata.clone(), window, cx);
2042 editor
2043 })
2044 });
2045
2046 // Construct expected values for verification
2047 let expected_uri = MentionUri::Thread {
2048 id: thread_metadata.id.clone(),
2049 name: thread_metadata.title.to_string(),
2050 };
2051 let expected_link = format!("[@{}]({})", thread_metadata.title, expected_uri.to_uri());
2052
2053 message_editor.read_with(cx, |editor, cx| {
2054 let text = editor.text(cx);
2055
2056 assert!(
2057 text.contains(&expected_link),
2058 "Expected editor text to contain thread mention link.\nExpected substring: {}\nActual text: {}",
2059 expected_link,
2060 text
2061 );
2062
2063 let mentions = editor.mention_set().read(cx).mentions();
2064 assert_eq!(
2065 mentions.len(),
2066 1,
2067 "Expected exactly one mention after inserting thread summary"
2068 );
2069
2070 assert!(
2071 mentions.contains(&expected_uri),
2072 "Expected mentions to contain the thread URI"
2073 );
2074 });
2075 }
2076
2077 #[gpui::test]
2078 async fn test_whitespace_trimming(cx: &mut TestAppContext) {
2079 init_test(cx);
2080
2081 let fs = FakeFs::new(cx.executor());
2082 fs.insert_tree("/project", json!({"file.rs": "fn main() {}"}))
2083 .await;
2084 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
2085
2086 let (workspace, cx) =
2087 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
2088
2089 let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx));
2090 let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx));
2091
2092 let message_editor = cx.update(|window, cx| {
2093 cx.new(|cx| {
2094 MessageEditor::new(
2095 workspace.downgrade(),
2096 project.clone(),
2097 history_store.clone(),
2098 None,
2099 Default::default(),
2100 Default::default(),
2101 "Test Agent".into(),
2102 "Test",
2103 EditorMode::AutoHeight {
2104 min_lines: 1,
2105 max_lines: None,
2106 },
2107 window,
2108 cx,
2109 )
2110 })
2111 });
2112 let editor = message_editor.update(cx, |message_editor, _| message_editor.editor.clone());
2113
2114 cx.run_until_parked();
2115
2116 editor.update_in(cx, |editor, window, cx| {
2117 editor.set_text(" \u{A0}してhello world ", window, cx);
2118 });
2119
2120 let (content, _) = message_editor
2121 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
2122 .await
2123 .unwrap();
2124
2125 assert_eq!(content, vec!["してhello world".into()]);
2126 }
2127
2128 #[gpui::test]
2129 async fn test_editor_respects_embedded_context_capability(cx: &mut TestAppContext) {
2130 init_test(cx);
2131
2132 let fs = FakeFs::new(cx.executor());
2133
2134 let file_content = "fn main() { println!(\"Hello, world!\"); }\n";
2135
2136 fs.insert_tree(
2137 "/project",
2138 json!({
2139 "src": {
2140 "main.rs": file_content,
2141 }
2142 }),
2143 )
2144 .await;
2145
2146 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
2147
2148 let (workspace, cx) =
2149 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
2150
2151 let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx));
2152 let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx));
2153
2154 let (message_editor, editor) = workspace.update_in(cx, |workspace, window, cx| {
2155 let workspace_handle = cx.weak_entity();
2156 let message_editor = cx.new(|cx| {
2157 MessageEditor::new(
2158 workspace_handle,
2159 project.clone(),
2160 history_store.clone(),
2161 None,
2162 Default::default(),
2163 Default::default(),
2164 "Test Agent".into(),
2165 "Test",
2166 EditorMode::AutoHeight {
2167 max_lines: None,
2168 min_lines: 1,
2169 },
2170 window,
2171 cx,
2172 )
2173 });
2174 workspace.active_pane().update(cx, |pane, cx| {
2175 pane.add_item(
2176 Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
2177 true,
2178 true,
2179 None,
2180 window,
2181 cx,
2182 );
2183 });
2184 message_editor.read(cx).focus_handle(cx).focus(window);
2185 let editor = message_editor.read(cx).editor().clone();
2186 (message_editor, editor)
2187 });
2188
2189 cx.simulate_input("What is in @file main");
2190
2191 editor.update_in(cx, |editor, window, cx| {
2192 assert!(editor.has_visible_completions_menu());
2193 assert_eq!(editor.text(cx), "What is in @file main");
2194 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2195 });
2196
2197 let content = message_editor
2198 .update(cx, |editor, cx| editor.contents(false, cx))
2199 .await
2200 .unwrap()
2201 .0;
2202
2203 let main_rs_uri = if cfg!(windows) {
2204 "file:///C:/project/src/main.rs"
2205 } else {
2206 "file:///project/src/main.rs"
2207 };
2208
2209 // When embedded context is `false` we should get a resource link
2210 pretty_assertions::assert_eq!(
2211 content,
2212 vec![
2213 "What is in ".into(),
2214 acp::ContentBlock::ResourceLink(acp::ResourceLink::new("main.rs", main_rs_uri))
2215 ]
2216 );
2217
2218 message_editor.update(cx, |editor, _cx| {
2219 editor
2220 .prompt_capabilities
2221 .replace(acp::PromptCapabilities::new().embedded_context(true))
2222 });
2223
2224 let content = message_editor
2225 .update(cx, |editor, cx| editor.contents(false, cx))
2226 .await
2227 .unwrap()
2228 .0;
2229
2230 // When embedded context is `true` we should get a resource
2231 pretty_assertions::assert_eq!(
2232 content,
2233 vec![
2234 "What is in ".into(),
2235 acp::ContentBlock::Resource(acp::EmbeddedResource::new(
2236 acp::EmbeddedResourceResource::TextResourceContents(
2237 acp::TextResourceContents::new(file_content, main_rs_uri)
2238 )
2239 ))
2240 ]
2241 );
2242 }
2243
2244 #[gpui::test]
2245 async fn test_autoscroll_after_insert_selections(cx: &mut TestAppContext) {
2246 init_test(cx);
2247
2248 let app_state = cx.update(AppState::test);
2249
2250 cx.update(|cx| {
2251 editor::init(cx);
2252 workspace::init(app_state.clone(), cx);
2253 });
2254
2255 app_state
2256 .fs
2257 .as_fake()
2258 .insert_tree(
2259 path!("/dir"),
2260 json!({
2261 "test.txt": "line1\nline2\nline3\nline4\nline5\n",
2262 }),
2263 )
2264 .await;
2265
2266 let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
2267 let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
2268 let workspace = window.root(cx).unwrap();
2269
2270 let worktree = project.update(cx, |project, cx| {
2271 let mut worktrees = project.worktrees(cx).collect::<Vec<_>>();
2272 assert_eq!(worktrees.len(), 1);
2273 worktrees.pop().unwrap()
2274 });
2275 let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id());
2276
2277 let mut cx = VisualTestContext::from_window(*window, cx);
2278
2279 // Open a regular editor with the created file, and select a portion of
2280 // the text that will be used for the selections that are meant to be
2281 // inserted in the agent panel.
2282 let editor = workspace
2283 .update_in(&mut cx, |workspace, window, cx| {
2284 workspace.open_path(
2285 ProjectPath {
2286 worktree_id,
2287 path: rel_path("test.txt").into(),
2288 },
2289 None,
2290 false,
2291 window,
2292 cx,
2293 )
2294 })
2295 .await
2296 .unwrap()
2297 .downcast::<Editor>()
2298 .unwrap();
2299
2300 editor.update_in(&mut cx, |editor, window, cx| {
2301 editor.change_selections(Default::default(), window, cx, |selections| {
2302 selections.select_ranges([Point::new(0, 0)..Point::new(0, 5)]);
2303 });
2304 });
2305
2306 let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx));
2307 let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx));
2308
2309 // Create a new `MessageEditor`. The `EditorMode::full()` has to be used
2310 // to ensure we have a fixed viewport, so we can eventually actually
2311 // place the cursor outside of the visible area.
2312 let message_editor = workspace.update_in(&mut cx, |workspace, window, cx| {
2313 let workspace_handle = cx.weak_entity();
2314 let message_editor = cx.new(|cx| {
2315 MessageEditor::new(
2316 workspace_handle,
2317 project.clone(),
2318 history_store.clone(),
2319 None,
2320 Default::default(),
2321 Default::default(),
2322 "Test Agent".into(),
2323 "Test",
2324 EditorMode::full(),
2325 window,
2326 cx,
2327 )
2328 });
2329 workspace.active_pane().update(cx, |pane, cx| {
2330 pane.add_item(
2331 Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
2332 true,
2333 true,
2334 None,
2335 window,
2336 cx,
2337 );
2338 });
2339
2340 message_editor
2341 });
2342
2343 message_editor.update_in(&mut cx, |message_editor, window, cx| {
2344 message_editor.editor.update(cx, |editor, cx| {
2345 // Update the Agent Panel's Message Editor text to have 100
2346 // lines, ensuring that the cursor is set at line 90 and that we
2347 // then scroll all the way to the top, so the cursor's position
2348 // remains off screen.
2349 let mut lines = String::new();
2350 for _ in 1..=100 {
2351 lines.push_str(&"Another line in the agent panel's message editor\n");
2352 }
2353 editor.set_text(lines.as_str(), window, cx);
2354 editor.change_selections(Default::default(), window, cx, |selections| {
2355 selections.select_ranges([Point::new(90, 0)..Point::new(90, 0)]);
2356 });
2357 editor.set_scroll_position(gpui::Point::new(0., 0.), window, cx);
2358 });
2359 });
2360
2361 cx.run_until_parked();
2362
2363 // Before proceeding, let's assert that the cursor is indeed off screen,
2364 // otherwise the rest of the test doesn't make sense.
2365 message_editor.update_in(&mut cx, |message_editor, window, cx| {
2366 message_editor.editor.update(cx, |editor, cx| {
2367 let snapshot = editor.snapshot(window, cx);
2368 let cursor_row = editor.selections.newest::<Point>(&snapshot).head().row;
2369 let scroll_top = snapshot.scroll_position().y as u32;
2370 let visible_lines = editor.visible_line_count().unwrap() as u32;
2371 let visible_range = scroll_top..(scroll_top + visible_lines);
2372
2373 assert!(!visible_range.contains(&cursor_row));
2374 })
2375 });
2376
2377 // Now let's insert the selection in the Agent Panel's editor and
2378 // confirm that, after the insertion, the cursor is now in the visible
2379 // range.
2380 message_editor.update_in(&mut cx, |message_editor, window, cx| {
2381 message_editor.insert_selections(window, cx);
2382 });
2383
2384 cx.run_until_parked();
2385
2386 message_editor.update_in(&mut cx, |message_editor, window, cx| {
2387 message_editor.editor.update(cx, |editor, cx| {
2388 let snapshot = editor.snapshot(window, cx);
2389 let cursor_row = editor.selections.newest::<Point>(&snapshot).head().row;
2390 let scroll_top = snapshot.scroll_position().y as u32;
2391 let visible_lines = editor.visible_line_count().unwrap() as u32;
2392 let visible_range = scroll_top..(scroll_top + visible_lines);
2393
2394 assert!(visible_range.contains(&cursor_row));
2395 })
2396 });
2397 }
2398}