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