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