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