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