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