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