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