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