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