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