1use crate::SendImmediately;
2use crate::acp::AcpThreadHistory;
3use crate::{
4 ChatWithFollow,
5 completion_provider::{
6 PromptCompletionProvider, PromptCompletionProviderDelegate, PromptContextAction,
7 PromptContextType, SlashCommandCompletion,
8 },
9 mention_set::{
10 Mention, MentionImage, MentionSet, insert_crease_for_mention, paste_images_as_context,
11 },
12};
13use acp_thread::{AgentSessionInfo, MentionUri};
14use agent::ThreadStore;
15use agent_client_protocol as acp;
16use anyhow::{Result, anyhow};
17use collections::HashSet;
18use editor::{
19 Addon, AnchorRangeExt, ContextMenuOptions, ContextMenuPlacement, Editor, EditorElement,
20 EditorEvent, EditorMode, EditorStyle, Inlay, MultiBuffer, MultiBufferOffset,
21 MultiBufferSnapshot, ToOffset, actions::Paste, code_context_menus::CodeContextMenu,
22 scroll::Autoscroll,
23};
24use futures::{FutureExt as _, future::join_all};
25use gpui::{
26 AppContext, ClipboardEntry, Context, Entity, EventEmitter, FocusHandle, Focusable, ImageFormat,
27 KeyContext, SharedString, Subscription, Task, TextStyle, WeakEntity,
28};
29use language::{Buffer, Language, language_settings::InlayHintKind};
30use project::{CompletionIntent, InlayHint, InlayHintLabel, InlayId, Project, Worktree};
31use prompt_store::PromptStore;
32use rope::Point;
33use settings::Settings;
34use std::{cell::RefCell, fmt::Write, ops::Range, rc::Rc, sync::Arc};
35use theme::ThemeSettings;
36use ui::{ButtonLike, ButtonStyle, ContextMenu, Disclosure, ElevationIndex, prelude::*};
37use util::paths::PathStyle;
38use util::{ResultExt, debug_panic};
39use workspace::{CollaboratorId, Workspace};
40use zed_actions::agent::{Chat, PasteRaw};
41
42pub struct MessageEditor {
43 mention_set: Entity<MentionSet>,
44 editor: Entity<Editor>,
45 workspace: WeakEntity<Workspace>,
46 prompt_capabilities: Rc<RefCell<acp::PromptCapabilities>>,
47 available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
48 agent_name: SharedString,
49 thread_store: Option<Entity<ThreadStore>>,
50 _subscriptions: Vec<Subscription>,
51 _parse_slash_command_task: Task<()>,
52}
53
54#[derive(Clone, Copy, Debug)]
55pub enum MessageEditorEvent {
56 Send,
57 SendImmediately,
58 Cancel,
59 Focus,
60 LostFocus,
61}
62
63impl EventEmitter<MessageEditorEvent> for MessageEditor {}
64
65const COMMAND_HINT_INLAY_ID: InlayId = InlayId::Hint(0);
66
67impl PromptCompletionProviderDelegate for Entity<MessageEditor> {
68 fn supports_images(&self, cx: &App) -> bool {
69 self.read(cx).prompt_capabilities.borrow().image
70 }
71
72 fn supported_modes(&self, cx: &App) -> Vec<PromptContextType> {
73 let mut supported = vec![PromptContextType::File, PromptContextType::Symbol];
74 if self.read(cx).prompt_capabilities.borrow().embedded_context {
75 if self.read(cx).thread_store.is_some() {
76 supported.push(PromptContextType::Thread);
77 }
78 supported.extend(&[
79 PromptContextType::Diagnostics,
80 PromptContextType::Fetch,
81 PromptContextType::Rules,
82 ]);
83 }
84 supported
85 }
86
87 fn available_commands(&self, cx: &App) -> Vec<crate::completion_provider::AvailableCommand> {
88 self.read(cx)
89 .available_commands
90 .borrow()
91 .iter()
92 .map(|cmd| crate::completion_provider::AvailableCommand {
93 name: cmd.name.clone().into(),
94 description: cmd.description.clone().into(),
95 requires_argument: cmd.input.is_some(),
96 })
97 .collect()
98 }
99
100 fn confirm_command(&self, cx: &mut App) {
101 self.update(cx, |this, cx| this.send(cx));
102 }
103}
104
105impl MessageEditor {
106 pub fn new(
107 workspace: WeakEntity<Workspace>,
108 project: WeakEntity<Project>,
109 thread_store: Option<Entity<ThreadStore>>,
110 history: WeakEntity<AcpThreadHistory>,
111 prompt_store: Option<Entity<PromptStore>>,
112 prompt_capabilities: Rc<RefCell<acp::PromptCapabilities>>,
113 available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
114 agent_name: SharedString,
115 placeholder: &str,
116 mode: EditorMode,
117 window: &mut Window,
118 cx: &mut Context<Self>,
119 ) -> Self {
120 let language = Language::new(
121 language::LanguageConfig {
122 completion_query_characters: HashSet::from_iter(['.', '-', '_', '@']),
123 ..Default::default()
124 },
125 None,
126 );
127
128 let editor = cx.new(|cx| {
129 let buffer = cx.new(|cx| Buffer::local("", cx).with_language(Arc::new(language), cx));
130 let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
131
132 let mut editor = Editor::new(mode, buffer, None, window, cx);
133 editor.set_placeholder_text(placeholder, window, cx);
134 editor.set_show_indent_guides(false, cx);
135 editor.set_show_completions_on_input(Some(true));
136 editor.set_soft_wrap();
137 editor.set_use_modal_editing(true);
138 editor.set_context_menu_options(ContextMenuOptions {
139 min_entries_visible: 12,
140 max_entries_visible: 12,
141 placement: Some(ContextMenuPlacement::Above),
142 });
143 editor.register_addon(MessageEditorAddon::new());
144
145 editor.set_custom_context_menu(|editor, _point, window, cx| {
146 let has_selection = editor.has_non_empty_selection(&editor.display_snapshot(cx));
147
148 Some(ContextMenu::build(window, cx, |menu, _, _| {
149 menu.action("Cut", Box::new(editor::actions::Cut))
150 .action_disabled_when(
151 !has_selection,
152 "Copy",
153 Box::new(editor::actions::Copy),
154 )
155 .action("Paste", Box::new(editor::actions::Paste))
156 }))
157 });
158
159 editor
160 });
161 let mention_set =
162 cx.new(|_cx| MentionSet::new(project, thread_store.clone(), prompt_store.clone()));
163 let completion_provider = Rc::new(PromptCompletionProvider::new(
164 cx.entity(),
165 editor.downgrade(),
166 mention_set.clone(),
167 history,
168 prompt_store.clone(),
169 workspace.clone(),
170 ));
171 editor.update(cx, |editor, _cx| {
172 editor.set_completion_provider(Some(completion_provider.clone()))
173 });
174
175 cx.on_focus_in(&editor.focus_handle(cx), window, |_, _, cx| {
176 cx.emit(MessageEditorEvent::Focus)
177 })
178 .detach();
179 cx.on_focus_out(&editor.focus_handle(cx), window, |_, _, _, cx| {
180 cx.emit(MessageEditorEvent::LostFocus)
181 })
182 .detach();
183
184 let mut has_hint = false;
185 let mut subscriptions = Vec::new();
186
187 subscriptions.push(cx.subscribe_in(&editor, window, {
188 move |this, editor, event, window, cx| {
189 if let EditorEvent::Edited { .. } = event
190 && !editor.read(cx).read_only(cx)
191 {
192 editor.update(cx, |editor, cx| {
193 let snapshot = editor.snapshot(window, cx);
194 this.mention_set
195 .update(cx, |mention_set, _cx| mention_set.remove_invalid(&snapshot));
196
197 let new_hints = this
198 .command_hint(snapshot.buffer())
199 .into_iter()
200 .collect::<Vec<_>>();
201 let has_new_hint = !new_hints.is_empty();
202 editor.splice_inlays(
203 if has_hint {
204 &[COMMAND_HINT_INLAY_ID]
205 } else {
206 &[]
207 },
208 new_hints,
209 cx,
210 );
211 has_hint = has_new_hint;
212 });
213 cx.notify();
214 }
215 }
216 }));
217
218 Self {
219 editor,
220 mention_set,
221 workspace,
222 prompt_capabilities,
223 available_commands,
224 agent_name,
225 thread_store,
226 _subscriptions: subscriptions,
227 _parse_slash_command_task: Task::ready(()),
228 }
229 }
230
231 pub fn set_command_state(
232 &mut self,
233 prompt_capabilities: Rc<RefCell<acp::PromptCapabilities>>,
234 available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
235 _cx: &mut Context<Self>,
236 ) {
237 self.prompt_capabilities = prompt_capabilities;
238 self.available_commands = available_commands;
239 }
240
241 fn command_hint(&self, snapshot: &MultiBufferSnapshot) -> Option<Inlay> {
242 let available_commands = self.available_commands.borrow();
243 if available_commands.is_empty() {
244 return None;
245 }
246
247 let parsed_command = SlashCommandCompletion::try_parse(&snapshot.text(), 0)?;
248 if parsed_command.argument.is_some() {
249 return None;
250 }
251
252 let command_name = parsed_command.command?;
253 let available_command = available_commands
254 .iter()
255 .find(|command| command.name == command_name)?;
256
257 let acp::AvailableCommandInput::Unstructured(acp::UnstructuredCommandInput {
258 mut hint,
259 ..
260 }) = available_command.input.clone()?
261 else {
262 return None;
263 };
264
265 let mut hint_pos = MultiBufferOffset(parsed_command.source_range.end) + 1usize;
266 if hint_pos > snapshot.len() {
267 hint_pos = snapshot.len();
268 hint.insert(0, ' ');
269 }
270
271 let hint_pos = snapshot.anchor_after(hint_pos);
272
273 Some(Inlay::hint(
274 COMMAND_HINT_INLAY_ID,
275 hint_pos,
276 &InlayHint {
277 position: hint_pos.text_anchor,
278 label: InlayHintLabel::String(hint),
279 kind: Some(InlayHintKind::Parameter),
280 padding_left: false,
281 padding_right: false,
282 tooltip: None,
283 resolve_state: project::ResolveState::Resolved,
284 },
285 ))
286 }
287
288 pub fn insert_thread_summary(
289 &mut self,
290 thread: AgentSessionInfo,
291 window: &mut Window,
292 cx: &mut Context<Self>,
293 ) {
294 if self.thread_store.is_none() {
295 return;
296 }
297 let Some(workspace) = self.workspace.upgrade() else {
298 return;
299 };
300 let thread_title = thread
301 .title
302 .clone()
303 .filter(|title| !title.is_empty())
304 .unwrap_or_else(|| SharedString::new_static("New Thread"));
305 let uri = MentionUri::Thread {
306 id: thread.session_id,
307 name: thread_title.to_string(),
308 };
309 let content = format!("{}\n", uri.as_link());
310
311 let content_len = content.len() - 1;
312
313 let start = self.editor.update(cx, |editor, cx| {
314 editor.set_text(content, window, cx);
315 editor
316 .buffer()
317 .read(cx)
318 .snapshot(cx)
319 .anchor_before(Point::zero())
320 .text_anchor
321 });
322
323 let supports_images = self.prompt_capabilities.borrow().image;
324
325 self.mention_set
326 .update(cx, |mention_set, cx| {
327 mention_set.confirm_mention_completion(
328 thread_title,
329 start,
330 content_len,
331 uri,
332 supports_images,
333 self.editor.clone(),
334 &workspace,
335 window,
336 cx,
337 )
338 })
339 .detach();
340 }
341
342 #[cfg(test)]
343 pub(crate) fn editor(&self) -> &Entity<Editor> {
344 &self.editor
345 }
346
347 pub fn is_empty(&self, cx: &App) -> bool {
348 self.editor.read(cx).is_empty(cx)
349 }
350
351 pub fn is_completions_menu_visible(&self, cx: &App) -> bool {
352 self.editor
353 .read(cx)
354 .context_menu()
355 .borrow()
356 .as_ref()
357 .is_some_and(|menu| matches!(menu, CodeContextMenu::Completions(_)) && menu.visible())
358 }
359
360 #[cfg(test)]
361 pub fn mention_set(&self) -> &Entity<MentionSet> {
362 &self.mention_set
363 }
364
365 fn validate_slash_commands(
366 text: &str,
367 available_commands: &[acp::AvailableCommand],
368 agent_name: &str,
369 ) -> Result<()> {
370 if let Some(parsed_command) = SlashCommandCompletion::try_parse(text, 0) {
371 if let Some(command_name) = parsed_command.command {
372 // Check if this command is in the list of available commands from the server
373 let is_supported = available_commands
374 .iter()
375 .any(|cmd| cmd.name == command_name);
376
377 if !is_supported {
378 return Err(anyhow!(
379 "The /{} command is not supported by {}.\n\nAvailable commands: {}",
380 command_name,
381 agent_name,
382 if available_commands.is_empty() {
383 "none".to_string()
384 } else {
385 available_commands
386 .iter()
387 .map(|cmd| format!("/{}", cmd.name))
388 .collect::<Vec<_>>()
389 .join(", ")
390 }
391 ));
392 }
393 }
394 }
395 Ok(())
396 }
397
398 pub fn contents(
399 &self,
400 full_mention_content: bool,
401 cx: &mut Context<Self>,
402 ) -> Task<Result<(Vec<acp::ContentBlock>, Vec<Entity<Buffer>>)>> {
403 let text = self.editor.read(cx).text(cx);
404 let available_commands = self.available_commands.borrow().clone();
405 let agent_name = self.agent_name.clone();
406
407 let contents = self
408 .mention_set
409 .update(cx, |store, cx| store.contents(full_mention_content, cx));
410 let editor = self.editor.clone();
411 let supports_embedded_context = self.prompt_capabilities.borrow().embedded_context;
412
413 cx.spawn(async move |_, cx| {
414 Self::validate_slash_commands(&text, &available_commands, &agent_name)?;
415
416 let contents = contents.await?;
417 let mut all_tracked_buffers = Vec::new();
418
419 let result = editor.update(cx, |editor, cx| {
420 let (mut ix, _) = text
421 .char_indices()
422 .find(|(_, c)| !c.is_whitespace())
423 .unwrap_or((0, '\0'));
424 let mut chunks: Vec<acp::ContentBlock> = Vec::new();
425 let text = editor.text(cx);
426 editor.display_map.update(cx, |map, cx| {
427 let snapshot = map.snapshot(cx);
428 for (crease_id, crease) in snapshot.crease_snapshot.creases() {
429 let Some((uri, mention)) = contents.get(&crease_id) else {
430 continue;
431 };
432
433 let crease_range = crease.range().to_offset(&snapshot.buffer_snapshot());
434 if crease_range.start.0 > ix {
435 let chunk = text[ix..crease_range.start.0].into();
436 chunks.push(chunk);
437 }
438 let chunk = match mention {
439 Mention::Text {
440 content,
441 tracked_buffers,
442 } => {
443 all_tracked_buffers.extend(tracked_buffers.iter().cloned());
444 if supports_embedded_context {
445 acp::ContentBlock::Resource(acp::EmbeddedResource::new(
446 acp::EmbeddedResourceResource::TextResourceContents(
447 acp::TextResourceContents::new(
448 content.clone(),
449 uri.to_uri().to_string(),
450 ),
451 ),
452 ))
453 } else {
454 acp::ContentBlock::ResourceLink(acp::ResourceLink::new(
455 uri.name(),
456 uri.to_uri().to_string(),
457 ))
458 }
459 }
460 Mention::Image(mention_image) => acp::ContentBlock::Image(
461 acp::ImageContent::new(
462 mention_image.data.clone(),
463 mention_image.format.mime_type(),
464 )
465 .uri(match uri {
466 MentionUri::File { .. } => Some(uri.to_uri().to_string()),
467 MentionUri::PastedImage => None,
468 other => {
469 debug_panic!(
470 "unexpected mention uri for image: {:?}",
471 other
472 );
473 None
474 }
475 }),
476 ),
477 Mention::Link => acp::ContentBlock::ResourceLink(
478 acp::ResourceLink::new(uri.name(), uri.to_uri().to_string()),
479 ),
480 };
481 chunks.push(chunk);
482 ix = crease_range.end.0;
483 }
484
485 if ix < text.len() {
486 let last_chunk = text[ix..].trim_end().to_owned();
487 if !last_chunk.is_empty() {
488 chunks.push(last_chunk.into());
489 }
490 }
491 });
492 anyhow::Ok((chunks, all_tracked_buffers))
493 })?;
494 Ok(result)
495 })
496 }
497
498 pub fn clear(&mut self, window: &mut Window, cx: &mut Context<Self>) {
499 self.editor.update(cx, |editor, cx| {
500 editor.clear(window, cx);
501 editor.remove_creases(
502 self.mention_set.update(cx, |mention_set, _cx| {
503 mention_set
504 .clear()
505 .map(|(crease_id, _)| crease_id)
506 .collect::<Vec<_>>()
507 }),
508 cx,
509 )
510 });
511 }
512
513 pub fn send(&mut self, cx: &mut Context<Self>) {
514 if !self.is_empty(cx) {
515 self.editor.update(cx, |editor, cx| {
516 editor.clear_inlay_hints(cx);
517 });
518 }
519 cx.emit(MessageEditorEvent::Send)
520 }
521
522 pub fn trigger_completion_menu(&mut self, window: &mut Window, cx: &mut Context<Self>) {
523 self.insert_context_prefix("@", window, cx);
524 }
525
526 pub fn insert_context_type(
527 &mut self,
528 context_keyword: &str,
529 window: &mut Window,
530 cx: &mut Context<Self>,
531 ) {
532 let prefix = format!("@{}", context_keyword);
533 self.insert_context_prefix(&prefix, window, cx);
534 }
535
536 fn insert_context_prefix(&mut self, prefix: &str, window: &mut Window, cx: &mut Context<Self>) {
537 let editor = self.editor.clone();
538 let prefix = prefix.to_string();
539
540 cx.spawn_in(window, async move |_, cx| {
541 editor
542 .update_in(cx, |editor, window, cx| {
543 let menu_is_open =
544 editor.context_menu().borrow().as_ref().is_some_and(|menu| {
545 matches!(menu, CodeContextMenu::Completions(_)) && menu.visible()
546 });
547
548 let has_prefix = {
549 let snapshot = editor.display_snapshot(cx);
550 let cursor = editor.selections.newest::<text::Point>(&snapshot).head();
551 let offset = cursor.to_offset(&snapshot);
552 let buffer_snapshot = snapshot.buffer_snapshot();
553 let prefix_char_count = prefix.chars().count();
554 buffer_snapshot
555 .reversed_chars_at(offset)
556 .take(prefix_char_count)
557 .eq(prefix.chars().rev())
558 };
559
560 if menu_is_open && has_prefix {
561 return;
562 }
563
564 editor.insert(&prefix, window, cx);
565 editor.show_completions(&editor::actions::ShowCompletions, window, cx);
566 })
567 .log_err();
568 })
569 .detach();
570 }
571
572 fn chat(&mut self, _: &Chat, _: &mut Window, cx: &mut Context<Self>) {
573 self.send(cx);
574 }
575
576 fn send_immediately(&mut self, _: &SendImmediately, _: &mut Window, cx: &mut Context<Self>) {
577 if self.is_empty(cx) {
578 return;
579 }
580
581 self.editor.update(cx, |editor, cx| {
582 editor.clear_inlay_hints(cx);
583 });
584
585 cx.emit(MessageEditorEvent::SendImmediately)
586 }
587
588 fn chat_with_follow(
589 &mut self,
590 _: &ChatWithFollow,
591 window: &mut Window,
592 cx: &mut Context<Self>,
593 ) {
594 self.workspace
595 .update(cx, |this, cx| {
596 this.follow(CollaboratorId::Agent, window, cx)
597 })
598 .log_err();
599
600 self.send(cx);
601 }
602
603 fn cancel(&mut self, _: &editor::actions::Cancel, _: &mut Window, cx: &mut Context<Self>) {
604 cx.emit(MessageEditorEvent::Cancel)
605 }
606
607 fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context<Self>) {
608 let Some(workspace) = self.workspace.upgrade() else {
609 return;
610 };
611 let editor_clipboard_selections = cx
612 .read_from_clipboard()
613 .and_then(|item| item.entries().first().cloned())
614 .and_then(|entry| match entry {
615 ClipboardEntry::String(text) => {
616 text.metadata_json::<Vec<editor::ClipboardSelection>>()
617 }
618 _ => None,
619 });
620
621 // Insert creases for pasted clipboard selections that:
622 // 1. Contain exactly one selection
623 // 2. Have an associated file path
624 // 3. Span multiple lines (not single-line selections)
625 // 4. Belong to a file that exists in the current project
626 let should_insert_creases = util::maybe!({
627 let selections = editor_clipboard_selections.as_ref()?;
628 if selections.len() > 1 {
629 return Some(false);
630 }
631 let selection = selections.first()?;
632 let file_path = selection.file_path.as_ref()?;
633 let line_range = selection.line_range.as_ref()?;
634
635 if line_range.start() == line_range.end() {
636 return Some(false);
637 }
638
639 Some(
640 workspace
641 .read(cx)
642 .project()
643 .read(cx)
644 .project_path_for_absolute_path(file_path, cx)
645 .is_some(),
646 )
647 })
648 .unwrap_or(false);
649
650 if should_insert_creases && let Some(selections) = editor_clipboard_selections {
651 cx.stop_propagation();
652 let insertion_target = self
653 .editor
654 .read(cx)
655 .selections
656 .newest_anchor()
657 .start
658 .text_anchor;
659
660 let project = workspace.read(cx).project().clone();
661 for selection in selections {
662 if let (Some(file_path), Some(line_range)) =
663 (selection.file_path, selection.line_range)
664 {
665 let crease_text =
666 acp_thread::selection_name(Some(file_path.as_ref()), &line_range);
667
668 let mention_uri = MentionUri::Selection {
669 abs_path: Some(file_path.clone()),
670 line_range: line_range.clone(),
671 };
672
673 let mention_text = mention_uri.as_link().to_string();
674 let (excerpt_id, text_anchor, content_len) =
675 self.editor.update(cx, |editor, cx| {
676 let buffer = editor.buffer().read(cx);
677 let snapshot = buffer.snapshot(cx);
678 let (excerpt_id, _, buffer_snapshot) = snapshot.as_singleton().unwrap();
679 let text_anchor = insertion_target.bias_left(&buffer_snapshot);
680
681 editor.insert(&mention_text, window, cx);
682 editor.insert(" ", window, cx);
683
684 (excerpt_id, text_anchor, mention_text.len())
685 });
686
687 let Some((crease_id, tx)) = insert_crease_for_mention(
688 excerpt_id,
689 text_anchor,
690 content_len,
691 crease_text.into(),
692 mention_uri.icon_path(cx),
693 None,
694 self.editor.clone(),
695 window,
696 cx,
697 ) else {
698 continue;
699 };
700 drop(tx);
701
702 let mention_task = cx
703 .spawn({
704 let project = project.clone();
705 async move |_, cx| {
706 let project_path = project
707 .update(cx, |project, cx| {
708 project.project_path_for_absolute_path(&file_path, cx)
709 })
710 .ok_or_else(|| "project path not found".to_string())?;
711
712 let buffer = project
713 .update(cx, |project, cx| project.open_buffer(project_path, cx))
714 .await
715 .map_err(|e| e.to_string())?;
716
717 Ok(buffer.update(cx, |buffer, cx| {
718 let start =
719 Point::new(*line_range.start(), 0).min(buffer.max_point());
720 let end = Point::new(*line_range.end() + 1, 0)
721 .min(buffer.max_point());
722 let content = buffer.text_for_range(start..end).collect();
723 Mention::Text {
724 content,
725 tracked_buffers: vec![cx.entity()],
726 }
727 }))
728 }
729 })
730 .shared();
731
732 self.mention_set.update(cx, |mention_set, _cx| {
733 mention_set.insert_mention(crease_id, mention_uri.clone(), mention_task)
734 });
735 }
736 }
737 return;
738 }
739 // Handle text paste with potential markdown mention links.
740 // This must be checked BEFORE paste_images_as_context because that function
741 // returns a task even when there are no images in the clipboard.
742 if let Some(clipboard_text) = cx
743 .read_from_clipboard()
744 .and_then(|item| item.entries().first().cloned())
745 .and_then(|entry| match entry {
746 ClipboardEntry::String(text) => Some(text.text().to_string()),
747 _ => None,
748 })
749 {
750 if clipboard_text.contains("[@") {
751 cx.stop_propagation();
752 let selections_before = self.editor.update(cx, |editor, cx| {
753 let snapshot = editor.buffer().read(cx).snapshot(cx);
754 editor
755 .selections
756 .disjoint_anchors()
757 .iter()
758 .map(|selection| {
759 (
760 selection.start.bias_left(&snapshot),
761 selection.end.bias_right(&snapshot),
762 )
763 })
764 .collect::<Vec<_>>()
765 });
766
767 self.editor.update(cx, |editor, cx| {
768 editor.insert(&clipboard_text, window, cx);
769 });
770
771 let snapshot = self.editor.read(cx).buffer().read(cx).snapshot(cx);
772 let path_style = workspace.read(cx).project().read(cx).path_style(cx);
773
774 let mut all_mentions = Vec::new();
775 for (start_anchor, end_anchor) in selections_before {
776 let start_offset = start_anchor.to_offset(&snapshot);
777 let end_offset = end_anchor.to_offset(&snapshot);
778
779 // Get the actual inserted text from the buffer (may differ due to auto-indent)
780 let inserted_text: String =
781 snapshot.text_for_range(start_offset..end_offset).collect();
782
783 let parsed_mentions = parse_mention_links(&inserted_text, path_style);
784 for (range, mention_uri) in parsed_mentions {
785 let mention_start_offset = MultiBufferOffset(start_offset.0 + range.start);
786 let anchor = snapshot.anchor_before(mention_start_offset);
787 let content_len = range.end - range.start;
788 all_mentions.push((anchor, content_len, mention_uri));
789 }
790 }
791
792 if !all_mentions.is_empty() {
793 let supports_images = self.prompt_capabilities.borrow().image;
794 let http_client = workspace.read(cx).client().http_client();
795
796 for (anchor, content_len, mention_uri) in all_mentions {
797 let Some((crease_id, tx)) = insert_crease_for_mention(
798 anchor.excerpt_id,
799 anchor.text_anchor,
800 content_len,
801 mention_uri.name().into(),
802 mention_uri.icon_path(cx),
803 None,
804 self.editor.clone(),
805 window,
806 cx,
807 ) else {
808 continue;
809 };
810
811 // Create the confirmation task based on the mention URI type.
812 // This properly loads file content, fetches URLs, etc.
813 let task = self.mention_set.update(cx, |mention_set, cx| {
814 mention_set.confirm_mention_for_uri(
815 mention_uri.clone(),
816 supports_images,
817 http_client.clone(),
818 cx,
819 )
820 });
821 let task = cx
822 .spawn(async move |_, _| task.await.map_err(|e| e.to_string()))
823 .shared();
824
825 self.mention_set.update(cx, |mention_set, _cx| {
826 mention_set.insert_mention(crease_id, mention_uri.clone(), task.clone())
827 });
828
829 // Drop the tx after inserting to signal the crease is ready
830 drop(tx);
831 }
832 return;
833 }
834 }
835 }
836
837 if self.prompt_capabilities.borrow().image
838 && let Some(task) = paste_images_as_context(
839 self.editor.clone(),
840 self.mention_set.clone(),
841 self.workspace.clone(),
842 window,
843 cx,
844 )
845 {
846 task.detach();
847 return;
848 }
849
850 // Fall through to default editor paste
851 cx.propagate();
852 }
853
854 fn paste_raw(&mut self, _: &PasteRaw, window: &mut Window, cx: &mut Context<Self>) {
855 let editor = self.editor.clone();
856 window.defer(cx, move |window, cx| {
857 editor.update(cx, |editor, cx| editor.paste(&Paste, window, cx));
858 });
859 }
860
861 pub fn insert_dragged_files(
862 &mut self,
863 paths: Vec<project::ProjectPath>,
864 added_worktrees: Vec<Entity<Worktree>>,
865 window: &mut Window,
866 cx: &mut Context<Self>,
867 ) {
868 let Some(workspace) = self.workspace.upgrade() else {
869 return;
870 };
871 let project = workspace.read(cx).project().clone();
872 let path_style = project.read(cx).path_style(cx);
873 let buffer = self.editor.read(cx).buffer().clone();
874 let Some(buffer) = buffer.read(cx).as_singleton() else {
875 return;
876 };
877 let mut tasks = Vec::new();
878 for path in paths {
879 let Some(entry) = project.read(cx).entry_for_path(&path, cx) else {
880 continue;
881 };
882 let Some(worktree) = project.read(cx).worktree_for_id(path.worktree_id, cx) else {
883 continue;
884 };
885 let abs_path = worktree.read(cx).absolutize(&path.path);
886 let (file_name, _) = crate::completion_provider::extract_file_name_and_directory(
887 &path.path,
888 worktree.read(cx).root_name(),
889 path_style,
890 );
891
892 let uri = if entry.is_dir() {
893 MentionUri::Directory { abs_path }
894 } else {
895 MentionUri::File { abs_path }
896 };
897
898 let new_text = format!("{} ", uri.as_link());
899 let content_len = new_text.len() - 1;
900
901 let anchor = buffer.update(cx, |buffer, _cx| buffer.anchor_before(buffer.len()));
902
903 self.editor.update(cx, |message_editor, cx| {
904 message_editor.edit(
905 [(
906 multi_buffer::Anchor::max()..multi_buffer::Anchor::max(),
907 new_text,
908 )],
909 cx,
910 );
911 });
912 let supports_images = self.prompt_capabilities.borrow().image;
913 tasks.push(self.mention_set.update(cx, |mention_set, cx| {
914 mention_set.confirm_mention_completion(
915 file_name,
916 anchor,
917 content_len,
918 uri,
919 supports_images,
920 self.editor.clone(),
921 &workspace,
922 window,
923 cx,
924 )
925 }));
926 }
927 cx.spawn(async move |_, _| {
928 join_all(tasks).await;
929 drop(added_worktrees);
930 })
931 .detach();
932 }
933
934 /// Inserts code snippets as creases into the editor.
935 /// Each tuple contains (code_text, crease_title).
936 pub fn insert_code_creases(
937 &mut self,
938 creases: Vec<(String, String)>,
939 window: &mut Window,
940 cx: &mut Context<Self>,
941 ) {
942 self.editor.update(cx, |editor, cx| {
943 editor.insert("\n", window, cx);
944 });
945 for (text, crease_title) in creases {
946 self.insert_crease_impl(text, crease_title, IconName::TextSnippet, true, window, cx);
947 }
948 }
949
950 pub fn insert_terminal_crease(
951 &mut self,
952 text: String,
953 window: &mut Window,
954 cx: &mut Context<Self>,
955 ) {
956 let line_count = text.lines().count() as u32;
957 let mention_uri = MentionUri::TerminalSelection { line_count };
958 let mention_text = mention_uri.as_link().to_string();
959
960 let (excerpt_id, text_anchor, content_len) = self.editor.update(cx, |editor, cx| {
961 let buffer = editor.buffer().read(cx);
962 let snapshot = buffer.snapshot(cx);
963 let (excerpt_id, _, buffer_snapshot) = snapshot.as_singleton().unwrap();
964 let text_anchor = editor
965 .selections
966 .newest_anchor()
967 .start
968 .text_anchor
969 .bias_left(&buffer_snapshot);
970
971 editor.insert(&mention_text, window, cx);
972 editor.insert(" ", window, cx);
973
974 (excerpt_id, text_anchor, mention_text.len())
975 });
976
977 let Some((crease_id, tx)) = insert_crease_for_mention(
978 excerpt_id,
979 text_anchor,
980 content_len,
981 mention_uri.name().into(),
982 mention_uri.icon_path(cx),
983 None,
984 self.editor.clone(),
985 window,
986 cx,
987 ) else {
988 return;
989 };
990 drop(tx);
991
992 let mention_task = Task::ready(Ok(Mention::Text {
993 content: text,
994 tracked_buffers: vec![],
995 }))
996 .shared();
997
998 self.mention_set.update(cx, |mention_set, _| {
999 mention_set.insert_mention(crease_id, mention_uri, mention_task);
1000 });
1001 }
1002
1003 fn insert_crease_impl(
1004 &mut self,
1005 text: String,
1006 title: String,
1007 icon: IconName,
1008 add_trailing_newline: bool,
1009 window: &mut Window,
1010 cx: &mut Context<Self>,
1011 ) {
1012 use editor::display_map::{Crease, FoldPlaceholder};
1013 use multi_buffer::MultiBufferRow;
1014 use rope::Point;
1015
1016 self.editor.update(cx, |editor, cx| {
1017 let point = editor
1018 .selections
1019 .newest::<Point>(&editor.display_snapshot(cx))
1020 .head();
1021 let start_row = MultiBufferRow(point.row);
1022
1023 editor.insert(&text, window, cx);
1024
1025 let snapshot = editor.buffer().read(cx).snapshot(cx);
1026 let anchor_before = snapshot.anchor_after(point);
1027 let anchor_after = editor
1028 .selections
1029 .newest_anchor()
1030 .head()
1031 .bias_left(&snapshot);
1032
1033 if add_trailing_newline {
1034 editor.insert("\n", window, cx);
1035 }
1036
1037 let fold_placeholder = FoldPlaceholder {
1038 render: Arc::new({
1039 let title = title.clone();
1040 move |_fold_id, _fold_range, _cx| {
1041 ButtonLike::new("crease")
1042 .style(ButtonStyle::Filled)
1043 .layer(ElevationIndex::ElevatedSurface)
1044 .child(Icon::new(icon))
1045 .child(Label::new(title.clone()).single_line())
1046 .into_any_element()
1047 }
1048 }),
1049 merge_adjacent: false,
1050 ..Default::default()
1051 };
1052
1053 let crease = Crease::inline(
1054 anchor_before..anchor_after,
1055 fold_placeholder,
1056 |row, is_folded, fold, _window, _cx| {
1057 Disclosure::new(("crease-toggle", row.0 as u64), !is_folded)
1058 .toggle_state(is_folded)
1059 .on_click(move |_e, window, cx| fold(!is_folded, window, cx))
1060 .into_any_element()
1061 },
1062 |_, _, _, _| gpui::Empty.into_any(),
1063 );
1064 editor.insert_creases(vec![crease], cx);
1065 editor.fold_at(start_row, window, cx);
1066 });
1067 }
1068
1069 pub fn insert_selections(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1070 let editor = self.editor.read(cx);
1071 let editor_buffer = editor.buffer().read(cx);
1072 let Some(buffer) = editor_buffer.as_singleton() else {
1073 return;
1074 };
1075 let cursor_anchor = editor.selections.newest_anchor().head();
1076 let cursor_offset = cursor_anchor.to_offset(&editor_buffer.snapshot(cx));
1077 let anchor = buffer.update(cx, |buffer, _cx| {
1078 buffer.anchor_before(cursor_offset.0.min(buffer.len()))
1079 });
1080 let Some(workspace) = self.workspace.upgrade() else {
1081 return;
1082 };
1083 let Some(completion) =
1084 PromptCompletionProvider::<Entity<MessageEditor>>::completion_for_action(
1085 PromptContextAction::AddSelections,
1086 anchor..anchor,
1087 self.editor.downgrade(),
1088 self.mention_set.downgrade(),
1089 &workspace,
1090 cx,
1091 )
1092 else {
1093 return;
1094 };
1095
1096 self.editor.update(cx, |message_editor, cx| {
1097 message_editor.edit([(cursor_anchor..cursor_anchor, completion.new_text)], cx);
1098 message_editor.request_autoscroll(Autoscroll::fit(), cx);
1099 });
1100 if let Some(confirm) = completion.confirm {
1101 confirm(CompletionIntent::Complete, window, cx);
1102 }
1103 }
1104
1105 pub fn add_images_from_picker(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1106 if !self.prompt_capabilities.borrow().image {
1107 return;
1108 }
1109
1110 let editor = self.editor.clone();
1111 let mention_set = self.mention_set.clone();
1112 let workspace = self.workspace.clone();
1113
1114 let paths_receiver = cx.prompt_for_paths(gpui::PathPromptOptions {
1115 files: true,
1116 directories: false,
1117 multiple: true,
1118 prompt: Some("Select Images".into()),
1119 });
1120
1121 window
1122 .spawn(cx, async move |cx| {
1123 let paths = match paths_receiver.await {
1124 Ok(Ok(Some(paths))) => paths,
1125 _ => return Ok::<(), anyhow::Error>(()),
1126 };
1127
1128 let supported_formats = [
1129 ("png", gpui::ImageFormat::Png),
1130 ("jpg", gpui::ImageFormat::Jpeg),
1131 ("jpeg", gpui::ImageFormat::Jpeg),
1132 ("webp", gpui::ImageFormat::Webp),
1133 ("gif", gpui::ImageFormat::Gif),
1134 ("bmp", gpui::ImageFormat::Bmp),
1135 ("tiff", gpui::ImageFormat::Tiff),
1136 ("tif", gpui::ImageFormat::Tiff),
1137 ("ico", gpui::ImageFormat::Ico),
1138 ];
1139
1140 let mut images = Vec::new();
1141 for path in paths {
1142 let extension = path
1143 .extension()
1144 .and_then(|ext| ext.to_str())
1145 .map(|s| s.to_lowercase());
1146
1147 let Some(format) = extension.and_then(|ext| {
1148 supported_formats
1149 .iter()
1150 .find(|(e, _)| *e == ext)
1151 .map(|(_, f)| *f)
1152 }) else {
1153 continue;
1154 };
1155
1156 let Ok(content) = async_fs::read(&path).await else {
1157 continue;
1158 };
1159
1160 images.push(gpui::Image::from_bytes(format, content));
1161 }
1162
1163 crate::mention_set::insert_images_as_context(
1164 images,
1165 editor,
1166 mention_set,
1167 workspace,
1168 cx,
1169 )
1170 .await;
1171 Ok(())
1172 })
1173 .detach_and_log_err(cx);
1174 }
1175
1176 pub fn set_read_only(&mut self, read_only: bool, cx: &mut Context<Self>) {
1177 self.editor.update(cx, |message_editor, cx| {
1178 message_editor.set_read_only(read_only);
1179 cx.notify()
1180 })
1181 }
1182
1183 pub fn set_mode(&mut self, mode: EditorMode, cx: &mut Context<Self>) {
1184 self.editor.update(cx, |editor, cx| {
1185 editor.set_mode(mode);
1186 cx.notify()
1187 });
1188 }
1189
1190 pub fn set_message(
1191 &mut self,
1192 message: Vec<acp::ContentBlock>,
1193 window: &mut Window,
1194 cx: &mut Context<Self>,
1195 ) {
1196 let Some(workspace) = self.workspace.upgrade() else {
1197 return;
1198 };
1199
1200 self.clear(window, cx);
1201
1202 let path_style = workspace.read(cx).project().read(cx).path_style(cx);
1203 let mut text = String::new();
1204 let mut mentions = Vec::new();
1205
1206 for chunk in message {
1207 match chunk {
1208 acp::ContentBlock::Text(text_content) => {
1209 text.push_str(&text_content.text);
1210 }
1211 acp::ContentBlock::Resource(acp::EmbeddedResource {
1212 resource: acp::EmbeddedResourceResource::TextResourceContents(resource),
1213 ..
1214 }) => {
1215 let Some(mention_uri) = MentionUri::parse(&resource.uri, path_style).log_err()
1216 else {
1217 continue;
1218 };
1219 let start = text.len();
1220 write!(&mut text, "{}", mention_uri.as_link()).ok();
1221 let end = text.len();
1222 mentions.push((
1223 start..end,
1224 mention_uri,
1225 Mention::Text {
1226 content: resource.text,
1227 tracked_buffers: Vec::new(),
1228 },
1229 ));
1230 }
1231 acp::ContentBlock::ResourceLink(resource) => {
1232 if let Some(mention_uri) =
1233 MentionUri::parse(&resource.uri, path_style).log_err()
1234 {
1235 let start = text.len();
1236 write!(&mut text, "{}", mention_uri.as_link()).ok();
1237 let end = text.len();
1238 mentions.push((start..end, mention_uri, Mention::Link));
1239 }
1240 }
1241 acp::ContentBlock::Image(acp::ImageContent {
1242 uri,
1243 data,
1244 mime_type,
1245 ..
1246 }) => {
1247 let mention_uri = if let Some(uri) = uri {
1248 MentionUri::parse(&uri, path_style)
1249 } else {
1250 Ok(MentionUri::PastedImage)
1251 };
1252 let Some(mention_uri) = mention_uri.log_err() else {
1253 continue;
1254 };
1255 let Some(format) = ImageFormat::from_mime_type(&mime_type) else {
1256 log::error!("failed to parse MIME type for image: {mime_type:?}");
1257 continue;
1258 };
1259 let start = text.len();
1260 write!(&mut text, "{}", mention_uri.as_link()).ok();
1261 let end = text.len();
1262 mentions.push((
1263 start..end,
1264 mention_uri,
1265 Mention::Image(MentionImage {
1266 data: data.into(),
1267 format,
1268 }),
1269 ));
1270 }
1271 _ => {}
1272 }
1273 }
1274
1275 let snapshot = self.editor.update(cx, |editor, cx| {
1276 editor.set_text(text, window, cx);
1277 editor.buffer().read(cx).snapshot(cx)
1278 });
1279
1280 for (range, mention_uri, mention) in mentions {
1281 let anchor = snapshot.anchor_before(MultiBufferOffset(range.start));
1282 let Some((crease_id, tx)) = insert_crease_for_mention(
1283 anchor.excerpt_id,
1284 anchor.text_anchor,
1285 range.end - range.start,
1286 mention_uri.name().into(),
1287 mention_uri.icon_path(cx),
1288 None,
1289 self.editor.clone(),
1290 window,
1291 cx,
1292 ) else {
1293 continue;
1294 };
1295 drop(tx);
1296
1297 self.mention_set.update(cx, |mention_set, _cx| {
1298 mention_set.insert_mention(
1299 crease_id,
1300 mention_uri.clone(),
1301 Task::ready(Ok(mention)).shared(),
1302 )
1303 });
1304 }
1305 cx.notify();
1306 }
1307
1308 pub fn text(&self, cx: &App) -> String {
1309 self.editor.read(cx).text(cx)
1310 }
1311
1312 pub fn set_placeholder_text(
1313 &mut self,
1314 placeholder: &str,
1315 window: &mut Window,
1316 cx: &mut Context<Self>,
1317 ) {
1318 self.editor.update(cx, |editor, cx| {
1319 editor.set_placeholder_text(placeholder, window, cx);
1320 });
1321 }
1322
1323 #[cfg(test)]
1324 pub fn set_text(&mut self, text: &str, window: &mut Window, cx: &mut Context<Self>) {
1325 self.editor.update(cx, |editor, cx| {
1326 editor.set_text(text, window, cx);
1327 });
1328 }
1329}
1330
1331impl Focusable for MessageEditor {
1332 fn focus_handle(&self, cx: &App) -> FocusHandle {
1333 self.editor.focus_handle(cx)
1334 }
1335}
1336
1337impl Render for MessageEditor {
1338 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1339 div()
1340 .key_context("MessageEditor")
1341 .on_action(cx.listener(Self::chat))
1342 .on_action(cx.listener(Self::send_immediately))
1343 .on_action(cx.listener(Self::chat_with_follow))
1344 .on_action(cx.listener(Self::cancel))
1345 .on_action(cx.listener(Self::paste_raw))
1346 .capture_action(cx.listener(Self::paste))
1347 .flex_1()
1348 .child({
1349 let settings = ThemeSettings::get_global(cx);
1350
1351 let text_style = TextStyle {
1352 color: cx.theme().colors().text,
1353 font_family: settings.buffer_font.family.clone(),
1354 font_fallbacks: settings.buffer_font.fallbacks.clone(),
1355 font_features: settings.buffer_font.features.clone(),
1356 font_size: settings.agent_buffer_font_size(cx).into(),
1357 line_height: relative(settings.buffer_line_height.value()),
1358 ..Default::default()
1359 };
1360
1361 EditorElement::new(
1362 &self.editor,
1363 EditorStyle {
1364 background: cx.theme().colors().editor_background,
1365 local_player: cx.theme().players().local(),
1366 text: text_style,
1367 syntax: cx.theme().syntax().clone(),
1368 inlay_hints_style: editor::make_inlay_hints_style(cx),
1369 ..Default::default()
1370 },
1371 )
1372 })
1373 }
1374}
1375
1376pub struct MessageEditorAddon {}
1377
1378impl MessageEditorAddon {
1379 pub fn new() -> Self {
1380 Self {}
1381 }
1382}
1383
1384impl Addon for MessageEditorAddon {
1385 fn to_any(&self) -> &dyn std::any::Any {
1386 self
1387 }
1388
1389 fn to_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
1390 Some(self)
1391 }
1392
1393 fn extend_key_context(&self, key_context: &mut KeyContext, cx: &App) {
1394 let settings = agent_settings::AgentSettings::get_global(cx);
1395 if settings.use_modifier_to_send {
1396 key_context.add("use_modifier_to_send");
1397 }
1398 }
1399}
1400
1401/// Parses markdown mention links in the format `[@name](uri)` from text.
1402/// Returns a vector of (range, MentionUri) pairs where range is the byte range in the text.
1403fn parse_mention_links(text: &str, path_style: PathStyle) -> Vec<(Range<usize>, MentionUri)> {
1404 let mut mentions = Vec::new();
1405 let mut search_start = 0;
1406
1407 while let Some(link_start) = text[search_start..].find("[@") {
1408 let absolute_start = search_start + link_start;
1409
1410 // Find the matching closing bracket for the name, handling nested brackets.
1411 // Start at the '[' character so find_matching_bracket can track depth correctly.
1412 let Some(name_end) = find_matching_bracket(&text[absolute_start..], '[', ']') else {
1413 search_start = absolute_start + 2;
1414 continue;
1415 };
1416 let name_end = absolute_start + name_end;
1417
1418 // Check for opening parenthesis immediately after
1419 if text.get(name_end + 1..name_end + 2) != Some("(") {
1420 search_start = name_end + 1;
1421 continue;
1422 }
1423
1424 // Find the matching closing parenthesis for the URI, handling nested parens
1425 let uri_start = name_end + 2;
1426 let Some(uri_end_relative) = find_matching_bracket(&text[name_end + 1..], '(', ')') else {
1427 search_start = uri_start;
1428 continue;
1429 };
1430 let uri_end = name_end + 1 + uri_end_relative;
1431 let link_end = uri_end + 1;
1432
1433 let uri_str = &text[uri_start..uri_end];
1434
1435 // Try to parse the URI as a MentionUri
1436 if let Ok(mention_uri) = MentionUri::parse(uri_str, path_style) {
1437 mentions.push((absolute_start..link_end, mention_uri));
1438 }
1439
1440 search_start = link_end;
1441 }
1442
1443 mentions
1444}
1445
1446/// Finds the position of the matching closing bracket, handling nested brackets.
1447/// The input `text` should start with the opening bracket.
1448/// Returns the index of the matching closing bracket relative to `text`.
1449fn find_matching_bracket(text: &str, open: char, close: char) -> Option<usize> {
1450 let mut depth = 0;
1451 for (index, character) in text.char_indices() {
1452 if character == open {
1453 depth += 1;
1454 } else if character == close {
1455 depth -= 1;
1456 if depth == 0 {
1457 return Some(index);
1458 }
1459 }
1460 }
1461 None
1462}
1463
1464#[cfg(test)]
1465mod tests {
1466 use std::{cell::RefCell, ops::Range, path::Path, rc::Rc, sync::Arc};
1467
1468 use acp_thread::{AgentSessionInfo, MentionUri};
1469 use agent::{ThreadStore, outline};
1470 use agent_client_protocol as acp;
1471 use editor::{
1472 AnchorRangeExt as _, Editor, EditorMode, MultiBufferOffset, SelectionEffects,
1473 actions::Paste,
1474 };
1475
1476 use fs::FakeFs;
1477 use futures::StreamExt as _;
1478 use gpui::{
1479 AppContext, ClipboardItem, Entity, EventEmitter, FocusHandle, Focusable, TestAppContext,
1480 VisualTestContext,
1481 };
1482 use language_model::LanguageModelRegistry;
1483 use lsp::{CompletionContext, CompletionTriggerKind};
1484 use project::{CompletionIntent, Project, ProjectPath};
1485 use serde_json::json;
1486
1487 use text::Point;
1488 use ui::{App, Context, IntoElement, Render, SharedString, Window};
1489 use util::{path, paths::PathStyle, rel_path::rel_path};
1490 use workspace::{AppState, Item, MultiWorkspace};
1491
1492 use crate::acp::{
1493 message_editor::{Mention, MessageEditor, parse_mention_links},
1494 thread_view::tests::init_test,
1495 };
1496 use crate::completion_provider::{PromptCompletionProviderDelegate, PromptContextType};
1497
1498 #[test]
1499 fn test_parse_mention_links() {
1500 // Single file mention
1501 let text = "[@bundle-mac](file:///Users/test/zed/script/bundle-mac)";
1502 let mentions = parse_mention_links(text, PathStyle::local());
1503 assert_eq!(mentions.len(), 1);
1504 assert_eq!(mentions[0].0, 0..text.len());
1505 assert!(matches!(mentions[0].1, MentionUri::File { .. }));
1506
1507 // Multiple mentions
1508 let text = "Check [@file1](file:///path/to/file1) and [@file2](file:///path/to/file2)!";
1509 let mentions = parse_mention_links(text, PathStyle::local());
1510 assert_eq!(mentions.len(), 2);
1511
1512 // Text without mentions
1513 let text = "Just some regular text without mentions";
1514 let mentions = parse_mention_links(text, PathStyle::local());
1515 assert_eq!(mentions.len(), 0);
1516
1517 // Malformed mentions (should be skipped)
1518 let text = "[@incomplete](invalid://uri) and [@missing](";
1519 let mentions = parse_mention_links(text, PathStyle::local());
1520 assert_eq!(mentions.len(), 0);
1521
1522 // Mixed content with valid mention
1523 let text = "Before [@valid](file:///path/to/file) after";
1524 let mentions = parse_mention_links(text, PathStyle::local());
1525 assert_eq!(mentions.len(), 1);
1526 assert_eq!(mentions[0].0.start, 7);
1527
1528 // HTTP URL mention (Fetch)
1529 let text = "Check out [@docs](https://example.com/docs) for more info";
1530 let mentions = parse_mention_links(text, PathStyle::local());
1531 assert_eq!(mentions.len(), 1);
1532 assert!(matches!(mentions[0].1, MentionUri::Fetch { .. }));
1533
1534 // Directory mention (trailing slash)
1535 let text = "[@src](file:///path/to/src/)";
1536 let mentions = parse_mention_links(text, PathStyle::local());
1537 assert_eq!(mentions.len(), 1);
1538 assert!(matches!(mentions[0].1, MentionUri::Directory { .. }));
1539
1540 // Multiple different mention types
1541 let text = "File [@f](file:///a) and URL [@u](https://b.com) and dir [@d](file:///c/)";
1542 let mentions = parse_mention_links(text, PathStyle::local());
1543 assert_eq!(mentions.len(), 3);
1544 assert!(matches!(mentions[0].1, MentionUri::File { .. }));
1545 assert!(matches!(mentions[1].1, MentionUri::Fetch { .. }));
1546 assert!(matches!(mentions[2].1, MentionUri::Directory { .. }));
1547
1548 // Adjacent mentions without separator
1549 let text = "[@a](file:///a)[@b](file:///b)";
1550 let mentions = parse_mention_links(text, PathStyle::local());
1551 assert_eq!(mentions.len(), 2);
1552
1553 // Regular markdown link (not a mention) should be ignored
1554 let text = "[regular link](https://example.com)";
1555 let mentions = parse_mention_links(text, PathStyle::local());
1556 assert_eq!(mentions.len(), 0);
1557
1558 // Incomplete mention link patterns
1559 let text = "[@name] without url and [@name( malformed";
1560 let mentions = parse_mention_links(text, PathStyle::local());
1561 assert_eq!(mentions.len(), 0);
1562
1563 // Nested brackets in name portion
1564 let text = "[@name [with brackets]](file:///path/to/file)";
1565 let mentions = parse_mention_links(text, PathStyle::local());
1566 assert_eq!(mentions.len(), 1);
1567 assert_eq!(mentions[0].0, 0..text.len());
1568
1569 // Deeply nested brackets
1570 let text = "[@outer [inner [deep]]](file:///path)";
1571 let mentions = parse_mention_links(text, PathStyle::local());
1572 assert_eq!(mentions.len(), 1);
1573
1574 // Unbalanced brackets should fail gracefully
1575 let text = "[@unbalanced [bracket](file:///path)";
1576 let mentions = parse_mention_links(text, PathStyle::local());
1577 assert_eq!(mentions.len(), 0);
1578
1579 // Nested parentheses in URI (common in URLs with query params)
1580 let text = "[@wiki](https://en.wikipedia.org/wiki/Rust_(programming_language))";
1581 let mentions = parse_mention_links(text, PathStyle::local());
1582 assert_eq!(mentions.len(), 1);
1583 if let MentionUri::Fetch { url } = &mentions[0].1 {
1584 assert!(url.as_str().contains("Rust_(programming_language)"));
1585 } else {
1586 panic!("Expected Fetch URI");
1587 }
1588 }
1589
1590 #[gpui::test]
1591 async fn test_at_mention_removal(cx: &mut TestAppContext) {
1592 init_test(cx);
1593
1594 let fs = FakeFs::new(cx.executor());
1595 fs.insert_tree("/project", json!({"file": ""})).await;
1596 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
1597
1598 let (multi_workspace, cx) =
1599 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1600 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
1601
1602 let thread_store = None;
1603 let history = cx
1604 .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
1605
1606 let message_editor = cx.update(|window, cx| {
1607 cx.new(|cx| {
1608 MessageEditor::new(
1609 workspace.downgrade(),
1610 project.downgrade(),
1611 thread_store.clone(),
1612 history.downgrade(),
1613 None,
1614 Default::default(),
1615 Default::default(),
1616 "Test Agent".into(),
1617 "Test",
1618 EditorMode::AutoHeight {
1619 min_lines: 1,
1620 max_lines: None,
1621 },
1622 window,
1623 cx,
1624 )
1625 })
1626 });
1627 let editor = message_editor.update(cx, |message_editor, _| message_editor.editor.clone());
1628
1629 cx.run_until_parked();
1630
1631 let excerpt_id = editor.update(cx, |editor, cx| {
1632 editor
1633 .buffer()
1634 .read(cx)
1635 .excerpt_ids()
1636 .into_iter()
1637 .next()
1638 .unwrap()
1639 });
1640 let completions = editor.update_in(cx, |editor, window, cx| {
1641 editor.set_text("Hello @file ", window, cx);
1642 let buffer = editor.buffer().read(cx).as_singleton().unwrap();
1643 let completion_provider = editor.completion_provider().unwrap();
1644 completion_provider.completions(
1645 excerpt_id,
1646 &buffer,
1647 text::Anchor::MAX,
1648 CompletionContext {
1649 trigger_kind: CompletionTriggerKind::TRIGGER_CHARACTER,
1650 trigger_character: Some("@".into()),
1651 },
1652 window,
1653 cx,
1654 )
1655 });
1656 let [_, completion]: [_; 2] = completions
1657 .await
1658 .unwrap()
1659 .into_iter()
1660 .flat_map(|response| response.completions)
1661 .collect::<Vec<_>>()
1662 .try_into()
1663 .unwrap();
1664
1665 editor.update_in(cx, |editor, window, cx| {
1666 let snapshot = editor.buffer().read(cx).snapshot(cx);
1667 let range = snapshot
1668 .anchor_range_in_excerpt(excerpt_id, completion.replace_range)
1669 .unwrap();
1670 editor.edit([(range, completion.new_text)], cx);
1671 (completion.confirm.unwrap())(CompletionIntent::Complete, window, cx);
1672 });
1673
1674 cx.run_until_parked();
1675
1676 // Backspace over the inserted crease (and the following space).
1677 editor.update_in(cx, |editor, window, cx| {
1678 editor.backspace(&Default::default(), window, cx);
1679 editor.backspace(&Default::default(), window, cx);
1680 });
1681
1682 let (content, _) = message_editor
1683 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
1684 .await
1685 .unwrap();
1686
1687 // We don't send a resource link for the deleted crease.
1688 pretty_assertions::assert_matches!(content.as_slice(), [acp::ContentBlock::Text { .. }]);
1689 }
1690
1691 #[gpui::test]
1692 async fn test_slash_command_validation(cx: &mut gpui::TestAppContext) {
1693 init_test(cx);
1694 let fs = FakeFs::new(cx.executor());
1695 fs.insert_tree(
1696 "/test",
1697 json!({
1698 ".zed": {
1699 "tasks.json": r#"[{"label": "test", "command": "echo"}]"#
1700 },
1701 "src": {
1702 "main.rs": "fn main() {}",
1703 },
1704 }),
1705 )
1706 .await;
1707
1708 let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
1709 let thread_store = None;
1710 let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default()));
1711 // Start with no available commands - simulating Claude which doesn't support slash commands
1712 let available_commands = Rc::new(RefCell::new(vec![]));
1713
1714 let (multi_workspace, cx) =
1715 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1716 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
1717 let history = cx
1718 .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
1719 let workspace_handle = workspace.downgrade();
1720 let message_editor = workspace.update_in(cx, |_, window, cx| {
1721 cx.new(|cx| {
1722 MessageEditor::new(
1723 workspace_handle.clone(),
1724 project.downgrade(),
1725 thread_store.clone(),
1726 history.downgrade(),
1727 None,
1728 prompt_capabilities.clone(),
1729 available_commands.clone(),
1730 "Claude Agent".into(),
1731 "Test",
1732 EditorMode::AutoHeight {
1733 min_lines: 1,
1734 max_lines: None,
1735 },
1736 window,
1737 cx,
1738 )
1739 })
1740 });
1741 let editor = message_editor.update(cx, |message_editor, _| message_editor.editor.clone());
1742
1743 // Test that slash commands fail when no available_commands are set (empty list means no commands supported)
1744 editor.update_in(cx, |editor, window, cx| {
1745 editor.set_text("/file test.txt", window, cx);
1746 });
1747
1748 let contents_result = message_editor
1749 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
1750 .await;
1751
1752 // Should fail because available_commands is empty (no commands supported)
1753 assert!(contents_result.is_err());
1754 let error_message = contents_result.unwrap_err().to_string();
1755 assert!(error_message.contains("not supported by Claude Agent"));
1756 assert!(error_message.contains("Available commands: none"));
1757
1758 // Now simulate Claude providing its list of available commands (which doesn't include file)
1759 available_commands.replace(vec![acp::AvailableCommand::new("help", "Get help")]);
1760
1761 // Test that unsupported slash commands trigger an error when we have a list of available commands
1762 editor.update_in(cx, |editor, window, cx| {
1763 editor.set_text("/file test.txt", window, cx);
1764 });
1765
1766 let contents_result = message_editor
1767 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
1768 .await;
1769
1770 assert!(contents_result.is_err());
1771 let error_message = contents_result.unwrap_err().to_string();
1772 assert!(error_message.contains("not supported by Claude Agent"));
1773 assert!(error_message.contains("/file"));
1774 assert!(error_message.contains("Available commands: /help"));
1775
1776 // Test that supported commands work fine
1777 editor.update_in(cx, |editor, window, cx| {
1778 editor.set_text("/help", window, cx);
1779 });
1780
1781 let contents_result = message_editor
1782 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
1783 .await;
1784
1785 // Should succeed because /help is in available_commands
1786 assert!(contents_result.is_ok());
1787
1788 // Test that regular text works fine
1789 editor.update_in(cx, |editor, window, cx| {
1790 editor.set_text("Hello Claude!", window, cx);
1791 });
1792
1793 let (content, _) = message_editor
1794 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
1795 .await
1796 .unwrap();
1797
1798 assert_eq!(content.len(), 1);
1799 if let acp::ContentBlock::Text(text) = &content[0] {
1800 assert_eq!(text.text, "Hello Claude!");
1801 } else {
1802 panic!("Expected ContentBlock::Text");
1803 }
1804
1805 // Test that @ mentions still work
1806 editor.update_in(cx, |editor, window, cx| {
1807 editor.set_text("Check this @", window, cx);
1808 });
1809
1810 // The @ mention functionality should not be affected
1811 let (content, _) = message_editor
1812 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
1813 .await
1814 .unwrap();
1815
1816 assert_eq!(content.len(), 1);
1817 if let acp::ContentBlock::Text(text) = &content[0] {
1818 assert_eq!(text.text, "Check this @");
1819 } else {
1820 panic!("Expected ContentBlock::Text");
1821 }
1822 }
1823
1824 struct MessageEditorItem(Entity<MessageEditor>);
1825
1826 impl Item for MessageEditorItem {
1827 type Event = ();
1828
1829 fn include_in_nav_history() -> bool {
1830 false
1831 }
1832
1833 fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
1834 "Test".into()
1835 }
1836 }
1837
1838 impl EventEmitter<()> for MessageEditorItem {}
1839
1840 impl Focusable for MessageEditorItem {
1841 fn focus_handle(&self, cx: &App) -> FocusHandle {
1842 self.0.read(cx).focus_handle(cx)
1843 }
1844 }
1845
1846 impl Render for MessageEditorItem {
1847 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
1848 self.0.clone().into_any_element()
1849 }
1850 }
1851
1852 #[gpui::test]
1853 async fn test_completion_provider_commands(cx: &mut TestAppContext) {
1854 init_test(cx);
1855
1856 let app_state = cx.update(AppState::test);
1857
1858 cx.update(|cx| {
1859 editor::init(cx);
1860 workspace::init(app_state.clone(), cx);
1861 });
1862
1863 let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
1864 let window =
1865 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1866 let workspace = window
1867 .read_with(cx, |mw, _| mw.workspace().clone())
1868 .unwrap();
1869
1870 let mut cx = VisualTestContext::from_window(window.into(), cx);
1871
1872 let thread_store = None;
1873 let history = cx
1874 .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
1875 let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default()));
1876 let available_commands = Rc::new(RefCell::new(vec![
1877 acp::AvailableCommand::new("quick-math", "2 + 2 = 4 - 1 = 3"),
1878 acp::AvailableCommand::new("say-hello", "Say hello to whoever you want").input(
1879 acp::AvailableCommandInput::Unstructured(acp::UnstructuredCommandInput::new(
1880 "<name>",
1881 )),
1882 ),
1883 ]));
1884
1885 let editor = workspace.update_in(&mut cx, |workspace, window, cx| {
1886 let workspace_handle = cx.weak_entity();
1887 let message_editor = cx.new(|cx| {
1888 MessageEditor::new(
1889 workspace_handle,
1890 project.downgrade(),
1891 thread_store.clone(),
1892 history.downgrade(),
1893 None,
1894 prompt_capabilities.clone(),
1895 available_commands.clone(),
1896 "Test Agent".into(),
1897 "Test",
1898 EditorMode::AutoHeight {
1899 max_lines: None,
1900 min_lines: 1,
1901 },
1902 window,
1903 cx,
1904 )
1905 });
1906 workspace.active_pane().update(cx, |pane, cx| {
1907 pane.add_item(
1908 Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
1909 true,
1910 true,
1911 None,
1912 window,
1913 cx,
1914 );
1915 });
1916 message_editor.read(cx).focus_handle(cx).focus(window, cx);
1917 message_editor.read(cx).editor().clone()
1918 });
1919
1920 cx.simulate_input("/");
1921
1922 editor.update_in(&mut cx, |editor, window, cx| {
1923 assert_eq!(editor.text(cx), "/");
1924 assert!(editor.has_visible_completions_menu());
1925
1926 assert_eq!(
1927 current_completion_labels_with_documentation(editor),
1928 &[
1929 ("quick-math".into(), "2 + 2 = 4 - 1 = 3".into()),
1930 ("say-hello".into(), "Say hello to whoever you want".into())
1931 ]
1932 );
1933 editor.set_text("", window, cx);
1934 });
1935
1936 cx.simulate_input("/qui");
1937
1938 editor.update_in(&mut cx, |editor, window, cx| {
1939 assert_eq!(editor.text(cx), "/qui");
1940 assert!(editor.has_visible_completions_menu());
1941
1942 assert_eq!(
1943 current_completion_labels_with_documentation(editor),
1944 &[("quick-math".into(), "2 + 2 = 4 - 1 = 3".into())]
1945 );
1946 editor.set_text("", window, cx);
1947 });
1948
1949 editor.update_in(&mut cx, |editor, window, cx| {
1950 assert!(editor.has_visible_completions_menu());
1951 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
1952 });
1953
1954 cx.run_until_parked();
1955
1956 editor.update_in(&mut cx, |editor, window, cx| {
1957 assert_eq!(editor.display_text(cx), "/quick-math ");
1958 assert!(!editor.has_visible_completions_menu());
1959 editor.set_text("", window, cx);
1960 });
1961
1962 cx.simulate_input("/say");
1963
1964 editor.update_in(&mut cx, |editor, _window, cx| {
1965 assert_eq!(editor.display_text(cx), "/say");
1966 assert!(editor.has_visible_completions_menu());
1967
1968 assert_eq!(
1969 current_completion_labels_with_documentation(editor),
1970 &[("say-hello".into(), "Say hello to whoever you want".into())]
1971 );
1972 });
1973
1974 editor.update_in(&mut cx, |editor, window, cx| {
1975 assert!(editor.has_visible_completions_menu());
1976 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
1977 });
1978
1979 cx.run_until_parked();
1980
1981 editor.update_in(&mut cx, |editor, _window, cx| {
1982 assert_eq!(editor.text(cx), "/say-hello ");
1983 assert_eq!(editor.display_text(cx), "/say-hello <name>");
1984 assert!(!editor.has_visible_completions_menu());
1985 });
1986
1987 cx.simulate_input("GPT5");
1988
1989 cx.run_until_parked();
1990
1991 editor.update_in(&mut cx, |editor, window, cx| {
1992 assert_eq!(editor.text(cx), "/say-hello GPT5");
1993 assert_eq!(editor.display_text(cx), "/say-hello GPT5");
1994 assert!(!editor.has_visible_completions_menu());
1995
1996 // Delete argument
1997 for _ in 0..5 {
1998 editor.backspace(&editor::actions::Backspace, window, cx);
1999 }
2000 });
2001
2002 cx.run_until_parked();
2003
2004 editor.update_in(&mut cx, |editor, window, cx| {
2005 assert_eq!(editor.text(cx), "/say-hello");
2006 // Hint is visible because argument was deleted
2007 assert_eq!(editor.display_text(cx), "/say-hello <name>");
2008
2009 // Delete last command letter
2010 editor.backspace(&editor::actions::Backspace, window, cx);
2011 });
2012
2013 cx.run_until_parked();
2014
2015 editor.update_in(&mut cx, |editor, _window, cx| {
2016 // Hint goes away once command no longer matches an available one
2017 assert_eq!(editor.text(cx), "/say-hell");
2018 assert_eq!(editor.display_text(cx), "/say-hell");
2019 assert!(!editor.has_visible_completions_menu());
2020 });
2021 }
2022
2023 #[gpui::test]
2024 async fn test_context_completion_provider_mentions(cx: &mut TestAppContext) {
2025 init_test(cx);
2026
2027 let app_state = cx.update(AppState::test);
2028
2029 cx.update(|cx| {
2030 editor::init(cx);
2031 workspace::init(app_state.clone(), cx);
2032 });
2033
2034 app_state
2035 .fs
2036 .as_fake()
2037 .insert_tree(
2038 path!("/dir"),
2039 json!({
2040 "editor": "",
2041 "a": {
2042 "one.txt": "1",
2043 "two.txt": "2",
2044 "three.txt": "3",
2045 "four.txt": "4"
2046 },
2047 "b": {
2048 "five.txt": "5",
2049 "six.txt": "6",
2050 "seven.txt": "7",
2051 "eight.txt": "8",
2052 },
2053 "x.png": "",
2054 }),
2055 )
2056 .await;
2057
2058 let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
2059 let window =
2060 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2061 let workspace = window
2062 .read_with(cx, |mw, _| mw.workspace().clone())
2063 .unwrap();
2064
2065 let worktree = project.update(cx, |project, cx| {
2066 let mut worktrees = project.worktrees(cx).collect::<Vec<_>>();
2067 assert_eq!(worktrees.len(), 1);
2068 worktrees.pop().unwrap()
2069 });
2070 let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id());
2071
2072 let mut cx = VisualTestContext::from_window(window.into(), cx);
2073
2074 let paths = vec![
2075 rel_path("a/one.txt"),
2076 rel_path("a/two.txt"),
2077 rel_path("a/three.txt"),
2078 rel_path("a/four.txt"),
2079 rel_path("b/five.txt"),
2080 rel_path("b/six.txt"),
2081 rel_path("b/seven.txt"),
2082 rel_path("b/eight.txt"),
2083 ];
2084
2085 let slash = PathStyle::local().primary_separator();
2086
2087 let mut opened_editors = Vec::new();
2088 for path in paths {
2089 let buffer = workspace
2090 .update_in(&mut cx, |workspace, window, cx| {
2091 workspace.open_path(
2092 ProjectPath {
2093 worktree_id,
2094 path: path.into(),
2095 },
2096 None,
2097 false,
2098 window,
2099 cx,
2100 )
2101 })
2102 .await
2103 .unwrap();
2104 opened_editors.push(buffer);
2105 }
2106
2107 let thread_store = cx.new(|cx| ThreadStore::new(cx));
2108 let history = cx
2109 .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
2110 let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default()));
2111
2112 let (message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| {
2113 let workspace_handle = cx.weak_entity();
2114 let message_editor = cx.new(|cx| {
2115 MessageEditor::new(
2116 workspace_handle,
2117 project.downgrade(),
2118 Some(thread_store),
2119 history.downgrade(),
2120 None,
2121 prompt_capabilities.clone(),
2122 Default::default(),
2123 "Test Agent".into(),
2124 "Test",
2125 EditorMode::AutoHeight {
2126 max_lines: None,
2127 min_lines: 1,
2128 },
2129 window,
2130 cx,
2131 )
2132 });
2133 workspace.active_pane().update(cx, |pane, cx| {
2134 pane.add_item(
2135 Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
2136 true,
2137 true,
2138 None,
2139 window,
2140 cx,
2141 );
2142 });
2143 message_editor.read(cx).focus_handle(cx).focus(window, cx);
2144 let editor = message_editor.read(cx).editor().clone();
2145 (message_editor, editor)
2146 });
2147
2148 cx.simulate_input("Lorem @");
2149
2150 editor.update_in(&mut cx, |editor, window, cx| {
2151 assert_eq!(editor.text(cx), "Lorem @");
2152 assert!(editor.has_visible_completions_menu());
2153
2154 assert_eq!(
2155 current_completion_labels(editor),
2156 &[
2157 format!("eight.txt b{slash}"),
2158 format!("seven.txt b{slash}"),
2159 format!("six.txt b{slash}"),
2160 format!("five.txt b{slash}"),
2161 "Files & Directories".into(),
2162 "Symbols".into()
2163 ]
2164 );
2165 editor.set_text("", window, cx);
2166 });
2167
2168 prompt_capabilities.replace(
2169 acp::PromptCapabilities::new()
2170 .image(true)
2171 .audio(true)
2172 .embedded_context(true),
2173 );
2174
2175 cx.simulate_input("Lorem ");
2176
2177 editor.update(&mut cx, |editor, cx| {
2178 assert_eq!(editor.text(cx), "Lorem ");
2179 assert!(!editor.has_visible_completions_menu());
2180 });
2181
2182 cx.simulate_input("@");
2183
2184 editor.update(&mut cx, |editor, cx| {
2185 assert_eq!(editor.text(cx), "Lorem @");
2186 assert!(editor.has_visible_completions_menu());
2187 assert_eq!(
2188 current_completion_labels(editor),
2189 &[
2190 format!("eight.txt b{slash}"),
2191 format!("seven.txt b{slash}"),
2192 format!("six.txt b{slash}"),
2193 format!("five.txt b{slash}"),
2194 "Files & Directories".into(),
2195 "Symbols".into(),
2196 "Threads".into(),
2197 "Fetch".into()
2198 ]
2199 );
2200 });
2201
2202 // Select and confirm "File"
2203 editor.update_in(&mut cx, |editor, window, cx| {
2204 assert!(editor.has_visible_completions_menu());
2205 editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
2206 editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
2207 editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
2208 editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
2209 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2210 });
2211
2212 cx.run_until_parked();
2213
2214 editor.update(&mut cx, |editor, cx| {
2215 assert_eq!(editor.text(cx), "Lorem @file ");
2216 assert!(editor.has_visible_completions_menu());
2217 });
2218
2219 cx.simulate_input("one");
2220
2221 editor.update(&mut cx, |editor, cx| {
2222 assert_eq!(editor.text(cx), "Lorem @file one");
2223 assert!(editor.has_visible_completions_menu());
2224 assert_eq!(
2225 current_completion_labels(editor),
2226 vec![format!("one.txt a{slash}")]
2227 );
2228 });
2229
2230 editor.update_in(&mut cx, |editor, window, cx| {
2231 assert!(editor.has_visible_completions_menu());
2232 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2233 });
2234
2235 let url_one = MentionUri::File {
2236 abs_path: path!("/dir/a/one.txt").into(),
2237 }
2238 .to_uri()
2239 .to_string();
2240 editor.update(&mut cx, |editor, cx| {
2241 let text = editor.text(cx);
2242 assert_eq!(text, format!("Lorem [@one.txt]({url_one}) "));
2243 assert!(!editor.has_visible_completions_menu());
2244 assert_eq!(fold_ranges(editor, cx).len(), 1);
2245 });
2246
2247 let contents = message_editor
2248 .update(&mut cx, |message_editor, cx| {
2249 message_editor
2250 .mention_set()
2251 .update(cx, |mention_set, cx| mention_set.contents(false, cx))
2252 })
2253 .await
2254 .unwrap()
2255 .into_values()
2256 .collect::<Vec<_>>();
2257
2258 {
2259 let [(uri, Mention::Text { content, .. })] = contents.as_slice() else {
2260 panic!("Unexpected mentions");
2261 };
2262 pretty_assertions::assert_eq!(content, "1");
2263 pretty_assertions::assert_eq!(
2264 uri,
2265 &MentionUri::parse(&url_one, PathStyle::local()).unwrap()
2266 );
2267 }
2268
2269 cx.simulate_input(" ");
2270
2271 editor.update(&mut cx, |editor, cx| {
2272 let text = editor.text(cx);
2273 assert_eq!(text, format!("Lorem [@one.txt]({url_one}) "));
2274 assert!(!editor.has_visible_completions_menu());
2275 assert_eq!(fold_ranges(editor, cx).len(), 1);
2276 });
2277
2278 cx.simulate_input("Ipsum ");
2279
2280 editor.update(&mut cx, |editor, cx| {
2281 let text = editor.text(cx);
2282 assert_eq!(text, format!("Lorem [@one.txt]({url_one}) Ipsum "),);
2283 assert!(!editor.has_visible_completions_menu());
2284 assert_eq!(fold_ranges(editor, cx).len(), 1);
2285 });
2286
2287 cx.simulate_input("@file ");
2288
2289 editor.update(&mut cx, |editor, cx| {
2290 let text = editor.text(cx);
2291 assert_eq!(text, format!("Lorem [@one.txt]({url_one}) Ipsum @file "),);
2292 assert!(editor.has_visible_completions_menu());
2293 assert_eq!(fold_ranges(editor, cx).len(), 1);
2294 });
2295
2296 editor.update_in(&mut cx, |editor, window, cx| {
2297 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2298 });
2299
2300 cx.run_until_parked();
2301
2302 let contents = message_editor
2303 .update(&mut cx, |message_editor, cx| {
2304 message_editor
2305 .mention_set()
2306 .update(cx, |mention_set, cx| mention_set.contents(false, cx))
2307 })
2308 .await
2309 .unwrap()
2310 .into_values()
2311 .collect::<Vec<_>>();
2312
2313 let url_eight = MentionUri::File {
2314 abs_path: path!("/dir/b/eight.txt").into(),
2315 }
2316 .to_uri()
2317 .to_string();
2318
2319 {
2320 let [_, (uri, Mention::Text { content, .. })] = contents.as_slice() else {
2321 panic!("Unexpected mentions");
2322 };
2323 pretty_assertions::assert_eq!(content, "8");
2324 pretty_assertions::assert_eq!(
2325 uri,
2326 &MentionUri::parse(&url_eight, PathStyle::local()).unwrap()
2327 );
2328 }
2329
2330 editor.update(&mut cx, |editor, cx| {
2331 assert_eq!(
2332 editor.text(cx),
2333 format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) ")
2334 );
2335 assert!(!editor.has_visible_completions_menu());
2336 assert_eq!(fold_ranges(editor, cx).len(), 2);
2337 });
2338
2339 let plain_text_language = Arc::new(language::Language::new(
2340 language::LanguageConfig {
2341 name: "Plain Text".into(),
2342 matcher: language::LanguageMatcher {
2343 path_suffixes: vec!["txt".to_string()],
2344 ..Default::default()
2345 },
2346 ..Default::default()
2347 },
2348 None,
2349 ));
2350
2351 // Register the language and fake LSP
2352 let language_registry = project.read_with(&cx, |project, _| project.languages().clone());
2353 language_registry.add(plain_text_language);
2354
2355 let mut fake_language_servers = language_registry.register_fake_lsp(
2356 "Plain Text",
2357 language::FakeLspAdapter {
2358 capabilities: lsp::ServerCapabilities {
2359 workspace_symbol_provider: Some(lsp::OneOf::Left(true)),
2360 ..Default::default()
2361 },
2362 ..Default::default()
2363 },
2364 );
2365
2366 // Open the buffer to trigger LSP initialization
2367 let buffer = project
2368 .update(&mut cx, |project, cx| {
2369 project.open_local_buffer(path!("/dir/a/one.txt"), cx)
2370 })
2371 .await
2372 .unwrap();
2373
2374 // Register the buffer with language servers
2375 let _handle = project.update(&mut cx, |project, cx| {
2376 project.register_buffer_with_language_servers(&buffer, cx)
2377 });
2378
2379 cx.run_until_parked();
2380
2381 let fake_language_server = fake_language_servers.next().await.unwrap();
2382 fake_language_server.set_request_handler::<lsp::WorkspaceSymbolRequest, _, _>(
2383 move |_, _| async move {
2384 Ok(Some(lsp::WorkspaceSymbolResponse::Flat(vec![
2385 #[allow(deprecated)]
2386 lsp::SymbolInformation {
2387 name: "MySymbol".into(),
2388 location: lsp::Location {
2389 uri: lsp::Uri::from_file_path(path!("/dir/a/one.txt")).unwrap(),
2390 range: lsp::Range::new(
2391 lsp::Position::new(0, 0),
2392 lsp::Position::new(0, 1),
2393 ),
2394 },
2395 kind: lsp::SymbolKind::CONSTANT,
2396 tags: None,
2397 container_name: None,
2398 deprecated: None,
2399 },
2400 ])))
2401 },
2402 );
2403
2404 cx.simulate_input("@symbol ");
2405
2406 editor.update(&mut cx, |editor, cx| {
2407 assert_eq!(
2408 editor.text(cx),
2409 format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) @symbol ")
2410 );
2411 assert!(editor.has_visible_completions_menu());
2412 assert_eq!(current_completion_labels(editor), &["MySymbol one.txt L1"]);
2413 });
2414
2415 editor.update_in(&mut cx, |editor, window, cx| {
2416 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2417 });
2418
2419 let symbol = MentionUri::Symbol {
2420 abs_path: path!("/dir/a/one.txt").into(),
2421 name: "MySymbol".into(),
2422 line_range: 0..=0,
2423 };
2424
2425 let contents = message_editor
2426 .update(&mut cx, |message_editor, cx| {
2427 message_editor
2428 .mention_set()
2429 .update(cx, |mention_set, cx| mention_set.contents(false, cx))
2430 })
2431 .await
2432 .unwrap()
2433 .into_values()
2434 .collect::<Vec<_>>();
2435
2436 {
2437 let [_, _, (uri, Mention::Text { content, .. })] = contents.as_slice() else {
2438 panic!("Unexpected mentions");
2439 };
2440 pretty_assertions::assert_eq!(content, "1");
2441 pretty_assertions::assert_eq!(uri, &symbol);
2442 }
2443
2444 cx.run_until_parked();
2445
2446 editor.read_with(&cx, |editor, cx| {
2447 assert_eq!(
2448 editor.text(cx),
2449 format!(
2450 "Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) ",
2451 symbol.to_uri(),
2452 )
2453 );
2454 });
2455
2456 // Try to mention an "image" file that will fail to load
2457 cx.simulate_input("@file x.png");
2458
2459 editor.update(&mut cx, |editor, cx| {
2460 assert_eq!(
2461 editor.text(cx),
2462 format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) @file x.png", symbol.to_uri())
2463 );
2464 assert!(editor.has_visible_completions_menu());
2465 assert_eq!(current_completion_labels(editor), &["x.png "]);
2466 });
2467
2468 editor.update_in(&mut cx, |editor, window, cx| {
2469 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2470 });
2471
2472 // Getting the message contents fails
2473 message_editor
2474 .update(&mut cx, |message_editor, cx| {
2475 message_editor
2476 .mention_set()
2477 .update(cx, |mention_set, cx| mention_set.contents(false, cx))
2478 })
2479 .await
2480 .expect_err("Should fail to load x.png");
2481
2482 cx.run_until_parked();
2483
2484 // Mention was removed
2485 editor.read_with(&cx, |editor, cx| {
2486 assert_eq!(
2487 editor.text(cx),
2488 format!(
2489 "Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) ",
2490 symbol.to_uri()
2491 )
2492 );
2493 });
2494
2495 // Once more
2496 cx.simulate_input("@file x.png");
2497
2498 editor.update(&mut cx, |editor, cx| {
2499 assert_eq!(
2500 editor.text(cx),
2501 format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) @file x.png", symbol.to_uri())
2502 );
2503 assert!(editor.has_visible_completions_menu());
2504 assert_eq!(current_completion_labels(editor), &["x.png "]);
2505 });
2506
2507 editor.update_in(&mut cx, |editor, window, cx| {
2508 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2509 });
2510
2511 // This time don't immediately get the contents, just let the confirmed completion settle
2512 cx.run_until_parked();
2513
2514 // Mention was removed
2515 editor.read_with(&cx, |editor, cx| {
2516 assert_eq!(
2517 editor.text(cx),
2518 format!(
2519 "Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) ",
2520 symbol.to_uri()
2521 )
2522 );
2523 });
2524
2525 // Now getting the contents succeeds, because the invalid mention was removed
2526 let contents = message_editor
2527 .update(&mut cx, |message_editor, cx| {
2528 message_editor
2529 .mention_set()
2530 .update(cx, |mention_set, cx| mention_set.contents(false, cx))
2531 })
2532 .await
2533 .unwrap();
2534 assert_eq!(contents.len(), 3);
2535 }
2536
2537 fn fold_ranges(editor: &Editor, cx: &mut App) -> Vec<Range<Point>> {
2538 let snapshot = editor.buffer().read(cx).snapshot(cx);
2539 editor.display_map.update(cx, |display_map, cx| {
2540 display_map
2541 .snapshot(cx)
2542 .folds_in_range(MultiBufferOffset(0)..snapshot.len())
2543 .map(|fold| fold.range.to_point(&snapshot))
2544 .collect()
2545 })
2546 }
2547
2548 fn current_completion_labels(editor: &Editor) -> Vec<String> {
2549 let completions = editor.current_completions().expect("Missing completions");
2550 completions
2551 .into_iter()
2552 .map(|completion| completion.label.text)
2553 .collect::<Vec<_>>()
2554 }
2555
2556 fn current_completion_labels_with_documentation(editor: &Editor) -> Vec<(String, String)> {
2557 let completions = editor.current_completions().expect("Missing completions");
2558 completions
2559 .into_iter()
2560 .map(|completion| {
2561 (
2562 completion.label.text,
2563 completion
2564 .documentation
2565 .map(|d| d.text().to_string())
2566 .unwrap_or_default(),
2567 )
2568 })
2569 .collect::<Vec<_>>()
2570 }
2571
2572 #[gpui::test]
2573 async fn test_large_file_mention_fallback(cx: &mut TestAppContext) {
2574 init_test(cx);
2575
2576 let fs = FakeFs::new(cx.executor());
2577
2578 // Create a large file that exceeds AUTO_OUTLINE_SIZE
2579 // Using plain text without a configured language, so no outline is available
2580 const LINE: &str = "This is a line of text in the file\n";
2581 let large_content = LINE.repeat(2 * (outline::AUTO_OUTLINE_SIZE / LINE.len()));
2582 assert!(large_content.len() > outline::AUTO_OUTLINE_SIZE);
2583
2584 // Create a small file that doesn't exceed AUTO_OUTLINE_SIZE
2585 let small_content = "fn small_function() { /* small */ }\n";
2586 assert!(small_content.len() < outline::AUTO_OUTLINE_SIZE);
2587
2588 fs.insert_tree(
2589 "/project",
2590 json!({
2591 "large_file.txt": large_content.clone(),
2592 "small_file.txt": small_content,
2593 }),
2594 )
2595 .await;
2596
2597 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
2598
2599 let (multi_workspace, cx) =
2600 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2601 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2602
2603 let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
2604 let history = cx
2605 .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
2606
2607 let message_editor = cx.update(|window, cx| {
2608 cx.new(|cx| {
2609 let editor = MessageEditor::new(
2610 workspace.downgrade(),
2611 project.downgrade(),
2612 thread_store.clone(),
2613 history.downgrade(),
2614 None,
2615 Default::default(),
2616 Default::default(),
2617 "Test Agent".into(),
2618 "Test",
2619 EditorMode::AutoHeight {
2620 min_lines: 1,
2621 max_lines: None,
2622 },
2623 window,
2624 cx,
2625 );
2626 // Enable embedded context so files are actually included
2627 editor
2628 .prompt_capabilities
2629 .replace(acp::PromptCapabilities::new().embedded_context(true));
2630 editor
2631 })
2632 });
2633
2634 // Test large file mention
2635 // Get the absolute path using the project's worktree
2636 let large_file_abs_path = project.read_with(cx, |project, cx| {
2637 let worktree = project.worktrees(cx).next().unwrap();
2638 let worktree_root = worktree.read(cx).abs_path();
2639 worktree_root.join("large_file.txt")
2640 });
2641 let large_file_task = message_editor.update(cx, |editor, cx| {
2642 editor.mention_set().update(cx, |set, cx| {
2643 set.confirm_mention_for_file(large_file_abs_path, true, cx)
2644 })
2645 });
2646
2647 let large_file_mention = large_file_task.await.unwrap();
2648 match large_file_mention {
2649 Mention::Text { content, .. } => {
2650 // Should contain some of the content but not all of it
2651 assert!(
2652 content.contains(LINE),
2653 "Should contain some of the file content"
2654 );
2655 assert!(
2656 !content.contains(&LINE.repeat(100)),
2657 "Should not contain the full file"
2658 );
2659 // Should be much smaller than original
2660 assert!(
2661 content.len() < large_content.len() / 10,
2662 "Should be significantly truncated"
2663 );
2664 }
2665 _ => panic!("Expected Text mention for large file"),
2666 }
2667
2668 // Test small file mention
2669 // Get the absolute path using the project's worktree
2670 let small_file_abs_path = project.read_with(cx, |project, cx| {
2671 let worktree = project.worktrees(cx).next().unwrap();
2672 let worktree_root = worktree.read(cx).abs_path();
2673 worktree_root.join("small_file.txt")
2674 });
2675 let small_file_task = message_editor.update(cx, |editor, cx| {
2676 editor.mention_set().update(cx, |set, cx| {
2677 set.confirm_mention_for_file(small_file_abs_path, true, cx)
2678 })
2679 });
2680
2681 let small_file_mention = small_file_task.await.unwrap();
2682 match small_file_mention {
2683 Mention::Text { content, .. } => {
2684 // Should contain the full actual content
2685 assert_eq!(content, small_content);
2686 }
2687 _ => panic!("Expected Text mention for small file"),
2688 }
2689 }
2690
2691 #[gpui::test]
2692 async fn test_insert_thread_summary(cx: &mut TestAppContext) {
2693 init_test(cx);
2694 cx.update(LanguageModelRegistry::test);
2695
2696 let fs = FakeFs::new(cx.executor());
2697 fs.insert_tree("/project", json!({"file": ""})).await;
2698 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
2699
2700 let (multi_workspace, cx) =
2701 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2702 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2703
2704 let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
2705 let history = cx
2706 .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
2707
2708 // Create a thread metadata to insert as summary
2709 let thread_metadata = AgentSessionInfo {
2710 session_id: acp::SessionId::new("thread-123"),
2711 cwd: None,
2712 title: Some("Previous Conversation".into()),
2713 updated_at: Some(chrono::Utc::now()),
2714 meta: None,
2715 };
2716
2717 let message_editor = cx.update(|window, cx| {
2718 cx.new(|cx| {
2719 let mut editor = MessageEditor::new(
2720 workspace.downgrade(),
2721 project.downgrade(),
2722 thread_store.clone(),
2723 history.downgrade(),
2724 None,
2725 Default::default(),
2726 Default::default(),
2727 "Test Agent".into(),
2728 "Test",
2729 EditorMode::AutoHeight {
2730 min_lines: 1,
2731 max_lines: None,
2732 },
2733 window,
2734 cx,
2735 );
2736 editor.insert_thread_summary(thread_metadata.clone(), window, cx);
2737 editor
2738 })
2739 });
2740
2741 // Construct expected values for verification
2742 let expected_uri = MentionUri::Thread {
2743 id: thread_metadata.session_id.clone(),
2744 name: thread_metadata.title.as_ref().unwrap().to_string(),
2745 };
2746 let expected_title = thread_metadata.title.as_ref().unwrap();
2747 let expected_link = format!("[@{}]({})", expected_title, expected_uri.to_uri());
2748
2749 message_editor.read_with(cx, |editor, cx| {
2750 let text = editor.text(cx);
2751
2752 assert!(
2753 text.contains(&expected_link),
2754 "Expected editor text to contain thread mention link.\nExpected substring: {}\nActual text: {}",
2755 expected_link,
2756 text
2757 );
2758
2759 let mentions = editor.mention_set().read(cx).mentions();
2760 assert_eq!(
2761 mentions.len(),
2762 1,
2763 "Expected exactly one mention after inserting thread summary"
2764 );
2765
2766 assert!(
2767 mentions.contains(&expected_uri),
2768 "Expected mentions to contain the thread URI"
2769 );
2770 });
2771 }
2772
2773 #[gpui::test]
2774 async fn test_insert_thread_summary_skipped_for_external_agents(cx: &mut TestAppContext) {
2775 init_test(cx);
2776 cx.update(LanguageModelRegistry::test);
2777
2778 let fs = FakeFs::new(cx.executor());
2779 fs.insert_tree("/project", json!({"file": ""})).await;
2780 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
2781
2782 let (multi_workspace, cx) =
2783 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2784 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2785
2786 let thread_store = None;
2787 let history = cx
2788 .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
2789
2790 let thread_metadata = AgentSessionInfo {
2791 session_id: acp::SessionId::new("thread-123"),
2792 cwd: None,
2793 title: Some("Previous Conversation".into()),
2794 updated_at: Some(chrono::Utc::now()),
2795 meta: None,
2796 };
2797
2798 let message_editor = cx.update(|window, cx| {
2799 cx.new(|cx| {
2800 let mut editor = MessageEditor::new(
2801 workspace.downgrade(),
2802 project.downgrade(),
2803 thread_store.clone(),
2804 history.downgrade(),
2805 None,
2806 Default::default(),
2807 Default::default(),
2808 "Test Agent".into(),
2809 "Test",
2810 EditorMode::AutoHeight {
2811 min_lines: 1,
2812 max_lines: None,
2813 },
2814 window,
2815 cx,
2816 );
2817 editor.insert_thread_summary(thread_metadata, window, cx);
2818 editor
2819 })
2820 });
2821
2822 message_editor.read_with(cx, |editor, cx| {
2823 assert!(
2824 editor.text(cx).is_empty(),
2825 "Expected thread summary to be skipped for external agents"
2826 );
2827 assert!(
2828 editor.mention_set().read(cx).mentions().is_empty(),
2829 "Expected no mentions when thread summary is skipped"
2830 );
2831 });
2832 }
2833
2834 #[gpui::test]
2835 async fn test_thread_mode_hidden_when_disabled(cx: &mut TestAppContext) {
2836 init_test(cx);
2837
2838 let fs = FakeFs::new(cx.executor());
2839 fs.insert_tree("/project", json!({"file": ""})).await;
2840 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
2841
2842 let (multi_workspace, cx) =
2843 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2844 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2845
2846 let thread_store = None;
2847 let history = cx
2848 .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
2849
2850 let message_editor = cx.update(|window, cx| {
2851 cx.new(|cx| {
2852 MessageEditor::new(
2853 workspace.downgrade(),
2854 project.downgrade(),
2855 thread_store.clone(),
2856 history.downgrade(),
2857 None,
2858 Default::default(),
2859 Default::default(),
2860 "Test Agent".into(),
2861 "Test",
2862 EditorMode::AutoHeight {
2863 min_lines: 1,
2864 max_lines: None,
2865 },
2866 window,
2867 cx,
2868 )
2869 })
2870 });
2871
2872 message_editor.update(cx, |editor, _cx| {
2873 editor
2874 .prompt_capabilities
2875 .replace(acp::PromptCapabilities::new().embedded_context(true));
2876 });
2877
2878 let supported_modes = {
2879 let app = cx.app.borrow();
2880 message_editor.supported_modes(&app)
2881 };
2882
2883 assert!(
2884 !supported_modes.contains(&PromptContextType::Thread),
2885 "Expected thread mode to be hidden when thread mentions are disabled"
2886 );
2887 }
2888
2889 #[gpui::test]
2890 async fn test_thread_mode_visible_when_enabled(cx: &mut TestAppContext) {
2891 init_test(cx);
2892
2893 let fs = FakeFs::new(cx.executor());
2894 fs.insert_tree("/project", json!({"file": ""})).await;
2895 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
2896
2897 let (multi_workspace, cx) =
2898 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2899 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2900
2901 let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
2902 let history = cx
2903 .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
2904
2905 let message_editor = cx.update(|window, cx| {
2906 cx.new(|cx| {
2907 MessageEditor::new(
2908 workspace.downgrade(),
2909 project.downgrade(),
2910 thread_store.clone(),
2911 history.downgrade(),
2912 None,
2913 Default::default(),
2914 Default::default(),
2915 "Test Agent".into(),
2916 "Test",
2917 EditorMode::AutoHeight {
2918 min_lines: 1,
2919 max_lines: None,
2920 },
2921 window,
2922 cx,
2923 )
2924 })
2925 });
2926
2927 message_editor.update(cx, |editor, _cx| {
2928 editor
2929 .prompt_capabilities
2930 .replace(acp::PromptCapabilities::new().embedded_context(true));
2931 });
2932
2933 let supported_modes = {
2934 let app = cx.app.borrow();
2935 message_editor.supported_modes(&app)
2936 };
2937
2938 assert!(
2939 supported_modes.contains(&PromptContextType::Thread),
2940 "Expected thread mode to be visible when enabled"
2941 );
2942 }
2943
2944 #[gpui::test]
2945 async fn test_whitespace_trimming(cx: &mut TestAppContext) {
2946 init_test(cx);
2947
2948 let fs = FakeFs::new(cx.executor());
2949 fs.insert_tree("/project", json!({"file.rs": "fn main() {}"}))
2950 .await;
2951 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
2952
2953 let (multi_workspace, cx) =
2954 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2955 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2956
2957 let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
2958 let history = cx
2959 .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
2960
2961 let message_editor = cx.update(|window, cx| {
2962 cx.new(|cx| {
2963 MessageEditor::new(
2964 workspace.downgrade(),
2965 project.downgrade(),
2966 thread_store.clone(),
2967 history.downgrade(),
2968 None,
2969 Default::default(),
2970 Default::default(),
2971 "Test Agent".into(),
2972 "Test",
2973 EditorMode::AutoHeight {
2974 min_lines: 1,
2975 max_lines: None,
2976 },
2977 window,
2978 cx,
2979 )
2980 })
2981 });
2982 let editor = message_editor.update(cx, |message_editor, _| message_editor.editor.clone());
2983
2984 cx.run_until_parked();
2985
2986 editor.update_in(cx, |editor, window, cx| {
2987 editor.set_text(" \u{A0}してhello world ", window, cx);
2988 });
2989
2990 let (content, _) = message_editor
2991 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
2992 .await
2993 .unwrap();
2994
2995 assert_eq!(content, vec!["してhello world".into()]);
2996 }
2997
2998 #[gpui::test]
2999 async fn test_editor_respects_embedded_context_capability(cx: &mut TestAppContext) {
3000 init_test(cx);
3001
3002 let fs = FakeFs::new(cx.executor());
3003
3004 let file_content = "fn main() { println!(\"Hello, world!\"); }\n";
3005
3006 fs.insert_tree(
3007 "/project",
3008 json!({
3009 "src": {
3010 "main.rs": file_content,
3011 }
3012 }),
3013 )
3014 .await;
3015
3016 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
3017
3018 let (multi_workspace, cx) =
3019 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3020 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
3021
3022 let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
3023 let history = cx
3024 .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
3025
3026 let (message_editor, editor) = workspace.update_in(cx, |workspace, window, cx| {
3027 let workspace_handle = cx.weak_entity();
3028 let message_editor = cx.new(|cx| {
3029 MessageEditor::new(
3030 workspace_handle,
3031 project.downgrade(),
3032 thread_store.clone(),
3033 history.downgrade(),
3034 None,
3035 Default::default(),
3036 Default::default(),
3037 "Test Agent".into(),
3038 "Test",
3039 EditorMode::AutoHeight {
3040 max_lines: None,
3041 min_lines: 1,
3042 },
3043 window,
3044 cx,
3045 )
3046 });
3047 workspace.active_pane().update(cx, |pane, cx| {
3048 pane.add_item(
3049 Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
3050 true,
3051 true,
3052 None,
3053 window,
3054 cx,
3055 );
3056 });
3057 message_editor.read(cx).focus_handle(cx).focus(window, cx);
3058 let editor = message_editor.read(cx).editor().clone();
3059 (message_editor, editor)
3060 });
3061
3062 cx.simulate_input("What is in @file main");
3063
3064 editor.update_in(cx, |editor, window, cx| {
3065 assert!(editor.has_visible_completions_menu());
3066 assert_eq!(editor.text(cx), "What is in @file main");
3067 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
3068 });
3069
3070 let content = message_editor
3071 .update(cx, |editor, cx| editor.contents(false, cx))
3072 .await
3073 .unwrap()
3074 .0;
3075
3076 let main_rs_uri = if cfg!(windows) {
3077 "file:///C:/project/src/main.rs"
3078 } else {
3079 "file:///project/src/main.rs"
3080 };
3081
3082 // When embedded context is `false` we should get a resource link
3083 pretty_assertions::assert_eq!(
3084 content,
3085 vec![
3086 "What is in ".into(),
3087 acp::ContentBlock::ResourceLink(acp::ResourceLink::new("main.rs", main_rs_uri))
3088 ]
3089 );
3090
3091 message_editor.update(cx, |editor, _cx| {
3092 editor
3093 .prompt_capabilities
3094 .replace(acp::PromptCapabilities::new().embedded_context(true))
3095 });
3096
3097 let content = message_editor
3098 .update(cx, |editor, cx| editor.contents(false, cx))
3099 .await
3100 .unwrap()
3101 .0;
3102
3103 // When embedded context is `true` we should get a resource
3104 pretty_assertions::assert_eq!(
3105 content,
3106 vec![
3107 "What is in ".into(),
3108 acp::ContentBlock::Resource(acp::EmbeddedResource::new(
3109 acp::EmbeddedResourceResource::TextResourceContents(
3110 acp::TextResourceContents::new(file_content, main_rs_uri)
3111 )
3112 ))
3113 ]
3114 );
3115 }
3116
3117 #[gpui::test]
3118 async fn test_autoscroll_after_insert_selections(cx: &mut TestAppContext) {
3119 init_test(cx);
3120
3121 let app_state = cx.update(AppState::test);
3122
3123 cx.update(|cx| {
3124 editor::init(cx);
3125 workspace::init(app_state.clone(), cx);
3126 });
3127
3128 app_state
3129 .fs
3130 .as_fake()
3131 .insert_tree(
3132 path!("/dir"),
3133 json!({
3134 "test.txt": "line1\nline2\nline3\nline4\nline5\n",
3135 }),
3136 )
3137 .await;
3138
3139 let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
3140 let window =
3141 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3142 let workspace = window
3143 .read_with(cx, |mw, _| mw.workspace().clone())
3144 .unwrap();
3145
3146 let worktree = project.update(cx, |project, cx| {
3147 let mut worktrees = project.worktrees(cx).collect::<Vec<_>>();
3148 assert_eq!(worktrees.len(), 1);
3149 worktrees.pop().unwrap()
3150 });
3151 let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id());
3152
3153 let mut cx = VisualTestContext::from_window(window.into(), cx);
3154
3155 // Open a regular editor with the created file, and select a portion of
3156 // the text that will be used for the selections that are meant to be
3157 // inserted in the agent panel.
3158 let editor = workspace
3159 .update_in(&mut cx, |workspace, window, cx| {
3160 workspace.open_path(
3161 ProjectPath {
3162 worktree_id,
3163 path: rel_path("test.txt").into(),
3164 },
3165 None,
3166 false,
3167 window,
3168 cx,
3169 )
3170 })
3171 .await
3172 .unwrap()
3173 .downcast::<Editor>()
3174 .unwrap();
3175
3176 editor.update_in(&mut cx, |editor, window, cx| {
3177 editor.change_selections(Default::default(), window, cx, |selections| {
3178 selections.select_ranges([Point::new(0, 0)..Point::new(0, 5)]);
3179 });
3180 });
3181
3182 let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
3183 let history = cx
3184 .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
3185
3186 // Create a new `MessageEditor`. The `EditorMode::full()` has to be used
3187 // to ensure we have a fixed viewport, so we can eventually actually
3188 // place the cursor outside of the visible area.
3189 let message_editor = workspace.update_in(&mut cx, |workspace, window, cx| {
3190 let workspace_handle = cx.weak_entity();
3191 let message_editor = cx.new(|cx| {
3192 MessageEditor::new(
3193 workspace_handle,
3194 project.downgrade(),
3195 thread_store.clone(),
3196 history.downgrade(),
3197 None,
3198 Default::default(),
3199 Default::default(),
3200 "Test Agent".into(),
3201 "Test",
3202 EditorMode::full(),
3203 window,
3204 cx,
3205 )
3206 });
3207 workspace.active_pane().update(cx, |pane, cx| {
3208 pane.add_item(
3209 Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
3210 true,
3211 true,
3212 None,
3213 window,
3214 cx,
3215 );
3216 });
3217
3218 message_editor
3219 });
3220
3221 message_editor.update_in(&mut cx, |message_editor, window, cx| {
3222 message_editor.editor.update(cx, |editor, cx| {
3223 // Update the Agent Panel's Message Editor text to have 100
3224 // lines, ensuring that the cursor is set at line 90 and that we
3225 // then scroll all the way to the top, so the cursor's position
3226 // remains off screen.
3227 let mut lines = String::new();
3228 for _ in 1..=100 {
3229 lines.push_str(&"Another line in the agent panel's message editor\n");
3230 }
3231 editor.set_text(lines.as_str(), window, cx);
3232 editor.change_selections(Default::default(), window, cx, |selections| {
3233 selections.select_ranges([Point::new(90, 0)..Point::new(90, 0)]);
3234 });
3235 editor.set_scroll_position(gpui::Point::new(0., 0.), window, cx);
3236 });
3237 });
3238
3239 cx.run_until_parked();
3240
3241 // Before proceeding, let's assert that the cursor is indeed off screen,
3242 // otherwise the rest of the test doesn't make sense.
3243 message_editor.update_in(&mut cx, |message_editor, window, cx| {
3244 message_editor.editor.update(cx, |editor, cx| {
3245 let snapshot = editor.snapshot(window, cx);
3246 let cursor_row = editor.selections.newest::<Point>(&snapshot).head().row;
3247 let scroll_top = snapshot.scroll_position().y as u32;
3248 let visible_lines = editor.visible_line_count().unwrap() as u32;
3249 let visible_range = scroll_top..(scroll_top + visible_lines);
3250
3251 assert!(!visible_range.contains(&cursor_row));
3252 })
3253 });
3254
3255 // Now let's insert the selection in the Agent Panel's editor and
3256 // confirm that, after the insertion, the cursor is now in the visible
3257 // range.
3258 message_editor.update_in(&mut cx, |message_editor, window, cx| {
3259 message_editor.insert_selections(window, cx);
3260 });
3261
3262 cx.run_until_parked();
3263
3264 message_editor.update_in(&mut cx, |message_editor, window, cx| {
3265 message_editor.editor.update(cx, |editor, cx| {
3266 let snapshot = editor.snapshot(window, cx);
3267 let cursor_row = editor.selections.newest::<Point>(&snapshot).head().row;
3268 let scroll_top = snapshot.scroll_position().y as u32;
3269 let visible_lines = editor.visible_line_count().unwrap() as u32;
3270 let visible_range = scroll_top..(scroll_top + visible_lines);
3271
3272 assert!(visible_range.contains(&cursor_row));
3273 })
3274 });
3275 }
3276
3277 #[gpui::test]
3278 async fn test_insert_context_with_multibyte_characters(cx: &mut TestAppContext) {
3279 init_test(cx);
3280
3281 let app_state = cx.update(AppState::test);
3282
3283 cx.update(|cx| {
3284 editor::init(cx);
3285 workspace::init(app_state.clone(), cx);
3286 });
3287
3288 app_state
3289 .fs
3290 .as_fake()
3291 .insert_tree(path!("/dir"), json!({}))
3292 .await;
3293
3294 let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
3295 let window =
3296 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3297 let workspace = window
3298 .read_with(cx, |mw, _| mw.workspace().clone())
3299 .unwrap();
3300
3301 let mut cx = VisualTestContext::from_window(window.into(), cx);
3302
3303 let thread_store = cx.new(|cx| ThreadStore::new(cx));
3304 let history = cx
3305 .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
3306
3307 let (message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| {
3308 let workspace_handle = cx.weak_entity();
3309 let message_editor = cx.new(|cx| {
3310 MessageEditor::new(
3311 workspace_handle,
3312 project.downgrade(),
3313 Some(thread_store),
3314 history.downgrade(),
3315 None,
3316 Default::default(),
3317 Default::default(),
3318 "Test Agent".into(),
3319 "Test",
3320 EditorMode::AutoHeight {
3321 max_lines: None,
3322 min_lines: 1,
3323 },
3324 window,
3325 cx,
3326 )
3327 });
3328 workspace.active_pane().update(cx, |pane, cx| {
3329 pane.add_item(
3330 Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
3331 true,
3332 true,
3333 None,
3334 window,
3335 cx,
3336 );
3337 });
3338 message_editor.read(cx).focus_handle(cx).focus(window, cx);
3339 let editor = message_editor.read(cx).editor().clone();
3340 (message_editor, editor)
3341 });
3342
3343 editor.update_in(&mut cx, |editor, window, cx| {
3344 editor.set_text("😄😄", window, cx);
3345 });
3346
3347 cx.run_until_parked();
3348
3349 message_editor.update_in(&mut cx, |message_editor, window, cx| {
3350 message_editor.insert_context_type("file", window, cx);
3351 });
3352
3353 cx.run_until_parked();
3354
3355 editor.update(&mut cx, |editor, cx| {
3356 assert_eq!(editor.text(cx), "😄😄@file");
3357 });
3358 }
3359
3360 #[gpui::test]
3361 async fn test_paste_mention_link_with_multiple_selections(cx: &mut TestAppContext) {
3362 init_test(cx);
3363
3364 let app_state = cx.update(AppState::test);
3365
3366 cx.update(|cx| {
3367 editor::init(cx);
3368 workspace::init(app_state.clone(), cx);
3369 });
3370
3371 app_state
3372 .fs
3373 .as_fake()
3374 .insert_tree(path!("/project"), json!({"file.txt": "content"}))
3375 .await;
3376
3377 let project = Project::test(app_state.fs.clone(), [path!("/project").as_ref()], cx).await;
3378 let window =
3379 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3380 let workspace = window
3381 .read_with(cx, |mw, _| mw.workspace().clone())
3382 .unwrap();
3383
3384 let mut cx = VisualTestContext::from_window(window.into(), cx);
3385
3386 let thread_store = cx.new(|cx| ThreadStore::new(cx));
3387 let history = cx
3388 .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
3389
3390 let (message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| {
3391 let workspace_handle = cx.weak_entity();
3392 let message_editor = cx.new(|cx| {
3393 MessageEditor::new(
3394 workspace_handle,
3395 project.downgrade(),
3396 Some(thread_store),
3397 history.downgrade(),
3398 None,
3399 Default::default(),
3400 Default::default(),
3401 "Test Agent".into(),
3402 "Test",
3403 EditorMode::AutoHeight {
3404 max_lines: None,
3405 min_lines: 1,
3406 },
3407 window,
3408 cx,
3409 )
3410 });
3411 workspace.active_pane().update(cx, |pane, cx| {
3412 pane.add_item(
3413 Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
3414 true,
3415 true,
3416 None,
3417 window,
3418 cx,
3419 );
3420 });
3421 message_editor.read(cx).focus_handle(cx).focus(window, cx);
3422 let editor = message_editor.read(cx).editor().clone();
3423 (message_editor, editor)
3424 });
3425
3426 editor.update_in(&mut cx, |editor, window, cx| {
3427 editor.set_text(
3428 "AAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAA",
3429 window,
3430 cx,
3431 );
3432 });
3433
3434 cx.run_until_parked();
3435
3436 editor.update_in(&mut cx, |editor, window, cx| {
3437 editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
3438 s.select_ranges([
3439 MultiBufferOffset(0)..MultiBufferOffset(25), // First selection (large)
3440 MultiBufferOffset(30)..MultiBufferOffset(55), // Second selection (newest)
3441 ]);
3442 });
3443 });
3444
3445 let mention_link = "[@f](file:///test.txt)";
3446 cx.write_to_clipboard(ClipboardItem::new_string(mention_link.into()));
3447
3448 message_editor.update_in(&mut cx, |message_editor, window, cx| {
3449 message_editor.paste(&Paste, window, cx);
3450 });
3451
3452 let text = editor.update(&mut cx, |editor, cx| editor.text(cx));
3453 assert!(
3454 text.contains("[@f](file:///test.txt)"),
3455 "Expected mention link to be pasted, got: {}",
3456 text
3457 );
3458 }
3459}