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 line_height: relative(settings.buffer_line_height.value()),
1338 ..Default::default()
1339 };
1340
1341 EditorElement::new(
1342 &self.editor,
1343 EditorStyle {
1344 background: cx.theme().colors().editor_background,
1345 local_player: cx.theme().players().local(),
1346 text: text_style,
1347 syntax: cx.theme().syntax().clone(),
1348 inlay_hints_style: editor::make_inlay_hints_style(cx),
1349 ..Default::default()
1350 },
1351 )
1352 })
1353 }
1354}
1355
1356pub struct MessageEditorAddon {}
1357
1358impl MessageEditorAddon {
1359 pub fn new() -> Self {
1360 Self {}
1361 }
1362}
1363
1364impl Addon for MessageEditorAddon {
1365 fn to_any(&self) -> &dyn std::any::Any {
1366 self
1367 }
1368
1369 fn to_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
1370 Some(self)
1371 }
1372
1373 fn extend_key_context(&self, key_context: &mut KeyContext, cx: &App) {
1374 let settings = agent_settings::AgentSettings::get_global(cx);
1375 if settings.use_modifier_to_send {
1376 key_context.add("use_modifier_to_send");
1377 }
1378 }
1379}
1380
1381/// Parses markdown mention links in the format `[@name](uri)` from text.
1382/// Returns a vector of (range, MentionUri) pairs where range is the byte range in the text.
1383fn parse_mention_links(text: &str, path_style: PathStyle) -> Vec<(Range<usize>, MentionUri)> {
1384 let mut mentions = Vec::new();
1385 let mut search_start = 0;
1386
1387 while let Some(link_start) = text[search_start..].find("[@") {
1388 let absolute_start = search_start + link_start;
1389
1390 // Find the matching closing bracket for the name, handling nested brackets.
1391 // Start at the '[' character so find_matching_bracket can track depth correctly.
1392 let Some(name_end) = find_matching_bracket(&text[absolute_start..], '[', ']') else {
1393 search_start = absolute_start + 2;
1394 continue;
1395 };
1396 let name_end = absolute_start + name_end;
1397
1398 // Check for opening parenthesis immediately after
1399 if text.get(name_end + 1..name_end + 2) != Some("(") {
1400 search_start = name_end + 1;
1401 continue;
1402 }
1403
1404 // Find the matching closing parenthesis for the URI, handling nested parens
1405 let uri_start = name_end + 2;
1406 let Some(uri_end_relative) = find_matching_bracket(&text[name_end + 1..], '(', ')') else {
1407 search_start = uri_start;
1408 continue;
1409 };
1410 let uri_end = name_end + 1 + uri_end_relative;
1411 let link_end = uri_end + 1;
1412
1413 let uri_str = &text[uri_start..uri_end];
1414
1415 // Try to parse the URI as a MentionUri
1416 if let Ok(mention_uri) = MentionUri::parse(uri_str, path_style) {
1417 mentions.push((absolute_start..link_end, mention_uri));
1418 }
1419
1420 search_start = link_end;
1421 }
1422
1423 mentions
1424}
1425
1426/// Finds the position of the matching closing bracket, handling nested brackets.
1427/// The input `text` should start with the opening bracket.
1428/// Returns the index of the matching closing bracket relative to `text`.
1429fn find_matching_bracket(text: &str, open: char, close: char) -> Option<usize> {
1430 let mut depth = 0;
1431 for (index, character) in text.char_indices() {
1432 if character == open {
1433 depth += 1;
1434 } else if character == close {
1435 depth -= 1;
1436 if depth == 0 {
1437 return Some(index);
1438 }
1439 }
1440 }
1441 None
1442}
1443
1444#[cfg(test)]
1445mod tests {
1446 use std::{cell::RefCell, ops::Range, path::Path, rc::Rc, sync::Arc};
1447
1448 use acp_thread::{AgentSessionInfo, MentionUri};
1449 use agent::{ThreadStore, outline};
1450 use agent_client_protocol as acp;
1451 use editor::{AnchorRangeExt as _, Editor, EditorMode, MultiBufferOffset};
1452
1453 use fs::FakeFs;
1454 use futures::StreamExt as _;
1455 use gpui::{
1456 AppContext, Entity, EventEmitter, FocusHandle, Focusable, TestAppContext, VisualTestContext,
1457 };
1458 use language_model::LanguageModelRegistry;
1459 use lsp::{CompletionContext, CompletionTriggerKind};
1460 use project::{CompletionIntent, Project, ProjectPath};
1461 use serde_json::json;
1462
1463 use text::Point;
1464 use ui::{App, Context, IntoElement, Render, SharedString, Window};
1465 use util::{path, paths::PathStyle, rel_path::rel_path};
1466 use workspace::{AppState, Item, MultiWorkspace};
1467
1468 use crate::acp::{
1469 message_editor::{Mention, MessageEditor, parse_mention_links},
1470 thread_view::tests::init_test,
1471 };
1472 use crate::completion_provider::{PromptCompletionProviderDelegate, PromptContextType};
1473
1474 #[test]
1475 fn test_parse_mention_links() {
1476 // Single file mention
1477 let text = "[@bundle-mac](file:///Users/test/zed/script/bundle-mac)";
1478 let mentions = parse_mention_links(text, PathStyle::local());
1479 assert_eq!(mentions.len(), 1);
1480 assert_eq!(mentions[0].0, 0..text.len());
1481 assert!(matches!(mentions[0].1, MentionUri::File { .. }));
1482
1483 // Multiple mentions
1484 let text = "Check [@file1](file:///path/to/file1) and [@file2](file:///path/to/file2)!";
1485 let mentions = parse_mention_links(text, PathStyle::local());
1486 assert_eq!(mentions.len(), 2);
1487
1488 // Text without mentions
1489 let text = "Just some regular text without mentions";
1490 let mentions = parse_mention_links(text, PathStyle::local());
1491 assert_eq!(mentions.len(), 0);
1492
1493 // Malformed mentions (should be skipped)
1494 let text = "[@incomplete](invalid://uri) and [@missing](";
1495 let mentions = parse_mention_links(text, PathStyle::local());
1496 assert_eq!(mentions.len(), 0);
1497
1498 // Mixed content with valid mention
1499 let text = "Before [@valid](file:///path/to/file) after";
1500 let mentions = parse_mention_links(text, PathStyle::local());
1501 assert_eq!(mentions.len(), 1);
1502 assert_eq!(mentions[0].0.start, 7);
1503
1504 // HTTP URL mention (Fetch)
1505 let text = "Check out [@docs](https://example.com/docs) for more info";
1506 let mentions = parse_mention_links(text, PathStyle::local());
1507 assert_eq!(mentions.len(), 1);
1508 assert!(matches!(mentions[0].1, MentionUri::Fetch { .. }));
1509
1510 // Directory mention (trailing slash)
1511 let text = "[@src](file:///path/to/src/)";
1512 let mentions = parse_mention_links(text, PathStyle::local());
1513 assert_eq!(mentions.len(), 1);
1514 assert!(matches!(mentions[0].1, MentionUri::Directory { .. }));
1515
1516 // Multiple different mention types
1517 let text = "File [@f](file:///a) and URL [@u](https://b.com) and dir [@d](file:///c/)";
1518 let mentions = parse_mention_links(text, PathStyle::local());
1519 assert_eq!(mentions.len(), 3);
1520 assert!(matches!(mentions[0].1, MentionUri::File { .. }));
1521 assert!(matches!(mentions[1].1, MentionUri::Fetch { .. }));
1522 assert!(matches!(mentions[2].1, MentionUri::Directory { .. }));
1523
1524 // Adjacent mentions without separator
1525 let text = "[@a](file:///a)[@b](file:///b)";
1526 let mentions = parse_mention_links(text, PathStyle::local());
1527 assert_eq!(mentions.len(), 2);
1528
1529 // Regular markdown link (not a mention) should be ignored
1530 let text = "[regular link](https://example.com)";
1531 let mentions = parse_mention_links(text, PathStyle::local());
1532 assert_eq!(mentions.len(), 0);
1533
1534 // Incomplete mention link patterns
1535 let text = "[@name] without url and [@name( malformed";
1536 let mentions = parse_mention_links(text, PathStyle::local());
1537 assert_eq!(mentions.len(), 0);
1538
1539 // Nested brackets in name portion
1540 let text = "[@name [with brackets]](file:///path/to/file)";
1541 let mentions = parse_mention_links(text, PathStyle::local());
1542 assert_eq!(mentions.len(), 1);
1543 assert_eq!(mentions[0].0, 0..text.len());
1544
1545 // Deeply nested brackets
1546 let text = "[@outer [inner [deep]]](file:///path)";
1547 let mentions = parse_mention_links(text, PathStyle::local());
1548 assert_eq!(mentions.len(), 1);
1549
1550 // Unbalanced brackets should fail gracefully
1551 let text = "[@unbalanced [bracket](file:///path)";
1552 let mentions = parse_mention_links(text, PathStyle::local());
1553 assert_eq!(mentions.len(), 0);
1554
1555 // Nested parentheses in URI (common in URLs with query params)
1556 let text = "[@wiki](https://en.wikipedia.org/wiki/Rust_(programming_language))";
1557 let mentions = parse_mention_links(text, PathStyle::local());
1558 assert_eq!(mentions.len(), 1);
1559 if let MentionUri::Fetch { url } = &mentions[0].1 {
1560 assert!(url.as_str().contains("Rust_(programming_language)"));
1561 } else {
1562 panic!("Expected Fetch URI");
1563 }
1564 }
1565
1566 #[gpui::test]
1567 async fn test_at_mention_removal(cx: &mut TestAppContext) {
1568 init_test(cx);
1569
1570 let fs = FakeFs::new(cx.executor());
1571 fs.insert_tree("/project", json!({"file": ""})).await;
1572 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
1573
1574 let (multi_workspace, cx) =
1575 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1576 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
1577
1578 let thread_store = None;
1579 let history = cx
1580 .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
1581
1582 let message_editor = cx.update(|window, cx| {
1583 cx.new(|cx| {
1584 MessageEditor::new(
1585 workspace.downgrade(),
1586 project.downgrade(),
1587 thread_store.clone(),
1588 history.downgrade(),
1589 None,
1590 Default::default(),
1591 Default::default(),
1592 "Test Agent".into(),
1593 "Test",
1594 EditorMode::AutoHeight {
1595 min_lines: 1,
1596 max_lines: None,
1597 },
1598 window,
1599 cx,
1600 )
1601 })
1602 });
1603 let editor = message_editor.update(cx, |message_editor, _| message_editor.editor.clone());
1604
1605 cx.run_until_parked();
1606
1607 let excerpt_id = editor.update(cx, |editor, cx| {
1608 editor
1609 .buffer()
1610 .read(cx)
1611 .excerpt_ids()
1612 .into_iter()
1613 .next()
1614 .unwrap()
1615 });
1616 let completions = editor.update_in(cx, |editor, window, cx| {
1617 editor.set_text("Hello @file ", window, cx);
1618 let buffer = editor.buffer().read(cx).as_singleton().unwrap();
1619 let completion_provider = editor.completion_provider().unwrap();
1620 completion_provider.completions(
1621 excerpt_id,
1622 &buffer,
1623 text::Anchor::MAX,
1624 CompletionContext {
1625 trigger_kind: CompletionTriggerKind::TRIGGER_CHARACTER,
1626 trigger_character: Some("@".into()),
1627 },
1628 window,
1629 cx,
1630 )
1631 });
1632 let [_, completion]: [_; 2] = completions
1633 .await
1634 .unwrap()
1635 .into_iter()
1636 .flat_map(|response| response.completions)
1637 .collect::<Vec<_>>()
1638 .try_into()
1639 .unwrap();
1640
1641 editor.update_in(cx, |editor, window, cx| {
1642 let snapshot = editor.buffer().read(cx).snapshot(cx);
1643 let range = snapshot
1644 .anchor_range_in_excerpt(excerpt_id, completion.replace_range)
1645 .unwrap();
1646 editor.edit([(range, completion.new_text)], cx);
1647 (completion.confirm.unwrap())(CompletionIntent::Complete, window, cx);
1648 });
1649
1650 cx.run_until_parked();
1651
1652 // Backspace over the inserted crease (and the following space).
1653 editor.update_in(cx, |editor, window, cx| {
1654 editor.backspace(&Default::default(), window, cx);
1655 editor.backspace(&Default::default(), window, cx);
1656 });
1657
1658 let (content, _) = message_editor
1659 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
1660 .await
1661 .unwrap();
1662
1663 // We don't send a resource link for the deleted crease.
1664 pretty_assertions::assert_matches!(content.as_slice(), [acp::ContentBlock::Text { .. }]);
1665 }
1666
1667 #[gpui::test]
1668 async fn test_slash_command_validation(cx: &mut gpui::TestAppContext) {
1669 init_test(cx);
1670 let fs = FakeFs::new(cx.executor());
1671 fs.insert_tree(
1672 "/test",
1673 json!({
1674 ".zed": {
1675 "tasks.json": r#"[{"label": "test", "command": "echo"}]"#
1676 },
1677 "src": {
1678 "main.rs": "fn main() {}",
1679 },
1680 }),
1681 )
1682 .await;
1683
1684 let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
1685 let thread_store = None;
1686 let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default()));
1687 // Start with no available commands - simulating Claude which doesn't support slash commands
1688 let available_commands = Rc::new(RefCell::new(vec![]));
1689
1690 let (multi_workspace, cx) =
1691 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1692 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
1693 let history = cx
1694 .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
1695 let workspace_handle = workspace.downgrade();
1696 let message_editor = workspace.update_in(cx, |_, window, cx| {
1697 cx.new(|cx| {
1698 MessageEditor::new(
1699 workspace_handle.clone(),
1700 project.downgrade(),
1701 thread_store.clone(),
1702 history.downgrade(),
1703 None,
1704 prompt_capabilities.clone(),
1705 available_commands.clone(),
1706 "Claude Agent".into(),
1707 "Test",
1708 EditorMode::AutoHeight {
1709 min_lines: 1,
1710 max_lines: None,
1711 },
1712 window,
1713 cx,
1714 )
1715 })
1716 });
1717 let editor = message_editor.update(cx, |message_editor, _| message_editor.editor.clone());
1718
1719 // Test that slash commands fail when no available_commands are set (empty list means no commands supported)
1720 editor.update_in(cx, |editor, window, cx| {
1721 editor.set_text("/file test.txt", window, cx);
1722 });
1723
1724 let contents_result = message_editor
1725 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
1726 .await;
1727
1728 // Should fail because available_commands is empty (no commands supported)
1729 assert!(contents_result.is_err());
1730 let error_message = contents_result.unwrap_err().to_string();
1731 assert!(error_message.contains("not supported by Claude Agent"));
1732 assert!(error_message.contains("Available commands: none"));
1733
1734 // Now simulate Claude providing its list of available commands (which doesn't include file)
1735 available_commands.replace(vec![acp::AvailableCommand::new("help", "Get help")]);
1736
1737 // Test that unsupported slash commands trigger an error when we have a list of available commands
1738 editor.update_in(cx, |editor, window, cx| {
1739 editor.set_text("/file test.txt", window, cx);
1740 });
1741
1742 let contents_result = message_editor
1743 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
1744 .await;
1745
1746 assert!(contents_result.is_err());
1747 let error_message = contents_result.unwrap_err().to_string();
1748 assert!(error_message.contains("not supported by Claude Agent"));
1749 assert!(error_message.contains("/file"));
1750 assert!(error_message.contains("Available commands: /help"));
1751
1752 // Test that supported commands work fine
1753 editor.update_in(cx, |editor, window, cx| {
1754 editor.set_text("/help", window, cx);
1755 });
1756
1757 let contents_result = message_editor
1758 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
1759 .await;
1760
1761 // Should succeed because /help is in available_commands
1762 assert!(contents_result.is_ok());
1763
1764 // Test that regular text works fine
1765 editor.update_in(cx, |editor, window, cx| {
1766 editor.set_text("Hello Claude!", window, cx);
1767 });
1768
1769 let (content, _) = message_editor
1770 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
1771 .await
1772 .unwrap();
1773
1774 assert_eq!(content.len(), 1);
1775 if let acp::ContentBlock::Text(text) = &content[0] {
1776 assert_eq!(text.text, "Hello Claude!");
1777 } else {
1778 panic!("Expected ContentBlock::Text");
1779 }
1780
1781 // Test that @ mentions still work
1782 editor.update_in(cx, |editor, window, cx| {
1783 editor.set_text("Check this @", window, cx);
1784 });
1785
1786 // The @ mention functionality should not be affected
1787 let (content, _) = message_editor
1788 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
1789 .await
1790 .unwrap();
1791
1792 assert_eq!(content.len(), 1);
1793 if let acp::ContentBlock::Text(text) = &content[0] {
1794 assert_eq!(text.text, "Check this @");
1795 } else {
1796 panic!("Expected ContentBlock::Text");
1797 }
1798 }
1799
1800 struct MessageEditorItem(Entity<MessageEditor>);
1801
1802 impl Item for MessageEditorItem {
1803 type Event = ();
1804
1805 fn include_in_nav_history() -> bool {
1806 false
1807 }
1808
1809 fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
1810 "Test".into()
1811 }
1812 }
1813
1814 impl EventEmitter<()> for MessageEditorItem {}
1815
1816 impl Focusable for MessageEditorItem {
1817 fn focus_handle(&self, cx: &App) -> FocusHandle {
1818 self.0.read(cx).focus_handle(cx)
1819 }
1820 }
1821
1822 impl Render for MessageEditorItem {
1823 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
1824 self.0.clone().into_any_element()
1825 }
1826 }
1827
1828 #[gpui::test]
1829 async fn test_completion_provider_commands(cx: &mut TestAppContext) {
1830 init_test(cx);
1831
1832 let app_state = cx.update(AppState::test);
1833
1834 cx.update(|cx| {
1835 editor::init(cx);
1836 workspace::init(app_state.clone(), cx);
1837 });
1838
1839 let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
1840 let window =
1841 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1842 let workspace = window
1843 .read_with(cx, |mw, _| mw.workspace().clone())
1844 .unwrap();
1845
1846 let mut cx = VisualTestContext::from_window(window.into(), cx);
1847
1848 let thread_store = None;
1849 let history = cx
1850 .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
1851 let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default()));
1852 let available_commands = Rc::new(RefCell::new(vec![
1853 acp::AvailableCommand::new("quick-math", "2 + 2 = 4 - 1 = 3"),
1854 acp::AvailableCommand::new("say-hello", "Say hello to whoever you want").input(
1855 acp::AvailableCommandInput::Unstructured(acp::UnstructuredCommandInput::new(
1856 "<name>",
1857 )),
1858 ),
1859 ]));
1860
1861 let editor = workspace.update_in(&mut cx, |workspace, window, cx| {
1862 let workspace_handle = cx.weak_entity();
1863 let message_editor = cx.new(|cx| {
1864 MessageEditor::new(
1865 workspace_handle,
1866 project.downgrade(),
1867 thread_store.clone(),
1868 history.downgrade(),
1869 None,
1870 prompt_capabilities.clone(),
1871 available_commands.clone(),
1872 "Test Agent".into(),
1873 "Test",
1874 EditorMode::AutoHeight {
1875 max_lines: None,
1876 min_lines: 1,
1877 },
1878 window,
1879 cx,
1880 )
1881 });
1882 workspace.active_pane().update(cx, |pane, cx| {
1883 pane.add_item(
1884 Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
1885 true,
1886 true,
1887 None,
1888 window,
1889 cx,
1890 );
1891 });
1892 message_editor.read(cx).focus_handle(cx).focus(window, cx);
1893 message_editor.read(cx).editor().clone()
1894 });
1895
1896 cx.simulate_input("/");
1897
1898 editor.update_in(&mut cx, |editor, window, cx| {
1899 assert_eq!(editor.text(cx), "/");
1900 assert!(editor.has_visible_completions_menu());
1901
1902 assert_eq!(
1903 current_completion_labels_with_documentation(editor),
1904 &[
1905 ("quick-math".into(), "2 + 2 = 4 - 1 = 3".into()),
1906 ("say-hello".into(), "Say hello to whoever you want".into())
1907 ]
1908 );
1909 editor.set_text("", window, cx);
1910 });
1911
1912 cx.simulate_input("/qui");
1913
1914 editor.update_in(&mut cx, |editor, window, cx| {
1915 assert_eq!(editor.text(cx), "/qui");
1916 assert!(editor.has_visible_completions_menu());
1917
1918 assert_eq!(
1919 current_completion_labels_with_documentation(editor),
1920 &[("quick-math".into(), "2 + 2 = 4 - 1 = 3".into())]
1921 );
1922 editor.set_text("", window, cx);
1923 });
1924
1925 editor.update_in(&mut cx, |editor, window, cx| {
1926 assert!(editor.has_visible_completions_menu());
1927 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
1928 });
1929
1930 cx.run_until_parked();
1931
1932 editor.update_in(&mut cx, |editor, window, cx| {
1933 assert_eq!(editor.display_text(cx), "/quick-math ");
1934 assert!(!editor.has_visible_completions_menu());
1935 editor.set_text("", window, cx);
1936 });
1937
1938 cx.simulate_input("/say");
1939
1940 editor.update_in(&mut cx, |editor, _window, cx| {
1941 assert_eq!(editor.display_text(cx), "/say");
1942 assert!(editor.has_visible_completions_menu());
1943
1944 assert_eq!(
1945 current_completion_labels_with_documentation(editor),
1946 &[("say-hello".into(), "Say hello to whoever you want".into())]
1947 );
1948 });
1949
1950 editor.update_in(&mut cx, |editor, window, cx| {
1951 assert!(editor.has_visible_completions_menu());
1952 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
1953 });
1954
1955 cx.run_until_parked();
1956
1957 editor.update_in(&mut cx, |editor, _window, cx| {
1958 assert_eq!(editor.text(cx), "/say-hello ");
1959 assert_eq!(editor.display_text(cx), "/say-hello <name>");
1960 assert!(!editor.has_visible_completions_menu());
1961 });
1962
1963 cx.simulate_input("GPT5");
1964
1965 cx.run_until_parked();
1966
1967 editor.update_in(&mut cx, |editor, window, cx| {
1968 assert_eq!(editor.text(cx), "/say-hello GPT5");
1969 assert_eq!(editor.display_text(cx), "/say-hello GPT5");
1970 assert!(!editor.has_visible_completions_menu());
1971
1972 // Delete argument
1973 for _ in 0..5 {
1974 editor.backspace(&editor::actions::Backspace, window, cx);
1975 }
1976 });
1977
1978 cx.run_until_parked();
1979
1980 editor.update_in(&mut cx, |editor, window, cx| {
1981 assert_eq!(editor.text(cx), "/say-hello");
1982 // Hint is visible because argument was deleted
1983 assert_eq!(editor.display_text(cx), "/say-hello <name>");
1984
1985 // Delete last command letter
1986 editor.backspace(&editor::actions::Backspace, window, cx);
1987 });
1988
1989 cx.run_until_parked();
1990
1991 editor.update_in(&mut cx, |editor, _window, cx| {
1992 // Hint goes away once command no longer matches an available one
1993 assert_eq!(editor.text(cx), "/say-hell");
1994 assert_eq!(editor.display_text(cx), "/say-hell");
1995 assert!(!editor.has_visible_completions_menu());
1996 });
1997 }
1998
1999 #[gpui::test]
2000 async fn test_context_completion_provider_mentions(cx: &mut TestAppContext) {
2001 init_test(cx);
2002
2003 let app_state = cx.update(AppState::test);
2004
2005 cx.update(|cx| {
2006 editor::init(cx);
2007 workspace::init(app_state.clone(), cx);
2008 });
2009
2010 app_state
2011 .fs
2012 .as_fake()
2013 .insert_tree(
2014 path!("/dir"),
2015 json!({
2016 "editor": "",
2017 "a": {
2018 "one.txt": "1",
2019 "two.txt": "2",
2020 "three.txt": "3",
2021 "four.txt": "4"
2022 },
2023 "b": {
2024 "five.txt": "5",
2025 "six.txt": "6",
2026 "seven.txt": "7",
2027 "eight.txt": "8",
2028 },
2029 "x.png": "",
2030 }),
2031 )
2032 .await;
2033
2034 let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
2035 let window =
2036 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2037 let workspace = window
2038 .read_with(cx, |mw, _| mw.workspace().clone())
2039 .unwrap();
2040
2041 let worktree = project.update(cx, |project, cx| {
2042 let mut worktrees = project.worktrees(cx).collect::<Vec<_>>();
2043 assert_eq!(worktrees.len(), 1);
2044 worktrees.pop().unwrap()
2045 });
2046 let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id());
2047
2048 let mut cx = VisualTestContext::from_window(window.into(), cx);
2049
2050 let paths = vec![
2051 rel_path("a/one.txt"),
2052 rel_path("a/two.txt"),
2053 rel_path("a/three.txt"),
2054 rel_path("a/four.txt"),
2055 rel_path("b/five.txt"),
2056 rel_path("b/six.txt"),
2057 rel_path("b/seven.txt"),
2058 rel_path("b/eight.txt"),
2059 ];
2060
2061 let slash = PathStyle::local().primary_separator();
2062
2063 let mut opened_editors = Vec::new();
2064 for path in paths {
2065 let buffer = workspace
2066 .update_in(&mut cx, |workspace, window, cx| {
2067 workspace.open_path(
2068 ProjectPath {
2069 worktree_id,
2070 path: path.into(),
2071 },
2072 None,
2073 false,
2074 window,
2075 cx,
2076 )
2077 })
2078 .await
2079 .unwrap();
2080 opened_editors.push(buffer);
2081 }
2082
2083 let thread_store = cx.new(|cx| ThreadStore::new(cx));
2084 let history = cx
2085 .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
2086 let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default()));
2087
2088 let (message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| {
2089 let workspace_handle = cx.weak_entity();
2090 let message_editor = cx.new(|cx| {
2091 MessageEditor::new(
2092 workspace_handle,
2093 project.downgrade(),
2094 Some(thread_store),
2095 history.downgrade(),
2096 None,
2097 prompt_capabilities.clone(),
2098 Default::default(),
2099 "Test Agent".into(),
2100 "Test",
2101 EditorMode::AutoHeight {
2102 max_lines: None,
2103 min_lines: 1,
2104 },
2105 window,
2106 cx,
2107 )
2108 });
2109 workspace.active_pane().update(cx, |pane, cx| {
2110 pane.add_item(
2111 Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
2112 true,
2113 true,
2114 None,
2115 window,
2116 cx,
2117 );
2118 });
2119 message_editor.read(cx).focus_handle(cx).focus(window, cx);
2120 let editor = message_editor.read(cx).editor().clone();
2121 (message_editor, editor)
2122 });
2123
2124 cx.simulate_input("Lorem @");
2125
2126 editor.update_in(&mut cx, |editor, window, cx| {
2127 assert_eq!(editor.text(cx), "Lorem @");
2128 assert!(editor.has_visible_completions_menu());
2129
2130 assert_eq!(
2131 current_completion_labels(editor),
2132 &[
2133 format!("eight.txt b{slash}"),
2134 format!("seven.txt b{slash}"),
2135 format!("six.txt b{slash}"),
2136 format!("five.txt b{slash}"),
2137 "Files & Directories".into(),
2138 "Symbols".into()
2139 ]
2140 );
2141 editor.set_text("", window, cx);
2142 });
2143
2144 prompt_capabilities.replace(
2145 acp::PromptCapabilities::new()
2146 .image(true)
2147 .audio(true)
2148 .embedded_context(true),
2149 );
2150
2151 cx.simulate_input("Lorem ");
2152
2153 editor.update(&mut cx, |editor, cx| {
2154 assert_eq!(editor.text(cx), "Lorem ");
2155 assert!(!editor.has_visible_completions_menu());
2156 });
2157
2158 cx.simulate_input("@");
2159
2160 editor.update(&mut cx, |editor, cx| {
2161 assert_eq!(editor.text(cx), "Lorem @");
2162 assert!(editor.has_visible_completions_menu());
2163 assert_eq!(
2164 current_completion_labels(editor),
2165 &[
2166 format!("eight.txt b{slash}"),
2167 format!("seven.txt b{slash}"),
2168 format!("six.txt b{slash}"),
2169 format!("five.txt b{slash}"),
2170 "Files & Directories".into(),
2171 "Symbols".into(),
2172 "Threads".into(),
2173 "Fetch".into()
2174 ]
2175 );
2176 });
2177
2178 // Select and confirm "File"
2179 editor.update_in(&mut cx, |editor, window, cx| {
2180 assert!(editor.has_visible_completions_menu());
2181 editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
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.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2186 });
2187
2188 cx.run_until_parked();
2189
2190 editor.update(&mut cx, |editor, cx| {
2191 assert_eq!(editor.text(cx), "Lorem @file ");
2192 assert!(editor.has_visible_completions_menu());
2193 });
2194
2195 cx.simulate_input("one");
2196
2197 editor.update(&mut cx, |editor, cx| {
2198 assert_eq!(editor.text(cx), "Lorem @file one");
2199 assert!(editor.has_visible_completions_menu());
2200 assert_eq!(
2201 current_completion_labels(editor),
2202 vec![format!("one.txt a{slash}")]
2203 );
2204 });
2205
2206 editor.update_in(&mut cx, |editor, window, cx| {
2207 assert!(editor.has_visible_completions_menu());
2208 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2209 });
2210
2211 let url_one = MentionUri::File {
2212 abs_path: path!("/dir/a/one.txt").into(),
2213 }
2214 .to_uri()
2215 .to_string();
2216 editor.update(&mut cx, |editor, cx| {
2217 let text = editor.text(cx);
2218 assert_eq!(text, format!("Lorem [@one.txt]({url_one}) "));
2219 assert!(!editor.has_visible_completions_menu());
2220 assert_eq!(fold_ranges(editor, cx).len(), 1);
2221 });
2222
2223 let contents = message_editor
2224 .update(&mut cx, |message_editor, cx| {
2225 message_editor
2226 .mention_set()
2227 .update(cx, |mention_set, cx| mention_set.contents(false, cx))
2228 })
2229 .await
2230 .unwrap()
2231 .into_values()
2232 .collect::<Vec<_>>();
2233
2234 {
2235 let [(uri, Mention::Text { content, .. })] = contents.as_slice() else {
2236 panic!("Unexpected mentions");
2237 };
2238 pretty_assertions::assert_eq!(content, "1");
2239 pretty_assertions::assert_eq!(
2240 uri,
2241 &MentionUri::parse(&url_one, PathStyle::local()).unwrap()
2242 );
2243 }
2244
2245 cx.simulate_input(" ");
2246
2247 editor.update(&mut cx, |editor, cx| {
2248 let text = editor.text(cx);
2249 assert_eq!(text, format!("Lorem [@one.txt]({url_one}) "));
2250 assert!(!editor.has_visible_completions_menu());
2251 assert_eq!(fold_ranges(editor, cx).len(), 1);
2252 });
2253
2254 cx.simulate_input("Ipsum ");
2255
2256 editor.update(&mut cx, |editor, cx| {
2257 let text = editor.text(cx);
2258 assert_eq!(text, format!("Lorem [@one.txt]({url_one}) Ipsum "),);
2259 assert!(!editor.has_visible_completions_menu());
2260 assert_eq!(fold_ranges(editor, cx).len(), 1);
2261 });
2262
2263 cx.simulate_input("@file ");
2264
2265 editor.update(&mut cx, |editor, cx| {
2266 let text = editor.text(cx);
2267 assert_eq!(text, format!("Lorem [@one.txt]({url_one}) Ipsum @file "),);
2268 assert!(editor.has_visible_completions_menu());
2269 assert_eq!(fold_ranges(editor, cx).len(), 1);
2270 });
2271
2272 editor.update_in(&mut cx, |editor, window, cx| {
2273 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2274 });
2275
2276 cx.run_until_parked();
2277
2278 let contents = message_editor
2279 .update(&mut cx, |message_editor, cx| {
2280 message_editor
2281 .mention_set()
2282 .update(cx, |mention_set, cx| mention_set.contents(false, cx))
2283 })
2284 .await
2285 .unwrap()
2286 .into_values()
2287 .collect::<Vec<_>>();
2288
2289 let url_eight = MentionUri::File {
2290 abs_path: path!("/dir/b/eight.txt").into(),
2291 }
2292 .to_uri()
2293 .to_string();
2294
2295 {
2296 let [_, (uri, Mention::Text { content, .. })] = contents.as_slice() else {
2297 panic!("Unexpected mentions");
2298 };
2299 pretty_assertions::assert_eq!(content, "8");
2300 pretty_assertions::assert_eq!(
2301 uri,
2302 &MentionUri::parse(&url_eight, PathStyle::local()).unwrap()
2303 );
2304 }
2305
2306 editor.update(&mut cx, |editor, cx| {
2307 assert_eq!(
2308 editor.text(cx),
2309 format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) ")
2310 );
2311 assert!(!editor.has_visible_completions_menu());
2312 assert_eq!(fold_ranges(editor, cx).len(), 2);
2313 });
2314
2315 let plain_text_language = Arc::new(language::Language::new(
2316 language::LanguageConfig {
2317 name: "Plain Text".into(),
2318 matcher: language::LanguageMatcher {
2319 path_suffixes: vec!["txt".to_string()],
2320 ..Default::default()
2321 },
2322 ..Default::default()
2323 },
2324 None,
2325 ));
2326
2327 // Register the language and fake LSP
2328 let language_registry = project.read_with(&cx, |project, _| project.languages().clone());
2329 language_registry.add(plain_text_language);
2330
2331 let mut fake_language_servers = language_registry.register_fake_lsp(
2332 "Plain Text",
2333 language::FakeLspAdapter {
2334 capabilities: lsp::ServerCapabilities {
2335 workspace_symbol_provider: Some(lsp::OneOf::Left(true)),
2336 ..Default::default()
2337 },
2338 ..Default::default()
2339 },
2340 );
2341
2342 // Open the buffer to trigger LSP initialization
2343 let buffer = project
2344 .update(&mut cx, |project, cx| {
2345 project.open_local_buffer(path!("/dir/a/one.txt"), cx)
2346 })
2347 .await
2348 .unwrap();
2349
2350 // Register the buffer with language servers
2351 let _handle = project.update(&mut cx, |project, cx| {
2352 project.register_buffer_with_language_servers(&buffer, cx)
2353 });
2354
2355 cx.run_until_parked();
2356
2357 let fake_language_server = fake_language_servers.next().await.unwrap();
2358 fake_language_server.set_request_handler::<lsp::WorkspaceSymbolRequest, _, _>(
2359 move |_, _| async move {
2360 Ok(Some(lsp::WorkspaceSymbolResponse::Flat(vec![
2361 #[allow(deprecated)]
2362 lsp::SymbolInformation {
2363 name: "MySymbol".into(),
2364 location: lsp::Location {
2365 uri: lsp::Uri::from_file_path(path!("/dir/a/one.txt")).unwrap(),
2366 range: lsp::Range::new(
2367 lsp::Position::new(0, 0),
2368 lsp::Position::new(0, 1),
2369 ),
2370 },
2371 kind: lsp::SymbolKind::CONSTANT,
2372 tags: None,
2373 container_name: None,
2374 deprecated: None,
2375 },
2376 ])))
2377 },
2378 );
2379
2380 cx.simulate_input("@symbol ");
2381
2382 editor.update(&mut cx, |editor, cx| {
2383 assert_eq!(
2384 editor.text(cx),
2385 format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) @symbol ")
2386 );
2387 assert!(editor.has_visible_completions_menu());
2388 assert_eq!(current_completion_labels(editor), &["MySymbol one.txt L1"]);
2389 });
2390
2391 editor.update_in(&mut cx, |editor, window, cx| {
2392 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2393 });
2394
2395 let symbol = MentionUri::Symbol {
2396 abs_path: path!("/dir/a/one.txt").into(),
2397 name: "MySymbol".into(),
2398 line_range: 0..=0,
2399 };
2400
2401 let contents = message_editor
2402 .update(&mut cx, |message_editor, cx| {
2403 message_editor
2404 .mention_set()
2405 .update(cx, |mention_set, cx| mention_set.contents(false, cx))
2406 })
2407 .await
2408 .unwrap()
2409 .into_values()
2410 .collect::<Vec<_>>();
2411
2412 {
2413 let [_, _, (uri, Mention::Text { content, .. })] = contents.as_slice() else {
2414 panic!("Unexpected mentions");
2415 };
2416 pretty_assertions::assert_eq!(content, "1");
2417 pretty_assertions::assert_eq!(uri, &symbol);
2418 }
2419
2420 cx.run_until_parked();
2421
2422 editor.read_with(&cx, |editor, cx| {
2423 assert_eq!(
2424 editor.text(cx),
2425 format!(
2426 "Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) ",
2427 symbol.to_uri(),
2428 )
2429 );
2430 });
2431
2432 // Try to mention an "image" file that will fail to load
2433 cx.simulate_input("@file x.png");
2434
2435 editor.update(&mut cx, |editor, cx| {
2436 assert_eq!(
2437 editor.text(cx),
2438 format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) @file x.png", symbol.to_uri())
2439 );
2440 assert!(editor.has_visible_completions_menu());
2441 assert_eq!(current_completion_labels(editor), &["x.png "]);
2442 });
2443
2444 editor.update_in(&mut cx, |editor, window, cx| {
2445 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2446 });
2447
2448 // Getting the message contents fails
2449 message_editor
2450 .update(&mut cx, |message_editor, cx| {
2451 message_editor
2452 .mention_set()
2453 .update(cx, |mention_set, cx| mention_set.contents(false, cx))
2454 })
2455 .await
2456 .expect_err("Should fail to load x.png");
2457
2458 cx.run_until_parked();
2459
2460 // Mention was removed
2461 editor.read_with(&cx, |editor, cx| {
2462 assert_eq!(
2463 editor.text(cx),
2464 format!(
2465 "Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) ",
2466 symbol.to_uri()
2467 )
2468 );
2469 });
2470
2471 // Once more
2472 cx.simulate_input("@file x.png");
2473
2474 editor.update(&mut cx, |editor, cx| {
2475 assert_eq!(
2476 editor.text(cx),
2477 format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) @file x.png", symbol.to_uri())
2478 );
2479 assert!(editor.has_visible_completions_menu());
2480 assert_eq!(current_completion_labels(editor), &["x.png "]);
2481 });
2482
2483 editor.update_in(&mut cx, |editor, window, cx| {
2484 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2485 });
2486
2487 // This time don't immediately get the contents, just let the confirmed completion settle
2488 cx.run_until_parked();
2489
2490 // Mention was removed
2491 editor.read_with(&cx, |editor, cx| {
2492 assert_eq!(
2493 editor.text(cx),
2494 format!(
2495 "Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) ",
2496 symbol.to_uri()
2497 )
2498 );
2499 });
2500
2501 // Now getting the contents succeeds, because the invalid mention was removed
2502 let contents = message_editor
2503 .update(&mut cx, |message_editor, cx| {
2504 message_editor
2505 .mention_set()
2506 .update(cx, |mention_set, cx| mention_set.contents(false, cx))
2507 })
2508 .await
2509 .unwrap();
2510 assert_eq!(contents.len(), 3);
2511 }
2512
2513 fn fold_ranges(editor: &Editor, cx: &mut App) -> Vec<Range<Point>> {
2514 let snapshot = editor.buffer().read(cx).snapshot(cx);
2515 editor.display_map.update(cx, |display_map, cx| {
2516 display_map
2517 .snapshot(cx)
2518 .folds_in_range(MultiBufferOffset(0)..snapshot.len())
2519 .map(|fold| fold.range.to_point(&snapshot))
2520 .collect()
2521 })
2522 }
2523
2524 fn current_completion_labels(editor: &Editor) -> Vec<String> {
2525 let completions = editor.current_completions().expect("Missing completions");
2526 completions
2527 .into_iter()
2528 .map(|completion| completion.label.text)
2529 .collect::<Vec<_>>()
2530 }
2531
2532 fn current_completion_labels_with_documentation(editor: &Editor) -> Vec<(String, String)> {
2533 let completions = editor.current_completions().expect("Missing completions");
2534 completions
2535 .into_iter()
2536 .map(|completion| {
2537 (
2538 completion.label.text,
2539 completion
2540 .documentation
2541 .map(|d| d.text().to_string())
2542 .unwrap_or_default(),
2543 )
2544 })
2545 .collect::<Vec<_>>()
2546 }
2547
2548 #[gpui::test]
2549 async fn test_large_file_mention_fallback(cx: &mut TestAppContext) {
2550 init_test(cx);
2551
2552 let fs = FakeFs::new(cx.executor());
2553
2554 // Create a large file that exceeds AUTO_OUTLINE_SIZE
2555 // Using plain text without a configured language, so no outline is available
2556 const LINE: &str = "This is a line of text in the file\n";
2557 let large_content = LINE.repeat(2 * (outline::AUTO_OUTLINE_SIZE / LINE.len()));
2558 assert!(large_content.len() > outline::AUTO_OUTLINE_SIZE);
2559
2560 // Create a small file that doesn't exceed AUTO_OUTLINE_SIZE
2561 let small_content = "fn small_function() { /* small */ }\n";
2562 assert!(small_content.len() < outline::AUTO_OUTLINE_SIZE);
2563
2564 fs.insert_tree(
2565 "/project",
2566 json!({
2567 "large_file.txt": large_content.clone(),
2568 "small_file.txt": small_content,
2569 }),
2570 )
2571 .await;
2572
2573 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
2574
2575 let (multi_workspace, cx) =
2576 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2577 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2578
2579 let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
2580 let history = cx
2581 .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
2582
2583 let message_editor = cx.update(|window, cx| {
2584 cx.new(|cx| {
2585 let editor = MessageEditor::new(
2586 workspace.downgrade(),
2587 project.downgrade(),
2588 thread_store.clone(),
2589 history.downgrade(),
2590 None,
2591 Default::default(),
2592 Default::default(),
2593 "Test Agent".into(),
2594 "Test",
2595 EditorMode::AutoHeight {
2596 min_lines: 1,
2597 max_lines: None,
2598 },
2599 window,
2600 cx,
2601 );
2602 // Enable embedded context so files are actually included
2603 editor
2604 .prompt_capabilities
2605 .replace(acp::PromptCapabilities::new().embedded_context(true));
2606 editor
2607 })
2608 });
2609
2610 // Test large file mention
2611 // Get the absolute path using the project's worktree
2612 let large_file_abs_path = project.read_with(cx, |project, cx| {
2613 let worktree = project.worktrees(cx).next().unwrap();
2614 let worktree_root = worktree.read(cx).abs_path();
2615 worktree_root.join("large_file.txt")
2616 });
2617 let large_file_task = message_editor.update(cx, |editor, cx| {
2618 editor.mention_set().update(cx, |set, cx| {
2619 set.confirm_mention_for_file(large_file_abs_path, true, cx)
2620 })
2621 });
2622
2623 let large_file_mention = large_file_task.await.unwrap();
2624 match large_file_mention {
2625 Mention::Text { content, .. } => {
2626 // Should contain some of the content but not all of it
2627 assert!(
2628 content.contains(LINE),
2629 "Should contain some of the file content"
2630 );
2631 assert!(
2632 !content.contains(&LINE.repeat(100)),
2633 "Should not contain the full file"
2634 );
2635 // Should be much smaller than original
2636 assert!(
2637 content.len() < large_content.len() / 10,
2638 "Should be significantly truncated"
2639 );
2640 }
2641 _ => panic!("Expected Text mention for large file"),
2642 }
2643
2644 // Test small file mention
2645 // Get the absolute path using the project's worktree
2646 let small_file_abs_path = project.read_with(cx, |project, cx| {
2647 let worktree = project.worktrees(cx).next().unwrap();
2648 let worktree_root = worktree.read(cx).abs_path();
2649 worktree_root.join("small_file.txt")
2650 });
2651 let small_file_task = message_editor.update(cx, |editor, cx| {
2652 editor.mention_set().update(cx, |set, cx| {
2653 set.confirm_mention_for_file(small_file_abs_path, true, cx)
2654 })
2655 });
2656
2657 let small_file_mention = small_file_task.await.unwrap();
2658 match small_file_mention {
2659 Mention::Text { content, .. } => {
2660 // Should contain the full actual content
2661 assert_eq!(content, small_content);
2662 }
2663 _ => panic!("Expected Text mention for small file"),
2664 }
2665 }
2666
2667 #[gpui::test]
2668 async fn test_insert_thread_summary(cx: &mut TestAppContext) {
2669 init_test(cx);
2670 cx.update(LanguageModelRegistry::test);
2671
2672 let fs = FakeFs::new(cx.executor());
2673 fs.insert_tree("/project", json!({"file": ""})).await;
2674 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
2675
2676 let (multi_workspace, cx) =
2677 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2678 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2679
2680 let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
2681 let history = cx
2682 .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
2683
2684 // Create a thread metadata to insert as summary
2685 let thread_metadata = AgentSessionInfo {
2686 session_id: acp::SessionId::new("thread-123"),
2687 cwd: None,
2688 title: Some("Previous Conversation".into()),
2689 updated_at: Some(chrono::Utc::now()),
2690 meta: None,
2691 };
2692
2693 let message_editor = cx.update(|window, cx| {
2694 cx.new(|cx| {
2695 let mut editor = MessageEditor::new(
2696 workspace.downgrade(),
2697 project.downgrade(),
2698 thread_store.clone(),
2699 history.downgrade(),
2700 None,
2701 Default::default(),
2702 Default::default(),
2703 "Test Agent".into(),
2704 "Test",
2705 EditorMode::AutoHeight {
2706 min_lines: 1,
2707 max_lines: None,
2708 },
2709 window,
2710 cx,
2711 );
2712 editor.insert_thread_summary(thread_metadata.clone(), window, cx);
2713 editor
2714 })
2715 });
2716
2717 // Construct expected values for verification
2718 let expected_uri = MentionUri::Thread {
2719 id: thread_metadata.session_id.clone(),
2720 name: thread_metadata.title.as_ref().unwrap().to_string(),
2721 };
2722 let expected_title = thread_metadata.title.as_ref().unwrap();
2723 let expected_link = format!("[@{}]({})", expected_title, expected_uri.to_uri());
2724
2725 message_editor.read_with(cx, |editor, cx| {
2726 let text = editor.text(cx);
2727
2728 assert!(
2729 text.contains(&expected_link),
2730 "Expected editor text to contain thread mention link.\nExpected substring: {}\nActual text: {}",
2731 expected_link,
2732 text
2733 );
2734
2735 let mentions = editor.mention_set().read(cx).mentions();
2736 assert_eq!(
2737 mentions.len(),
2738 1,
2739 "Expected exactly one mention after inserting thread summary"
2740 );
2741
2742 assert!(
2743 mentions.contains(&expected_uri),
2744 "Expected mentions to contain the thread URI"
2745 );
2746 });
2747 }
2748
2749 #[gpui::test]
2750 async fn test_insert_thread_summary_skipped_for_external_agents(cx: &mut TestAppContext) {
2751 init_test(cx);
2752 cx.update(LanguageModelRegistry::test);
2753
2754 let fs = FakeFs::new(cx.executor());
2755 fs.insert_tree("/project", json!({"file": ""})).await;
2756 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
2757
2758 let (multi_workspace, cx) =
2759 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2760 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2761
2762 let thread_store = None;
2763 let history = cx
2764 .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
2765
2766 let thread_metadata = AgentSessionInfo {
2767 session_id: acp::SessionId::new("thread-123"),
2768 cwd: None,
2769 title: Some("Previous Conversation".into()),
2770 updated_at: Some(chrono::Utc::now()),
2771 meta: None,
2772 };
2773
2774 let message_editor = cx.update(|window, cx| {
2775 cx.new(|cx| {
2776 let mut editor = MessageEditor::new(
2777 workspace.downgrade(),
2778 project.downgrade(),
2779 thread_store.clone(),
2780 history.downgrade(),
2781 None,
2782 Default::default(),
2783 Default::default(),
2784 "Test Agent".into(),
2785 "Test",
2786 EditorMode::AutoHeight {
2787 min_lines: 1,
2788 max_lines: None,
2789 },
2790 window,
2791 cx,
2792 );
2793 editor.insert_thread_summary(thread_metadata, window, cx);
2794 editor
2795 })
2796 });
2797
2798 message_editor.read_with(cx, |editor, cx| {
2799 assert!(
2800 editor.text(cx).is_empty(),
2801 "Expected thread summary to be skipped for external agents"
2802 );
2803 assert!(
2804 editor.mention_set().read(cx).mentions().is_empty(),
2805 "Expected no mentions when thread summary is skipped"
2806 );
2807 });
2808 }
2809
2810 #[gpui::test]
2811 async fn test_thread_mode_hidden_when_disabled(cx: &mut TestAppContext) {
2812 init_test(cx);
2813
2814 let fs = FakeFs::new(cx.executor());
2815 fs.insert_tree("/project", json!({"file": ""})).await;
2816 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
2817
2818 let (multi_workspace, cx) =
2819 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2820 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2821
2822 let thread_store = None;
2823 let history = cx
2824 .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
2825
2826 let message_editor = cx.update(|window, cx| {
2827 cx.new(|cx| {
2828 MessageEditor::new(
2829 workspace.downgrade(),
2830 project.downgrade(),
2831 thread_store.clone(),
2832 history.downgrade(),
2833 None,
2834 Default::default(),
2835 Default::default(),
2836 "Test Agent".into(),
2837 "Test",
2838 EditorMode::AutoHeight {
2839 min_lines: 1,
2840 max_lines: None,
2841 },
2842 window,
2843 cx,
2844 )
2845 })
2846 });
2847
2848 message_editor.update(cx, |editor, _cx| {
2849 editor
2850 .prompt_capabilities
2851 .replace(acp::PromptCapabilities::new().embedded_context(true));
2852 });
2853
2854 let supported_modes = {
2855 let app = cx.app.borrow();
2856 message_editor.supported_modes(&app)
2857 };
2858
2859 assert!(
2860 !supported_modes.contains(&PromptContextType::Thread),
2861 "Expected thread mode to be hidden when thread mentions are disabled"
2862 );
2863 }
2864
2865 #[gpui::test]
2866 async fn test_thread_mode_visible_when_enabled(cx: &mut TestAppContext) {
2867 init_test(cx);
2868
2869 let fs = FakeFs::new(cx.executor());
2870 fs.insert_tree("/project", json!({"file": ""})).await;
2871 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
2872
2873 let (multi_workspace, cx) =
2874 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2875 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2876
2877 let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
2878 let history = cx
2879 .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
2880
2881 let message_editor = cx.update(|window, cx| {
2882 cx.new(|cx| {
2883 MessageEditor::new(
2884 workspace.downgrade(),
2885 project.downgrade(),
2886 thread_store.clone(),
2887 history.downgrade(),
2888 None,
2889 Default::default(),
2890 Default::default(),
2891 "Test Agent".into(),
2892 "Test",
2893 EditorMode::AutoHeight {
2894 min_lines: 1,
2895 max_lines: None,
2896 },
2897 window,
2898 cx,
2899 )
2900 })
2901 });
2902
2903 message_editor.update(cx, |editor, _cx| {
2904 editor
2905 .prompt_capabilities
2906 .replace(acp::PromptCapabilities::new().embedded_context(true));
2907 });
2908
2909 let supported_modes = {
2910 let app = cx.app.borrow();
2911 message_editor.supported_modes(&app)
2912 };
2913
2914 assert!(
2915 supported_modes.contains(&PromptContextType::Thread),
2916 "Expected thread mode to be visible when enabled"
2917 );
2918 }
2919
2920 #[gpui::test]
2921 async fn test_whitespace_trimming(cx: &mut TestAppContext) {
2922 init_test(cx);
2923
2924 let fs = FakeFs::new(cx.executor());
2925 fs.insert_tree("/project", json!({"file.rs": "fn main() {}"}))
2926 .await;
2927 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
2928
2929 let (multi_workspace, cx) =
2930 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2931 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2932
2933 let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
2934 let history = cx
2935 .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
2936
2937 let message_editor = cx.update(|window, cx| {
2938 cx.new(|cx| {
2939 MessageEditor::new(
2940 workspace.downgrade(),
2941 project.downgrade(),
2942 thread_store.clone(),
2943 history.downgrade(),
2944 None,
2945 Default::default(),
2946 Default::default(),
2947 "Test Agent".into(),
2948 "Test",
2949 EditorMode::AutoHeight {
2950 min_lines: 1,
2951 max_lines: None,
2952 },
2953 window,
2954 cx,
2955 )
2956 })
2957 });
2958 let editor = message_editor.update(cx, |message_editor, _| message_editor.editor.clone());
2959
2960 cx.run_until_parked();
2961
2962 editor.update_in(cx, |editor, window, cx| {
2963 editor.set_text(" \u{A0}してhello world ", window, cx);
2964 });
2965
2966 let (content, _) = message_editor
2967 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
2968 .await
2969 .unwrap();
2970
2971 assert_eq!(content, vec!["してhello world".into()]);
2972 }
2973
2974 #[gpui::test]
2975 async fn test_editor_respects_embedded_context_capability(cx: &mut TestAppContext) {
2976 init_test(cx);
2977
2978 let fs = FakeFs::new(cx.executor());
2979
2980 let file_content = "fn main() { println!(\"Hello, world!\"); }\n";
2981
2982 fs.insert_tree(
2983 "/project",
2984 json!({
2985 "src": {
2986 "main.rs": file_content,
2987 }
2988 }),
2989 )
2990 .await;
2991
2992 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
2993
2994 let (multi_workspace, cx) =
2995 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2996 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2997
2998 let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
2999 let history = cx
3000 .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
3001
3002 let (message_editor, editor) = workspace.update_in(cx, |workspace, window, cx| {
3003 let workspace_handle = cx.weak_entity();
3004 let message_editor = cx.new(|cx| {
3005 MessageEditor::new(
3006 workspace_handle,
3007 project.downgrade(),
3008 thread_store.clone(),
3009 history.downgrade(),
3010 None,
3011 Default::default(),
3012 Default::default(),
3013 "Test Agent".into(),
3014 "Test",
3015 EditorMode::AutoHeight {
3016 max_lines: None,
3017 min_lines: 1,
3018 },
3019 window,
3020 cx,
3021 )
3022 });
3023 workspace.active_pane().update(cx, |pane, cx| {
3024 pane.add_item(
3025 Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
3026 true,
3027 true,
3028 None,
3029 window,
3030 cx,
3031 );
3032 });
3033 message_editor.read(cx).focus_handle(cx).focus(window, cx);
3034 let editor = message_editor.read(cx).editor().clone();
3035 (message_editor, editor)
3036 });
3037
3038 cx.simulate_input("What is in @file main");
3039
3040 editor.update_in(cx, |editor, window, cx| {
3041 assert!(editor.has_visible_completions_menu());
3042 assert_eq!(editor.text(cx), "What is in @file main");
3043 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
3044 });
3045
3046 let content = message_editor
3047 .update(cx, |editor, cx| editor.contents(false, cx))
3048 .await
3049 .unwrap()
3050 .0;
3051
3052 let main_rs_uri = if cfg!(windows) {
3053 "file:///C:/project/src/main.rs"
3054 } else {
3055 "file:///project/src/main.rs"
3056 };
3057
3058 // When embedded context is `false` we should get a resource link
3059 pretty_assertions::assert_eq!(
3060 content,
3061 vec![
3062 "What is in ".into(),
3063 acp::ContentBlock::ResourceLink(acp::ResourceLink::new("main.rs", main_rs_uri))
3064 ]
3065 );
3066
3067 message_editor.update(cx, |editor, _cx| {
3068 editor
3069 .prompt_capabilities
3070 .replace(acp::PromptCapabilities::new().embedded_context(true))
3071 });
3072
3073 let content = message_editor
3074 .update(cx, |editor, cx| editor.contents(false, cx))
3075 .await
3076 .unwrap()
3077 .0;
3078
3079 // When embedded context is `true` we should get a resource
3080 pretty_assertions::assert_eq!(
3081 content,
3082 vec![
3083 "What is in ".into(),
3084 acp::ContentBlock::Resource(acp::EmbeddedResource::new(
3085 acp::EmbeddedResourceResource::TextResourceContents(
3086 acp::TextResourceContents::new(file_content, main_rs_uri)
3087 )
3088 ))
3089 ]
3090 );
3091 }
3092
3093 #[gpui::test]
3094 async fn test_autoscroll_after_insert_selections(cx: &mut TestAppContext) {
3095 init_test(cx);
3096
3097 let app_state = cx.update(AppState::test);
3098
3099 cx.update(|cx| {
3100 editor::init(cx);
3101 workspace::init(app_state.clone(), cx);
3102 });
3103
3104 app_state
3105 .fs
3106 .as_fake()
3107 .insert_tree(
3108 path!("/dir"),
3109 json!({
3110 "test.txt": "line1\nline2\nline3\nline4\nline5\n",
3111 }),
3112 )
3113 .await;
3114
3115 let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
3116 let window =
3117 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3118 let workspace = window
3119 .read_with(cx, |mw, _| mw.workspace().clone())
3120 .unwrap();
3121
3122 let worktree = project.update(cx, |project, cx| {
3123 let mut worktrees = project.worktrees(cx).collect::<Vec<_>>();
3124 assert_eq!(worktrees.len(), 1);
3125 worktrees.pop().unwrap()
3126 });
3127 let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id());
3128
3129 let mut cx = VisualTestContext::from_window(window.into(), cx);
3130
3131 // Open a regular editor with the created file, and select a portion of
3132 // the text that will be used for the selections that are meant to be
3133 // inserted in the agent panel.
3134 let editor = workspace
3135 .update_in(&mut cx, |workspace, window, cx| {
3136 workspace.open_path(
3137 ProjectPath {
3138 worktree_id,
3139 path: rel_path("test.txt").into(),
3140 },
3141 None,
3142 false,
3143 window,
3144 cx,
3145 )
3146 })
3147 .await
3148 .unwrap()
3149 .downcast::<Editor>()
3150 .unwrap();
3151
3152 editor.update_in(&mut cx, |editor, window, cx| {
3153 editor.change_selections(Default::default(), window, cx, |selections| {
3154 selections.select_ranges([Point::new(0, 0)..Point::new(0, 5)]);
3155 });
3156 });
3157
3158 let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
3159 let history = cx
3160 .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
3161
3162 // Create a new `MessageEditor`. The `EditorMode::full()` has to be used
3163 // to ensure we have a fixed viewport, so we can eventually actually
3164 // place the cursor outside of the visible area.
3165 let message_editor = workspace.update_in(&mut cx, |workspace, window, cx| {
3166 let workspace_handle = cx.weak_entity();
3167 let message_editor = cx.new(|cx| {
3168 MessageEditor::new(
3169 workspace_handle,
3170 project.downgrade(),
3171 thread_store.clone(),
3172 history.downgrade(),
3173 None,
3174 Default::default(),
3175 Default::default(),
3176 "Test Agent".into(),
3177 "Test",
3178 EditorMode::full(),
3179 window,
3180 cx,
3181 )
3182 });
3183 workspace.active_pane().update(cx, |pane, cx| {
3184 pane.add_item(
3185 Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
3186 true,
3187 true,
3188 None,
3189 window,
3190 cx,
3191 );
3192 });
3193
3194 message_editor
3195 });
3196
3197 message_editor.update_in(&mut cx, |message_editor, window, cx| {
3198 message_editor.editor.update(cx, |editor, cx| {
3199 // Update the Agent Panel's Message Editor text to have 100
3200 // lines, ensuring that the cursor is set at line 90 and that we
3201 // then scroll all the way to the top, so the cursor's position
3202 // remains off screen.
3203 let mut lines = String::new();
3204 for _ in 1..=100 {
3205 lines.push_str(&"Another line in the agent panel's message editor\n");
3206 }
3207 editor.set_text(lines.as_str(), window, cx);
3208 editor.change_selections(Default::default(), window, cx, |selections| {
3209 selections.select_ranges([Point::new(90, 0)..Point::new(90, 0)]);
3210 });
3211 editor.set_scroll_position(gpui::Point::new(0., 0.), window, cx);
3212 });
3213 });
3214
3215 cx.run_until_parked();
3216
3217 // Before proceeding, let's assert that the cursor is indeed off screen,
3218 // otherwise the rest of the test doesn't make sense.
3219 message_editor.update_in(&mut cx, |message_editor, window, cx| {
3220 message_editor.editor.update(cx, |editor, cx| {
3221 let snapshot = editor.snapshot(window, cx);
3222 let cursor_row = editor.selections.newest::<Point>(&snapshot).head().row;
3223 let scroll_top = snapshot.scroll_position().y as u32;
3224 let visible_lines = editor.visible_line_count().unwrap() as u32;
3225 let visible_range = scroll_top..(scroll_top + visible_lines);
3226
3227 assert!(!visible_range.contains(&cursor_row));
3228 })
3229 });
3230
3231 // Now let's insert the selection in the Agent Panel's editor and
3232 // confirm that, after the insertion, the cursor is now in the visible
3233 // range.
3234 message_editor.update_in(&mut cx, |message_editor, window, cx| {
3235 message_editor.insert_selections(window, cx);
3236 });
3237
3238 cx.run_until_parked();
3239
3240 message_editor.update_in(&mut cx, |message_editor, window, cx| {
3241 message_editor.editor.update(cx, |editor, cx| {
3242 let snapshot = editor.snapshot(window, cx);
3243 let cursor_row = editor.selections.newest::<Point>(&snapshot).head().row;
3244 let scroll_top = snapshot.scroll_position().y as u32;
3245 let visible_lines = editor.visible_line_count().unwrap() as u32;
3246 let visible_range = scroll_top..(scroll_top + visible_lines);
3247
3248 assert!(visible_range.contains(&cursor_row));
3249 })
3250 });
3251 }
3252
3253 #[gpui::test]
3254 async fn test_insert_context_with_multibyte_characters(cx: &mut TestAppContext) {
3255 init_test(cx);
3256
3257 let app_state = cx.update(AppState::test);
3258
3259 cx.update(|cx| {
3260 editor::init(cx);
3261 workspace::init(app_state.clone(), cx);
3262 });
3263
3264 app_state
3265 .fs
3266 .as_fake()
3267 .insert_tree(path!("/dir"), json!({}))
3268 .await;
3269
3270 let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
3271 let window =
3272 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3273 let workspace = window
3274 .read_with(cx, |mw, _| mw.workspace().clone())
3275 .unwrap();
3276
3277 let mut cx = VisualTestContext::from_window(window.into(), cx);
3278
3279 let thread_store = cx.new(|cx| ThreadStore::new(cx));
3280 let history = cx
3281 .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
3282
3283 let (message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| {
3284 let workspace_handle = cx.weak_entity();
3285 let message_editor = cx.new(|cx| {
3286 MessageEditor::new(
3287 workspace_handle,
3288 project.downgrade(),
3289 Some(thread_store),
3290 history.downgrade(),
3291 None,
3292 Default::default(),
3293 Default::default(),
3294 "Test Agent".into(),
3295 "Test",
3296 EditorMode::AutoHeight {
3297 max_lines: None,
3298 min_lines: 1,
3299 },
3300 window,
3301 cx,
3302 )
3303 });
3304 workspace.active_pane().update(cx, |pane, cx| {
3305 pane.add_item(
3306 Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
3307 true,
3308 true,
3309 None,
3310 window,
3311 cx,
3312 );
3313 });
3314 message_editor.read(cx).focus_handle(cx).focus(window, cx);
3315 let editor = message_editor.read(cx).editor().clone();
3316 (message_editor, editor)
3317 });
3318
3319 editor.update_in(&mut cx, |editor, window, cx| {
3320 editor.set_text("😄😄", window, cx);
3321 });
3322
3323 cx.run_until_parked();
3324
3325 message_editor.update_in(&mut cx, |message_editor, window, cx| {
3326 message_editor.insert_context_type("file", window, cx);
3327 });
3328
3329 cx.run_until_parked();
3330
3331 editor.update(&mut cx, |editor, cx| {
3332 assert_eq!(editor.text(cx), "😄😄@file");
3333 });
3334 }
3335}