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