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