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