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