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::MentionUri;
14use agent::ThreadStore;
15use agent_client_protocol as acp;
16use anyhow::{Result, anyhow};
17use collections::HashSet;
18use editor::{
19 Addon, AnchorRangeExt, ContextMenuOptions, Editor, EditorElement, EditorEvent, EditorMode,
20 EditorStyle, Inlay, MultiBuffer, MultiBufferOffset, MultiBufferSnapshot, ToOffset,
21 actions::Paste, code_context_menus::CodeContextMenu, scroll::Autoscroll,
22};
23use futures::{FutureExt as _, future::join_all};
24use gpui::{
25 AppContext, ClipboardEntry, Context, Entity, EventEmitter, FocusHandle, Focusable, ImageFormat,
26 KeyContext, SharedString, Subscription, Task, TextStyle, WeakEntity,
27};
28use language::{Buffer, Language, language_settings::InlayHintKind};
29use project::AgentId;
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::{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_id: AgentId,
49 thread_store: Option<Entity<ThreadStore>>,
50 _subscriptions: Vec<Subscription>,
51 _parse_slash_command_task: Task<()>,
52}
53
54#[derive(Clone, Debug)]
55pub enum MessageEditorEvent {
56 Send,
57 SendImmediately,
58 Cancel,
59 Focus,
60 LostFocus,
61 InputAttempted(Arc<str>),
62}
63
64impl EventEmitter<MessageEditorEvent> for MessageEditor {}
65
66const COMMAND_HINT_INLAY_ID: InlayId = InlayId::Hint(0);
67
68impl PromptCompletionProviderDelegate for Entity<MessageEditor> {
69 fn supports_images(&self, cx: &App) -> bool {
70 self.read(cx).prompt_capabilities.borrow().image
71 }
72
73 fn supported_modes(&self, cx: &App) -> Vec<PromptContextType> {
74 let mut supported = vec![PromptContextType::File, PromptContextType::Symbol];
75 if self.read(cx).prompt_capabilities.borrow().embedded_context {
76 if self.read(cx).thread_store.is_some() {
77 supported.push(PromptContextType::Thread);
78 }
79 supported.extend(&[
80 PromptContextType::Diagnostics,
81 PromptContextType::Fetch,
82 PromptContextType::Rules,
83 PromptContextType::BranchDiff,
84 ]);
85 }
86 supported
87 }
88
89 fn available_commands(&self, cx: &App) -> Vec<crate::completion_provider::AvailableCommand> {
90 self.read(cx)
91 .available_commands
92 .borrow()
93 .iter()
94 .map(|cmd| crate::completion_provider::AvailableCommand {
95 name: cmd.name.clone().into(),
96 description: cmd.description.clone().into(),
97 requires_argument: cmd.input.is_some(),
98 })
99 .collect()
100 }
101
102 fn confirm_command(&self, cx: &mut App) {
103 self.update(cx, |this, cx| this.send(cx));
104 }
105}
106
107impl MessageEditor {
108 pub fn new(
109 workspace: WeakEntity<Workspace>,
110 project: WeakEntity<Project>,
111 thread_store: Option<Entity<ThreadStore>>,
112 history: Option<WeakEntity<ThreadHistory>>,
113 prompt_store: Option<Entity<PromptStore>>,
114 prompt_capabilities: Rc<RefCell<acp::PromptCapabilities>>,
115 available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
116 agent_id: AgentId,
117 placeholder: &str,
118 mode: EditorMode,
119 window: &mut Window,
120 cx: &mut Context<Self>,
121 ) -> Self {
122 let language = Language::new(
123 language::LanguageConfig {
124 completion_query_characters: HashSet::from_iter(['.', '-', '_', '@']),
125 ..Default::default()
126 },
127 None,
128 );
129
130 let editor = cx.new(|cx| {
131 let buffer = cx.new(|cx| Buffer::local("", cx).with_language(Arc::new(language), cx));
132 let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
133
134 let mut editor = Editor::new(mode, buffer, None, window, cx);
135 editor.set_placeholder_text(placeholder, window, cx);
136 editor.set_show_indent_guides(false, cx);
137 editor.set_show_completions_on_input(Some(true));
138 editor.set_soft_wrap();
139 editor.set_use_modal_editing(true);
140 editor.set_context_menu_options(ContextMenuOptions {
141 min_entries_visible: 12,
142 max_entries_visible: 12,
143 placement: None,
144 });
145 editor.register_addon(MessageEditorAddon::new());
146
147 editor.set_custom_context_menu(|editor, _point, window, cx| {
148 let has_selection = editor.has_non_empty_selection(&editor.display_snapshot(cx));
149
150 Some(ContextMenu::build(window, cx, |menu, _, _| {
151 menu.action("Cut", Box::new(editor::actions::Cut))
152 .action_disabled_when(
153 !has_selection,
154 "Copy",
155 Box::new(editor::actions::Copy),
156 )
157 .action("Paste", Box::new(editor::actions::Paste))
158 .action("Paste as Plain Text", Box::new(PasteRaw))
159 }))
160 });
161
162 editor
163 });
164 let mention_set =
165 cx.new(|_cx| MentionSet::new(project, thread_store.clone(), prompt_store.clone()));
166 let completion_provider = Rc::new(PromptCompletionProvider::new(
167 cx.entity(),
168 editor.downgrade(),
169 mention_set.clone(),
170 history,
171 prompt_store.clone(),
172 workspace.clone(),
173 ));
174 editor.update(cx, |editor, _cx| {
175 editor.set_completion_provider(Some(completion_provider.clone()))
176 });
177
178 cx.on_focus_in(&editor.focus_handle(cx), window, |_, _, cx| {
179 cx.emit(MessageEditorEvent::Focus)
180 })
181 .detach();
182 cx.on_focus_out(&editor.focus_handle(cx), window, |_, _, _, cx| {
183 cx.emit(MessageEditorEvent::LostFocus)
184 })
185 .detach();
186
187 let mut has_hint = false;
188 let mut subscriptions = Vec::new();
189
190 subscriptions.push(cx.subscribe_in(&editor, window, {
191 move |this, editor, event, window, cx| {
192 let input_attempted_text = match event {
193 EditorEvent::InputHandled { text, .. } => Some(text),
194 EditorEvent::InputIgnored { text } => Some(text),
195 _ => None,
196 };
197 if let Some(text) = input_attempted_text
198 && editor.read(cx).read_only(cx)
199 && !text.is_empty()
200 {
201 cx.emit(MessageEditorEvent::InputAttempted(text.clone()));
202 }
203
204 if let EditorEvent::Edited { .. } = event
205 && !editor.read(cx).read_only(cx)
206 {
207 editor.update(cx, |editor, cx| {
208 let snapshot = editor.snapshot(window, cx);
209 this.mention_set
210 .update(cx, |mention_set, _cx| mention_set.remove_invalid(&snapshot));
211
212 let new_hints = this
213 .command_hint(snapshot.buffer())
214 .into_iter()
215 .collect::<Vec<_>>();
216 let has_new_hint = !new_hints.is_empty();
217 editor.splice_inlays(
218 if has_hint {
219 &[COMMAND_HINT_INLAY_ID]
220 } else {
221 &[]
222 },
223 new_hints,
224 cx,
225 );
226 has_hint = has_new_hint;
227 });
228 cx.notify();
229 }
230 }
231 }));
232
233 Self {
234 editor,
235 mention_set,
236 workspace,
237 prompt_capabilities,
238 available_commands,
239 agent_id,
240 thread_store,
241 _subscriptions: subscriptions,
242 _parse_slash_command_task: Task::ready(()),
243 }
244 }
245
246 pub fn set_command_state(
247 &mut self,
248 prompt_capabilities: Rc<RefCell<acp::PromptCapabilities>>,
249 available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
250 _cx: &mut Context<Self>,
251 ) {
252 self.prompt_capabilities = prompt_capabilities;
253 self.available_commands = available_commands;
254 }
255
256 fn command_hint(&self, snapshot: &MultiBufferSnapshot) -> Option<Inlay> {
257 let available_commands = self.available_commands.borrow();
258 if available_commands.is_empty() {
259 return None;
260 }
261
262 let parsed_command = SlashCommandCompletion::try_parse(&snapshot.text(), 0)?;
263 if parsed_command.argument.is_some() {
264 return None;
265 }
266
267 let command_name = parsed_command.command?;
268 let available_command = available_commands
269 .iter()
270 .find(|command| command.name == command_name)?;
271
272 let acp::AvailableCommandInput::Unstructured(acp::UnstructuredCommandInput {
273 mut hint,
274 ..
275 }) = available_command.input.clone()?
276 else {
277 return None;
278 };
279
280 let mut hint_pos = MultiBufferOffset(parsed_command.source_range.end) + 1usize;
281 if hint_pos > snapshot.len() {
282 hint_pos = snapshot.len();
283 hint.insert(0, ' ');
284 }
285
286 let hint_pos = snapshot.anchor_after(hint_pos);
287
288 Some(Inlay::hint(
289 COMMAND_HINT_INLAY_ID,
290 hint_pos,
291 &InlayHint {
292 position: hint_pos.text_anchor,
293 label: InlayHintLabel::String(hint),
294 kind: Some(InlayHintKind::Parameter),
295 padding_left: false,
296 padding_right: false,
297 tooltip: None,
298 resolve_state: project::ResolveState::Resolved,
299 },
300 ))
301 }
302
303 pub fn insert_thread_summary(
304 &mut self,
305 session_id: acp::SessionId,
306 title: Option<SharedString>,
307 window: &mut Window,
308 cx: &mut Context<Self>,
309 ) {
310 if self.thread_store.is_none() {
311 return;
312 }
313 let Some(workspace) = self.workspace.upgrade() else {
314 return;
315 };
316 let thread_title = title
317 .filter(|title| !title.is_empty())
318 .unwrap_or_else(|| SharedString::new_static("New Thread"));
319 let uri = MentionUri::Thread {
320 id: session_id,
321 name: thread_title.to_string(),
322 };
323 let content = format!("{}\n", uri.as_link());
324
325 let content_len = content.len() - 1;
326
327 let start = self.editor.update(cx, |editor, cx| {
328 editor.set_text(content, window, cx);
329 editor
330 .buffer()
331 .read(cx)
332 .snapshot(cx)
333 .anchor_before(Point::zero())
334 .text_anchor
335 });
336
337 let supports_images = self.prompt_capabilities.borrow().image;
338
339 self.mention_set
340 .update(cx, |mention_set, cx| {
341 mention_set.confirm_mention_completion(
342 thread_title,
343 start,
344 content_len,
345 uri,
346 supports_images,
347 self.editor.clone(),
348 &workspace,
349 window,
350 cx,
351 )
352 })
353 .detach();
354 }
355
356 #[cfg(test)]
357 pub(crate) fn editor(&self) -> &Entity<Editor> {
358 &self.editor
359 }
360
361 pub fn is_empty(&self, cx: &App) -> bool {
362 self.editor.read(cx).is_empty(cx)
363 }
364
365 pub fn is_completions_menu_visible(&self, cx: &App) -> bool {
366 self.editor
367 .read(cx)
368 .context_menu()
369 .borrow()
370 .as_ref()
371 .is_some_and(|menu| matches!(menu, CodeContextMenu::Completions(_)) && menu.visible())
372 }
373
374 #[cfg(test)]
375 pub fn mention_set(&self) -> &Entity<MentionSet> {
376 &self.mention_set
377 }
378
379 fn validate_slash_commands(
380 text: &str,
381 available_commands: &[acp::AvailableCommand],
382 agent_id: &AgentId,
383 ) -> Result<()> {
384 if let Some(parsed_command) = SlashCommandCompletion::try_parse(text, 0) {
385 if let Some(command_name) = parsed_command.command {
386 // Check if this command is in the list of available commands from the server
387 let is_supported = available_commands
388 .iter()
389 .any(|cmd| cmd.name == command_name);
390
391 if !is_supported {
392 return Err(anyhow!(
393 "The /{} command is not supported by {}.\n\nAvailable commands: {}",
394 command_name,
395 agent_id,
396 if available_commands.is_empty() {
397 "none".to_string()
398 } else {
399 available_commands
400 .iter()
401 .map(|cmd| format!("/{}", cmd.name))
402 .collect::<Vec<_>>()
403 .join(", ")
404 }
405 ));
406 }
407 }
408 }
409 Ok(())
410 }
411
412 pub fn contents(
413 &self,
414 full_mention_content: bool,
415 cx: &mut Context<Self>,
416 ) -> Task<Result<(Vec<acp::ContentBlock>, Vec<Entity<Buffer>>)>> {
417 let text = self.editor.read(cx).text(cx);
418 let available_commands = self.available_commands.borrow().clone();
419 let agent_id = self.agent_id.clone();
420 let build_task = self.build_content_blocks(full_mention_content, cx);
421
422 cx.spawn(async move |_, _cx| {
423 Self::validate_slash_commands(&text, &available_commands, &agent_id)?;
424 build_task.await
425 })
426 }
427
428 pub fn draft_contents(&self, cx: &mut Context<Self>) -> Task<Result<Vec<acp::ContentBlock>>> {
429 let build_task = self.build_content_blocks(false, cx);
430 cx.spawn(async move |_, _cx| {
431 let (blocks, _tracked_buffers) = build_task.await?;
432 Ok(blocks)
433 })
434 }
435
436 fn build_content_blocks(
437 &self,
438 full_mention_content: bool,
439 cx: &mut Context<Self>,
440 ) -> Task<Result<(Vec<acp::ContentBlock>, Vec<Entity<Buffer>>)>> {
441 let contents = self
442 .mention_set
443 .update(cx, |store, cx| store.contents(full_mention_content, cx));
444 let editor = self.editor.clone();
445 let supports_embedded_context = self.prompt_capabilities.borrow().embedded_context;
446
447 cx.spawn(async move |_, cx| {
448 let contents = contents.await?;
449 let mut all_tracked_buffers = Vec::new();
450
451 let result = editor.update(cx, |editor, cx| {
452 let text = editor.text(cx);
453 let (mut ix, _) = text
454 .char_indices()
455 .find(|(_, c)| !c.is_whitespace())
456 .unwrap_or((0, '\0'));
457 let mut chunks: Vec<acp::ContentBlock> = Vec::new();
458 editor.display_map.update(cx, |map, cx| {
459 let snapshot = map.snapshot(cx);
460 for (crease_id, crease) in snapshot.crease_snapshot.creases() {
461 let Some((uri, mention)) = contents.get(&crease_id) else {
462 continue;
463 };
464
465 let crease_range = crease.range().to_offset(&snapshot.buffer_snapshot());
466 if crease_range.start.0 > ix {
467 let chunk = text[ix..crease_range.start.0].into();
468 chunks.push(chunk);
469 }
470 let chunk = match mention {
471 Mention::Text {
472 content,
473 tracked_buffers,
474 } => {
475 all_tracked_buffers.extend(tracked_buffers.iter().cloned());
476 if supports_embedded_context {
477 acp::ContentBlock::Resource(acp::EmbeddedResource::new(
478 acp::EmbeddedResourceResource::TextResourceContents(
479 acp::TextResourceContents::new(
480 content.clone(),
481 uri.to_uri().to_string(),
482 ),
483 ),
484 ))
485 } else {
486 acp::ContentBlock::ResourceLink(acp::ResourceLink::new(
487 uri.name(),
488 uri.to_uri().to_string(),
489 ))
490 }
491 }
492 Mention::Image(mention_image) => acp::ContentBlock::Image(
493 acp::ImageContent::new(
494 mention_image.data.clone(),
495 mention_image.format.mime_type(),
496 )
497 .uri(match uri {
498 MentionUri::File { .. } => Some(uri.to_uri().to_string()),
499 MentionUri::PastedImage => None,
500 other => {
501 debug_panic!(
502 "unexpected mention uri for image: {:?}",
503 other
504 );
505 None
506 }
507 }),
508 ),
509 Mention::Link => acp::ContentBlock::ResourceLink(
510 acp::ResourceLink::new(uri.name(), uri.to_uri().to_string()),
511 ),
512 };
513 chunks.push(chunk);
514 ix = crease_range.end.0;
515 }
516
517 if ix < text.len() {
518 let last_chunk = text[ix..].trim_end().to_owned();
519 if !last_chunk.is_empty() {
520 chunks.push(last_chunk.into());
521 }
522 }
523 });
524 anyhow::Ok((chunks, all_tracked_buffers))
525 })?;
526 Ok(result)
527 })
528 }
529
530 pub fn clear(&mut self, window: &mut Window, cx: &mut Context<Self>) {
531 self.editor.update(cx, |editor, cx| {
532 editor.clear(window, cx);
533 editor.remove_creases(
534 self.mention_set.update(cx, |mention_set, _cx| {
535 mention_set
536 .clear()
537 .map(|(crease_id, _)| crease_id)
538 .collect::<Vec<_>>()
539 }),
540 cx,
541 )
542 });
543 }
544
545 pub fn send(&mut self, cx: &mut Context<Self>) {
546 if !self.is_empty(cx) {
547 self.editor.update(cx, |editor, cx| {
548 editor.clear_inlay_hints(cx);
549 });
550 }
551 cx.emit(MessageEditorEvent::Send)
552 }
553
554 pub fn trigger_completion_menu(&mut self, window: &mut Window, cx: &mut Context<Self>) {
555 self.insert_context_prefix("@", window, cx);
556 }
557
558 pub fn insert_context_type(
559 &mut self,
560 context_keyword: &str,
561 window: &mut Window,
562 cx: &mut Context<Self>,
563 ) {
564 let prefix = format!("@{}", context_keyword);
565 self.insert_context_prefix(&prefix, window, cx);
566 }
567
568 fn insert_context_prefix(&mut self, prefix: &str, window: &mut Window, cx: &mut Context<Self>) {
569 let editor = self.editor.clone();
570 let prefix = prefix.to_string();
571
572 cx.spawn_in(window, async move |_, cx| {
573 editor
574 .update_in(cx, |editor, window, cx| {
575 let menu_is_open =
576 editor.context_menu().borrow().as_ref().is_some_and(|menu| {
577 matches!(menu, CodeContextMenu::Completions(_)) && menu.visible()
578 });
579
580 let has_prefix = {
581 let snapshot = editor.display_snapshot(cx);
582 let cursor = editor.selections.newest::<text::Point>(&snapshot).head();
583 let offset = cursor.to_offset(&snapshot);
584 let buffer_snapshot = snapshot.buffer_snapshot();
585 let prefix_char_count = prefix.chars().count();
586 buffer_snapshot
587 .reversed_chars_at(offset)
588 .take(prefix_char_count)
589 .eq(prefix.chars().rev())
590 };
591
592 if menu_is_open && has_prefix {
593 return;
594 }
595
596 editor.insert(&prefix, window, cx);
597 editor.show_completions(&editor::actions::ShowCompletions, window, cx);
598 })
599 .log_err();
600 })
601 .detach();
602 }
603
604 fn chat(&mut self, _: &Chat, _: &mut Window, cx: &mut Context<Self>) {
605 self.send(cx);
606 }
607
608 fn send_immediately(&mut self, _: &SendImmediately, _: &mut Window, cx: &mut Context<Self>) {
609 if self.is_empty(cx) {
610 return;
611 }
612
613 self.editor.update(cx, |editor, cx| {
614 editor.clear_inlay_hints(cx);
615 });
616
617 cx.emit(MessageEditorEvent::SendImmediately)
618 }
619
620 fn chat_with_follow(
621 &mut self,
622 _: &ChatWithFollow,
623 window: &mut Window,
624 cx: &mut Context<Self>,
625 ) {
626 self.workspace
627 .update(cx, |this, cx| {
628 this.follow(CollaboratorId::Agent, window, cx)
629 })
630 .log_err();
631
632 self.send(cx);
633 }
634
635 fn cancel(&mut self, _: &editor::actions::Cancel, _: &mut Window, cx: &mut Context<Self>) {
636 cx.emit(MessageEditorEvent::Cancel)
637 }
638
639 fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context<Self>) {
640 let Some(workspace) = self.workspace.upgrade() else {
641 return;
642 };
643 let editor_clipboard_selections = cx.read_from_clipboard().and_then(|item| {
644 item.entries().iter().find_map(|entry| match entry {
645 ClipboardEntry::String(text) => {
646 text.metadata_json::<Vec<editor::ClipboardSelection>>()
647 }
648 _ => None,
649 })
650 });
651
652 // Insert creases for pasted clipboard selections that:
653 // 1. Contain exactly one selection
654 // 2. Have an associated file path
655 // 3. Span multiple lines (not single-line selections)
656 // 4. Belong to a file that exists in the current project
657 let should_insert_creases = util::maybe!({
658 let selections = editor_clipboard_selections.as_ref()?;
659 if selections.len() > 1 {
660 return Some(false);
661 }
662 let selection = selections.first()?;
663 let file_path = selection.file_path.as_ref()?;
664 let line_range = selection.line_range.as_ref()?;
665
666 if line_range.start() == line_range.end() {
667 return Some(false);
668 }
669
670 Some(
671 workspace
672 .read(cx)
673 .project()
674 .read(cx)
675 .project_path_for_absolute_path(file_path, cx)
676 .is_some(),
677 )
678 })
679 .unwrap_or(false);
680
681 if should_insert_creases && let Some(selections) = editor_clipboard_selections {
682 cx.stop_propagation();
683 let insertion_target = self
684 .editor
685 .read(cx)
686 .selections
687 .newest_anchor()
688 .start
689 .text_anchor;
690
691 let project = workspace.read(cx).project().clone();
692 for selection in selections {
693 if let (Some(file_path), Some(line_range)) =
694 (selection.file_path, selection.line_range)
695 {
696 let crease_text =
697 acp_thread::selection_name(Some(file_path.as_ref()), &line_range);
698
699 let mention_uri = MentionUri::Selection {
700 abs_path: Some(file_path.clone()),
701 line_range: line_range.clone(),
702 };
703
704 let mention_text = mention_uri.as_link().to_string();
705 let (excerpt_id, text_anchor, content_len) =
706 self.editor.update(cx, |editor, cx| {
707 let buffer = editor.buffer().read(cx);
708 let snapshot = buffer.snapshot(cx);
709 let (excerpt_id, _, buffer_snapshot) = snapshot.as_singleton().unwrap();
710 let text_anchor = insertion_target.bias_left(&buffer_snapshot);
711
712 editor.insert(&mention_text, window, cx);
713 editor.insert(" ", window, cx);
714
715 (excerpt_id, text_anchor, mention_text.len())
716 });
717
718 let Some((crease_id, tx)) = insert_crease_for_mention(
719 excerpt_id,
720 text_anchor,
721 content_len,
722 crease_text.into(),
723 mention_uri.icon_path(cx),
724 mention_uri.tooltip_text(),
725 Some(mention_uri.clone()),
726 Some(self.workspace.clone()),
727 None,
728 self.editor.clone(),
729 window,
730 cx,
731 ) else {
732 continue;
733 };
734 drop(tx);
735
736 let mention_task = cx
737 .spawn({
738 let project = project.clone();
739 async move |_, cx| {
740 let project_path = project
741 .update(cx, |project, cx| {
742 project.project_path_for_absolute_path(&file_path, cx)
743 })
744 .ok_or_else(|| "project path not found".to_string())?;
745
746 let buffer = project
747 .update(cx, |project, cx| project.open_buffer(project_path, cx))
748 .await
749 .map_err(|e| e.to_string())?;
750
751 Ok(buffer.update(cx, |buffer, cx| {
752 let start =
753 Point::new(*line_range.start(), 0).min(buffer.max_point());
754 let end = Point::new(*line_range.end() + 1, 0)
755 .min(buffer.max_point());
756 let content = buffer.text_for_range(start..end).collect();
757 Mention::Text {
758 content,
759 tracked_buffers: vec![cx.entity()],
760 }
761 }))
762 }
763 })
764 .shared();
765
766 self.mention_set.update(cx, |mention_set, _cx| {
767 mention_set.insert_mention(crease_id, mention_uri.clone(), mention_task)
768 });
769 }
770 }
771 return;
772 }
773 // Handle text paste with potential markdown mention links.
774 // This must be checked BEFORE paste_images_as_context because that function
775 // returns a task even when there are no images in the clipboard.
776 if let Some(clipboard_text) = cx.read_from_clipboard().and_then(|item| {
777 item.entries().iter().find_map(|entry| match entry {
778 ClipboardEntry::String(text) => Some(text.text().to_string()),
779 _ => None,
780 })
781 }) {
782 if clipboard_text.contains("[@") {
783 cx.stop_propagation();
784 let selections_before = self.editor.update(cx, |editor, cx| {
785 let snapshot = editor.buffer().read(cx).snapshot(cx);
786 editor
787 .selections
788 .disjoint_anchors()
789 .iter()
790 .map(|selection| {
791 (
792 selection.start.bias_left(&snapshot),
793 selection.end.bias_right(&snapshot),
794 )
795 })
796 .collect::<Vec<_>>()
797 });
798
799 self.editor.update(cx, |editor, cx| {
800 editor.insert(&clipboard_text, window, cx);
801 });
802
803 let snapshot = self.editor.read(cx).buffer().read(cx).snapshot(cx);
804 let path_style = workspace.read(cx).project().read(cx).path_style(cx);
805
806 let mut all_mentions = Vec::new();
807 for (start_anchor, end_anchor) in selections_before {
808 let start_offset = start_anchor.to_offset(&snapshot);
809 let end_offset = end_anchor.to_offset(&snapshot);
810
811 // Get the actual inserted text from the buffer (may differ due to auto-indent)
812 let inserted_text: String =
813 snapshot.text_for_range(start_offset..end_offset).collect();
814
815 let parsed_mentions = parse_mention_links(&inserted_text, path_style);
816 for (range, mention_uri) in parsed_mentions {
817 let mention_start_offset = MultiBufferOffset(start_offset.0 + range.start);
818 let anchor = snapshot.anchor_before(mention_start_offset);
819 let content_len = range.end - range.start;
820 all_mentions.push((anchor, content_len, mention_uri));
821 }
822 }
823
824 if !all_mentions.is_empty() {
825 let supports_images = self.prompt_capabilities.borrow().image;
826 let http_client = workspace.read(cx).client().http_client();
827
828 for (anchor, content_len, mention_uri) in all_mentions {
829 let Some((crease_id, tx)) = insert_crease_for_mention(
830 anchor.excerpt_id,
831 anchor.text_anchor,
832 content_len,
833 mention_uri.name().into(),
834 mention_uri.icon_path(cx),
835 mention_uri.tooltip_text(),
836 Some(mention_uri.clone()),
837 Some(self.workspace.clone()),
838 None,
839 self.editor.clone(),
840 window,
841 cx,
842 ) else {
843 continue;
844 };
845
846 // Create the confirmation task based on the mention URI type.
847 // This properly loads file content, fetches URLs, etc.
848 let task = self.mention_set.update(cx, |mention_set, cx| {
849 mention_set.confirm_mention_for_uri(
850 mention_uri.clone(),
851 supports_images,
852 http_client.clone(),
853 cx,
854 )
855 });
856 let task = cx
857 .spawn(async move |_, _| task.await.map_err(|e| e.to_string()))
858 .shared();
859
860 self.mention_set.update(cx, |mention_set, _cx| {
861 mention_set.insert_mention(crease_id, mention_uri.clone(), task.clone())
862 });
863
864 // Drop the tx after inserting to signal the crease is ready
865 drop(tx);
866 }
867 return;
868 }
869 }
870 }
871
872 let has_non_text_content = cx
873 .read_from_clipboard()
874 .map(|item| {
875 item.entries().iter().any(|entry| {
876 matches!(
877 entry,
878 ClipboardEntry::Image(_) | ClipboardEntry::ExternalPaths(_)
879 )
880 })
881 })
882 .unwrap_or(false);
883
884 if self.prompt_capabilities.borrow().image
885 && has_non_text_content
886 && let Some(task) = paste_images_as_context(
887 self.editor.clone(),
888 self.mention_set.clone(),
889 self.workspace.clone(),
890 window,
891 cx,
892 )
893 {
894 cx.stop_propagation();
895 task.detach();
896 return;
897 }
898
899 // Fall through to default editor paste
900 cx.propagate();
901 }
902
903 fn paste_raw(&mut self, _: &PasteRaw, window: &mut Window, cx: &mut Context<Self>) {
904 let editor = self.editor.clone();
905 window.defer(cx, move |window, cx| {
906 editor.update(cx, |editor, cx| editor.paste(&Paste, window, cx));
907 });
908 }
909
910 pub fn insert_dragged_files(
911 &mut self,
912 paths: Vec<project::ProjectPath>,
913 added_worktrees: Vec<Entity<Worktree>>,
914 window: &mut Window,
915 cx: &mut Context<Self>,
916 ) {
917 let Some(workspace) = self.workspace.upgrade() else {
918 return;
919 };
920 let project = workspace.read(cx).project().clone();
921 let path_style = project.read(cx).path_style(cx);
922 let buffer = self.editor.read(cx).buffer().clone();
923 let Some(buffer) = buffer.read(cx).as_singleton() else {
924 return;
925 };
926 let mut tasks = Vec::new();
927 for path in paths {
928 let Some(entry) = project.read(cx).entry_for_path(&path, cx) else {
929 continue;
930 };
931 let Some(worktree) = project.read(cx).worktree_for_id(path.worktree_id, cx) else {
932 continue;
933 };
934 let abs_path = worktree.read(cx).absolutize(&path.path);
935 let (file_name, _) = crate::completion_provider::extract_file_name_and_directory(
936 &path.path,
937 worktree.read(cx).root_name(),
938 path_style,
939 );
940
941 let uri = if entry.is_dir() {
942 MentionUri::Directory { abs_path }
943 } else {
944 MentionUri::File { abs_path }
945 };
946
947 let new_text = format!("{} ", uri.as_link());
948 let content_len = new_text.len() - 1;
949
950 let anchor = buffer.update(cx, |buffer, _cx| buffer.anchor_before(buffer.len()));
951
952 self.editor.update(cx, |message_editor, cx| {
953 message_editor.edit(
954 [(
955 multi_buffer::Anchor::max()..multi_buffer::Anchor::max(),
956 new_text,
957 )],
958 cx,
959 );
960 });
961 let supports_images = self.prompt_capabilities.borrow().image;
962 tasks.push(self.mention_set.update(cx, |mention_set, cx| {
963 mention_set.confirm_mention_completion(
964 file_name,
965 anchor,
966 content_len,
967 uri,
968 supports_images,
969 self.editor.clone(),
970 &workspace,
971 window,
972 cx,
973 )
974 }));
975 }
976 cx.spawn(async move |_, _| {
977 join_all(tasks).await;
978 drop(added_worktrees);
979 })
980 .detach();
981 }
982
983 /// Inserts code snippets as creases into the editor.
984 /// Each tuple contains (code_text, crease_title).
985 pub fn insert_code_creases(
986 &mut self,
987 creases: Vec<(String, String)>,
988 window: &mut Window,
989 cx: &mut Context<Self>,
990 ) {
991 self.editor.update(cx, |editor, cx| {
992 editor.insert("\n", window, cx);
993 });
994 for (text, crease_title) in creases {
995 self.insert_crease_impl(text, crease_title, IconName::TextSnippet, true, window, cx);
996 }
997 }
998
999 pub fn insert_terminal_crease(
1000 &mut self,
1001 text: String,
1002 window: &mut Window,
1003 cx: &mut Context<Self>,
1004 ) {
1005 let line_count = text.lines().count() as u32;
1006 let mention_uri = MentionUri::TerminalSelection { line_count };
1007 let mention_text = mention_uri.as_link().to_string();
1008
1009 let (excerpt_id, text_anchor, content_len) = self.editor.update(cx, |editor, cx| {
1010 let buffer = editor.buffer().read(cx);
1011 let snapshot = buffer.snapshot(cx);
1012 let (excerpt_id, _, buffer_snapshot) = snapshot.as_singleton().unwrap();
1013 let text_anchor = editor
1014 .selections
1015 .newest_anchor()
1016 .start
1017 .text_anchor
1018 .bias_left(&buffer_snapshot);
1019
1020 editor.insert(&mention_text, window, cx);
1021 editor.insert(" ", window, cx);
1022
1023 (excerpt_id, text_anchor, mention_text.len())
1024 });
1025
1026 let Some((crease_id, tx)) = insert_crease_for_mention(
1027 excerpt_id,
1028 text_anchor,
1029 content_len,
1030 mention_uri.name().into(),
1031 mention_uri.icon_path(cx),
1032 mention_uri.tooltip_text(),
1033 Some(mention_uri.clone()),
1034 Some(self.workspace.clone()),
1035 None,
1036 self.editor.clone(),
1037 window,
1038 cx,
1039 ) else {
1040 return;
1041 };
1042 drop(tx);
1043
1044 let mention_task = Task::ready(Ok(Mention::Text {
1045 content: text,
1046 tracked_buffers: vec![],
1047 }))
1048 .shared();
1049
1050 self.mention_set.update(cx, |mention_set, _| {
1051 mention_set.insert_mention(crease_id, mention_uri, mention_task);
1052 });
1053 }
1054
1055 pub fn insert_branch_diff_crease(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1056 let Some(workspace) = self.workspace.upgrade() else {
1057 return;
1058 };
1059
1060 let project = workspace.read(cx).project().clone();
1061
1062 let Some(repo) = project.read(cx).active_repository(cx) else {
1063 return;
1064 };
1065
1066 let default_branch_receiver = repo.update(cx, |repo, _| repo.default_branch(false));
1067 let editor = self.editor.clone();
1068 let mention_set = self.mention_set.clone();
1069 let weak_workspace = self.workspace.clone();
1070
1071 window
1072 .spawn(cx, async move |cx| {
1073 let base_ref: SharedString = default_branch_receiver
1074 .await
1075 .ok()
1076 .and_then(|r| r.ok())
1077 .flatten()
1078 .ok_or_else(|| anyhow!("Could not determine default branch"))?;
1079
1080 cx.update(|window, cx| {
1081 let mention_uri = MentionUri::GitDiff {
1082 base_ref: base_ref.to_string(),
1083 };
1084 let mention_text = mention_uri.as_link().to_string();
1085
1086 let (excerpt_id, text_anchor, content_len) = editor.update(cx, |editor, cx| {
1087 let buffer = editor.buffer().read(cx);
1088 let snapshot = buffer.snapshot(cx);
1089 let (excerpt_id, _, buffer_snapshot) = snapshot.as_singleton().unwrap();
1090 let text_anchor = editor
1091 .selections
1092 .newest_anchor()
1093 .start
1094 .text_anchor
1095 .bias_left(&buffer_snapshot);
1096
1097 editor.insert(&mention_text, window, cx);
1098 editor.insert(" ", window, cx);
1099
1100 (excerpt_id, text_anchor, mention_text.len())
1101 });
1102
1103 let Some((crease_id, tx)) = insert_crease_for_mention(
1104 excerpt_id,
1105 text_anchor,
1106 content_len,
1107 mention_uri.name().into(),
1108 mention_uri.icon_path(cx),
1109 mention_uri.tooltip_text(),
1110 Some(mention_uri.clone()),
1111 Some(weak_workspace),
1112 None,
1113 editor,
1114 window,
1115 cx,
1116 ) else {
1117 return;
1118 };
1119 drop(tx);
1120
1121 let confirm_task = mention_set.update(cx, |mention_set, cx| {
1122 mention_set.confirm_mention_for_git_diff(base_ref, cx)
1123 });
1124
1125 let mention_task = cx
1126 .spawn(async move |_cx| confirm_task.await.map_err(|e| e.to_string()))
1127 .shared();
1128
1129 mention_set.update(cx, |mention_set, _| {
1130 mention_set.insert_mention(crease_id, mention_uri, mention_task);
1131 });
1132 })
1133 })
1134 .detach_and_log_err(cx);
1135 }
1136
1137 fn insert_crease_impl(
1138 &mut self,
1139 text: String,
1140 title: String,
1141 icon: IconName,
1142 add_trailing_newline: bool,
1143 window: &mut Window,
1144 cx: &mut Context<Self>,
1145 ) {
1146 use editor::display_map::{Crease, FoldPlaceholder};
1147 use multi_buffer::MultiBufferRow;
1148 use rope::Point;
1149
1150 self.editor.update(cx, |editor, cx| {
1151 let point = editor
1152 .selections
1153 .newest::<Point>(&editor.display_snapshot(cx))
1154 .head();
1155 let start_row = MultiBufferRow(point.row);
1156
1157 editor.insert(&text, window, cx);
1158
1159 let snapshot = editor.buffer().read(cx).snapshot(cx);
1160 let anchor_before = snapshot.anchor_after(point);
1161 let anchor_after = editor
1162 .selections
1163 .newest_anchor()
1164 .head()
1165 .bias_left(&snapshot);
1166
1167 if add_trailing_newline {
1168 editor.insert("\n", window, cx);
1169 }
1170
1171 let fold_placeholder = FoldPlaceholder {
1172 render: Arc::new({
1173 let title = title.clone();
1174 move |_fold_id, _fold_range, _cx| {
1175 Button::new("crease", title.clone())
1176 .layer(ElevationIndex::ElevatedSurface)
1177 .start_icon(Icon::new(icon))
1178 .into_any_element()
1179 }
1180 }),
1181 merge_adjacent: false,
1182 ..Default::default()
1183 };
1184
1185 let crease = Crease::inline(
1186 anchor_before..anchor_after,
1187 fold_placeholder,
1188 |row, is_folded, fold, _window, _cx| {
1189 Disclosure::new(("crease-toggle", row.0 as u64), !is_folded)
1190 .toggle_state(is_folded)
1191 .on_click(move |_e, window, cx| fold(!is_folded, window, cx))
1192 .into_any_element()
1193 },
1194 |_, _, _, _| gpui::Empty.into_any(),
1195 );
1196 editor.insert_creases(vec![crease], cx);
1197 editor.fold_at(start_row, window, cx);
1198 });
1199 }
1200
1201 pub fn insert_selections(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1202 let editor = self.editor.read(cx);
1203 let editor_buffer = editor.buffer().read(cx);
1204 let Some(buffer) = editor_buffer.as_singleton() else {
1205 return;
1206 };
1207 let cursor_anchor = editor.selections.newest_anchor().head();
1208 let cursor_offset = cursor_anchor.to_offset(&editor_buffer.snapshot(cx));
1209 let anchor = buffer.update(cx, |buffer, _cx| {
1210 buffer.anchor_before(cursor_offset.0.min(buffer.len()))
1211 });
1212 let Some(workspace) = self.workspace.upgrade() else {
1213 return;
1214 };
1215 let Some(completion) =
1216 PromptCompletionProvider::<Entity<MessageEditor>>::completion_for_action(
1217 PromptContextAction::AddSelections,
1218 anchor..anchor,
1219 self.editor.downgrade(),
1220 self.mention_set.downgrade(),
1221 &workspace,
1222 cx,
1223 )
1224 else {
1225 return;
1226 };
1227
1228 self.editor.update(cx, |message_editor, cx| {
1229 message_editor.edit([(cursor_anchor..cursor_anchor, completion.new_text)], cx);
1230 message_editor.request_autoscroll(Autoscroll::fit(), cx);
1231 });
1232 if let Some(confirm) = completion.confirm {
1233 confirm(CompletionIntent::Complete, window, cx);
1234 }
1235 }
1236
1237 pub fn add_images_from_picker(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1238 if !self.prompt_capabilities.borrow().image {
1239 return;
1240 }
1241
1242 let editor = self.editor.clone();
1243 let mention_set = self.mention_set.clone();
1244 let workspace = self.workspace.clone();
1245
1246 let paths_receiver = cx.prompt_for_paths(gpui::PathPromptOptions {
1247 files: true,
1248 directories: false,
1249 multiple: true,
1250 prompt: Some("Select Images".into()),
1251 });
1252
1253 window
1254 .spawn(cx, async move |cx| {
1255 let paths = match paths_receiver.await {
1256 Ok(Ok(Some(paths))) => paths,
1257 _ => return Ok::<(), anyhow::Error>(()),
1258 };
1259
1260 let supported_formats = [
1261 ("png", gpui::ImageFormat::Png),
1262 ("jpg", gpui::ImageFormat::Jpeg),
1263 ("jpeg", gpui::ImageFormat::Jpeg),
1264 ("webp", gpui::ImageFormat::Webp),
1265 ("gif", gpui::ImageFormat::Gif),
1266 ("bmp", gpui::ImageFormat::Bmp),
1267 ("tiff", gpui::ImageFormat::Tiff),
1268 ("tif", gpui::ImageFormat::Tiff),
1269 ("ico", gpui::ImageFormat::Ico),
1270 ];
1271
1272 let mut images = Vec::new();
1273 for path in paths {
1274 let extension = path
1275 .extension()
1276 .and_then(|ext| ext.to_str())
1277 .map(|s| s.to_lowercase());
1278
1279 let Some(format) = extension.and_then(|ext| {
1280 supported_formats
1281 .iter()
1282 .find(|(e, _)| *e == ext)
1283 .map(|(_, f)| *f)
1284 }) else {
1285 continue;
1286 };
1287
1288 let Ok(content) = async_fs::read(&path).await else {
1289 continue;
1290 };
1291
1292 images.push(gpui::Image::from_bytes(format, content));
1293 }
1294
1295 crate::mention_set::insert_images_as_context(
1296 images,
1297 editor,
1298 mention_set,
1299 workspace,
1300 cx,
1301 )
1302 .await;
1303 Ok(())
1304 })
1305 .detach_and_log_err(cx);
1306 }
1307
1308 pub fn set_read_only(&mut self, read_only: bool, cx: &mut Context<Self>) {
1309 self.editor.update(cx, |message_editor, cx| {
1310 message_editor.set_read_only(read_only);
1311 cx.notify()
1312 })
1313 }
1314
1315 pub fn set_mode(&mut self, mode: EditorMode, cx: &mut Context<Self>) {
1316 self.editor.update(cx, |editor, cx| {
1317 if *editor.mode() != mode {
1318 editor.set_mode(mode);
1319 cx.notify()
1320 }
1321 });
1322 }
1323
1324 pub fn set_message(
1325 &mut self,
1326 message: Vec<acp::ContentBlock>,
1327 window: &mut Window,
1328 cx: &mut Context<Self>,
1329 ) {
1330 self.clear(window, cx);
1331 self.insert_message_blocks(message, false, window, cx);
1332 }
1333
1334 pub fn append_message(
1335 &mut self,
1336 message: Vec<acp::ContentBlock>,
1337 separator: Option<&str>,
1338 window: &mut Window,
1339 cx: &mut Context<Self>,
1340 ) {
1341 if message.is_empty() {
1342 return;
1343 }
1344
1345 if let Some(separator) = separator
1346 && !separator.is_empty()
1347 && !self.is_empty(cx)
1348 {
1349 self.editor.update(cx, |editor, cx| {
1350 editor.insert(separator, window, cx);
1351 });
1352 }
1353
1354 self.insert_message_blocks(message, true, window, cx);
1355 }
1356
1357 fn insert_message_blocks(
1358 &mut self,
1359 message: Vec<acp::ContentBlock>,
1360 append_to_existing: bool,
1361 window: &mut Window,
1362 cx: &mut Context<Self>,
1363 ) {
1364 let Some(workspace) = self.workspace.upgrade() else {
1365 return;
1366 };
1367
1368 let path_style = workspace.read(cx).project().read(cx).path_style(cx);
1369 let mut text = String::new();
1370 let mut mentions = Vec::new();
1371
1372 for chunk in message {
1373 match chunk {
1374 acp::ContentBlock::Text(text_content) => {
1375 text.push_str(&text_content.text);
1376 }
1377 acp::ContentBlock::Resource(acp::EmbeddedResource {
1378 resource: acp::EmbeddedResourceResource::TextResourceContents(resource),
1379 ..
1380 }) => {
1381 let Some(mention_uri) = MentionUri::parse(&resource.uri, path_style).log_err()
1382 else {
1383 continue;
1384 };
1385 let start = text.len();
1386 write!(&mut text, "{}", mention_uri.as_link()).ok();
1387 let end = text.len();
1388 mentions.push((
1389 start..end,
1390 mention_uri,
1391 Mention::Text {
1392 content: resource.text,
1393 tracked_buffers: Vec::new(),
1394 },
1395 ));
1396 }
1397 acp::ContentBlock::ResourceLink(resource) => {
1398 if let Some(mention_uri) =
1399 MentionUri::parse(&resource.uri, path_style).log_err()
1400 {
1401 let start = text.len();
1402 write!(&mut text, "{}", mention_uri.as_link()).ok();
1403 let end = text.len();
1404 mentions.push((start..end, mention_uri, Mention::Link));
1405 }
1406 }
1407 acp::ContentBlock::Image(acp::ImageContent {
1408 uri,
1409 data,
1410 mime_type,
1411 ..
1412 }) => {
1413 let mention_uri = if let Some(uri) = uri {
1414 MentionUri::parse(&uri, path_style)
1415 } else {
1416 Ok(MentionUri::PastedImage)
1417 };
1418 let Some(mention_uri) = mention_uri.log_err() else {
1419 continue;
1420 };
1421 let Some(format) = ImageFormat::from_mime_type(&mime_type) else {
1422 log::error!("failed to parse MIME type for image: {mime_type:?}");
1423 continue;
1424 };
1425 let start = text.len();
1426 write!(&mut text, "{}", mention_uri.as_link()).ok();
1427 let end = text.len();
1428 mentions.push((
1429 start..end,
1430 mention_uri,
1431 Mention::Image(MentionImage {
1432 data: data.into(),
1433 format,
1434 }),
1435 ));
1436 }
1437 _ => {}
1438 }
1439 }
1440
1441 if text.is_empty() && mentions.is_empty() {
1442 return;
1443 }
1444
1445 let insertion_start = if append_to_existing {
1446 self.editor.read(cx).text(cx).len()
1447 } else {
1448 0
1449 };
1450
1451 let snapshot = if append_to_existing {
1452 self.editor.update(cx, |editor, cx| {
1453 editor.insert(&text, window, cx);
1454 editor.buffer().read(cx).snapshot(cx)
1455 })
1456 } else {
1457 self.editor.update(cx, |editor, cx| {
1458 editor.set_text(text, window, cx);
1459 editor.buffer().read(cx).snapshot(cx)
1460 })
1461 };
1462
1463 for (range, mention_uri, mention) in mentions {
1464 let adjusted_start = insertion_start + range.start;
1465 let anchor = snapshot.anchor_before(MultiBufferOffset(adjusted_start));
1466 let Some((crease_id, tx)) = insert_crease_for_mention(
1467 anchor.excerpt_id,
1468 anchor.text_anchor,
1469 range.end - range.start,
1470 mention_uri.name().into(),
1471 mention_uri.icon_path(cx),
1472 mention_uri.tooltip_text(),
1473 Some(mention_uri.clone()),
1474 Some(self.workspace.clone()),
1475 None,
1476 self.editor.clone(),
1477 window,
1478 cx,
1479 ) else {
1480 continue;
1481 };
1482 drop(tx);
1483
1484 self.mention_set.update(cx, |mention_set, _cx| {
1485 mention_set.insert_mention(
1486 crease_id,
1487 mention_uri.clone(),
1488 Task::ready(Ok(mention)).shared(),
1489 )
1490 });
1491 }
1492
1493 cx.notify();
1494 }
1495
1496 pub fn text(&self, cx: &App) -> String {
1497 self.editor.read(cx).text(cx)
1498 }
1499
1500 pub fn insert_text(&mut self, text: &str, window: &mut Window, cx: &mut Context<Self>) {
1501 if text.is_empty() {
1502 return;
1503 }
1504
1505 self.editor.update(cx, |editor, cx| {
1506 editor.insert(text, window, cx);
1507 });
1508 }
1509
1510 pub fn set_placeholder_text(
1511 &mut self,
1512 placeholder: &str,
1513 window: &mut Window,
1514 cx: &mut Context<Self>,
1515 ) {
1516 self.editor.update(cx, |editor, cx| {
1517 editor.set_placeholder_text(placeholder, window, cx);
1518 });
1519 }
1520
1521 #[cfg(any(test, feature = "test-support"))]
1522 pub fn set_text(&mut self, text: &str, window: &mut Window, cx: &mut Context<Self>) {
1523 self.editor.update(cx, |editor, cx| {
1524 editor.set_text(text, window, cx);
1525 });
1526 }
1527}
1528
1529impl Focusable for MessageEditor {
1530 fn focus_handle(&self, cx: &App) -> FocusHandle {
1531 self.editor.focus_handle(cx)
1532 }
1533}
1534
1535impl Render for MessageEditor {
1536 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1537 div()
1538 .key_context("MessageEditor")
1539 .on_action(cx.listener(Self::chat))
1540 .on_action(cx.listener(Self::send_immediately))
1541 .on_action(cx.listener(Self::chat_with_follow))
1542 .on_action(cx.listener(Self::cancel))
1543 .on_action(cx.listener(Self::paste_raw))
1544 .capture_action(cx.listener(Self::paste))
1545 .flex_1()
1546 .child({
1547 let settings = ThemeSettings::get_global(cx);
1548
1549 let text_style = TextStyle {
1550 color: cx.theme().colors().text,
1551 font_family: settings.buffer_font.family.clone(),
1552 font_fallbacks: settings.buffer_font.fallbacks.clone(),
1553 font_features: settings.buffer_font.features.clone(),
1554 font_size: settings.agent_buffer_font_size(cx).into(),
1555 font_weight: settings.buffer_font.weight,
1556 line_height: relative(settings.buffer_line_height.value()),
1557 ..Default::default()
1558 };
1559
1560 EditorElement::new(
1561 &self.editor,
1562 EditorStyle {
1563 background: cx.theme().colors().editor_background,
1564 local_player: cx.theme().players().local(),
1565 text: text_style,
1566 syntax: cx.theme().syntax().clone(),
1567 inlay_hints_style: editor::make_inlay_hints_style(cx),
1568 ..Default::default()
1569 },
1570 )
1571 })
1572 }
1573}
1574
1575pub struct MessageEditorAddon {}
1576
1577impl MessageEditorAddon {
1578 pub fn new() -> Self {
1579 Self {}
1580 }
1581}
1582
1583impl Addon for MessageEditorAddon {
1584 fn to_any(&self) -> &dyn std::any::Any {
1585 self
1586 }
1587
1588 fn to_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
1589 Some(self)
1590 }
1591
1592 fn extend_key_context(&self, key_context: &mut KeyContext, cx: &App) {
1593 let settings = agent_settings::AgentSettings::get_global(cx);
1594 if settings.use_modifier_to_send {
1595 key_context.add("use_modifier_to_send");
1596 }
1597 }
1598}
1599
1600/// Parses markdown mention links in the format `[@name](uri)` from text.
1601/// Returns a vector of (range, MentionUri) pairs where range is the byte range in the text.
1602fn parse_mention_links(text: &str, path_style: PathStyle) -> Vec<(Range<usize>, MentionUri)> {
1603 let mut mentions = Vec::new();
1604 let mut search_start = 0;
1605
1606 while let Some(link_start) = text[search_start..].find("[@") {
1607 let absolute_start = search_start + link_start;
1608
1609 // Find the matching closing bracket for the name, handling nested brackets.
1610 // Start at the '[' character so find_matching_bracket can track depth correctly.
1611 let Some(name_end) = find_matching_bracket(&text[absolute_start..], '[', ']') else {
1612 search_start = absolute_start + 2;
1613 continue;
1614 };
1615 let name_end = absolute_start + name_end;
1616
1617 // Check for opening parenthesis immediately after
1618 if text.get(name_end + 1..name_end + 2) != Some("(") {
1619 search_start = name_end + 1;
1620 continue;
1621 }
1622
1623 // Find the matching closing parenthesis for the URI, handling nested parens
1624 let uri_start = name_end + 2;
1625 let Some(uri_end_relative) = find_matching_bracket(&text[name_end + 1..], '(', ')') else {
1626 search_start = uri_start;
1627 continue;
1628 };
1629 let uri_end = name_end + 1 + uri_end_relative;
1630 let link_end = uri_end + 1;
1631
1632 let uri_str = &text[uri_start..uri_end];
1633
1634 // Try to parse the URI as a MentionUri
1635 if let Ok(mention_uri) = MentionUri::parse(uri_str, path_style) {
1636 mentions.push((absolute_start..link_end, mention_uri));
1637 }
1638
1639 search_start = link_end;
1640 }
1641
1642 mentions
1643}
1644
1645/// Finds the position of the matching closing bracket, handling nested brackets.
1646/// The input `text` should start with the opening bracket.
1647/// Returns the index of the matching closing bracket relative to `text`.
1648fn find_matching_bracket(text: &str, open: char, close: char) -> Option<usize> {
1649 let mut depth = 0;
1650 for (index, character) in text.char_indices() {
1651 if character == open {
1652 depth += 1;
1653 } else if character == close {
1654 depth -= 1;
1655 if depth == 0 {
1656 return Some(index);
1657 }
1658 }
1659 }
1660 None
1661}
1662
1663#[cfg(test)]
1664mod tests {
1665 use std::{cell::RefCell, ops::Range, path::Path, rc::Rc, sync::Arc};
1666
1667 use acp_thread::MentionUri;
1668 use agent::{ThreadStore, outline};
1669 use agent_client_protocol as acp;
1670 use editor::{
1671 AnchorRangeExt as _, Editor, EditorMode, MultiBufferOffset, SelectionEffects,
1672 actions::Paste,
1673 };
1674
1675 use fs::FakeFs;
1676 use futures::StreamExt as _;
1677 use gpui::{
1678 AppContext, ClipboardItem, Entity, EventEmitter, FocusHandle, Focusable, TestAppContext,
1679 VisualTestContext,
1680 };
1681 use language_model::LanguageModelRegistry;
1682 use lsp::{CompletionContext, CompletionTriggerKind};
1683 use project::{CompletionIntent, Project, ProjectPath};
1684 use serde_json::json;
1685
1686 use text::Point;
1687 use ui::{App, Context, IntoElement, Render, SharedString, Window};
1688 use util::{path, paths::PathStyle, rel_path::rel_path};
1689 use workspace::{AppState, Item, MultiWorkspace};
1690
1691 use crate::completion_provider::{PromptCompletionProviderDelegate, PromptContextType};
1692 use crate::{
1693 conversation_view::tests::init_test,
1694 message_editor::{Mention, MessageEditor, parse_mention_links},
1695 };
1696
1697 #[test]
1698 fn test_parse_mention_links() {
1699 // Single file mention
1700 let text = "[@bundle-mac](file:///Users/test/zed/script/bundle-mac)";
1701 let mentions = parse_mention_links(text, PathStyle::local());
1702 assert_eq!(mentions.len(), 1);
1703 assert_eq!(mentions[0].0, 0..text.len());
1704 assert!(matches!(mentions[0].1, MentionUri::File { .. }));
1705
1706 // Multiple mentions
1707 let text = "Check [@file1](file:///path/to/file1) and [@file2](file:///path/to/file2)!";
1708 let mentions = parse_mention_links(text, PathStyle::local());
1709 assert_eq!(mentions.len(), 2);
1710
1711 // Text without mentions
1712 let text = "Just some regular text without mentions";
1713 let mentions = parse_mention_links(text, PathStyle::local());
1714 assert_eq!(mentions.len(), 0);
1715
1716 // Malformed mentions (should be skipped)
1717 let text = "[@incomplete](invalid://uri) and [@missing](";
1718 let mentions = parse_mention_links(text, PathStyle::local());
1719 assert_eq!(mentions.len(), 0);
1720
1721 // Mixed content with valid mention
1722 let text = "Before [@valid](file:///path/to/file) after";
1723 let mentions = parse_mention_links(text, PathStyle::local());
1724 assert_eq!(mentions.len(), 1);
1725 assert_eq!(mentions[0].0.start, 7);
1726
1727 // HTTP URL mention (Fetch)
1728 let text = "Check out [@docs](https://example.com/docs) for more info";
1729 let mentions = parse_mention_links(text, PathStyle::local());
1730 assert_eq!(mentions.len(), 1);
1731 assert!(matches!(mentions[0].1, MentionUri::Fetch { .. }));
1732
1733 // Directory mention (trailing slash)
1734 let text = "[@src](file:///path/to/src/)";
1735 let mentions = parse_mention_links(text, PathStyle::local());
1736 assert_eq!(mentions.len(), 1);
1737 assert!(matches!(mentions[0].1, MentionUri::Directory { .. }));
1738
1739 // Multiple different mention types
1740 let text = "File [@f](file:///a) and URL [@u](https://b.com) and dir [@d](file:///c/)";
1741 let mentions = parse_mention_links(text, PathStyle::local());
1742 assert_eq!(mentions.len(), 3);
1743 assert!(matches!(mentions[0].1, MentionUri::File { .. }));
1744 assert!(matches!(mentions[1].1, MentionUri::Fetch { .. }));
1745 assert!(matches!(mentions[2].1, MentionUri::Directory { .. }));
1746
1747 // Adjacent mentions without separator
1748 let text = "[@a](file:///a)[@b](file:///b)";
1749 let mentions = parse_mention_links(text, PathStyle::local());
1750 assert_eq!(mentions.len(), 2);
1751
1752 // Regular markdown link (not a mention) should be ignored
1753 let text = "[regular link](https://example.com)";
1754 let mentions = parse_mention_links(text, PathStyle::local());
1755 assert_eq!(mentions.len(), 0);
1756
1757 // Incomplete mention link patterns
1758 let text = "[@name] without url and [@name( malformed";
1759 let mentions = parse_mention_links(text, PathStyle::local());
1760 assert_eq!(mentions.len(), 0);
1761
1762 // Nested brackets in name portion
1763 let text = "[@name [with brackets]](file:///path/to/file)";
1764 let mentions = parse_mention_links(text, PathStyle::local());
1765 assert_eq!(mentions.len(), 1);
1766 assert_eq!(mentions[0].0, 0..text.len());
1767
1768 // Deeply nested brackets
1769 let text = "[@outer [inner [deep]]](file:///path)";
1770 let mentions = parse_mention_links(text, PathStyle::local());
1771 assert_eq!(mentions.len(), 1);
1772
1773 // Unbalanced brackets should fail gracefully
1774 let text = "[@unbalanced [bracket](file:///path)";
1775 let mentions = parse_mention_links(text, PathStyle::local());
1776 assert_eq!(mentions.len(), 0);
1777
1778 // Nested parentheses in URI (common in URLs with query params)
1779 let text = "[@wiki](https://en.wikipedia.org/wiki/Rust_(programming_language))";
1780 let mentions = parse_mention_links(text, PathStyle::local());
1781 assert_eq!(mentions.len(), 1);
1782 if let MentionUri::Fetch { url } = &mentions[0].1 {
1783 assert!(url.as_str().contains("Rust_(programming_language)"));
1784 } else {
1785 panic!("Expected Fetch URI");
1786 }
1787 }
1788
1789 #[gpui::test]
1790 async fn test_at_mention_removal(cx: &mut TestAppContext) {
1791 init_test(cx);
1792
1793 let fs = FakeFs::new(cx.executor());
1794 fs.insert_tree("/project", json!({"file": ""})).await;
1795 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
1796
1797 let (multi_workspace, cx) =
1798 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1799 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
1800
1801 let thread_store = None;
1802
1803 let message_editor = cx.update(|window, cx| {
1804 cx.new(|cx| {
1805 MessageEditor::new(
1806 workspace.downgrade(),
1807 project.downgrade(),
1808 thread_store.clone(),
1809 None,
1810 None,
1811 Default::default(),
1812 Default::default(),
1813 "Test Agent".into(),
1814 "Test",
1815 EditorMode::AutoHeight {
1816 min_lines: 1,
1817 max_lines: None,
1818 },
1819 window,
1820 cx,
1821 )
1822 })
1823 });
1824 let editor = message_editor.update(cx, |message_editor, _| message_editor.editor.clone());
1825
1826 cx.run_until_parked();
1827
1828 let excerpt_id = editor.update(cx, |editor, cx| {
1829 editor
1830 .buffer()
1831 .read(cx)
1832 .excerpt_ids()
1833 .into_iter()
1834 .next()
1835 .unwrap()
1836 });
1837 let completions = editor.update_in(cx, |editor, window, cx| {
1838 editor.set_text("Hello @file ", window, cx);
1839 let buffer = editor.buffer().read(cx).as_singleton().unwrap();
1840 let completion_provider = editor.completion_provider().unwrap();
1841 completion_provider.completions(
1842 excerpt_id,
1843 &buffer,
1844 text::Anchor::MAX,
1845 CompletionContext {
1846 trigger_kind: CompletionTriggerKind::TRIGGER_CHARACTER,
1847 trigger_character: Some("@".into()),
1848 },
1849 window,
1850 cx,
1851 )
1852 });
1853 let [_, completion]: [_; 2] = completions
1854 .await
1855 .unwrap()
1856 .into_iter()
1857 .flat_map(|response| response.completions)
1858 .collect::<Vec<_>>()
1859 .try_into()
1860 .unwrap();
1861
1862 editor.update_in(cx, |editor, window, cx| {
1863 let snapshot = editor.buffer().read(cx).snapshot(cx);
1864 let range = snapshot
1865 .anchor_range_in_excerpt(excerpt_id, completion.replace_range)
1866 .unwrap();
1867 editor.edit([(range, completion.new_text)], cx);
1868 (completion.confirm.unwrap())(CompletionIntent::Complete, window, cx);
1869 });
1870
1871 cx.run_until_parked();
1872
1873 // Backspace over the inserted crease (and the following space).
1874 editor.update_in(cx, |editor, window, cx| {
1875 editor.backspace(&Default::default(), window, cx);
1876 editor.backspace(&Default::default(), window, cx);
1877 });
1878
1879 let (content, _) = message_editor
1880 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
1881 .await
1882 .unwrap();
1883
1884 // We don't send a resource link for the deleted crease.
1885 pretty_assertions::assert_matches!(content.as_slice(), [acp::ContentBlock::Text { .. }]);
1886 }
1887
1888 #[gpui::test]
1889 async fn test_slash_command_validation(cx: &mut gpui::TestAppContext) {
1890 init_test(cx);
1891 let fs = FakeFs::new(cx.executor());
1892 fs.insert_tree(
1893 "/test",
1894 json!({
1895 ".zed": {
1896 "tasks.json": r#"[{"label": "test", "command": "echo"}]"#
1897 },
1898 "src": {
1899 "main.rs": "fn main() {}",
1900 },
1901 }),
1902 )
1903 .await;
1904
1905 let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
1906 let thread_store = None;
1907 let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default()));
1908 // Start with no available commands - simulating Claude which doesn't support slash commands
1909 let available_commands = Rc::new(RefCell::new(vec![]));
1910
1911 let (multi_workspace, cx) =
1912 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1913 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
1914 let workspace_handle = workspace.downgrade();
1915 let message_editor = workspace.update_in(cx, |_, window, cx| {
1916 cx.new(|cx| {
1917 MessageEditor::new(
1918 workspace_handle.clone(),
1919 project.downgrade(),
1920 thread_store.clone(),
1921 None,
1922 None,
1923 prompt_capabilities.clone(),
1924 available_commands.clone(),
1925 "Claude Agent".into(),
1926 "Test",
1927 EditorMode::AutoHeight {
1928 min_lines: 1,
1929 max_lines: None,
1930 },
1931 window,
1932 cx,
1933 )
1934 })
1935 });
1936 let editor = message_editor.update(cx, |message_editor, _| message_editor.editor.clone());
1937
1938 // Test that slash commands fail when no available_commands are set (empty list means no commands supported)
1939 editor.update_in(cx, |editor, window, cx| {
1940 editor.set_text("/file test.txt", window, cx);
1941 });
1942
1943 let contents_result = message_editor
1944 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
1945 .await;
1946
1947 // Should fail because available_commands is empty (no commands supported)
1948 assert!(contents_result.is_err());
1949 let error_message = contents_result.unwrap_err().to_string();
1950 assert!(error_message.contains("not supported by Claude Agent"));
1951 assert!(error_message.contains("Available commands: none"));
1952
1953 // Now simulate Claude providing its list of available commands (which doesn't include file)
1954 available_commands.replace(vec![acp::AvailableCommand::new("help", "Get help")]);
1955
1956 // Test that unsupported slash commands trigger an error when we have a list of available commands
1957 editor.update_in(cx, |editor, window, cx| {
1958 editor.set_text("/file test.txt", window, cx);
1959 });
1960
1961 let contents_result = message_editor
1962 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
1963 .await;
1964
1965 assert!(contents_result.is_err());
1966 let error_message = contents_result.unwrap_err().to_string();
1967 assert!(error_message.contains("not supported by Claude Agent"));
1968 assert!(error_message.contains("/file"));
1969 assert!(error_message.contains("Available commands: /help"));
1970
1971 // Test that supported commands work fine
1972 editor.update_in(cx, |editor, window, cx| {
1973 editor.set_text("/help", window, cx);
1974 });
1975
1976 let contents_result = message_editor
1977 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
1978 .await;
1979
1980 // Should succeed because /help is in available_commands
1981 assert!(contents_result.is_ok());
1982
1983 // Test that regular text works fine
1984 editor.update_in(cx, |editor, window, cx| {
1985 editor.set_text("Hello Claude!", window, cx);
1986 });
1987
1988 let (content, _) = message_editor
1989 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
1990 .await
1991 .unwrap();
1992
1993 assert_eq!(content.len(), 1);
1994 if let acp::ContentBlock::Text(text) = &content[0] {
1995 assert_eq!(text.text, "Hello Claude!");
1996 } else {
1997 panic!("Expected ContentBlock::Text");
1998 }
1999
2000 // Test that @ mentions still work
2001 editor.update_in(cx, |editor, window, cx| {
2002 editor.set_text("Check this @", window, cx);
2003 });
2004
2005 // The @ mention functionality should not be affected
2006 let (content, _) = message_editor
2007 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
2008 .await
2009 .unwrap();
2010
2011 assert_eq!(content.len(), 1);
2012 if let acp::ContentBlock::Text(text) = &content[0] {
2013 assert_eq!(text.text, "Check this @");
2014 } else {
2015 panic!("Expected ContentBlock::Text");
2016 }
2017 }
2018
2019 struct MessageEditorItem(Entity<MessageEditor>);
2020
2021 impl Item for MessageEditorItem {
2022 type Event = ();
2023
2024 fn include_in_nav_history() -> bool {
2025 false
2026 }
2027
2028 fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
2029 "Test".into()
2030 }
2031 }
2032
2033 impl EventEmitter<()> for MessageEditorItem {}
2034
2035 impl Focusable for MessageEditorItem {
2036 fn focus_handle(&self, cx: &App) -> FocusHandle {
2037 self.0.read(cx).focus_handle(cx)
2038 }
2039 }
2040
2041 impl Render for MessageEditorItem {
2042 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
2043 self.0.clone().into_any_element()
2044 }
2045 }
2046
2047 #[gpui::test]
2048 async fn test_completion_provider_commands(cx: &mut TestAppContext) {
2049 init_test(cx);
2050
2051 let app_state = cx.update(AppState::test);
2052
2053 cx.update(|cx| {
2054 editor::init(cx);
2055 workspace::init(app_state.clone(), cx);
2056 });
2057
2058 let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
2059 let window =
2060 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2061 let workspace = window
2062 .read_with(cx, |mw, _| mw.workspace().clone())
2063 .unwrap();
2064
2065 let mut cx = VisualTestContext::from_window(window.into(), cx);
2066
2067 let thread_store = None;
2068 let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default()));
2069 let available_commands = Rc::new(RefCell::new(vec![
2070 acp::AvailableCommand::new("quick-math", "2 + 2 = 4 - 1 = 3"),
2071 acp::AvailableCommand::new("say-hello", "Say hello to whoever you want").input(
2072 acp::AvailableCommandInput::Unstructured(acp::UnstructuredCommandInput::new(
2073 "<name>",
2074 )),
2075 ),
2076 ]));
2077
2078 let editor = workspace.update_in(&mut cx, |workspace, window, cx| {
2079 let workspace_handle = cx.weak_entity();
2080 let message_editor = cx.new(|cx| {
2081 MessageEditor::new(
2082 workspace_handle,
2083 project.downgrade(),
2084 thread_store.clone(),
2085 None,
2086 None,
2087 prompt_capabilities.clone(),
2088 available_commands.clone(),
2089 "Test Agent".into(),
2090 "Test",
2091 EditorMode::AutoHeight {
2092 max_lines: None,
2093 min_lines: 1,
2094 },
2095 window,
2096 cx,
2097 )
2098 });
2099 workspace.active_pane().update(cx, |pane, cx| {
2100 pane.add_item(
2101 Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
2102 true,
2103 true,
2104 None,
2105 window,
2106 cx,
2107 );
2108 });
2109 message_editor.read(cx).focus_handle(cx).focus(window, cx);
2110 message_editor.read(cx).editor().clone()
2111 });
2112
2113 cx.simulate_input("/");
2114
2115 editor.update_in(&mut cx, |editor, window, cx| {
2116 assert_eq!(editor.text(cx), "/");
2117 assert!(editor.has_visible_completions_menu());
2118
2119 assert_eq!(
2120 current_completion_labels_with_documentation(editor),
2121 &[
2122 ("quick-math".into(), "2 + 2 = 4 - 1 = 3".into()),
2123 ("say-hello".into(), "Say hello to whoever you want".into())
2124 ]
2125 );
2126 editor.set_text("", window, cx);
2127 });
2128
2129 cx.simulate_input("/qui");
2130
2131 editor.update_in(&mut cx, |editor, window, cx| {
2132 assert_eq!(editor.text(cx), "/qui");
2133 assert!(editor.has_visible_completions_menu());
2134
2135 assert_eq!(
2136 current_completion_labels_with_documentation(editor),
2137 &[("quick-math".into(), "2 + 2 = 4 - 1 = 3".into())]
2138 );
2139 editor.set_text("", window, cx);
2140 });
2141
2142 editor.update_in(&mut cx, |editor, window, cx| {
2143 assert!(editor.has_visible_completions_menu());
2144 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2145 });
2146
2147 cx.run_until_parked();
2148
2149 editor.update_in(&mut cx, |editor, window, cx| {
2150 assert_eq!(editor.display_text(cx), "/quick-math ");
2151 assert!(!editor.has_visible_completions_menu());
2152 editor.set_text("", window, cx);
2153 });
2154
2155 cx.simulate_input("/say");
2156
2157 editor.update_in(&mut cx, |editor, _window, cx| {
2158 assert_eq!(editor.display_text(cx), "/say");
2159 assert!(editor.has_visible_completions_menu());
2160
2161 assert_eq!(
2162 current_completion_labels_with_documentation(editor),
2163 &[("say-hello".into(), "Say hello to whoever you want".into())]
2164 );
2165 });
2166
2167 editor.update_in(&mut cx, |editor, window, cx| {
2168 assert!(editor.has_visible_completions_menu());
2169 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2170 });
2171
2172 cx.run_until_parked();
2173
2174 editor.update_in(&mut cx, |editor, _window, cx| {
2175 assert_eq!(editor.text(cx), "/say-hello ");
2176 assert_eq!(editor.display_text(cx), "/say-hello <name>");
2177 assert!(!editor.has_visible_completions_menu());
2178 });
2179
2180 cx.simulate_input("GPT5");
2181
2182 cx.run_until_parked();
2183
2184 editor.update_in(&mut cx, |editor, window, cx| {
2185 assert_eq!(editor.text(cx), "/say-hello GPT5");
2186 assert_eq!(editor.display_text(cx), "/say-hello GPT5");
2187 assert!(!editor.has_visible_completions_menu());
2188
2189 // Delete argument
2190 for _ in 0..5 {
2191 editor.backspace(&editor::actions::Backspace, window, cx);
2192 }
2193 });
2194
2195 cx.run_until_parked();
2196
2197 editor.update_in(&mut cx, |editor, window, cx| {
2198 assert_eq!(editor.text(cx), "/say-hello");
2199 // Hint is visible because argument was deleted
2200 assert_eq!(editor.display_text(cx), "/say-hello <name>");
2201
2202 // Delete last command letter
2203 editor.backspace(&editor::actions::Backspace, window, cx);
2204 });
2205
2206 cx.run_until_parked();
2207
2208 editor.update_in(&mut cx, |editor, _window, cx| {
2209 // Hint goes away once command no longer matches an available one
2210 assert_eq!(editor.text(cx), "/say-hell");
2211 assert_eq!(editor.display_text(cx), "/say-hell");
2212 assert!(!editor.has_visible_completions_menu());
2213 });
2214 }
2215
2216 #[gpui::test]
2217 async fn test_context_completion_provider_mentions(cx: &mut TestAppContext) {
2218 init_test(cx);
2219
2220 let app_state = cx.update(AppState::test);
2221
2222 cx.update(|cx| {
2223 editor::init(cx);
2224 workspace::init(app_state.clone(), cx);
2225 });
2226
2227 app_state
2228 .fs
2229 .as_fake()
2230 .insert_tree(
2231 path!("/dir"),
2232 json!({
2233 "editor": "",
2234 "a": {
2235 "one.txt": "1",
2236 "two.txt": "2",
2237 "three.txt": "3",
2238 "four.txt": "4"
2239 },
2240 "b": {
2241 "five.txt": "5",
2242 "six.txt": "6",
2243 "seven.txt": "7",
2244 "eight.txt": "8",
2245 },
2246 "x.png": "",
2247 }),
2248 )
2249 .await;
2250
2251 let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
2252 let window =
2253 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2254 let workspace = window
2255 .read_with(cx, |mw, _| mw.workspace().clone())
2256 .unwrap();
2257
2258 let worktree = project.update(cx, |project, cx| {
2259 let mut worktrees = project.worktrees(cx).collect::<Vec<_>>();
2260 assert_eq!(worktrees.len(), 1);
2261 worktrees.pop().unwrap()
2262 });
2263 let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id());
2264
2265 let mut cx = VisualTestContext::from_window(window.into(), cx);
2266
2267 let paths = vec![
2268 rel_path("a/one.txt"),
2269 rel_path("a/two.txt"),
2270 rel_path("a/three.txt"),
2271 rel_path("a/four.txt"),
2272 rel_path("b/five.txt"),
2273 rel_path("b/six.txt"),
2274 rel_path("b/seven.txt"),
2275 rel_path("b/eight.txt"),
2276 ];
2277
2278 let slash = PathStyle::local().primary_separator();
2279
2280 let mut opened_editors = Vec::new();
2281 for path in paths {
2282 let buffer = workspace
2283 .update_in(&mut cx, |workspace, window, cx| {
2284 workspace.open_path(
2285 ProjectPath {
2286 worktree_id,
2287 path: path.into(),
2288 },
2289 None,
2290 false,
2291 window,
2292 cx,
2293 )
2294 })
2295 .await
2296 .unwrap();
2297 opened_editors.push(buffer);
2298 }
2299
2300 let thread_store = cx.new(|cx| ThreadStore::new(cx));
2301 let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default()));
2302
2303 let (message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| {
2304 let workspace_handle = cx.weak_entity();
2305 let message_editor = cx.new(|cx| {
2306 MessageEditor::new(
2307 workspace_handle,
2308 project.downgrade(),
2309 Some(thread_store),
2310 None,
2311 None,
2312 prompt_capabilities.clone(),
2313 Default::default(),
2314 "Test Agent".into(),
2315 "Test",
2316 EditorMode::AutoHeight {
2317 max_lines: None,
2318 min_lines: 1,
2319 },
2320 window,
2321 cx,
2322 )
2323 });
2324 workspace.active_pane().update(cx, |pane, cx| {
2325 pane.add_item(
2326 Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
2327 true,
2328 true,
2329 None,
2330 window,
2331 cx,
2332 );
2333 });
2334 message_editor.read(cx).focus_handle(cx).focus(window, cx);
2335 let editor = message_editor.read(cx).editor().clone();
2336 (message_editor, editor)
2337 });
2338
2339 cx.simulate_input("Lorem @");
2340
2341 editor.update_in(&mut cx, |editor, window, cx| {
2342 assert_eq!(editor.text(cx), "Lorem @");
2343 assert!(editor.has_visible_completions_menu());
2344
2345 assert_eq!(
2346 current_completion_labels(editor),
2347 &[
2348 format!("eight.txt b{slash}"),
2349 format!("seven.txt b{slash}"),
2350 format!("six.txt b{slash}"),
2351 format!("five.txt b{slash}"),
2352 "Files & Directories".into(),
2353 "Symbols".into()
2354 ]
2355 );
2356 editor.set_text("", window, cx);
2357 });
2358
2359 prompt_capabilities.replace(
2360 acp::PromptCapabilities::new()
2361 .image(true)
2362 .audio(true)
2363 .embedded_context(true),
2364 );
2365
2366 cx.simulate_input("Lorem ");
2367
2368 editor.update(&mut cx, |editor, cx| {
2369 assert_eq!(editor.text(cx), "Lorem ");
2370 assert!(!editor.has_visible_completions_menu());
2371 });
2372
2373 cx.simulate_input("@");
2374
2375 editor.update(&mut cx, |editor, cx| {
2376 assert_eq!(editor.text(cx), "Lorem @");
2377 assert!(editor.has_visible_completions_menu());
2378 assert_eq!(
2379 current_completion_labels(editor),
2380 &[
2381 format!("eight.txt b{slash}"),
2382 format!("seven.txt b{slash}"),
2383 format!("six.txt b{slash}"),
2384 format!("five.txt b{slash}"),
2385 "Files & Directories".into(),
2386 "Symbols".into(),
2387 "Threads".into(),
2388 "Fetch".into()
2389 ]
2390 );
2391 });
2392
2393 // Select and confirm "File"
2394 editor.update_in(&mut cx, |editor, window, cx| {
2395 assert!(editor.has_visible_completions_menu());
2396 editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
2397 editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
2398 editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
2399 editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
2400 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2401 });
2402
2403 cx.run_until_parked();
2404
2405 editor.update(&mut cx, |editor, cx| {
2406 assert_eq!(editor.text(cx), "Lorem @file ");
2407 assert!(editor.has_visible_completions_menu());
2408 });
2409
2410 cx.simulate_input("one");
2411
2412 editor.update(&mut cx, |editor, cx| {
2413 assert_eq!(editor.text(cx), "Lorem @file one");
2414 assert!(editor.has_visible_completions_menu());
2415 assert_eq!(
2416 current_completion_labels(editor),
2417 vec![format!("one.txt a{slash}")]
2418 );
2419 });
2420
2421 editor.update_in(&mut cx, |editor, window, cx| {
2422 assert!(editor.has_visible_completions_menu());
2423 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2424 });
2425
2426 let url_one = MentionUri::File {
2427 abs_path: path!("/dir/a/one.txt").into(),
2428 }
2429 .to_uri()
2430 .to_string();
2431 editor.update(&mut cx, |editor, cx| {
2432 let text = editor.text(cx);
2433 assert_eq!(text, format!("Lorem [@one.txt]({url_one}) "));
2434 assert!(!editor.has_visible_completions_menu());
2435 assert_eq!(fold_ranges(editor, cx).len(), 1);
2436 });
2437
2438 let contents = message_editor
2439 .update(&mut cx, |message_editor, cx| {
2440 message_editor
2441 .mention_set()
2442 .update(cx, |mention_set, cx| mention_set.contents(false, cx))
2443 })
2444 .await
2445 .unwrap()
2446 .into_values()
2447 .collect::<Vec<_>>();
2448
2449 {
2450 let [(uri, Mention::Text { content, .. })] = contents.as_slice() else {
2451 panic!("Unexpected mentions");
2452 };
2453 pretty_assertions::assert_eq!(content, "1");
2454 pretty_assertions::assert_eq!(
2455 uri,
2456 &MentionUri::parse(&url_one, PathStyle::local()).unwrap()
2457 );
2458 }
2459
2460 cx.simulate_input(" ");
2461
2462 editor.update(&mut cx, |editor, cx| {
2463 let text = editor.text(cx);
2464 assert_eq!(text, format!("Lorem [@one.txt]({url_one}) "));
2465 assert!(!editor.has_visible_completions_menu());
2466 assert_eq!(fold_ranges(editor, cx).len(), 1);
2467 });
2468
2469 cx.simulate_input("Ipsum ");
2470
2471 editor.update(&mut cx, |editor, cx| {
2472 let text = editor.text(cx);
2473 assert_eq!(text, format!("Lorem [@one.txt]({url_one}) Ipsum "),);
2474 assert!(!editor.has_visible_completions_menu());
2475 assert_eq!(fold_ranges(editor, cx).len(), 1);
2476 });
2477
2478 cx.simulate_input("@file ");
2479
2480 editor.update(&mut cx, |editor, cx| {
2481 let text = editor.text(cx);
2482 assert_eq!(text, format!("Lorem [@one.txt]({url_one}) Ipsum @file "),);
2483 assert!(editor.has_visible_completions_menu());
2484 assert_eq!(fold_ranges(editor, cx).len(), 1);
2485 });
2486
2487 editor.update_in(&mut cx, |editor, window, cx| {
2488 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2489 });
2490
2491 cx.run_until_parked();
2492
2493 let contents = message_editor
2494 .update(&mut cx, |message_editor, cx| {
2495 message_editor
2496 .mention_set()
2497 .update(cx, |mention_set, cx| mention_set.contents(false, cx))
2498 })
2499 .await
2500 .unwrap()
2501 .into_values()
2502 .collect::<Vec<_>>();
2503
2504 let url_eight = MentionUri::File {
2505 abs_path: path!("/dir/b/eight.txt").into(),
2506 }
2507 .to_uri()
2508 .to_string();
2509
2510 {
2511 let [_, (uri, Mention::Text { content, .. })] = contents.as_slice() else {
2512 panic!("Unexpected mentions");
2513 };
2514 pretty_assertions::assert_eq!(content, "8");
2515 pretty_assertions::assert_eq!(
2516 uri,
2517 &MentionUri::parse(&url_eight, PathStyle::local()).unwrap()
2518 );
2519 }
2520
2521 editor.update(&mut cx, |editor, cx| {
2522 assert_eq!(
2523 editor.text(cx),
2524 format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) ")
2525 );
2526 assert!(!editor.has_visible_completions_menu());
2527 assert_eq!(fold_ranges(editor, cx).len(), 2);
2528 });
2529
2530 let plain_text_language = Arc::new(language::Language::new(
2531 language::LanguageConfig {
2532 name: "Plain Text".into(),
2533 matcher: language::LanguageMatcher {
2534 path_suffixes: vec!["txt".to_string()],
2535 ..Default::default()
2536 },
2537 ..Default::default()
2538 },
2539 None,
2540 ));
2541
2542 // Register the language and fake LSP
2543 let language_registry = project.read_with(&cx, |project, _| project.languages().clone());
2544 language_registry.add(plain_text_language);
2545
2546 let mut fake_language_servers = language_registry.register_fake_lsp(
2547 "Plain Text",
2548 language::FakeLspAdapter {
2549 capabilities: lsp::ServerCapabilities {
2550 workspace_symbol_provider: Some(lsp::OneOf::Left(true)),
2551 ..Default::default()
2552 },
2553 ..Default::default()
2554 },
2555 );
2556
2557 // Open the buffer to trigger LSP initialization
2558 let buffer = project
2559 .update(&mut cx, |project, cx| {
2560 project.open_local_buffer(path!("/dir/a/one.txt"), cx)
2561 })
2562 .await
2563 .unwrap();
2564
2565 // Register the buffer with language servers
2566 let _handle = project.update(&mut cx, |project, cx| {
2567 project.register_buffer_with_language_servers(&buffer, cx)
2568 });
2569
2570 cx.run_until_parked();
2571
2572 let fake_language_server = fake_language_servers.next().await.unwrap();
2573 fake_language_server.set_request_handler::<lsp::WorkspaceSymbolRequest, _, _>(
2574 move |_, _| async move {
2575 Ok(Some(lsp::WorkspaceSymbolResponse::Flat(vec![
2576 #[allow(deprecated)]
2577 lsp::SymbolInformation {
2578 name: "MySymbol".into(),
2579 location: lsp::Location {
2580 uri: lsp::Uri::from_file_path(path!("/dir/a/one.txt")).unwrap(),
2581 range: lsp::Range::new(
2582 lsp::Position::new(0, 0),
2583 lsp::Position::new(0, 1),
2584 ),
2585 },
2586 kind: lsp::SymbolKind::CONSTANT,
2587 tags: None,
2588 container_name: None,
2589 deprecated: None,
2590 },
2591 ])))
2592 },
2593 );
2594
2595 cx.simulate_input("@symbol ");
2596
2597 editor.update(&mut cx, |editor, cx| {
2598 assert_eq!(
2599 editor.text(cx),
2600 format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) @symbol ")
2601 );
2602 assert!(editor.has_visible_completions_menu());
2603 assert_eq!(current_completion_labels(editor), &["MySymbol one.txt L1"]);
2604 });
2605
2606 editor.update_in(&mut cx, |editor, window, cx| {
2607 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2608 });
2609
2610 let symbol = MentionUri::Symbol {
2611 abs_path: path!("/dir/a/one.txt").into(),
2612 name: "MySymbol".into(),
2613 line_range: 0..=0,
2614 };
2615
2616 let contents = message_editor
2617 .update(&mut cx, |message_editor, cx| {
2618 message_editor
2619 .mention_set()
2620 .update(cx, |mention_set, cx| mention_set.contents(false, cx))
2621 })
2622 .await
2623 .unwrap()
2624 .into_values()
2625 .collect::<Vec<_>>();
2626
2627 {
2628 let [_, _, (uri, Mention::Text { content, .. })] = contents.as_slice() else {
2629 panic!("Unexpected mentions");
2630 };
2631 pretty_assertions::assert_eq!(content, "1");
2632 pretty_assertions::assert_eq!(uri, &symbol);
2633 }
2634
2635 cx.run_until_parked();
2636
2637 editor.read_with(&cx, |editor, cx| {
2638 assert_eq!(
2639 editor.text(cx),
2640 format!(
2641 "Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) ",
2642 symbol.to_uri(),
2643 )
2644 );
2645 });
2646
2647 // Try to mention an "image" file that will fail to load
2648 cx.simulate_input("@file x.png");
2649
2650 editor.update(&mut cx, |editor, cx| {
2651 assert_eq!(
2652 editor.text(cx),
2653 format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) @file x.png", symbol.to_uri())
2654 );
2655 assert!(editor.has_visible_completions_menu());
2656 assert_eq!(current_completion_labels(editor), &["x.png "]);
2657 });
2658
2659 editor.update_in(&mut cx, |editor, window, cx| {
2660 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2661 });
2662
2663 // Getting the message contents fails
2664 message_editor
2665 .update(&mut cx, |message_editor, cx| {
2666 message_editor
2667 .mention_set()
2668 .update(cx, |mention_set, cx| mention_set.contents(false, cx))
2669 })
2670 .await
2671 .expect_err("Should fail to load x.png");
2672
2673 cx.run_until_parked();
2674
2675 // Mention was removed
2676 editor.read_with(&cx, |editor, cx| {
2677 assert_eq!(
2678 editor.text(cx),
2679 format!(
2680 "Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) ",
2681 symbol.to_uri()
2682 )
2683 );
2684 });
2685
2686 // Once more
2687 cx.simulate_input("@file x.png");
2688
2689 editor.update(&mut cx, |editor, cx| {
2690 assert_eq!(
2691 editor.text(cx),
2692 format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) @file x.png", symbol.to_uri())
2693 );
2694 assert!(editor.has_visible_completions_menu());
2695 assert_eq!(current_completion_labels(editor), &["x.png "]);
2696 });
2697
2698 editor.update_in(&mut cx, |editor, window, cx| {
2699 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2700 });
2701
2702 // This time don't immediately get the contents, just let the confirmed completion settle
2703 cx.run_until_parked();
2704
2705 // Mention was removed
2706 editor.read_with(&cx, |editor, cx| {
2707 assert_eq!(
2708 editor.text(cx),
2709 format!(
2710 "Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) ",
2711 symbol.to_uri()
2712 )
2713 );
2714 });
2715
2716 // Now getting the contents succeeds, because the invalid mention was removed
2717 let contents = message_editor
2718 .update(&mut cx, |message_editor, cx| {
2719 message_editor
2720 .mention_set()
2721 .update(cx, |mention_set, cx| mention_set.contents(false, cx))
2722 })
2723 .await
2724 .unwrap();
2725 assert_eq!(contents.len(), 3);
2726 }
2727
2728 fn fold_ranges(editor: &Editor, cx: &mut App) -> Vec<Range<Point>> {
2729 let snapshot = editor.buffer().read(cx).snapshot(cx);
2730 editor.display_map.update(cx, |display_map, cx| {
2731 display_map
2732 .snapshot(cx)
2733 .folds_in_range(MultiBufferOffset(0)..snapshot.len())
2734 .map(|fold| fold.range.to_point(&snapshot))
2735 .collect()
2736 })
2737 }
2738
2739 fn current_completion_labels(editor: &Editor) -> Vec<String> {
2740 let completions = editor.current_completions().expect("Missing completions");
2741 completions
2742 .into_iter()
2743 .map(|completion| completion.label.text)
2744 .collect::<Vec<_>>()
2745 }
2746
2747 fn current_completion_labels_with_documentation(editor: &Editor) -> Vec<(String, String)> {
2748 let completions = editor.current_completions().expect("Missing completions");
2749 completions
2750 .into_iter()
2751 .map(|completion| {
2752 (
2753 completion.label.text,
2754 completion
2755 .documentation
2756 .map(|d| d.text().to_string())
2757 .unwrap_or_default(),
2758 )
2759 })
2760 .collect::<Vec<_>>()
2761 }
2762
2763 #[gpui::test]
2764 async fn test_large_file_mention_fallback(cx: &mut TestAppContext) {
2765 init_test(cx);
2766
2767 let fs = FakeFs::new(cx.executor());
2768
2769 // Create a large file that exceeds AUTO_OUTLINE_SIZE
2770 // Using plain text without a configured language, so no outline is available
2771 const LINE: &str = "This is a line of text in the file\n";
2772 let large_content = LINE.repeat(2 * (outline::AUTO_OUTLINE_SIZE / LINE.len()));
2773 assert!(large_content.len() > outline::AUTO_OUTLINE_SIZE);
2774
2775 // Create a small file that doesn't exceed AUTO_OUTLINE_SIZE
2776 let small_content = "fn small_function() { /* small */ }\n";
2777 assert!(small_content.len() < outline::AUTO_OUTLINE_SIZE);
2778
2779 fs.insert_tree(
2780 "/project",
2781 json!({
2782 "large_file.txt": large_content.clone(),
2783 "small_file.txt": small_content,
2784 }),
2785 )
2786 .await;
2787
2788 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
2789
2790 let (multi_workspace, cx) =
2791 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2792 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2793
2794 let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
2795
2796 let message_editor = cx.update(|window, cx| {
2797 cx.new(|cx| {
2798 let editor = MessageEditor::new(
2799 workspace.downgrade(),
2800 project.downgrade(),
2801 thread_store.clone(),
2802 None,
2803 None,
2804 Default::default(),
2805 Default::default(),
2806 "Test Agent".into(),
2807 "Test",
2808 EditorMode::AutoHeight {
2809 min_lines: 1,
2810 max_lines: None,
2811 },
2812 window,
2813 cx,
2814 );
2815 // Enable embedded context so files are actually included
2816 editor
2817 .prompt_capabilities
2818 .replace(acp::PromptCapabilities::new().embedded_context(true));
2819 editor
2820 })
2821 });
2822
2823 // Test large file mention
2824 // Get the absolute path using the project's worktree
2825 let large_file_abs_path = project.read_with(cx, |project, cx| {
2826 let worktree = project.worktrees(cx).next().unwrap();
2827 let worktree_root = worktree.read(cx).abs_path();
2828 worktree_root.join("large_file.txt")
2829 });
2830 let large_file_task = message_editor.update(cx, |editor, cx| {
2831 editor.mention_set().update(cx, |set, cx| {
2832 set.confirm_mention_for_file(large_file_abs_path, true, cx)
2833 })
2834 });
2835
2836 let large_file_mention = large_file_task.await.unwrap();
2837 match large_file_mention {
2838 Mention::Text { content, .. } => {
2839 // Should contain some of the content but not all of it
2840 assert!(
2841 content.contains(LINE),
2842 "Should contain some of the file content"
2843 );
2844 assert!(
2845 !content.contains(&LINE.repeat(100)),
2846 "Should not contain the full file"
2847 );
2848 // Should be much smaller than original
2849 assert!(
2850 content.len() < large_content.len() / 10,
2851 "Should be significantly truncated"
2852 );
2853 }
2854 _ => panic!("Expected Text mention for large file"),
2855 }
2856
2857 // Test small file mention
2858 // Get the absolute path using the project's worktree
2859 let small_file_abs_path = project.read_with(cx, |project, cx| {
2860 let worktree = project.worktrees(cx).next().unwrap();
2861 let worktree_root = worktree.read(cx).abs_path();
2862 worktree_root.join("small_file.txt")
2863 });
2864 let small_file_task = message_editor.update(cx, |editor, cx| {
2865 editor.mention_set().update(cx, |set, cx| {
2866 set.confirm_mention_for_file(small_file_abs_path, true, cx)
2867 })
2868 });
2869
2870 let small_file_mention = small_file_task.await.unwrap();
2871 match small_file_mention {
2872 Mention::Text { content, .. } => {
2873 // Should contain the full actual content
2874 assert_eq!(content, small_content);
2875 }
2876 _ => panic!("Expected Text mention for small file"),
2877 }
2878 }
2879
2880 #[gpui::test]
2881 async fn test_insert_thread_summary(cx: &mut TestAppContext) {
2882 init_test(cx);
2883 cx.update(LanguageModelRegistry::test);
2884
2885 let fs = FakeFs::new(cx.executor());
2886 fs.insert_tree("/project", json!({"file": ""})).await;
2887 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
2888
2889 let (multi_workspace, cx) =
2890 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2891 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2892
2893 let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
2894
2895 let session_id = acp::SessionId::new("thread-123");
2896 let title = Some("Previous Conversation".into());
2897
2898 let message_editor = cx.update(|window, cx| {
2899 cx.new(|cx| {
2900 let mut editor = MessageEditor::new(
2901 workspace.downgrade(),
2902 project.downgrade(),
2903 thread_store.clone(),
2904 None,
2905 None,
2906 Default::default(),
2907 Default::default(),
2908 "Test Agent".into(),
2909 "Test",
2910 EditorMode::AutoHeight {
2911 min_lines: 1,
2912 max_lines: None,
2913 },
2914 window,
2915 cx,
2916 );
2917 editor.insert_thread_summary(session_id.clone(), title.clone(), window, cx);
2918 editor
2919 })
2920 });
2921
2922 // Construct expected values for verification
2923 let expected_uri = MentionUri::Thread {
2924 id: session_id.clone(),
2925 name: title.as_ref().unwrap().to_string(),
2926 };
2927 let expected_title = title.as_ref().unwrap();
2928 let expected_link = format!("[@{}]({})", expected_title, expected_uri.to_uri());
2929
2930 message_editor.read_with(cx, |editor, cx| {
2931 let text = editor.text(cx);
2932
2933 assert!(
2934 text.contains(&expected_link),
2935 "Expected editor text to contain thread mention link.\nExpected substring: {}\nActual text: {}",
2936 expected_link,
2937 text
2938 );
2939
2940 let mentions = editor.mention_set().read(cx).mentions();
2941 assert_eq!(
2942 mentions.len(),
2943 1,
2944 "Expected exactly one mention after inserting thread summary"
2945 );
2946
2947 assert!(
2948 mentions.contains(&expected_uri),
2949 "Expected mentions to contain the thread URI"
2950 );
2951 });
2952 }
2953
2954 #[gpui::test]
2955 async fn test_insert_thread_summary_skipped_for_external_agents(cx: &mut TestAppContext) {
2956 init_test(cx);
2957 cx.update(LanguageModelRegistry::test);
2958
2959 let fs = FakeFs::new(cx.executor());
2960 fs.insert_tree("/project", json!({"file": ""})).await;
2961 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
2962
2963 let (multi_workspace, cx) =
2964 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2965 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2966
2967 let thread_store = None;
2968
2969 let message_editor = cx.update(|window, cx| {
2970 cx.new(|cx| {
2971 let mut editor = MessageEditor::new(
2972 workspace.downgrade(),
2973 project.downgrade(),
2974 thread_store.clone(),
2975 None,
2976 None,
2977 Default::default(),
2978 Default::default(),
2979 "Test Agent".into(),
2980 "Test",
2981 EditorMode::AutoHeight {
2982 min_lines: 1,
2983 max_lines: None,
2984 },
2985 window,
2986 cx,
2987 );
2988 editor.insert_thread_summary(
2989 acp::SessionId::new("thread-123"),
2990 Some("Previous Conversation".into()),
2991 window,
2992 cx,
2993 );
2994 editor
2995 })
2996 });
2997
2998 message_editor.read_with(cx, |editor, cx| {
2999 assert!(
3000 editor.text(cx).is_empty(),
3001 "Expected thread summary to be skipped for external agents"
3002 );
3003 assert!(
3004 editor.mention_set().read(cx).mentions().is_empty(),
3005 "Expected no mentions when thread summary is skipped"
3006 );
3007 });
3008 }
3009
3010 #[gpui::test]
3011 async fn test_thread_mode_hidden_when_disabled(cx: &mut TestAppContext) {
3012 init_test(cx);
3013
3014 let fs = FakeFs::new(cx.executor());
3015 fs.insert_tree("/project", json!({"file": ""})).await;
3016 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
3017
3018 let (multi_workspace, cx) =
3019 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3020 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
3021
3022 let thread_store = None;
3023
3024 let message_editor = cx.update(|window, cx| {
3025 cx.new(|cx| {
3026 MessageEditor::new(
3027 workspace.downgrade(),
3028 project.downgrade(),
3029 thread_store.clone(),
3030 None,
3031 None,
3032 Default::default(),
3033 Default::default(),
3034 "Test Agent".into(),
3035 "Test",
3036 EditorMode::AutoHeight {
3037 min_lines: 1,
3038 max_lines: None,
3039 },
3040 window,
3041 cx,
3042 )
3043 })
3044 });
3045
3046 message_editor.update(cx, |editor, _cx| {
3047 editor
3048 .prompt_capabilities
3049 .replace(acp::PromptCapabilities::new().embedded_context(true));
3050 });
3051
3052 let supported_modes = {
3053 let app = cx.app.borrow();
3054 message_editor.supported_modes(&app)
3055 };
3056
3057 assert!(
3058 !supported_modes.contains(&PromptContextType::Thread),
3059 "Expected thread mode to be hidden when thread mentions are disabled"
3060 );
3061 }
3062
3063 #[gpui::test]
3064 async fn test_thread_mode_visible_when_enabled(cx: &mut TestAppContext) {
3065 init_test(cx);
3066
3067 let fs = FakeFs::new(cx.executor());
3068 fs.insert_tree("/project", json!({"file": ""})).await;
3069 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
3070
3071 let (multi_workspace, cx) =
3072 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3073 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
3074
3075 let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
3076
3077 let message_editor = cx.update(|window, cx| {
3078 cx.new(|cx| {
3079 MessageEditor::new(
3080 workspace.downgrade(),
3081 project.downgrade(),
3082 thread_store.clone(),
3083 None,
3084 None,
3085 Default::default(),
3086 Default::default(),
3087 "Test Agent".into(),
3088 "Test",
3089 EditorMode::AutoHeight {
3090 min_lines: 1,
3091 max_lines: None,
3092 },
3093 window,
3094 cx,
3095 )
3096 })
3097 });
3098
3099 message_editor.update(cx, |editor, _cx| {
3100 editor
3101 .prompt_capabilities
3102 .replace(acp::PromptCapabilities::new().embedded_context(true));
3103 });
3104
3105 let supported_modes = {
3106 let app = cx.app.borrow();
3107 message_editor.supported_modes(&app)
3108 };
3109
3110 assert!(
3111 supported_modes.contains(&PromptContextType::Thread),
3112 "Expected thread mode to be visible when enabled"
3113 );
3114 }
3115
3116 #[gpui::test]
3117 async fn test_whitespace_trimming(cx: &mut TestAppContext) {
3118 init_test(cx);
3119
3120 let fs = FakeFs::new(cx.executor());
3121 fs.insert_tree("/project", json!({"file.rs": "fn main() {}"}))
3122 .await;
3123 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
3124
3125 let (multi_workspace, cx) =
3126 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3127 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
3128
3129 let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
3130
3131 let message_editor = cx.update(|window, cx| {
3132 cx.new(|cx| {
3133 MessageEditor::new(
3134 workspace.downgrade(),
3135 project.downgrade(),
3136 thread_store.clone(),
3137 None,
3138 None,
3139 Default::default(),
3140 Default::default(),
3141 "Test Agent".into(),
3142 "Test",
3143 EditorMode::AutoHeight {
3144 min_lines: 1,
3145 max_lines: None,
3146 },
3147 window,
3148 cx,
3149 )
3150 })
3151 });
3152 let editor = message_editor.update(cx, |message_editor, _| message_editor.editor.clone());
3153
3154 cx.run_until_parked();
3155
3156 editor.update_in(cx, |editor, window, cx| {
3157 editor.set_text(" \u{A0}してhello world ", window, cx);
3158 });
3159
3160 let (content, _) = message_editor
3161 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
3162 .await
3163 .unwrap();
3164
3165 assert_eq!(content, vec!["してhello world".into()]);
3166 }
3167
3168 #[gpui::test]
3169 async fn test_editor_respects_embedded_context_capability(cx: &mut TestAppContext) {
3170 init_test(cx);
3171
3172 let fs = FakeFs::new(cx.executor());
3173
3174 let file_content = "fn main() { println!(\"Hello, world!\"); }\n";
3175
3176 fs.insert_tree(
3177 "/project",
3178 json!({
3179 "src": {
3180 "main.rs": file_content,
3181 }
3182 }),
3183 )
3184 .await;
3185
3186 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
3187
3188 let (multi_workspace, cx) =
3189 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3190 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
3191
3192 let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
3193
3194 let (message_editor, editor) = workspace.update_in(cx, |workspace, window, cx| {
3195 let workspace_handle = cx.weak_entity();
3196 let message_editor = cx.new(|cx| {
3197 MessageEditor::new(
3198 workspace_handle,
3199 project.downgrade(),
3200 thread_store.clone(),
3201 None,
3202 None,
3203 Default::default(),
3204 Default::default(),
3205 "Test Agent".into(),
3206 "Test",
3207 EditorMode::AutoHeight {
3208 max_lines: None,
3209 min_lines: 1,
3210 },
3211 window,
3212 cx,
3213 )
3214 });
3215 workspace.active_pane().update(cx, |pane, cx| {
3216 pane.add_item(
3217 Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
3218 true,
3219 true,
3220 None,
3221 window,
3222 cx,
3223 );
3224 });
3225 message_editor.read(cx).focus_handle(cx).focus(window, cx);
3226 let editor = message_editor.read(cx).editor().clone();
3227 (message_editor, editor)
3228 });
3229
3230 cx.simulate_input("What is in @file main");
3231
3232 editor.update_in(cx, |editor, window, cx| {
3233 assert!(editor.has_visible_completions_menu());
3234 assert_eq!(editor.text(cx), "What is in @file main");
3235 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
3236 });
3237
3238 let content = message_editor
3239 .update(cx, |editor, cx| editor.contents(false, cx))
3240 .await
3241 .unwrap()
3242 .0;
3243
3244 let main_rs_uri = if cfg!(windows) {
3245 "file:///C:/project/src/main.rs"
3246 } else {
3247 "file:///project/src/main.rs"
3248 };
3249
3250 // When embedded context is `false` we should get a resource link
3251 pretty_assertions::assert_eq!(
3252 content,
3253 vec![
3254 "What is in ".into(),
3255 acp::ContentBlock::ResourceLink(acp::ResourceLink::new("main.rs", main_rs_uri))
3256 ]
3257 );
3258
3259 message_editor.update(cx, |editor, _cx| {
3260 editor
3261 .prompt_capabilities
3262 .replace(acp::PromptCapabilities::new().embedded_context(true))
3263 });
3264
3265 let content = message_editor
3266 .update(cx, |editor, cx| editor.contents(false, cx))
3267 .await
3268 .unwrap()
3269 .0;
3270
3271 // When embedded context is `true` we should get a resource
3272 pretty_assertions::assert_eq!(
3273 content,
3274 vec![
3275 "What is in ".into(),
3276 acp::ContentBlock::Resource(acp::EmbeddedResource::new(
3277 acp::EmbeddedResourceResource::TextResourceContents(
3278 acp::TextResourceContents::new(file_content, main_rs_uri)
3279 )
3280 ))
3281 ]
3282 );
3283 }
3284
3285 #[gpui::test]
3286 async fn test_autoscroll_after_insert_selections(cx: &mut TestAppContext) {
3287 init_test(cx);
3288
3289 let app_state = cx.update(AppState::test);
3290
3291 cx.update(|cx| {
3292 editor::init(cx);
3293 workspace::init(app_state.clone(), cx);
3294 });
3295
3296 app_state
3297 .fs
3298 .as_fake()
3299 .insert_tree(
3300 path!("/dir"),
3301 json!({
3302 "test.txt": "line1\nline2\nline3\nline4\nline5\n",
3303 }),
3304 )
3305 .await;
3306
3307 let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
3308 let window =
3309 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3310 let workspace = window
3311 .read_with(cx, |mw, _| mw.workspace().clone())
3312 .unwrap();
3313
3314 let worktree = project.update(cx, |project, cx| {
3315 let mut worktrees = project.worktrees(cx).collect::<Vec<_>>();
3316 assert_eq!(worktrees.len(), 1);
3317 worktrees.pop().unwrap()
3318 });
3319 let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id());
3320
3321 let mut cx = VisualTestContext::from_window(window.into(), cx);
3322
3323 // Open a regular editor with the created file, and select a portion of
3324 // the text that will be used for the selections that are meant to be
3325 // inserted in the agent panel.
3326 let editor = workspace
3327 .update_in(&mut cx, |workspace, window, cx| {
3328 workspace.open_path(
3329 ProjectPath {
3330 worktree_id,
3331 path: rel_path("test.txt").into(),
3332 },
3333 None,
3334 false,
3335 window,
3336 cx,
3337 )
3338 })
3339 .await
3340 .unwrap()
3341 .downcast::<Editor>()
3342 .unwrap();
3343
3344 editor.update_in(&mut cx, |editor, window, cx| {
3345 editor.change_selections(Default::default(), window, cx, |selections| {
3346 selections.select_ranges([Point::new(0, 0)..Point::new(0, 5)]);
3347 });
3348 });
3349
3350 let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
3351
3352 // Create a new `MessageEditor`. The `EditorMode::full()` has to be used
3353 // to ensure we have a fixed viewport, so we can eventually actually
3354 // place the cursor outside of the visible area.
3355 let message_editor = workspace.update_in(&mut cx, |workspace, window, cx| {
3356 let workspace_handle = cx.weak_entity();
3357 let message_editor = cx.new(|cx| {
3358 MessageEditor::new(
3359 workspace_handle,
3360 project.downgrade(),
3361 thread_store.clone(),
3362 None,
3363 None,
3364 Default::default(),
3365 Default::default(),
3366 "Test Agent".into(),
3367 "Test",
3368 EditorMode::full(),
3369 window,
3370 cx,
3371 )
3372 });
3373 workspace.active_pane().update(cx, |pane, cx| {
3374 pane.add_item(
3375 Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
3376 true,
3377 true,
3378 None,
3379 window,
3380 cx,
3381 );
3382 });
3383
3384 message_editor
3385 });
3386
3387 message_editor.update_in(&mut cx, |message_editor, window, cx| {
3388 message_editor.editor.update(cx, |editor, cx| {
3389 // Update the Agent Panel's Message Editor text to have 100
3390 // lines, ensuring that the cursor is set at line 90 and that we
3391 // then scroll all the way to the top, so the cursor's position
3392 // remains off screen.
3393 let mut lines = String::new();
3394 for _ in 1..=100 {
3395 lines.push_str(&"Another line in the agent panel's message editor\n");
3396 }
3397 editor.set_text(lines.as_str(), window, cx);
3398 editor.change_selections(Default::default(), window, cx, |selections| {
3399 selections.select_ranges([Point::new(90, 0)..Point::new(90, 0)]);
3400 });
3401 editor.set_scroll_position(gpui::Point::new(0., 0.), window, cx);
3402 });
3403 });
3404
3405 cx.run_until_parked();
3406
3407 // Before proceeding, let's assert that the cursor is indeed off screen,
3408 // otherwise the rest of the test doesn't make sense.
3409 message_editor.update_in(&mut cx, |message_editor, window, cx| {
3410 message_editor.editor.update(cx, |editor, cx| {
3411 let snapshot = editor.snapshot(window, cx);
3412 let cursor_row = editor.selections.newest::<Point>(&snapshot).head().row;
3413 let scroll_top = snapshot.scroll_position().y as u32;
3414 let visible_lines = editor.visible_line_count().unwrap() as u32;
3415 let visible_range = scroll_top..(scroll_top + visible_lines);
3416
3417 assert!(!visible_range.contains(&cursor_row));
3418 })
3419 });
3420
3421 // Now let's insert the selection in the Agent Panel's editor and
3422 // confirm that, after the insertion, the cursor is now in the visible
3423 // range.
3424 message_editor.update_in(&mut cx, |message_editor, window, cx| {
3425 message_editor.insert_selections(window, cx);
3426 });
3427
3428 cx.run_until_parked();
3429
3430 message_editor.update_in(&mut cx, |message_editor, window, cx| {
3431 message_editor.editor.update(cx, |editor, cx| {
3432 let snapshot = editor.snapshot(window, cx);
3433 let cursor_row = editor.selections.newest::<Point>(&snapshot).head().row;
3434 let scroll_top = snapshot.scroll_position().y as u32;
3435 let visible_lines = editor.visible_line_count().unwrap() as u32;
3436 let visible_range = scroll_top..(scroll_top + visible_lines);
3437
3438 assert!(visible_range.contains(&cursor_row));
3439 })
3440 });
3441 }
3442
3443 #[gpui::test]
3444 async fn test_insert_context_with_multibyte_characters(cx: &mut TestAppContext) {
3445 init_test(cx);
3446
3447 let app_state = cx.update(AppState::test);
3448
3449 cx.update(|cx| {
3450 editor::init(cx);
3451 workspace::init(app_state.clone(), cx);
3452 });
3453
3454 app_state
3455 .fs
3456 .as_fake()
3457 .insert_tree(path!("/dir"), json!({}))
3458 .await;
3459
3460 let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
3461 let window =
3462 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3463 let workspace = window
3464 .read_with(cx, |mw, _| mw.workspace().clone())
3465 .unwrap();
3466
3467 let mut cx = VisualTestContext::from_window(window.into(), cx);
3468
3469 let thread_store = cx.new(|cx| ThreadStore::new(cx));
3470
3471 let (message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| {
3472 let workspace_handle = cx.weak_entity();
3473 let message_editor = cx.new(|cx| {
3474 MessageEditor::new(
3475 workspace_handle,
3476 project.downgrade(),
3477 Some(thread_store),
3478 None,
3479 None,
3480 Default::default(),
3481 Default::default(),
3482 "Test Agent".into(),
3483 "Test",
3484 EditorMode::AutoHeight {
3485 max_lines: None,
3486 min_lines: 1,
3487 },
3488 window,
3489 cx,
3490 )
3491 });
3492 workspace.active_pane().update(cx, |pane, cx| {
3493 pane.add_item(
3494 Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
3495 true,
3496 true,
3497 None,
3498 window,
3499 cx,
3500 );
3501 });
3502 message_editor.read(cx).focus_handle(cx).focus(window, cx);
3503 let editor = message_editor.read(cx).editor().clone();
3504 (message_editor, editor)
3505 });
3506
3507 editor.update_in(&mut cx, |editor, window, cx| {
3508 editor.set_text("😄😄", window, cx);
3509 });
3510
3511 cx.run_until_parked();
3512
3513 message_editor.update_in(&mut cx, |message_editor, window, cx| {
3514 message_editor.insert_context_type("file", window, cx);
3515 });
3516
3517 cx.run_until_parked();
3518
3519 editor.update(&mut cx, |editor, cx| {
3520 assert_eq!(editor.text(cx), "😄😄@file");
3521 });
3522 }
3523
3524 #[gpui::test]
3525 async fn test_paste_mention_link_with_multiple_selections(cx: &mut TestAppContext) {
3526 init_test(cx);
3527
3528 let app_state = cx.update(AppState::test);
3529
3530 cx.update(|cx| {
3531 editor::init(cx);
3532 workspace::init(app_state.clone(), cx);
3533 });
3534
3535 app_state
3536 .fs
3537 .as_fake()
3538 .insert_tree(path!("/project"), json!({"file.txt": "content"}))
3539 .await;
3540
3541 let project = Project::test(app_state.fs.clone(), [path!("/project").as_ref()], cx).await;
3542 let window =
3543 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3544 let workspace = window
3545 .read_with(cx, |mw, _| mw.workspace().clone())
3546 .unwrap();
3547
3548 let mut cx = VisualTestContext::from_window(window.into(), cx);
3549
3550 let thread_store = cx.new(|cx| ThreadStore::new(cx));
3551
3552 let (message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| {
3553 let workspace_handle = cx.weak_entity();
3554 let message_editor = cx.new(|cx| {
3555 MessageEditor::new(
3556 workspace_handle,
3557 project.downgrade(),
3558 Some(thread_store),
3559 None,
3560 None,
3561 Default::default(),
3562 Default::default(),
3563 "Test Agent".into(),
3564 "Test",
3565 EditorMode::AutoHeight {
3566 max_lines: None,
3567 min_lines: 1,
3568 },
3569 window,
3570 cx,
3571 )
3572 });
3573 workspace.active_pane().update(cx, |pane, cx| {
3574 pane.add_item(
3575 Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
3576 true,
3577 true,
3578 None,
3579 window,
3580 cx,
3581 );
3582 });
3583 message_editor.read(cx).focus_handle(cx).focus(window, cx);
3584 let editor = message_editor.read(cx).editor().clone();
3585 (message_editor, editor)
3586 });
3587
3588 editor.update_in(&mut cx, |editor, window, cx| {
3589 editor.set_text(
3590 "AAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAA",
3591 window,
3592 cx,
3593 );
3594 });
3595
3596 cx.run_until_parked();
3597
3598 editor.update_in(&mut cx, |editor, window, cx| {
3599 editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
3600 s.select_ranges([
3601 MultiBufferOffset(0)..MultiBufferOffset(25), // First selection (large)
3602 MultiBufferOffset(30)..MultiBufferOffset(55), // Second selection (newest)
3603 ]);
3604 });
3605 });
3606
3607 let mention_link = "[@f](file:///test.txt)";
3608 cx.write_to_clipboard(ClipboardItem::new_string(mention_link.into()));
3609
3610 message_editor.update_in(&mut cx, |message_editor, window, cx| {
3611 message_editor.paste(&Paste, window, cx);
3612 });
3613
3614 let text = editor.update(&mut cx, |editor, cx| editor.text(cx));
3615 assert!(
3616 text.contains("[@f](file:///test.txt)"),
3617 "Expected mention link to be pasted, got: {}",
3618 text
3619 );
3620 }
3621
3622 // Helper that creates a minimal MessageEditor inside a window, returning both
3623 // the entity and the underlying VisualTestContext so callers can drive updates.
3624 async fn setup_message_editor(
3625 cx: &mut TestAppContext,
3626 ) -> (Entity<MessageEditor>, &mut VisualTestContext) {
3627 let fs = FakeFs::new(cx.executor());
3628 fs.insert_tree("/project", json!({"file.txt": ""})).await;
3629 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
3630
3631 let (multi_workspace, cx) =
3632 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3633 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
3634
3635 let message_editor = cx.update(|window, cx| {
3636 cx.new(|cx| {
3637 MessageEditor::new(
3638 workspace.downgrade(),
3639 project.downgrade(),
3640 None,
3641 None,
3642 None,
3643 Default::default(),
3644 Default::default(),
3645 "Test Agent".into(),
3646 "Test",
3647 EditorMode::AutoHeight {
3648 min_lines: 1,
3649 max_lines: None,
3650 },
3651 window,
3652 cx,
3653 )
3654 })
3655 });
3656
3657 cx.run_until_parked();
3658 (message_editor, cx)
3659 }
3660
3661 #[gpui::test]
3662 async fn test_set_message_plain_text(cx: &mut TestAppContext) {
3663 init_test(cx);
3664 let (message_editor, cx) = setup_message_editor(cx).await;
3665
3666 message_editor.update_in(cx, |editor, window, cx| {
3667 editor.set_message(
3668 vec![acp::ContentBlock::Text(acp::TextContent::new(
3669 "hello world".to_string(),
3670 ))],
3671 window,
3672 cx,
3673 );
3674 });
3675
3676 let text = message_editor.update(cx, |editor, cx| editor.text(cx));
3677 assert_eq!(text, "hello world");
3678 assert!(!message_editor.update(cx, |editor, cx| editor.is_empty(cx)));
3679 }
3680
3681 #[gpui::test]
3682 async fn test_set_message_replaces_existing_content(cx: &mut TestAppContext) {
3683 init_test(cx);
3684 let (message_editor, cx) = setup_message_editor(cx).await;
3685
3686 // Set initial content.
3687 message_editor.update_in(cx, |editor, window, cx| {
3688 editor.set_message(
3689 vec![acp::ContentBlock::Text(acp::TextContent::new(
3690 "old content".to_string(),
3691 ))],
3692 window,
3693 cx,
3694 );
3695 });
3696
3697 // Replace with new content.
3698 message_editor.update_in(cx, |editor, window, cx| {
3699 editor.set_message(
3700 vec![acp::ContentBlock::Text(acp::TextContent::new(
3701 "new content".to_string(),
3702 ))],
3703 window,
3704 cx,
3705 );
3706 });
3707
3708 let text = message_editor.update(cx, |editor, cx| editor.text(cx));
3709 assert_eq!(
3710 text, "new content",
3711 "set_message should replace old content"
3712 );
3713 }
3714
3715 #[gpui::test]
3716 async fn test_append_message_to_empty_editor(cx: &mut TestAppContext) {
3717 init_test(cx);
3718 let (message_editor, cx) = setup_message_editor(cx).await;
3719
3720 message_editor.update_in(cx, |editor, window, cx| {
3721 editor.append_message(
3722 vec![acp::ContentBlock::Text(acp::TextContent::new(
3723 "appended".to_string(),
3724 ))],
3725 Some("\n\n"),
3726 window,
3727 cx,
3728 );
3729 });
3730
3731 let text = message_editor.update(cx, |editor, cx| editor.text(cx));
3732 assert_eq!(
3733 text, "appended",
3734 "No separator should be inserted when the editor is empty"
3735 );
3736 }
3737
3738 #[gpui::test]
3739 async fn test_append_message_to_non_empty_editor(cx: &mut TestAppContext) {
3740 init_test(cx);
3741 let (message_editor, cx) = setup_message_editor(cx).await;
3742
3743 // Seed initial content.
3744 message_editor.update_in(cx, |editor, window, cx| {
3745 editor.set_message(
3746 vec![acp::ContentBlock::Text(acp::TextContent::new(
3747 "initial".to_string(),
3748 ))],
3749 window,
3750 cx,
3751 );
3752 });
3753
3754 // Append with separator.
3755 message_editor.update_in(cx, |editor, window, cx| {
3756 editor.append_message(
3757 vec![acp::ContentBlock::Text(acp::TextContent::new(
3758 "appended".to_string(),
3759 ))],
3760 Some("\n\n"),
3761 window,
3762 cx,
3763 );
3764 });
3765
3766 let text = message_editor.update(cx, |editor, cx| editor.text(cx));
3767 assert_eq!(
3768 text, "initial\n\nappended",
3769 "Separator should appear between existing and appended content"
3770 );
3771 }
3772
3773 #[gpui::test]
3774 async fn test_append_message_preserves_mention_offset(cx: &mut TestAppContext) {
3775 init_test(cx);
3776
3777 let fs = FakeFs::new(cx.executor());
3778 fs.insert_tree("/project", json!({"file.txt": "content"}))
3779 .await;
3780 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
3781
3782 let (multi_workspace, cx) =
3783 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
3784 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
3785
3786 let message_editor = cx.update(|window, cx| {
3787 cx.new(|cx| {
3788 MessageEditor::new(
3789 workspace.downgrade(),
3790 project.downgrade(),
3791 None,
3792 None,
3793 None,
3794 Default::default(),
3795 Default::default(),
3796 "Test Agent".into(),
3797 "Test",
3798 EditorMode::AutoHeight {
3799 min_lines: 1,
3800 max_lines: None,
3801 },
3802 window,
3803 cx,
3804 )
3805 })
3806 });
3807
3808 cx.run_until_parked();
3809
3810 // Seed plain-text prefix so the editor is non-empty before appending.
3811 message_editor.update_in(cx, |editor, window, cx| {
3812 editor.set_message(
3813 vec![acp::ContentBlock::Text(acp::TextContent::new(
3814 "prefix text".to_string(),
3815 ))],
3816 window,
3817 cx,
3818 );
3819 });
3820
3821 // Append a message that contains a ResourceLink mention.
3822 message_editor.update_in(cx, |editor, window, cx| {
3823 editor.append_message(
3824 vec![acp::ContentBlock::ResourceLink(acp::ResourceLink::new(
3825 "file.txt",
3826 "file:///project/file.txt",
3827 ))],
3828 Some("\n\n"),
3829 window,
3830 cx,
3831 );
3832 });
3833
3834 cx.run_until_parked();
3835
3836 // The mention should be registered in the mention_set so that contents()
3837 // will emit it as a structured block rather than plain text.
3838 let mention_uris =
3839 message_editor.update(cx, |editor, cx| editor.mention_set.read(cx).mentions());
3840 assert_eq!(
3841 mention_uris.len(),
3842 1,
3843 "Expected exactly one mention in the mention_set after append, got: {mention_uris:?}"
3844 );
3845
3846 // The editor text should start with the prefix, then the separator, then
3847 // the mention placeholder — confirming the offset was computed correctly.
3848 let text = message_editor.update(cx, |editor, cx| editor.text(cx));
3849 assert!(
3850 text.starts_with("prefix text\n\n"),
3851 "Expected text to start with 'prefix text\\n\\n', got: {text:?}"
3852 );
3853 }
3854}