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