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