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