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