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 let path_style = workspace.read(cx).project().read(cx).path_style(cx);
751
752 // Parse markdown mention links in format: [@name](uri)
753 let parsed_mentions = parse_mention_links(&clipboard_text, path_style);
754
755 if !parsed_mentions.is_empty() {
756 cx.stop_propagation();
757
758 let insertion_offset = self.editor.update(cx, |editor, cx| {
759 let snapshot = editor.buffer().read(cx).snapshot(cx);
760 editor.selections.newest_anchor().start.to_offset(&snapshot)
761 });
762
763 // Insert the raw text first
764 self.editor.update(cx, |editor, cx| {
765 editor.insert(&clipboard_text, window, cx);
766 });
767
768 let supports_images = self.prompt_capabilities.borrow().image;
769 let http_client = workspace.read(cx).client().http_client();
770
771 // Now create creases for each mention and load their content
772 let snapshot = self.editor.read(cx).buffer().read(cx).snapshot(cx);
773 for (range, mention_uri) in parsed_mentions {
774 let start_offset = insertion_offset.0 + range.start;
775 let anchor = snapshot.anchor_before(MultiBufferOffset(start_offset));
776 let content_len = range.end - range.start;
777
778 let Some((crease_id, tx)) = insert_crease_for_mention(
779 anchor.excerpt_id,
780 anchor.text_anchor,
781 content_len,
782 mention_uri.name().into(),
783 mention_uri.icon_path(cx),
784 None,
785 self.editor.clone(),
786 window,
787 cx,
788 ) else {
789 continue;
790 };
791
792 // Create the confirmation task based on the mention URI type.
793 // This properly loads file content, fetches URLs, etc.
794 let task = self.mention_set.update(cx, |mention_set, cx| {
795 mention_set.confirm_mention_for_uri(
796 mention_uri.clone(),
797 supports_images,
798 http_client.clone(),
799 cx,
800 )
801 });
802 let task = cx
803 .spawn(async move |_, _| task.await.map_err(|e| e.to_string()))
804 .shared();
805
806 self.mention_set.update(cx, |mention_set, _cx| {
807 mention_set.insert_mention(crease_id, mention_uri.clone(), task.clone())
808 });
809
810 // Drop the tx after inserting to signal the crease is ready
811 drop(tx);
812 }
813 return;
814 }
815 }
816
817 if self.prompt_capabilities.borrow().image
818 && let Some(task) = paste_images_as_context(
819 self.editor.clone(),
820 self.mention_set.clone(),
821 self.workspace.clone(),
822 window,
823 cx,
824 )
825 {
826 task.detach();
827 return;
828 }
829
830 // Fall through to default editor paste
831 cx.propagate();
832 }
833
834 fn paste_raw(&mut self, _: &PasteRaw, window: &mut Window, cx: &mut Context<Self>) {
835 let editor = self.editor.clone();
836 window.defer(cx, move |window, cx| {
837 editor.update(cx, |editor, cx| editor.paste(&Paste, window, cx));
838 });
839 }
840
841 pub fn insert_dragged_files(
842 &mut self,
843 paths: Vec<project::ProjectPath>,
844 added_worktrees: Vec<Entity<Worktree>>,
845 window: &mut Window,
846 cx: &mut Context<Self>,
847 ) {
848 let Some(workspace) = self.workspace.upgrade() else {
849 return;
850 };
851 let project = workspace.read(cx).project().clone();
852 let path_style = project.read(cx).path_style(cx);
853 let buffer = self.editor.read(cx).buffer().clone();
854 let Some(buffer) = buffer.read(cx).as_singleton() else {
855 return;
856 };
857 let mut tasks = Vec::new();
858 for path in paths {
859 let Some(entry) = project.read(cx).entry_for_path(&path, cx) else {
860 continue;
861 };
862 let Some(worktree) = project.read(cx).worktree_for_id(path.worktree_id, cx) else {
863 continue;
864 };
865 let abs_path = worktree.read(cx).absolutize(&path.path);
866 let (file_name, _) = crate::completion_provider::extract_file_name_and_directory(
867 &path.path,
868 worktree.read(cx).root_name(),
869 path_style,
870 );
871
872 let uri = if entry.is_dir() {
873 MentionUri::Directory { abs_path }
874 } else {
875 MentionUri::File { abs_path }
876 };
877
878 let new_text = format!("{} ", uri.as_link());
879 let content_len = new_text.len() - 1;
880
881 let anchor = buffer.update(cx, |buffer, _cx| buffer.anchor_before(buffer.len()));
882
883 self.editor.update(cx, |message_editor, cx| {
884 message_editor.edit(
885 [(
886 multi_buffer::Anchor::max()..multi_buffer::Anchor::max(),
887 new_text,
888 )],
889 cx,
890 );
891 });
892 let supports_images = self.prompt_capabilities.borrow().image;
893 tasks.push(self.mention_set.update(cx, |mention_set, cx| {
894 mention_set.confirm_mention_completion(
895 file_name,
896 anchor,
897 content_len,
898 uri,
899 supports_images,
900 self.editor.clone(),
901 &workspace,
902 window,
903 cx,
904 )
905 }));
906 }
907 cx.spawn(async move |_, _| {
908 join_all(tasks).await;
909 drop(added_worktrees);
910 })
911 .detach();
912 }
913
914 /// Inserts code snippets as creases into the editor.
915 /// Each tuple contains (code_text, crease_title).
916 pub fn insert_code_creases(
917 &mut self,
918 creases: Vec<(String, String)>,
919 window: &mut Window,
920 cx: &mut Context<Self>,
921 ) {
922 self.editor.update(cx, |editor, cx| {
923 editor.insert("\n", window, cx);
924 });
925 for (text, crease_title) in creases {
926 self.insert_crease_impl(text, crease_title, IconName::TextSnippet, true, window, cx);
927 }
928 }
929
930 pub fn insert_terminal_crease(
931 &mut self,
932 text: String,
933 window: &mut Window,
934 cx: &mut Context<Self>,
935 ) {
936 let line_count = text.lines().count() as u32;
937 let mention_uri = MentionUri::TerminalSelection { line_count };
938 let mention_text = mention_uri.as_link().to_string();
939
940 let (excerpt_id, text_anchor, content_len) = self.editor.update(cx, |editor, cx| {
941 let buffer = editor.buffer().read(cx);
942 let snapshot = buffer.snapshot(cx);
943 let (excerpt_id, _, buffer_snapshot) = snapshot.as_singleton().unwrap();
944 let text_anchor = editor
945 .selections
946 .newest_anchor()
947 .start
948 .text_anchor
949 .bias_left(&buffer_snapshot);
950
951 editor.insert(&mention_text, window, cx);
952 editor.insert(" ", window, cx);
953
954 (excerpt_id, text_anchor, mention_text.len())
955 });
956
957 let Some((crease_id, tx)) = insert_crease_for_mention(
958 excerpt_id,
959 text_anchor,
960 content_len,
961 mention_uri.name().into(),
962 mention_uri.icon_path(cx),
963 None,
964 self.editor.clone(),
965 window,
966 cx,
967 ) else {
968 return;
969 };
970 drop(tx);
971
972 let mention_task = Task::ready(Ok(Mention::Text {
973 content: text,
974 tracked_buffers: vec![],
975 }))
976 .shared();
977
978 self.mention_set.update(cx, |mention_set, _| {
979 mention_set.insert_mention(crease_id, mention_uri, mention_task);
980 });
981 }
982
983 fn insert_crease_impl(
984 &mut self,
985 text: String,
986 title: String,
987 icon: IconName,
988 add_trailing_newline: bool,
989 window: &mut Window,
990 cx: &mut Context<Self>,
991 ) {
992 use editor::display_map::{Crease, FoldPlaceholder};
993 use multi_buffer::MultiBufferRow;
994 use rope::Point;
995
996 self.editor.update(cx, |editor, cx| {
997 let point = editor
998 .selections
999 .newest::<Point>(&editor.display_snapshot(cx))
1000 .head();
1001 let start_row = MultiBufferRow(point.row);
1002
1003 editor.insert(&text, window, cx);
1004
1005 let snapshot = editor.buffer().read(cx).snapshot(cx);
1006 let anchor_before = snapshot.anchor_after(point);
1007 let anchor_after = editor
1008 .selections
1009 .newest_anchor()
1010 .head()
1011 .bias_left(&snapshot);
1012
1013 if add_trailing_newline {
1014 editor.insert("\n", window, cx);
1015 }
1016
1017 let fold_placeholder = FoldPlaceholder {
1018 render: Arc::new({
1019 let title = title.clone();
1020 move |_fold_id, _fold_range, _cx| {
1021 ButtonLike::new("crease")
1022 .style(ButtonStyle::Filled)
1023 .layer(ElevationIndex::ElevatedSurface)
1024 .child(Icon::new(icon))
1025 .child(Label::new(title.clone()).single_line())
1026 .into_any_element()
1027 }
1028 }),
1029 merge_adjacent: false,
1030 ..Default::default()
1031 };
1032
1033 let crease = Crease::inline(
1034 anchor_before..anchor_after,
1035 fold_placeholder,
1036 |row, is_folded, fold, _window, _cx| {
1037 Disclosure::new(("crease-toggle", row.0 as u64), !is_folded)
1038 .toggle_state(is_folded)
1039 .on_click(move |_e, window, cx| fold(!is_folded, window, cx))
1040 .into_any_element()
1041 },
1042 |_, _, _, _| gpui::Empty.into_any(),
1043 );
1044 editor.insert_creases(vec![crease], cx);
1045 editor.fold_at(start_row, window, cx);
1046 });
1047 }
1048
1049 pub fn insert_selections(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1050 let editor = self.editor.read(cx);
1051 let editor_buffer = editor.buffer().read(cx);
1052 let Some(buffer) = editor_buffer.as_singleton() else {
1053 return;
1054 };
1055 let cursor_anchor = editor.selections.newest_anchor().head();
1056 let cursor_offset = cursor_anchor.to_offset(&editor_buffer.snapshot(cx));
1057 let anchor = buffer.update(cx, |buffer, _cx| {
1058 buffer.anchor_before(cursor_offset.0.min(buffer.len()))
1059 });
1060 let Some(workspace) = self.workspace.upgrade() else {
1061 return;
1062 };
1063 let Some(completion) =
1064 PromptCompletionProvider::<Entity<MessageEditor>>::completion_for_action(
1065 PromptContextAction::AddSelections,
1066 anchor..anchor,
1067 self.editor.downgrade(),
1068 self.mention_set.downgrade(),
1069 &workspace,
1070 cx,
1071 )
1072 else {
1073 return;
1074 };
1075
1076 self.editor.update(cx, |message_editor, cx| {
1077 message_editor.edit([(cursor_anchor..cursor_anchor, completion.new_text)], cx);
1078 message_editor.request_autoscroll(Autoscroll::fit(), cx);
1079 });
1080 if let Some(confirm) = completion.confirm {
1081 confirm(CompletionIntent::Complete, window, cx);
1082 }
1083 }
1084
1085 pub fn add_images_from_picker(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1086 if !self.prompt_capabilities.borrow().image {
1087 return;
1088 }
1089
1090 let editor = self.editor.clone();
1091 let mention_set = self.mention_set.clone();
1092 let workspace = self.workspace.clone();
1093
1094 let paths_receiver = cx.prompt_for_paths(gpui::PathPromptOptions {
1095 files: true,
1096 directories: false,
1097 multiple: true,
1098 prompt: Some("Select Images".into()),
1099 });
1100
1101 window
1102 .spawn(cx, async move |cx| {
1103 let paths = match paths_receiver.await {
1104 Ok(Ok(Some(paths))) => paths,
1105 _ => return Ok::<(), anyhow::Error>(()),
1106 };
1107
1108 let supported_formats = [
1109 ("png", gpui::ImageFormat::Png),
1110 ("jpg", gpui::ImageFormat::Jpeg),
1111 ("jpeg", gpui::ImageFormat::Jpeg),
1112 ("webp", gpui::ImageFormat::Webp),
1113 ("gif", gpui::ImageFormat::Gif),
1114 ("bmp", gpui::ImageFormat::Bmp),
1115 ("tiff", gpui::ImageFormat::Tiff),
1116 ("tif", gpui::ImageFormat::Tiff),
1117 ("ico", gpui::ImageFormat::Ico),
1118 ];
1119
1120 let mut images = Vec::new();
1121 for path in paths {
1122 let extension = path
1123 .extension()
1124 .and_then(|ext| ext.to_str())
1125 .map(|s| s.to_lowercase());
1126
1127 let Some(format) = extension.and_then(|ext| {
1128 supported_formats
1129 .iter()
1130 .find(|(e, _)| *e == ext)
1131 .map(|(_, f)| *f)
1132 }) else {
1133 continue;
1134 };
1135
1136 let Ok(content) = async_fs::read(&path).await else {
1137 continue;
1138 };
1139
1140 images.push(gpui::Image::from_bytes(format, content));
1141 }
1142
1143 crate::mention_set::insert_images_as_context(
1144 images,
1145 editor,
1146 mention_set,
1147 workspace,
1148 cx,
1149 )
1150 .await;
1151 Ok(())
1152 })
1153 .detach_and_log_err(cx);
1154 }
1155
1156 pub fn set_read_only(&mut self, read_only: bool, cx: &mut Context<Self>) {
1157 self.editor.update(cx, |message_editor, cx| {
1158 message_editor.set_read_only(read_only);
1159 cx.notify()
1160 })
1161 }
1162
1163 pub fn set_mode(&mut self, mode: EditorMode, cx: &mut Context<Self>) {
1164 self.editor.update(cx, |editor, cx| {
1165 editor.set_mode(mode);
1166 cx.notify()
1167 });
1168 }
1169
1170 pub fn set_message(
1171 &mut self,
1172 message: Vec<acp::ContentBlock>,
1173 window: &mut Window,
1174 cx: &mut Context<Self>,
1175 ) {
1176 let Some(workspace) = self.workspace.upgrade() else {
1177 return;
1178 };
1179
1180 self.clear(window, cx);
1181
1182 let path_style = workspace.read(cx).project().read(cx).path_style(cx);
1183 let mut text = String::new();
1184 let mut mentions = Vec::new();
1185
1186 for chunk in message {
1187 match chunk {
1188 acp::ContentBlock::Text(text_content) => {
1189 text.push_str(&text_content.text);
1190 }
1191 acp::ContentBlock::Resource(acp::EmbeddedResource {
1192 resource: acp::EmbeddedResourceResource::TextResourceContents(resource),
1193 ..
1194 }) => {
1195 let Some(mention_uri) = MentionUri::parse(&resource.uri, path_style).log_err()
1196 else {
1197 continue;
1198 };
1199 let start = text.len();
1200 write!(&mut text, "{}", mention_uri.as_link()).ok();
1201 let end = text.len();
1202 mentions.push((
1203 start..end,
1204 mention_uri,
1205 Mention::Text {
1206 content: resource.text,
1207 tracked_buffers: Vec::new(),
1208 },
1209 ));
1210 }
1211 acp::ContentBlock::ResourceLink(resource) => {
1212 if let Some(mention_uri) =
1213 MentionUri::parse(&resource.uri, path_style).log_err()
1214 {
1215 let start = text.len();
1216 write!(&mut text, "{}", mention_uri.as_link()).ok();
1217 let end = text.len();
1218 mentions.push((start..end, mention_uri, Mention::Link));
1219 }
1220 }
1221 acp::ContentBlock::Image(acp::ImageContent {
1222 uri,
1223 data,
1224 mime_type,
1225 ..
1226 }) => {
1227 let mention_uri = if let Some(uri) = uri {
1228 MentionUri::parse(&uri, path_style)
1229 } else {
1230 Ok(MentionUri::PastedImage)
1231 };
1232 let Some(mention_uri) = mention_uri.log_err() else {
1233 continue;
1234 };
1235 let Some(format) = ImageFormat::from_mime_type(&mime_type) else {
1236 log::error!("failed to parse MIME type for image: {mime_type:?}");
1237 continue;
1238 };
1239 let start = text.len();
1240 write!(&mut text, "{}", mention_uri.as_link()).ok();
1241 let end = text.len();
1242 mentions.push((
1243 start..end,
1244 mention_uri,
1245 Mention::Image(MentionImage {
1246 data: data.into(),
1247 format,
1248 }),
1249 ));
1250 }
1251 _ => {}
1252 }
1253 }
1254
1255 let snapshot = self.editor.update(cx, |editor, cx| {
1256 editor.set_text(text, window, cx);
1257 editor.buffer().read(cx).snapshot(cx)
1258 });
1259
1260 for (range, mention_uri, mention) in mentions {
1261 let anchor = snapshot.anchor_before(MultiBufferOffset(range.start));
1262 let Some((crease_id, tx)) = insert_crease_for_mention(
1263 anchor.excerpt_id,
1264 anchor.text_anchor,
1265 range.end - range.start,
1266 mention_uri.name().into(),
1267 mention_uri.icon_path(cx),
1268 None,
1269 self.editor.clone(),
1270 window,
1271 cx,
1272 ) else {
1273 continue;
1274 };
1275 drop(tx);
1276
1277 self.mention_set.update(cx, |mention_set, _cx| {
1278 mention_set.insert_mention(
1279 crease_id,
1280 mention_uri.clone(),
1281 Task::ready(Ok(mention)).shared(),
1282 )
1283 });
1284 }
1285 cx.notify();
1286 }
1287
1288 pub fn text(&self, cx: &App) -> String {
1289 self.editor.read(cx).text(cx)
1290 }
1291
1292 pub fn set_placeholder_text(
1293 &mut self,
1294 placeholder: &str,
1295 window: &mut Window,
1296 cx: &mut Context<Self>,
1297 ) {
1298 self.editor.update(cx, |editor, cx| {
1299 editor.set_placeholder_text(placeholder, window, cx);
1300 });
1301 }
1302
1303 #[cfg(test)]
1304 pub fn set_text(&mut self, text: &str, window: &mut Window, cx: &mut Context<Self>) {
1305 self.editor.update(cx, |editor, cx| {
1306 editor.set_text(text, window, cx);
1307 });
1308 }
1309}
1310
1311impl Focusable for MessageEditor {
1312 fn focus_handle(&self, cx: &App) -> FocusHandle {
1313 self.editor.focus_handle(cx)
1314 }
1315}
1316
1317impl Render for MessageEditor {
1318 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1319 div()
1320 .key_context("MessageEditor")
1321 .on_action(cx.listener(Self::chat))
1322 .on_action(cx.listener(Self::send_immediately))
1323 .on_action(cx.listener(Self::chat_with_follow))
1324 .on_action(cx.listener(Self::cancel))
1325 .on_action(cx.listener(Self::paste_raw))
1326 .capture_action(cx.listener(Self::paste))
1327 .flex_1()
1328 .child({
1329 let settings = ThemeSettings::get_global(cx);
1330
1331 let text_style = TextStyle {
1332 color: cx.theme().colors().text,
1333 font_family: settings.buffer_font.family.clone(),
1334 font_fallbacks: settings.buffer_font.fallbacks.clone(),
1335 font_features: settings.buffer_font.features.clone(),
1336 font_size: settings.agent_buffer_font_size(cx).into(),
1337 font_weight: settings.buffer_font.weight,
1338 line_height: relative(settings.buffer_line_height.value()),
1339 ..Default::default()
1340 };
1341
1342 EditorElement::new(
1343 &self.editor,
1344 EditorStyle {
1345 background: cx.theme().colors().editor_background,
1346 local_player: cx.theme().players().local(),
1347 text: text_style,
1348 syntax: cx.theme().syntax().clone(),
1349 inlay_hints_style: editor::make_inlay_hints_style(cx),
1350 ..Default::default()
1351 },
1352 )
1353 })
1354 }
1355}
1356
1357pub struct MessageEditorAddon {}
1358
1359impl MessageEditorAddon {
1360 pub fn new() -> Self {
1361 Self {}
1362 }
1363}
1364
1365impl Addon for MessageEditorAddon {
1366 fn to_any(&self) -> &dyn std::any::Any {
1367 self
1368 }
1369
1370 fn to_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
1371 Some(self)
1372 }
1373
1374 fn extend_key_context(&self, key_context: &mut KeyContext, cx: &App) {
1375 let settings = agent_settings::AgentSettings::get_global(cx);
1376 if settings.use_modifier_to_send {
1377 key_context.add("use_modifier_to_send");
1378 }
1379 }
1380}
1381
1382/// Parses markdown mention links in the format `[@name](uri)` from text.
1383/// Returns a vector of (range, MentionUri) pairs where range is the byte range in the text.
1384fn parse_mention_links(text: &str, path_style: PathStyle) -> Vec<(Range<usize>, MentionUri)> {
1385 let mut mentions = Vec::new();
1386 let mut search_start = 0;
1387
1388 while let Some(link_start) = text[search_start..].find("[@") {
1389 let absolute_start = search_start + link_start;
1390
1391 // Find the matching closing bracket for the name, handling nested brackets.
1392 // Start at the '[' character so find_matching_bracket can track depth correctly.
1393 let Some(name_end) = find_matching_bracket(&text[absolute_start..], '[', ']') else {
1394 search_start = absolute_start + 2;
1395 continue;
1396 };
1397 let name_end = absolute_start + name_end;
1398
1399 // Check for opening parenthesis immediately after
1400 if text.get(name_end + 1..name_end + 2) != Some("(") {
1401 search_start = name_end + 1;
1402 continue;
1403 }
1404
1405 // Find the matching closing parenthesis for the URI, handling nested parens
1406 let uri_start = name_end + 2;
1407 let Some(uri_end_relative) = find_matching_bracket(&text[name_end + 1..], '(', ')') else {
1408 search_start = uri_start;
1409 continue;
1410 };
1411 let uri_end = name_end + 1 + uri_end_relative;
1412 let link_end = uri_end + 1;
1413
1414 let uri_str = &text[uri_start..uri_end];
1415
1416 // Try to parse the URI as a MentionUri
1417 if let Ok(mention_uri) = MentionUri::parse(uri_str, path_style) {
1418 mentions.push((absolute_start..link_end, mention_uri));
1419 }
1420
1421 search_start = link_end;
1422 }
1423
1424 mentions
1425}
1426
1427/// Finds the position of the matching closing bracket, handling nested brackets.
1428/// The input `text` should start with the opening bracket.
1429/// Returns the index of the matching closing bracket relative to `text`.
1430fn find_matching_bracket(text: &str, open: char, close: char) -> Option<usize> {
1431 let mut depth = 0;
1432 for (index, character) in text.char_indices() {
1433 if character == open {
1434 depth += 1;
1435 } else if character == close {
1436 depth -= 1;
1437 if depth == 0 {
1438 return Some(index);
1439 }
1440 }
1441 }
1442 None
1443}
1444
1445#[cfg(test)]
1446mod tests {
1447 use std::{cell::RefCell, ops::Range, path::Path, rc::Rc, sync::Arc};
1448
1449 use acp_thread::{AgentSessionInfo, MentionUri};
1450 use agent::{ThreadStore, outline};
1451 use agent_client_protocol as acp;
1452 use editor::{AnchorRangeExt as _, Editor, EditorMode, MultiBufferOffset};
1453
1454 use fs::FakeFs;
1455 use futures::StreamExt as _;
1456 use gpui::{
1457 AppContext, Entity, EventEmitter, FocusHandle, Focusable, TestAppContext, VisualTestContext,
1458 };
1459 use language_model::LanguageModelRegistry;
1460 use lsp::{CompletionContext, CompletionTriggerKind};
1461 use project::{CompletionIntent, Project, ProjectPath};
1462 use serde_json::json;
1463
1464 use text::Point;
1465 use ui::{App, Context, IntoElement, Render, SharedString, Window};
1466 use util::{path, paths::PathStyle, rel_path::rel_path};
1467 use workspace::{AppState, Item, MultiWorkspace};
1468
1469 use crate::acp::{
1470 message_editor::{Mention, MessageEditor, parse_mention_links},
1471 thread_view::tests::init_test,
1472 };
1473 use crate::completion_provider::{PromptCompletionProviderDelegate, PromptContextType};
1474
1475 #[test]
1476 fn test_parse_mention_links() {
1477 // Single file mention
1478 let text = "[@bundle-mac](file:///Users/test/zed/script/bundle-mac)";
1479 let mentions = parse_mention_links(text, PathStyle::local());
1480 assert_eq!(mentions.len(), 1);
1481 assert_eq!(mentions[0].0, 0..text.len());
1482 assert!(matches!(mentions[0].1, MentionUri::File { .. }));
1483
1484 // Multiple mentions
1485 let text = "Check [@file1](file:///path/to/file1) and [@file2](file:///path/to/file2)!";
1486 let mentions = parse_mention_links(text, PathStyle::local());
1487 assert_eq!(mentions.len(), 2);
1488
1489 // Text without mentions
1490 let text = "Just some regular text without mentions";
1491 let mentions = parse_mention_links(text, PathStyle::local());
1492 assert_eq!(mentions.len(), 0);
1493
1494 // Malformed mentions (should be skipped)
1495 let text = "[@incomplete](invalid://uri) and [@missing](";
1496 let mentions = parse_mention_links(text, PathStyle::local());
1497 assert_eq!(mentions.len(), 0);
1498
1499 // Mixed content with valid mention
1500 let text = "Before [@valid](file:///path/to/file) after";
1501 let mentions = parse_mention_links(text, PathStyle::local());
1502 assert_eq!(mentions.len(), 1);
1503 assert_eq!(mentions[0].0.start, 7);
1504
1505 // HTTP URL mention (Fetch)
1506 let text = "Check out [@docs](https://example.com/docs) for more info";
1507 let mentions = parse_mention_links(text, PathStyle::local());
1508 assert_eq!(mentions.len(), 1);
1509 assert!(matches!(mentions[0].1, MentionUri::Fetch { .. }));
1510
1511 // Directory mention (trailing slash)
1512 let text = "[@src](file:///path/to/src/)";
1513 let mentions = parse_mention_links(text, PathStyle::local());
1514 assert_eq!(mentions.len(), 1);
1515 assert!(matches!(mentions[0].1, MentionUri::Directory { .. }));
1516
1517 // Multiple different mention types
1518 let text = "File [@f](file:///a) and URL [@u](https://b.com) and dir [@d](file:///c/)";
1519 let mentions = parse_mention_links(text, PathStyle::local());
1520 assert_eq!(mentions.len(), 3);
1521 assert!(matches!(mentions[0].1, MentionUri::File { .. }));
1522 assert!(matches!(mentions[1].1, MentionUri::Fetch { .. }));
1523 assert!(matches!(mentions[2].1, MentionUri::Directory { .. }));
1524
1525 // Adjacent mentions without separator
1526 let text = "[@a](file:///a)[@b](file:///b)";
1527 let mentions = parse_mention_links(text, PathStyle::local());
1528 assert_eq!(mentions.len(), 2);
1529
1530 // Regular markdown link (not a mention) should be ignored
1531 let text = "[regular link](https://example.com)";
1532 let mentions = parse_mention_links(text, PathStyle::local());
1533 assert_eq!(mentions.len(), 0);
1534
1535 // Incomplete mention link patterns
1536 let text = "[@name] without url and [@name( malformed";
1537 let mentions = parse_mention_links(text, PathStyle::local());
1538 assert_eq!(mentions.len(), 0);
1539
1540 // Nested brackets in name portion
1541 let text = "[@name [with brackets]](file:///path/to/file)";
1542 let mentions = parse_mention_links(text, PathStyle::local());
1543 assert_eq!(mentions.len(), 1);
1544 assert_eq!(mentions[0].0, 0..text.len());
1545
1546 // Deeply nested brackets
1547 let text = "[@outer [inner [deep]]](file:///path)";
1548 let mentions = parse_mention_links(text, PathStyle::local());
1549 assert_eq!(mentions.len(), 1);
1550
1551 // Unbalanced brackets should fail gracefully
1552 let text = "[@unbalanced [bracket](file:///path)";
1553 let mentions = parse_mention_links(text, PathStyle::local());
1554 assert_eq!(mentions.len(), 0);
1555
1556 // Nested parentheses in URI (common in URLs with query params)
1557 let text = "[@wiki](https://en.wikipedia.org/wiki/Rust_(programming_language))";
1558 let mentions = parse_mention_links(text, PathStyle::local());
1559 assert_eq!(mentions.len(), 1);
1560 if let MentionUri::Fetch { url } = &mentions[0].1 {
1561 assert!(url.as_str().contains("Rust_(programming_language)"));
1562 } else {
1563 panic!("Expected Fetch URI");
1564 }
1565 }
1566
1567 #[gpui::test]
1568 async fn test_at_mention_removal(cx: &mut TestAppContext) {
1569 init_test(cx);
1570
1571 let fs = FakeFs::new(cx.executor());
1572 fs.insert_tree("/project", json!({"file": ""})).await;
1573 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
1574
1575 let (multi_workspace, cx) =
1576 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1577 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
1578
1579 let thread_store = None;
1580 let history = cx
1581 .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
1582
1583 let message_editor = cx.update(|window, cx| {
1584 cx.new(|cx| {
1585 MessageEditor::new(
1586 workspace.downgrade(),
1587 project.downgrade(),
1588 thread_store.clone(),
1589 history.downgrade(),
1590 None,
1591 Default::default(),
1592 Default::default(),
1593 "Test Agent".into(),
1594 "Test",
1595 EditorMode::AutoHeight {
1596 min_lines: 1,
1597 max_lines: None,
1598 },
1599 window,
1600 cx,
1601 )
1602 })
1603 });
1604 let editor = message_editor.update(cx, |message_editor, _| message_editor.editor.clone());
1605
1606 cx.run_until_parked();
1607
1608 let excerpt_id = editor.update(cx, |editor, cx| {
1609 editor
1610 .buffer()
1611 .read(cx)
1612 .excerpt_ids()
1613 .into_iter()
1614 .next()
1615 .unwrap()
1616 });
1617 let completions = editor.update_in(cx, |editor, window, cx| {
1618 editor.set_text("Hello @file ", window, cx);
1619 let buffer = editor.buffer().read(cx).as_singleton().unwrap();
1620 let completion_provider = editor.completion_provider().unwrap();
1621 completion_provider.completions(
1622 excerpt_id,
1623 &buffer,
1624 text::Anchor::MAX,
1625 CompletionContext {
1626 trigger_kind: CompletionTriggerKind::TRIGGER_CHARACTER,
1627 trigger_character: Some("@".into()),
1628 },
1629 window,
1630 cx,
1631 )
1632 });
1633 let [_, completion]: [_; 2] = completions
1634 .await
1635 .unwrap()
1636 .into_iter()
1637 .flat_map(|response| response.completions)
1638 .collect::<Vec<_>>()
1639 .try_into()
1640 .unwrap();
1641
1642 editor.update_in(cx, |editor, window, cx| {
1643 let snapshot = editor.buffer().read(cx).snapshot(cx);
1644 let range = snapshot
1645 .anchor_range_in_excerpt(excerpt_id, completion.replace_range)
1646 .unwrap();
1647 editor.edit([(range, completion.new_text)], cx);
1648 (completion.confirm.unwrap())(CompletionIntent::Complete, window, cx);
1649 });
1650
1651 cx.run_until_parked();
1652
1653 // Backspace over the inserted crease (and the following space).
1654 editor.update_in(cx, |editor, window, cx| {
1655 editor.backspace(&Default::default(), window, cx);
1656 editor.backspace(&Default::default(), window, cx);
1657 });
1658
1659 let (content, _) = message_editor
1660 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
1661 .await
1662 .unwrap();
1663
1664 // We don't send a resource link for the deleted crease.
1665 pretty_assertions::assert_matches!(content.as_slice(), [acp::ContentBlock::Text { .. }]);
1666 }
1667
1668 #[gpui::test]
1669 async fn test_slash_command_validation(cx: &mut gpui::TestAppContext) {
1670 init_test(cx);
1671 let fs = FakeFs::new(cx.executor());
1672 fs.insert_tree(
1673 "/test",
1674 json!({
1675 ".zed": {
1676 "tasks.json": r#"[{"label": "test", "command": "echo"}]"#
1677 },
1678 "src": {
1679 "main.rs": "fn main() {}",
1680 },
1681 }),
1682 )
1683 .await;
1684
1685 let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
1686 let thread_store = None;
1687 let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default()));
1688 // Start with no available commands - simulating Claude which doesn't support slash commands
1689 let available_commands = Rc::new(RefCell::new(vec![]));
1690
1691 let (multi_workspace, cx) =
1692 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1693 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
1694 let history = cx
1695 .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
1696 let workspace_handle = workspace.downgrade();
1697 let message_editor = workspace.update_in(cx, |_, window, cx| {
1698 cx.new(|cx| {
1699 MessageEditor::new(
1700 workspace_handle.clone(),
1701 project.downgrade(),
1702 thread_store.clone(),
1703 history.downgrade(),
1704 None,
1705 prompt_capabilities.clone(),
1706 available_commands.clone(),
1707 "Claude Agent".into(),
1708 "Test",
1709 EditorMode::AutoHeight {
1710 min_lines: 1,
1711 max_lines: None,
1712 },
1713 window,
1714 cx,
1715 )
1716 })
1717 });
1718 let editor = message_editor.update(cx, |message_editor, _| message_editor.editor.clone());
1719
1720 // Test that slash commands fail when no available_commands are set (empty list means no commands supported)
1721 editor.update_in(cx, |editor, window, cx| {
1722 editor.set_text("/file test.txt", window, cx);
1723 });
1724
1725 let contents_result = message_editor
1726 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
1727 .await;
1728
1729 // Should fail because available_commands is empty (no commands supported)
1730 assert!(contents_result.is_err());
1731 let error_message = contents_result.unwrap_err().to_string();
1732 assert!(error_message.contains("not supported by Claude Agent"));
1733 assert!(error_message.contains("Available commands: none"));
1734
1735 // Now simulate Claude providing its list of available commands (which doesn't include file)
1736 available_commands.replace(vec![acp::AvailableCommand::new("help", "Get help")]);
1737
1738 // Test that unsupported slash commands trigger an error when we have a list of available commands
1739 editor.update_in(cx, |editor, window, cx| {
1740 editor.set_text("/file test.txt", window, cx);
1741 });
1742
1743 let contents_result = message_editor
1744 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
1745 .await;
1746
1747 assert!(contents_result.is_err());
1748 let error_message = contents_result.unwrap_err().to_string();
1749 assert!(error_message.contains("not supported by Claude Agent"));
1750 assert!(error_message.contains("/file"));
1751 assert!(error_message.contains("Available commands: /help"));
1752
1753 // Test that supported commands work fine
1754 editor.update_in(cx, |editor, window, cx| {
1755 editor.set_text("/help", window, cx);
1756 });
1757
1758 let contents_result = message_editor
1759 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
1760 .await;
1761
1762 // Should succeed because /help is in available_commands
1763 assert!(contents_result.is_ok());
1764
1765 // Test that regular text works fine
1766 editor.update_in(cx, |editor, window, cx| {
1767 editor.set_text("Hello Claude!", window, cx);
1768 });
1769
1770 let (content, _) = message_editor
1771 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
1772 .await
1773 .unwrap();
1774
1775 assert_eq!(content.len(), 1);
1776 if let acp::ContentBlock::Text(text) = &content[0] {
1777 assert_eq!(text.text, "Hello Claude!");
1778 } else {
1779 panic!("Expected ContentBlock::Text");
1780 }
1781
1782 // Test that @ mentions still work
1783 editor.update_in(cx, |editor, window, cx| {
1784 editor.set_text("Check this @", window, cx);
1785 });
1786
1787 // The @ mention functionality should not be affected
1788 let (content, _) = message_editor
1789 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
1790 .await
1791 .unwrap();
1792
1793 assert_eq!(content.len(), 1);
1794 if let acp::ContentBlock::Text(text) = &content[0] {
1795 assert_eq!(text.text, "Check this @");
1796 } else {
1797 panic!("Expected ContentBlock::Text");
1798 }
1799 }
1800
1801 struct MessageEditorItem(Entity<MessageEditor>);
1802
1803 impl Item for MessageEditorItem {
1804 type Event = ();
1805
1806 fn include_in_nav_history() -> bool {
1807 false
1808 }
1809
1810 fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
1811 "Test".into()
1812 }
1813 }
1814
1815 impl EventEmitter<()> for MessageEditorItem {}
1816
1817 impl Focusable for MessageEditorItem {
1818 fn focus_handle(&self, cx: &App) -> FocusHandle {
1819 self.0.read(cx).focus_handle(cx)
1820 }
1821 }
1822
1823 impl Render for MessageEditorItem {
1824 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
1825 self.0.clone().into_any_element()
1826 }
1827 }
1828
1829 #[gpui::test]
1830 async fn test_completion_provider_commands(cx: &mut TestAppContext) {
1831 init_test(cx);
1832
1833 let app_state = cx.update(AppState::test);
1834
1835 cx.update(|cx| {
1836 editor::init(cx);
1837 workspace::init(app_state.clone(), cx);
1838 });
1839
1840 let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
1841 let window =
1842 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1843 let workspace = window
1844 .read_with(cx, |mw, _| mw.workspace().clone())
1845 .unwrap();
1846
1847 let mut cx = VisualTestContext::from_window(window.into(), cx);
1848
1849 let thread_store = None;
1850 let history = cx
1851 .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
1852 let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default()));
1853 let available_commands = Rc::new(RefCell::new(vec![
1854 acp::AvailableCommand::new("quick-math", "2 + 2 = 4 - 1 = 3"),
1855 acp::AvailableCommand::new("say-hello", "Say hello to whoever you want").input(
1856 acp::AvailableCommandInput::Unstructured(acp::UnstructuredCommandInput::new(
1857 "<name>",
1858 )),
1859 ),
1860 ]));
1861
1862 let editor = workspace.update_in(&mut cx, |workspace, window, cx| {
1863 let workspace_handle = cx.weak_entity();
1864 let message_editor = cx.new(|cx| {
1865 MessageEditor::new(
1866 workspace_handle,
1867 project.downgrade(),
1868 thread_store.clone(),
1869 history.downgrade(),
1870 None,
1871 prompt_capabilities.clone(),
1872 available_commands.clone(),
1873 "Test Agent".into(),
1874 "Test",
1875 EditorMode::AutoHeight {
1876 max_lines: None,
1877 min_lines: 1,
1878 },
1879 window,
1880 cx,
1881 )
1882 });
1883 workspace.active_pane().update(cx, |pane, cx| {
1884 pane.add_item(
1885 Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
1886 true,
1887 true,
1888 None,
1889 window,
1890 cx,
1891 );
1892 });
1893 message_editor.read(cx).focus_handle(cx).focus(window, cx);
1894 message_editor.read(cx).editor().clone()
1895 });
1896
1897 cx.simulate_input("/");
1898
1899 editor.update_in(&mut cx, |editor, window, cx| {
1900 assert_eq!(editor.text(cx), "/");
1901 assert!(editor.has_visible_completions_menu());
1902
1903 assert_eq!(
1904 current_completion_labels_with_documentation(editor),
1905 &[
1906 ("quick-math".into(), "2 + 2 = 4 - 1 = 3".into()),
1907 ("say-hello".into(), "Say hello to whoever you want".into())
1908 ]
1909 );
1910 editor.set_text("", window, cx);
1911 });
1912
1913 cx.simulate_input("/qui");
1914
1915 editor.update_in(&mut cx, |editor, window, cx| {
1916 assert_eq!(editor.text(cx), "/qui");
1917 assert!(editor.has_visible_completions_menu());
1918
1919 assert_eq!(
1920 current_completion_labels_with_documentation(editor),
1921 &[("quick-math".into(), "2 + 2 = 4 - 1 = 3".into())]
1922 );
1923 editor.set_text("", window, cx);
1924 });
1925
1926 editor.update_in(&mut cx, |editor, window, cx| {
1927 assert!(editor.has_visible_completions_menu());
1928 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
1929 });
1930
1931 cx.run_until_parked();
1932
1933 editor.update_in(&mut cx, |editor, window, cx| {
1934 assert_eq!(editor.display_text(cx), "/quick-math ");
1935 assert!(!editor.has_visible_completions_menu());
1936 editor.set_text("", window, cx);
1937 });
1938
1939 cx.simulate_input("/say");
1940
1941 editor.update_in(&mut cx, |editor, _window, cx| {
1942 assert_eq!(editor.display_text(cx), "/say");
1943 assert!(editor.has_visible_completions_menu());
1944
1945 assert_eq!(
1946 current_completion_labels_with_documentation(editor),
1947 &[("say-hello".into(), "Say hello to whoever you want".into())]
1948 );
1949 });
1950
1951 editor.update_in(&mut cx, |editor, window, cx| {
1952 assert!(editor.has_visible_completions_menu());
1953 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
1954 });
1955
1956 cx.run_until_parked();
1957
1958 editor.update_in(&mut cx, |editor, _window, cx| {
1959 assert_eq!(editor.text(cx), "/say-hello ");
1960 assert_eq!(editor.display_text(cx), "/say-hello <name>");
1961 assert!(!editor.has_visible_completions_menu());
1962 });
1963
1964 cx.simulate_input("GPT5");
1965
1966 cx.run_until_parked();
1967
1968 editor.update_in(&mut cx, |editor, window, cx| {
1969 assert_eq!(editor.text(cx), "/say-hello GPT5");
1970 assert_eq!(editor.display_text(cx), "/say-hello GPT5");
1971 assert!(!editor.has_visible_completions_menu());
1972
1973 // Delete argument
1974 for _ in 0..5 {
1975 editor.backspace(&editor::actions::Backspace, window, cx);
1976 }
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 // Hint is visible because argument was deleted
1984 assert_eq!(editor.display_text(cx), "/say-hello <name>");
1985
1986 // Delete last command letter
1987 editor.backspace(&editor::actions::Backspace, window, cx);
1988 });
1989
1990 cx.run_until_parked();
1991
1992 editor.update_in(&mut cx, |editor, _window, cx| {
1993 // Hint goes away once command no longer matches an available one
1994 assert_eq!(editor.text(cx), "/say-hell");
1995 assert_eq!(editor.display_text(cx), "/say-hell");
1996 assert!(!editor.has_visible_completions_menu());
1997 });
1998 }
1999
2000 #[gpui::test]
2001 async fn test_context_completion_provider_mentions(cx: &mut TestAppContext) {
2002 init_test(cx);
2003
2004 let app_state = cx.update(AppState::test);
2005
2006 cx.update(|cx| {
2007 editor::init(cx);
2008 workspace::init(app_state.clone(), cx);
2009 });
2010
2011 app_state
2012 .fs
2013 .as_fake()
2014 .insert_tree(
2015 path!("/dir"),
2016 json!({
2017 "editor": "",
2018 "a": {
2019 "one.txt": "1",
2020 "two.txt": "2",
2021 "three.txt": "3",
2022 "four.txt": "4"
2023 },
2024 "b": {
2025 "five.txt": "5",
2026 "six.txt": "6",
2027 "seven.txt": "7",
2028 "eight.txt": "8",
2029 },
2030 "x.png": "",
2031 }),
2032 )
2033 .await;
2034
2035 let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
2036 let window =
2037 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2038 let workspace = window
2039 .read_with(cx, |mw, _| mw.workspace().clone())
2040 .unwrap();
2041
2042 let worktree = project.update(cx, |project, cx| {
2043 let mut worktrees = project.worktrees(cx).collect::<Vec<_>>();
2044 assert_eq!(worktrees.len(), 1);
2045 worktrees.pop().unwrap()
2046 });
2047 let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id());
2048
2049 let mut cx = VisualTestContext::from_window(window.into(), cx);
2050
2051 let paths = vec![
2052 rel_path("a/one.txt"),
2053 rel_path("a/two.txt"),
2054 rel_path("a/three.txt"),
2055 rel_path("a/four.txt"),
2056 rel_path("b/five.txt"),
2057 rel_path("b/six.txt"),
2058 rel_path("b/seven.txt"),
2059 rel_path("b/eight.txt"),
2060 ];
2061
2062 let slash = PathStyle::local().primary_separator();
2063
2064 let mut opened_editors = Vec::new();
2065 for path in paths {
2066 let buffer = workspace
2067 .update_in(&mut cx, |workspace, window, cx| {
2068 workspace.open_path(
2069 ProjectPath {
2070 worktree_id,
2071 path: path.into(),
2072 },
2073 None,
2074 false,
2075 window,
2076 cx,
2077 )
2078 })
2079 .await
2080 .unwrap();
2081 opened_editors.push(buffer);
2082 }
2083
2084 let thread_store = cx.new(|cx| ThreadStore::new(cx));
2085 let history = cx
2086 .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
2087 let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default()));
2088
2089 let (message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| {
2090 let workspace_handle = cx.weak_entity();
2091 let message_editor = cx.new(|cx| {
2092 MessageEditor::new(
2093 workspace_handle,
2094 project.downgrade(),
2095 Some(thread_store),
2096 history.downgrade(),
2097 None,
2098 prompt_capabilities.clone(),
2099 Default::default(),
2100 "Test Agent".into(),
2101 "Test",
2102 EditorMode::AutoHeight {
2103 max_lines: None,
2104 min_lines: 1,
2105 },
2106 window,
2107 cx,
2108 )
2109 });
2110 workspace.active_pane().update(cx, |pane, cx| {
2111 pane.add_item(
2112 Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
2113 true,
2114 true,
2115 None,
2116 window,
2117 cx,
2118 );
2119 });
2120 message_editor.read(cx).focus_handle(cx).focus(window, cx);
2121 let editor = message_editor.read(cx).editor().clone();
2122 (message_editor, editor)
2123 });
2124
2125 cx.simulate_input("Lorem @");
2126
2127 editor.update_in(&mut cx, |editor, window, cx| {
2128 assert_eq!(editor.text(cx), "Lorem @");
2129 assert!(editor.has_visible_completions_menu());
2130
2131 assert_eq!(
2132 current_completion_labels(editor),
2133 &[
2134 format!("eight.txt b{slash}"),
2135 format!("seven.txt b{slash}"),
2136 format!("six.txt b{slash}"),
2137 format!("five.txt b{slash}"),
2138 "Files & Directories".into(),
2139 "Symbols".into()
2140 ]
2141 );
2142 editor.set_text("", window, cx);
2143 });
2144
2145 prompt_capabilities.replace(
2146 acp::PromptCapabilities::new()
2147 .image(true)
2148 .audio(true)
2149 .embedded_context(true),
2150 );
2151
2152 cx.simulate_input("Lorem ");
2153
2154 editor.update(&mut cx, |editor, cx| {
2155 assert_eq!(editor.text(cx), "Lorem ");
2156 assert!(!editor.has_visible_completions_menu());
2157 });
2158
2159 cx.simulate_input("@");
2160
2161 editor.update(&mut cx, |editor, cx| {
2162 assert_eq!(editor.text(cx), "Lorem @");
2163 assert!(editor.has_visible_completions_menu());
2164 assert_eq!(
2165 current_completion_labels(editor),
2166 &[
2167 format!("eight.txt b{slash}"),
2168 format!("seven.txt b{slash}"),
2169 format!("six.txt b{slash}"),
2170 format!("five.txt b{slash}"),
2171 "Files & Directories".into(),
2172 "Symbols".into(),
2173 "Threads".into(),
2174 "Fetch".into()
2175 ]
2176 );
2177 });
2178
2179 // Select and confirm "File"
2180 editor.update_in(&mut cx, |editor, window, cx| {
2181 assert!(editor.has_visible_completions_menu());
2182 editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
2183 editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
2184 editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
2185 editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
2186 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2187 });
2188
2189 cx.run_until_parked();
2190
2191 editor.update(&mut cx, |editor, cx| {
2192 assert_eq!(editor.text(cx), "Lorem @file ");
2193 assert!(editor.has_visible_completions_menu());
2194 });
2195
2196 cx.simulate_input("one");
2197
2198 editor.update(&mut cx, |editor, cx| {
2199 assert_eq!(editor.text(cx), "Lorem @file one");
2200 assert!(editor.has_visible_completions_menu());
2201 assert_eq!(
2202 current_completion_labels(editor),
2203 vec![format!("one.txt a{slash}")]
2204 );
2205 });
2206
2207 editor.update_in(&mut cx, |editor, window, cx| {
2208 assert!(editor.has_visible_completions_menu());
2209 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2210 });
2211
2212 let url_one = MentionUri::File {
2213 abs_path: path!("/dir/a/one.txt").into(),
2214 }
2215 .to_uri()
2216 .to_string();
2217 editor.update(&mut cx, |editor, cx| {
2218 let text = editor.text(cx);
2219 assert_eq!(text, format!("Lorem [@one.txt]({url_one}) "));
2220 assert!(!editor.has_visible_completions_menu());
2221 assert_eq!(fold_ranges(editor, cx).len(), 1);
2222 });
2223
2224 let contents = message_editor
2225 .update(&mut cx, |message_editor, cx| {
2226 message_editor
2227 .mention_set()
2228 .update(cx, |mention_set, cx| mention_set.contents(false, cx))
2229 })
2230 .await
2231 .unwrap()
2232 .into_values()
2233 .collect::<Vec<_>>();
2234
2235 {
2236 let [(uri, Mention::Text { content, .. })] = contents.as_slice() else {
2237 panic!("Unexpected mentions");
2238 };
2239 pretty_assertions::assert_eq!(content, "1");
2240 pretty_assertions::assert_eq!(
2241 uri,
2242 &MentionUri::parse(&url_one, PathStyle::local()).unwrap()
2243 );
2244 }
2245
2246 cx.simulate_input(" ");
2247
2248 editor.update(&mut cx, |editor, cx| {
2249 let text = editor.text(cx);
2250 assert_eq!(text, format!("Lorem [@one.txt]({url_one}) "));
2251 assert!(!editor.has_visible_completions_menu());
2252 assert_eq!(fold_ranges(editor, cx).len(), 1);
2253 });
2254
2255 cx.simulate_input("Ipsum ");
2256
2257 editor.update(&mut cx, |editor, cx| {
2258 let text = editor.text(cx);
2259 assert_eq!(text, format!("Lorem [@one.txt]({url_one}) Ipsum "),);
2260 assert!(!editor.has_visible_completions_menu());
2261 assert_eq!(fold_ranges(editor, cx).len(), 1);
2262 });
2263
2264 cx.simulate_input("@file ");
2265
2266 editor.update(&mut cx, |editor, cx| {
2267 let text = editor.text(cx);
2268 assert_eq!(text, format!("Lorem [@one.txt]({url_one}) Ipsum @file "),);
2269 assert!(editor.has_visible_completions_menu());
2270 assert_eq!(fold_ranges(editor, cx).len(), 1);
2271 });
2272
2273 editor.update_in(&mut cx, |editor, window, cx| {
2274 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2275 });
2276
2277 cx.run_until_parked();
2278
2279 let contents = message_editor
2280 .update(&mut cx, |message_editor, cx| {
2281 message_editor
2282 .mention_set()
2283 .update(cx, |mention_set, cx| mention_set.contents(false, cx))
2284 })
2285 .await
2286 .unwrap()
2287 .into_values()
2288 .collect::<Vec<_>>();
2289
2290 let url_eight = MentionUri::File {
2291 abs_path: path!("/dir/b/eight.txt").into(),
2292 }
2293 .to_uri()
2294 .to_string();
2295
2296 {
2297 let [_, (uri, Mention::Text { content, .. })] = contents.as_slice() else {
2298 panic!("Unexpected mentions");
2299 };
2300 pretty_assertions::assert_eq!(content, "8");
2301 pretty_assertions::assert_eq!(
2302 uri,
2303 &MentionUri::parse(&url_eight, PathStyle::local()).unwrap()
2304 );
2305 }
2306
2307 editor.update(&mut cx, |editor, cx| {
2308 assert_eq!(
2309 editor.text(cx),
2310 format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) ")
2311 );
2312 assert!(!editor.has_visible_completions_menu());
2313 assert_eq!(fold_ranges(editor, cx).len(), 2);
2314 });
2315
2316 let plain_text_language = Arc::new(language::Language::new(
2317 language::LanguageConfig {
2318 name: "Plain Text".into(),
2319 matcher: language::LanguageMatcher {
2320 path_suffixes: vec!["txt".to_string()],
2321 ..Default::default()
2322 },
2323 ..Default::default()
2324 },
2325 None,
2326 ));
2327
2328 // Register the language and fake LSP
2329 let language_registry = project.read_with(&cx, |project, _| project.languages().clone());
2330 language_registry.add(plain_text_language);
2331
2332 let mut fake_language_servers = language_registry.register_fake_lsp(
2333 "Plain Text",
2334 language::FakeLspAdapter {
2335 capabilities: lsp::ServerCapabilities {
2336 workspace_symbol_provider: Some(lsp::OneOf::Left(true)),
2337 ..Default::default()
2338 },
2339 ..Default::default()
2340 },
2341 );
2342
2343 // Open the buffer to trigger LSP initialization
2344 let buffer = project
2345 .update(&mut cx, |project, cx| {
2346 project.open_local_buffer(path!("/dir/a/one.txt"), cx)
2347 })
2348 .await
2349 .unwrap();
2350
2351 // Register the buffer with language servers
2352 let _handle = project.update(&mut cx, |project, cx| {
2353 project.register_buffer_with_language_servers(&buffer, cx)
2354 });
2355
2356 cx.run_until_parked();
2357
2358 let fake_language_server = fake_language_servers.next().await.unwrap();
2359 fake_language_server.set_request_handler::<lsp::WorkspaceSymbolRequest, _, _>(
2360 move |_, _| async move {
2361 Ok(Some(lsp::WorkspaceSymbolResponse::Flat(vec![
2362 #[allow(deprecated)]
2363 lsp::SymbolInformation {
2364 name: "MySymbol".into(),
2365 location: lsp::Location {
2366 uri: lsp::Uri::from_file_path(path!("/dir/a/one.txt")).unwrap(),
2367 range: lsp::Range::new(
2368 lsp::Position::new(0, 0),
2369 lsp::Position::new(0, 1),
2370 ),
2371 },
2372 kind: lsp::SymbolKind::CONSTANT,
2373 tags: None,
2374 container_name: None,
2375 deprecated: None,
2376 },
2377 ])))
2378 },
2379 );
2380
2381 cx.simulate_input("@symbol ");
2382
2383 editor.update(&mut cx, |editor, cx| {
2384 assert_eq!(
2385 editor.text(cx),
2386 format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) @symbol ")
2387 );
2388 assert!(editor.has_visible_completions_menu());
2389 assert_eq!(current_completion_labels(editor), &["MySymbol one.txt L1"]);
2390 });
2391
2392 editor.update_in(&mut cx, |editor, window, cx| {
2393 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2394 });
2395
2396 let symbol = MentionUri::Symbol {
2397 abs_path: path!("/dir/a/one.txt").into(),
2398 name: "MySymbol".into(),
2399 line_range: 0..=0,
2400 };
2401
2402 let contents = message_editor
2403 .update(&mut cx, |message_editor, cx| {
2404 message_editor
2405 .mention_set()
2406 .update(cx, |mention_set, cx| mention_set.contents(false, cx))
2407 })
2408 .await
2409 .unwrap()
2410 .into_values()
2411 .collect::<Vec<_>>();
2412
2413 {
2414 let [_, _, (uri, Mention::Text { content, .. })] = contents.as_slice() else {
2415 panic!("Unexpected mentions");
2416 };
2417 pretty_assertions::assert_eq!(content, "1");
2418 pretty_assertions::assert_eq!(uri, &symbol);
2419 }
2420
2421 cx.run_until_parked();
2422
2423 editor.read_with(&cx, |editor, cx| {
2424 assert_eq!(
2425 editor.text(cx),
2426 format!(
2427 "Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) ",
2428 symbol.to_uri(),
2429 )
2430 );
2431 });
2432
2433 // Try to mention an "image" file that will fail to load
2434 cx.simulate_input("@file x.png");
2435
2436 editor.update(&mut cx, |editor, cx| {
2437 assert_eq!(
2438 editor.text(cx),
2439 format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) @file x.png", symbol.to_uri())
2440 );
2441 assert!(editor.has_visible_completions_menu());
2442 assert_eq!(current_completion_labels(editor), &["x.png "]);
2443 });
2444
2445 editor.update_in(&mut cx, |editor, window, cx| {
2446 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2447 });
2448
2449 // Getting the message contents fails
2450 message_editor
2451 .update(&mut cx, |message_editor, cx| {
2452 message_editor
2453 .mention_set()
2454 .update(cx, |mention_set, cx| mention_set.contents(false, cx))
2455 })
2456 .await
2457 .expect_err("Should fail to load x.png");
2458
2459 cx.run_until_parked();
2460
2461 // Mention was removed
2462 editor.read_with(&cx, |editor, cx| {
2463 assert_eq!(
2464 editor.text(cx),
2465 format!(
2466 "Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) ",
2467 symbol.to_uri()
2468 )
2469 );
2470 });
2471
2472 // Once more
2473 cx.simulate_input("@file x.png");
2474
2475 editor.update(&mut cx, |editor, cx| {
2476 assert_eq!(
2477 editor.text(cx),
2478 format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) @file x.png", symbol.to_uri())
2479 );
2480 assert!(editor.has_visible_completions_menu());
2481 assert_eq!(current_completion_labels(editor), &["x.png "]);
2482 });
2483
2484 editor.update_in(&mut cx, |editor, window, cx| {
2485 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2486 });
2487
2488 // This time don't immediately get the contents, just let the confirmed completion settle
2489 cx.run_until_parked();
2490
2491 // Mention was removed
2492 editor.read_with(&cx, |editor, cx| {
2493 assert_eq!(
2494 editor.text(cx),
2495 format!(
2496 "Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) ",
2497 symbol.to_uri()
2498 )
2499 );
2500 });
2501
2502 // Now getting the contents succeeds, because the invalid mention was removed
2503 let contents = message_editor
2504 .update(&mut cx, |message_editor, cx| {
2505 message_editor
2506 .mention_set()
2507 .update(cx, |mention_set, cx| mention_set.contents(false, cx))
2508 })
2509 .await
2510 .unwrap();
2511 assert_eq!(contents.len(), 3);
2512 }
2513
2514 fn fold_ranges(editor: &Editor, cx: &mut App) -> Vec<Range<Point>> {
2515 let snapshot = editor.buffer().read(cx).snapshot(cx);
2516 editor.display_map.update(cx, |display_map, cx| {
2517 display_map
2518 .snapshot(cx)
2519 .folds_in_range(MultiBufferOffset(0)..snapshot.len())
2520 .map(|fold| fold.range.to_point(&snapshot))
2521 .collect()
2522 })
2523 }
2524
2525 fn current_completion_labels(editor: &Editor) -> Vec<String> {
2526 let completions = editor.current_completions().expect("Missing completions");
2527 completions
2528 .into_iter()
2529 .map(|completion| completion.label.text)
2530 .collect::<Vec<_>>()
2531 }
2532
2533 fn current_completion_labels_with_documentation(editor: &Editor) -> Vec<(String, String)> {
2534 let completions = editor.current_completions().expect("Missing completions");
2535 completions
2536 .into_iter()
2537 .map(|completion| {
2538 (
2539 completion.label.text,
2540 completion
2541 .documentation
2542 .map(|d| d.text().to_string())
2543 .unwrap_or_default(),
2544 )
2545 })
2546 .collect::<Vec<_>>()
2547 }
2548
2549 #[gpui::test]
2550 async fn test_large_file_mention_fallback(cx: &mut TestAppContext) {
2551 init_test(cx);
2552
2553 let fs = FakeFs::new(cx.executor());
2554
2555 // Create a large file that exceeds AUTO_OUTLINE_SIZE
2556 // Using plain text without a configured language, so no outline is available
2557 const LINE: &str = "This is a line of text in the file\n";
2558 let large_content = LINE.repeat(2 * (outline::AUTO_OUTLINE_SIZE / LINE.len()));
2559 assert!(large_content.len() > outline::AUTO_OUTLINE_SIZE);
2560
2561 // Create a small file that doesn't exceed AUTO_OUTLINE_SIZE
2562 let small_content = "fn small_function() { /* small */ }\n";
2563 assert!(small_content.len() < outline::AUTO_OUTLINE_SIZE);
2564
2565 fs.insert_tree(
2566 "/project",
2567 json!({
2568 "large_file.txt": large_content.clone(),
2569 "small_file.txt": small_content,
2570 }),
2571 )
2572 .await;
2573
2574 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
2575
2576 let (multi_workspace, cx) =
2577 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2578 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2579
2580 let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
2581 let history = cx
2582 .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
2583
2584 let message_editor = cx.update(|window, cx| {
2585 cx.new(|cx| {
2586 let editor = MessageEditor::new(
2587 workspace.downgrade(),
2588 project.downgrade(),
2589 thread_store.clone(),
2590 history.downgrade(),
2591 None,
2592 Default::default(),
2593 Default::default(),
2594 "Test Agent".into(),
2595 "Test",
2596 EditorMode::AutoHeight {
2597 min_lines: 1,
2598 max_lines: None,
2599 },
2600 window,
2601 cx,
2602 );
2603 // Enable embedded context so files are actually included
2604 editor
2605 .prompt_capabilities
2606 .replace(acp::PromptCapabilities::new().embedded_context(true));
2607 editor
2608 })
2609 });
2610
2611 // Test large file mention
2612 // Get the absolute path using the project's worktree
2613 let large_file_abs_path = project.read_with(cx, |project, cx| {
2614 let worktree = project.worktrees(cx).next().unwrap();
2615 let worktree_root = worktree.read(cx).abs_path();
2616 worktree_root.join("large_file.txt")
2617 });
2618 let large_file_task = message_editor.update(cx, |editor, cx| {
2619 editor.mention_set().update(cx, |set, cx| {
2620 set.confirm_mention_for_file(large_file_abs_path, true, cx)
2621 })
2622 });
2623
2624 let large_file_mention = large_file_task.await.unwrap();
2625 match large_file_mention {
2626 Mention::Text { content, .. } => {
2627 // Should contain some of the content but not all of it
2628 assert!(
2629 content.contains(LINE),
2630 "Should contain some of the file content"
2631 );
2632 assert!(
2633 !content.contains(&LINE.repeat(100)),
2634 "Should not contain the full file"
2635 );
2636 // Should be much smaller than original
2637 assert!(
2638 content.len() < large_content.len() / 10,
2639 "Should be significantly truncated"
2640 );
2641 }
2642 _ => panic!("Expected Text mention for large file"),
2643 }
2644
2645 // Test small file mention
2646 // Get the absolute path using the project's worktree
2647 let small_file_abs_path = project.read_with(cx, |project, cx| {
2648 let worktree = project.worktrees(cx).next().unwrap();
2649 let worktree_root = worktree.read(cx).abs_path();
2650 worktree_root.join("small_file.txt")
2651 });
2652 let small_file_task = message_editor.update(cx, |editor, cx| {
2653 editor.mention_set().update(cx, |set, cx| {
2654 set.confirm_mention_for_file(small_file_abs_path, true, cx)
2655 })
2656 });
2657
2658 let small_file_mention = small_file_task.await.unwrap();
2659 match small_file_mention {
2660 Mention::Text { content, .. } => {
2661 // Should contain the full actual content
2662 assert_eq!(content, small_content);
2663 }
2664 _ => panic!("Expected Text mention for small file"),
2665 }
2666 }
2667
2668 #[gpui::test]
2669 async fn test_insert_thread_summary(cx: &mut TestAppContext) {
2670 init_test(cx);
2671 cx.update(LanguageModelRegistry::test);
2672
2673 let fs = FakeFs::new(cx.executor());
2674 fs.insert_tree("/project", json!({"file": ""})).await;
2675 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
2676
2677 let (multi_workspace, cx) =
2678 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2679 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2680
2681 let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
2682 let history = cx
2683 .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
2684
2685 // Create a thread metadata to insert as summary
2686 let thread_metadata = AgentSessionInfo {
2687 session_id: acp::SessionId::new("thread-123"),
2688 cwd: None,
2689 title: Some("Previous Conversation".into()),
2690 updated_at: Some(chrono::Utc::now()),
2691 meta: None,
2692 };
2693
2694 let message_editor = cx.update(|window, cx| {
2695 cx.new(|cx| {
2696 let mut editor = MessageEditor::new(
2697 workspace.downgrade(),
2698 project.downgrade(),
2699 thread_store.clone(),
2700 history.downgrade(),
2701 None,
2702 Default::default(),
2703 Default::default(),
2704 "Test Agent".into(),
2705 "Test",
2706 EditorMode::AutoHeight {
2707 min_lines: 1,
2708 max_lines: None,
2709 },
2710 window,
2711 cx,
2712 );
2713 editor.insert_thread_summary(thread_metadata.clone(), window, cx);
2714 editor
2715 })
2716 });
2717
2718 // Construct expected values for verification
2719 let expected_uri = MentionUri::Thread {
2720 id: thread_metadata.session_id.clone(),
2721 name: thread_metadata.title.as_ref().unwrap().to_string(),
2722 };
2723 let expected_title = thread_metadata.title.as_ref().unwrap();
2724 let expected_link = format!("[@{}]({})", expected_title, expected_uri.to_uri());
2725
2726 message_editor.read_with(cx, |editor, cx| {
2727 let text = editor.text(cx);
2728
2729 assert!(
2730 text.contains(&expected_link),
2731 "Expected editor text to contain thread mention link.\nExpected substring: {}\nActual text: {}",
2732 expected_link,
2733 text
2734 );
2735
2736 let mentions = editor.mention_set().read(cx).mentions();
2737 assert_eq!(
2738 mentions.len(),
2739 1,
2740 "Expected exactly one mention after inserting thread summary"
2741 );
2742
2743 assert!(
2744 mentions.contains(&expected_uri),
2745 "Expected mentions to contain the thread URI"
2746 );
2747 });
2748 }
2749
2750 #[gpui::test]
2751 async fn test_insert_thread_summary_skipped_for_external_agents(cx: &mut TestAppContext) {
2752 init_test(cx);
2753 cx.update(LanguageModelRegistry::test);
2754
2755 let fs = FakeFs::new(cx.executor());
2756 fs.insert_tree("/project", json!({"file": ""})).await;
2757 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
2758
2759 let (multi_workspace, cx) =
2760 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2761 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2762
2763 let thread_store = None;
2764 let history = cx
2765 .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
2766
2767 let thread_metadata = AgentSessionInfo {
2768 session_id: acp::SessionId::new("thread-123"),
2769 cwd: None,
2770 title: Some("Previous Conversation".into()),
2771 updated_at: Some(chrono::Utc::now()),
2772 meta: None,
2773 };
2774
2775 let message_editor = cx.update(|window, cx| {
2776 cx.new(|cx| {
2777 let mut editor = MessageEditor::new(
2778 workspace.downgrade(),
2779 project.downgrade(),
2780 thread_store.clone(),
2781 history.downgrade(),
2782 None,
2783 Default::default(),
2784 Default::default(),
2785 "Test Agent".into(),
2786 "Test",
2787 EditorMode::AutoHeight {
2788 min_lines: 1,
2789 max_lines: None,
2790 },
2791 window,
2792 cx,
2793 );
2794 editor.insert_thread_summary(thread_metadata, window, cx);
2795 editor
2796 })
2797 });
2798
2799 message_editor.read_with(cx, |editor, cx| {
2800 assert!(
2801 editor.text(cx).is_empty(),
2802 "Expected thread summary to be skipped for external agents"
2803 );
2804 assert!(
2805 editor.mention_set().read(cx).mentions().is_empty(),
2806 "Expected no mentions when thread summary is skipped"
2807 );
2808 });
2809 }
2810
2811 #[gpui::test]
2812 async fn test_thread_mode_hidden_when_disabled(cx: &mut TestAppContext) {
2813 init_test(cx);
2814
2815 let fs = FakeFs::new(cx.executor());
2816 fs.insert_tree("/project", json!({"file": ""})).await;
2817 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
2818
2819 let (multi_workspace, cx) =
2820 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2821 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2822
2823 let thread_store = None;
2824 let history = cx
2825 .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
2826
2827 let message_editor = cx.update(|window, cx| {
2828 cx.new(|cx| {
2829 MessageEditor::new(
2830 workspace.downgrade(),
2831 project.downgrade(),
2832 thread_store.clone(),
2833 history.downgrade(),
2834 None,
2835 Default::default(),
2836 Default::default(),
2837 "Test Agent".into(),
2838 "Test",
2839 EditorMode::AutoHeight {
2840 min_lines: 1,
2841 max_lines: None,
2842 },
2843 window,
2844 cx,
2845 )
2846 })
2847 });
2848
2849 message_editor.update(cx, |editor, _cx| {
2850 editor
2851 .prompt_capabilities
2852 .replace(acp::PromptCapabilities::new().embedded_context(true));
2853 });
2854
2855 let supported_modes = {
2856 let app = cx.app.borrow();
2857 message_editor.supported_modes(&app)
2858 };
2859
2860 assert!(
2861 !supported_modes.contains(&PromptContextType::Thread),
2862 "Expected thread mode to be hidden when thread mentions are disabled"
2863 );
2864 }
2865
2866 #[gpui::test]
2867 async fn test_thread_mode_visible_when_enabled(cx: &mut TestAppContext) {
2868 init_test(cx);
2869
2870 let fs = FakeFs::new(cx.executor());
2871 fs.insert_tree("/project", json!({"file": ""})).await;
2872 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
2873
2874 let (multi_workspace, cx) =
2875 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2876 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2877
2878 let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
2879 let history = cx
2880 .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
2881
2882 let message_editor = cx.update(|window, cx| {
2883 cx.new(|cx| {
2884 MessageEditor::new(
2885 workspace.downgrade(),
2886 project.downgrade(),
2887 thread_store.clone(),
2888 history.downgrade(),
2889 None,
2890 Default::default(),
2891 Default::default(),
2892 "Test Agent".into(),
2893 "Test",
2894 EditorMode::AutoHeight {
2895 min_lines: 1,
2896 max_lines: None,
2897 },
2898 window,
2899 cx,
2900 )
2901 })
2902 });
2903
2904 message_editor.update(cx, |editor, _cx| {
2905 editor
2906 .prompt_capabilities
2907 .replace(acp::PromptCapabilities::new().embedded_context(true));
2908 });
2909
2910 let supported_modes = {
2911 let app = cx.app.borrow();
2912 message_editor.supported_modes(&app)
2913 };
2914
2915 assert!(
2916 supported_modes.contains(&PromptContextType::Thread),
2917 "Expected thread mode to be visible when enabled"
2918 );
2919 }
2920
2921 #[gpui::test]
2922 async fn test_whitespace_trimming(cx: &mut TestAppContext) {
2923 init_test(cx);
2924
2925 let fs = FakeFs::new(cx.executor());
2926 fs.insert_tree("/project", json!({"file.rs": "fn main() {}"}))
2927 .await;
2928 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
2929
2930 let (multi_workspace, cx) =
2931 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2932 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2933
2934 let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
2935 let history = cx
2936 .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
2937
2938 let message_editor = cx.update(|window, cx| {
2939 cx.new(|cx| {
2940 MessageEditor::new(
2941 workspace.downgrade(),
2942 project.downgrade(),
2943 thread_store.clone(),
2944 history.downgrade(),
2945 None,
2946 Default::default(),
2947 Default::default(),
2948 "Test Agent".into(),
2949 "Test",
2950 EditorMode::AutoHeight {
2951 min_lines: 1,
2952 max_lines: None,
2953 },
2954 window,
2955 cx,
2956 )
2957 })
2958 });
2959 let editor = message_editor.update(cx, |message_editor, _| message_editor.editor.clone());
2960
2961 cx.run_until_parked();
2962
2963 editor.update_in(cx, |editor, window, cx| {
2964 editor.set_text(" \u{A0}してhello world ", window, cx);
2965 });
2966
2967 let (content, _) = message_editor
2968 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
2969 .await
2970 .unwrap();
2971
2972 assert_eq!(content, vec!["してhello world".into()]);
2973 }
2974
2975 #[gpui::test]
2976 async fn test_editor_respects_embedded_context_capability(cx: &mut TestAppContext) {
2977 init_test(cx);
2978
2979 let fs = FakeFs::new(cx.executor());
2980
2981 let file_content = "fn main() { println!(\"Hello, world!\"); }\n";
2982
2983 fs.insert_tree(
2984 "/project",
2985 json!({
2986 "src": {
2987 "main.rs": file_content,
2988 }
2989 }),
2990 )
2991 .await;
2992
2993 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
2994
2995 let (multi_workspace, cx) =
2996 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2997 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2998
2999 let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
3000 let history = cx
3001 .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
3002
3003 let (message_editor, editor) = workspace.update_in(cx, |workspace, window, cx| {
3004 let workspace_handle = cx.weak_entity();
3005 let message_editor = cx.new(|cx| {
3006 MessageEditor::new(
3007 workspace_handle,
3008 project.downgrade(),
3009 thread_store.clone(),
3010 history.downgrade(),
3011 None,
3012 Default::default(),
3013 Default::default(),
3014 "Test Agent".into(),
3015 "Test",
3016 EditorMode::AutoHeight {
3017 max_lines: None,
3018 min_lines: 1,
3019 },
3020 window,
3021 cx,
3022 )
3023 });
3024 workspace.active_pane().update(cx, |pane, cx| {
3025 pane.add_item(
3026 Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
3027 true,
3028 true,
3029 None,
3030 window,
3031 cx,
3032 );
3033 });
3034 message_editor.read(cx).focus_handle(cx).focus(window, cx);
3035 let editor = message_editor.read(cx).editor().clone();
3036 (message_editor, editor)
3037 });
3038
3039 cx.simulate_input("What is in @file main");
3040
3041 editor.update_in(cx, |editor, window, cx| {
3042 assert!(editor.has_visible_completions_menu());
3043 assert_eq!(editor.text(cx), "What is in @file main");
3044 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
3045 });
3046
3047 let content = message_editor
3048 .update(cx, |editor, cx| editor.contents(false, cx))
3049 .await
3050 .unwrap()
3051 .0;
3052
3053 let main_rs_uri = if cfg!(windows) {
3054 "file:///C:/project/src/main.rs"
3055 } else {
3056 "file:///project/src/main.rs"
3057 };
3058
3059 // When embedded context is `false` we should get a resource link
3060 pretty_assertions::assert_eq!(
3061 content,
3062 vec![
3063 "What is in ".into(),
3064 acp::ContentBlock::ResourceLink(acp::ResourceLink::new("main.rs", main_rs_uri))
3065 ]
3066 );
3067
3068 message_editor.update(cx, |editor, _cx| {
3069 editor
3070 .prompt_capabilities
3071 .replace(acp::PromptCapabilities::new().embedded_context(true))
3072 });
3073
3074 let content = message_editor
3075 .update(cx, |editor, cx| editor.contents(false, cx))
3076 .await
3077 .unwrap()
3078 .0;
3079
3080 // When embedded context is `true` we should get a resource
3081 pretty_assertions::assert_eq!(
3082 content,
3083 vec![
3084 "What is in ".into(),
3085 acp::ContentBlock::Resource(acp::EmbeddedResource::new(
3086 acp::EmbeddedResourceResource::TextResourceContents(
3087 acp::TextResourceContents::new(file_content, main_rs_uri)
3088 )
3089 ))
3090 ]
3091 );
3092 }
3093
3094 #[gpui::test]
3095 async fn test_autoscroll_after_insert_selections(cx: &mut TestAppContext) {
3096 init_test(cx);
3097
3098 let app_state = cx.update(AppState::test);
3099
3100 cx.update(|cx| {
3101 editor::init(cx);
3102 workspace::init(app_state.clone(), cx);
3103 });
3104
3105 app_state
3106 .fs
3107 .as_fake()
3108 .insert_tree(
3109 path!("/dir"),
3110 json!({
3111 "test.txt": "line1\nline2\nline3\nline4\nline5\n",
3112 }),
3113 )
3114 .await;
3115
3116 let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
3117 let window =
3118 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3119 let workspace = window
3120 .read_with(cx, |mw, _| mw.workspace().clone())
3121 .unwrap();
3122
3123 let worktree = project.update(cx, |project, cx| {
3124 let mut worktrees = project.worktrees(cx).collect::<Vec<_>>();
3125 assert_eq!(worktrees.len(), 1);
3126 worktrees.pop().unwrap()
3127 });
3128 let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id());
3129
3130 let mut cx = VisualTestContext::from_window(window.into(), cx);
3131
3132 // Open a regular editor with the created file, and select a portion of
3133 // the text that will be used for the selections that are meant to be
3134 // inserted in the agent panel.
3135 let editor = workspace
3136 .update_in(&mut cx, |workspace, window, cx| {
3137 workspace.open_path(
3138 ProjectPath {
3139 worktree_id,
3140 path: rel_path("test.txt").into(),
3141 },
3142 None,
3143 false,
3144 window,
3145 cx,
3146 )
3147 })
3148 .await
3149 .unwrap()
3150 .downcast::<Editor>()
3151 .unwrap();
3152
3153 editor.update_in(&mut cx, |editor, window, cx| {
3154 editor.change_selections(Default::default(), window, cx, |selections| {
3155 selections.select_ranges([Point::new(0, 0)..Point::new(0, 5)]);
3156 });
3157 });
3158
3159 let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
3160 let history = cx
3161 .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
3162
3163 // Create a new `MessageEditor`. The `EditorMode::full()` has to be used
3164 // to ensure we have a fixed viewport, so we can eventually actually
3165 // place the cursor outside of the visible area.
3166 let message_editor = workspace.update_in(&mut cx, |workspace, window, cx| {
3167 let workspace_handle = cx.weak_entity();
3168 let message_editor = cx.new(|cx| {
3169 MessageEditor::new(
3170 workspace_handle,
3171 project.downgrade(),
3172 thread_store.clone(),
3173 history.downgrade(),
3174 None,
3175 Default::default(),
3176 Default::default(),
3177 "Test Agent".into(),
3178 "Test",
3179 EditorMode::full(),
3180 window,
3181 cx,
3182 )
3183 });
3184 workspace.active_pane().update(cx, |pane, cx| {
3185 pane.add_item(
3186 Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
3187 true,
3188 true,
3189 None,
3190 window,
3191 cx,
3192 );
3193 });
3194
3195 message_editor
3196 });
3197
3198 message_editor.update_in(&mut cx, |message_editor, window, cx| {
3199 message_editor.editor.update(cx, |editor, cx| {
3200 // Update the Agent Panel's Message Editor text to have 100
3201 // lines, ensuring that the cursor is set at line 90 and that we
3202 // then scroll all the way to the top, so the cursor's position
3203 // remains off screen.
3204 let mut lines = String::new();
3205 for _ in 1..=100 {
3206 lines.push_str(&"Another line in the agent panel's message editor\n");
3207 }
3208 editor.set_text(lines.as_str(), window, cx);
3209 editor.change_selections(Default::default(), window, cx, |selections| {
3210 selections.select_ranges([Point::new(90, 0)..Point::new(90, 0)]);
3211 });
3212 editor.set_scroll_position(gpui::Point::new(0., 0.), window, cx);
3213 });
3214 });
3215
3216 cx.run_until_parked();
3217
3218 // Before proceeding, let's assert that the cursor is indeed off screen,
3219 // otherwise the rest of the test doesn't make sense.
3220 message_editor.update_in(&mut cx, |message_editor, window, cx| {
3221 message_editor.editor.update(cx, |editor, cx| {
3222 let snapshot = editor.snapshot(window, cx);
3223 let cursor_row = editor.selections.newest::<Point>(&snapshot).head().row;
3224 let scroll_top = snapshot.scroll_position().y as u32;
3225 let visible_lines = editor.visible_line_count().unwrap() as u32;
3226 let visible_range = scroll_top..(scroll_top + visible_lines);
3227
3228 assert!(!visible_range.contains(&cursor_row));
3229 })
3230 });
3231
3232 // Now let's insert the selection in the Agent Panel's editor and
3233 // confirm that, after the insertion, the cursor is now in the visible
3234 // range.
3235 message_editor.update_in(&mut cx, |message_editor, window, cx| {
3236 message_editor.insert_selections(window, cx);
3237 });
3238
3239 cx.run_until_parked();
3240
3241 message_editor.update_in(&mut cx, |message_editor, window, cx| {
3242 message_editor.editor.update(cx, |editor, cx| {
3243 let snapshot = editor.snapshot(window, cx);
3244 let cursor_row = editor.selections.newest::<Point>(&snapshot).head().row;
3245 let scroll_top = snapshot.scroll_position().y as u32;
3246 let visible_lines = editor.visible_line_count().unwrap() as u32;
3247 let visible_range = scroll_top..(scroll_top + visible_lines);
3248
3249 assert!(visible_range.contains(&cursor_row));
3250 })
3251 });
3252 }
3253
3254 #[gpui::test]
3255 async fn test_insert_context_with_multibyte_characters(cx: &mut TestAppContext) {
3256 init_test(cx);
3257
3258 let app_state = cx.update(AppState::test);
3259
3260 cx.update(|cx| {
3261 editor::init(cx);
3262 workspace::init(app_state.clone(), cx);
3263 });
3264
3265 app_state
3266 .fs
3267 .as_fake()
3268 .insert_tree(path!("/dir"), json!({}))
3269 .await;
3270
3271 let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
3272 let window =
3273 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3274 let workspace = window
3275 .read_with(cx, |mw, _| mw.workspace().clone())
3276 .unwrap();
3277
3278 let mut cx = VisualTestContext::from_window(window.into(), cx);
3279
3280 let thread_store = cx.new(|cx| ThreadStore::new(cx));
3281 let history = cx
3282 .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
3283
3284 let (message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| {
3285 let workspace_handle = cx.weak_entity();
3286 let message_editor = cx.new(|cx| {
3287 MessageEditor::new(
3288 workspace_handle,
3289 project.downgrade(),
3290 Some(thread_store),
3291 history.downgrade(),
3292 None,
3293 Default::default(),
3294 Default::default(),
3295 "Test Agent".into(),
3296 "Test",
3297 EditorMode::AutoHeight {
3298 max_lines: None,
3299 min_lines: 1,
3300 },
3301 window,
3302 cx,
3303 )
3304 });
3305 workspace.active_pane().update(cx, |pane, cx| {
3306 pane.add_item(
3307 Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
3308 true,
3309 true,
3310 None,
3311 window,
3312 cx,
3313 );
3314 });
3315 message_editor.read(cx).focus_handle(cx).focus(window, cx);
3316 let editor = message_editor.read(cx).editor().clone();
3317 (message_editor, editor)
3318 });
3319
3320 editor.update_in(&mut cx, |editor, window, cx| {
3321 editor.set_text("😄😄", window, cx);
3322 });
3323
3324 cx.run_until_parked();
3325
3326 message_editor.update_in(&mut cx, |message_editor, window, cx| {
3327 message_editor.insert_context_type("file", window, cx);
3328 });
3329
3330 cx.run_until_parked();
3331
3332 editor.update(&mut cx, |editor, cx| {
3333 assert_eq!(editor.text(cx), "😄😄@file");
3334 });
3335 }
3336}