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