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