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