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