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