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