1use crate::SendImmediately;
2use crate::acp::AcpThreadHistory;
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::{AgentSessionInfo, MentionUri};
14use agent::ThreadStore;
15use agent_client_protocol as acp;
16use anyhow::{Result, anyhow};
17use collections::HashSet;
18use editor::{
19 Addon, AnchorRangeExt, ContextMenuOptions, ContextMenuPlacement, Editor, EditorElement,
20 EditorEvent, EditorMode, EditorStyle, Inlay, MultiBuffer, MultiBufferOffset,
21 MultiBufferSnapshot, ToOffset, actions::Paste, code_context_menus::CodeContextMenu,
22 scroll::Autoscroll,
23};
24use futures::{FutureExt as _, future::join_all};
25use gpui::{
26 AppContext, ClipboardEntry, Context, Entity, EventEmitter, FocusHandle, Focusable, ImageFormat,
27 KeyContext, SharedString, Subscription, Task, TextStyle, WeakEntity,
28};
29use language::{Buffer, Language, language_settings::InlayHintKind};
30use project::{CompletionIntent, InlayHint, InlayHintLabel, InlayId, Project, Worktree};
31use prompt_store::PromptStore;
32use rope::Point;
33use settings::Settings;
34use std::{cell::RefCell, fmt::Write, rc::Rc, sync::Arc};
35use theme::ThemeSettings;
36use ui::{ButtonLike, ButtonStyle, ContextMenu, Disclosure, ElevationIndex, prelude::*};
37use util::{ResultExt, debug_panic};
38use workspace::{CollaboratorId, Workspace};
39use zed_actions::agent::{Chat, PasteRaw};
40
41pub struct MessageEditor {
42 mention_set: Entity<MentionSet>,
43 editor: Entity<Editor>,
44 workspace: WeakEntity<Workspace>,
45 prompt_capabilities: Rc<RefCell<acp::PromptCapabilities>>,
46 available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
47 agent_name: SharedString,
48 thread_store: Option<Entity<ThreadStore>>,
49 _subscriptions: Vec<Subscription>,
50 _parse_slash_command_task: Task<()>,
51}
52
53#[derive(Clone, Copy, Debug)]
54pub enum MessageEditorEvent {
55 Send,
56 SendImmediately,
57 Cancel,
58 Focus,
59 LostFocus,
60}
61
62impl EventEmitter<MessageEditorEvent> for MessageEditor {}
63
64const COMMAND_HINT_INLAY_ID: InlayId = InlayId::Hint(0);
65
66impl PromptCompletionProviderDelegate for Entity<MessageEditor> {
67 fn supports_images(&self, cx: &App) -> bool {
68 self.read(cx).prompt_capabilities.borrow().image
69 }
70
71 fn supported_modes(&self, cx: &App) -> Vec<PromptContextType> {
72 let mut supported = vec![PromptContextType::File, PromptContextType::Symbol];
73 if self.read(cx).prompt_capabilities.borrow().embedded_context {
74 if self.read(cx).thread_store.is_some() {
75 supported.push(PromptContextType::Thread);
76 }
77 supported.extend(&[
78 PromptContextType::Diagnostics,
79 PromptContextType::Fetch,
80 PromptContextType::Rules,
81 ]);
82 }
83 supported
84 }
85
86 fn available_commands(&self, cx: &App) -> Vec<crate::completion_provider::AvailableCommand> {
87 self.read(cx)
88 .available_commands
89 .borrow()
90 .iter()
91 .map(|cmd| crate::completion_provider::AvailableCommand {
92 name: cmd.name.clone().into(),
93 description: cmd.description.clone().into(),
94 requires_argument: cmd.input.is_some(),
95 })
96 .collect()
97 }
98
99 fn confirm_command(&self, cx: &mut App) {
100 self.update(cx, |this, cx| this.send(cx));
101 }
102}
103
104impl MessageEditor {
105 pub fn new(
106 workspace: WeakEntity<Workspace>,
107 project: WeakEntity<Project>,
108 thread_store: Option<Entity<ThreadStore>>,
109 history: WeakEntity<AcpThreadHistory>,
110 prompt_store: Option<Entity<PromptStore>>,
111 prompt_capabilities: Rc<RefCell<acp::PromptCapabilities>>,
112 available_commands: Rc<RefCell<Vec<acp::AvailableCommand>>>,
113 agent_name: SharedString,
114 placeholder: &str,
115 mode: EditorMode,
116 window: &mut Window,
117 cx: &mut Context<Self>,
118 ) -> Self {
119 let language = Language::new(
120 language::LanguageConfig {
121 completion_query_characters: HashSet::from_iter(['.', '-', '_', '@']),
122 ..Default::default()
123 },
124 None,
125 );
126
127 let editor = cx.new(|cx| {
128 let buffer = cx.new(|cx| Buffer::local("", cx).with_language(Arc::new(language), cx));
129 let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
130
131 let mut editor = Editor::new(mode, buffer, None, window, cx);
132 editor.set_placeholder_text(placeholder, window, cx);
133 editor.set_show_indent_guides(false, cx);
134 editor.set_show_completions_on_input(Some(true));
135 editor.set_soft_wrap();
136 editor.set_use_modal_editing(true);
137 editor.set_context_menu_options(ContextMenuOptions {
138 min_entries_visible: 12,
139 max_entries_visible: 12,
140 placement: Some(ContextMenuPlacement::Above),
141 });
142 editor.register_addon(MessageEditorAddon::new());
143
144 editor.set_custom_context_menu(|editor, _point, window, cx| {
145 let has_selection = editor.has_non_empty_selection(&editor.display_snapshot(cx));
146
147 Some(ContextMenu::build(window, cx, |menu, _, _| {
148 menu.action("Cut", Box::new(editor::actions::Cut))
149 .action_disabled_when(
150 !has_selection,
151 "Copy",
152 Box::new(editor::actions::Copy),
153 )
154 .action("Paste", Box::new(editor::actions::Paste))
155 }))
156 });
157
158 editor
159 });
160 let mention_set =
161 cx.new(|_cx| MentionSet::new(project, thread_store.clone(), prompt_store.clone()));
162 let completion_provider = Rc::new(PromptCompletionProvider::new(
163 cx.entity(),
164 editor.downgrade(),
165 mention_set.clone(),
166 history,
167 prompt_store.clone(),
168 workspace.clone(),
169 ));
170 editor.update(cx, |editor, _cx| {
171 editor.set_completion_provider(Some(completion_provider.clone()))
172 });
173
174 cx.on_focus_in(&editor.focus_handle(cx), window, |_, _, cx| {
175 cx.emit(MessageEditorEvent::Focus)
176 })
177 .detach();
178 cx.on_focus_out(&editor.focus_handle(cx), window, |_, _, _, cx| {
179 cx.emit(MessageEditorEvent::LostFocus)
180 })
181 .detach();
182
183 let mut has_hint = false;
184 let mut subscriptions = Vec::new();
185
186 subscriptions.push(cx.subscribe_in(&editor, window, {
187 move |this, editor, event, window, cx| {
188 if let EditorEvent::Edited { .. } = event
189 && !editor.read(cx).read_only(cx)
190 {
191 editor.update(cx, |editor, cx| {
192 let snapshot = editor.snapshot(window, cx);
193 this.mention_set
194 .update(cx, |mention_set, _cx| mention_set.remove_invalid(&snapshot));
195
196 let new_hints = this
197 .command_hint(snapshot.buffer())
198 .into_iter()
199 .collect::<Vec<_>>();
200 let has_new_hint = !new_hints.is_empty();
201 editor.splice_inlays(
202 if has_hint {
203 &[COMMAND_HINT_INLAY_ID]
204 } else {
205 &[]
206 },
207 new_hints,
208 cx,
209 );
210 has_hint = has_new_hint;
211 });
212 cx.notify();
213 }
214 }
215 }));
216
217 Self {
218 editor,
219 mention_set,
220 workspace,
221 prompt_capabilities,
222 available_commands,
223 agent_name,
224 thread_store,
225 _subscriptions: subscriptions,
226 _parse_slash_command_task: Task::ready(()),
227 }
228 }
229
230 fn command_hint(&self, snapshot: &MultiBufferSnapshot) -> Option<Inlay> {
231 let available_commands = self.available_commands.borrow();
232 if available_commands.is_empty() {
233 return None;
234 }
235
236 let parsed_command = SlashCommandCompletion::try_parse(&snapshot.text(), 0)?;
237 if parsed_command.argument.is_some() {
238 return None;
239 }
240
241 let command_name = parsed_command.command?;
242 let available_command = available_commands
243 .iter()
244 .find(|command| command.name == command_name)?;
245
246 let acp::AvailableCommandInput::Unstructured(acp::UnstructuredCommandInput {
247 mut hint,
248 ..
249 }) = available_command.input.clone()?
250 else {
251 return None;
252 };
253
254 let mut hint_pos = MultiBufferOffset(parsed_command.source_range.end) + 1usize;
255 if hint_pos > snapshot.len() {
256 hint_pos = snapshot.len();
257 hint.insert(0, ' ');
258 }
259
260 let hint_pos = snapshot.anchor_after(hint_pos);
261
262 Some(Inlay::hint(
263 COMMAND_HINT_INLAY_ID,
264 hint_pos,
265 &InlayHint {
266 position: hint_pos.text_anchor,
267 label: InlayHintLabel::String(hint),
268 kind: Some(InlayHintKind::Parameter),
269 padding_left: false,
270 padding_right: false,
271 tooltip: None,
272 resolve_state: project::ResolveState::Resolved,
273 },
274 ))
275 }
276
277 pub fn insert_thread_summary(
278 &mut self,
279 thread: AgentSessionInfo,
280 window: &mut Window,
281 cx: &mut Context<Self>,
282 ) {
283 if self.thread_store.is_none() {
284 return;
285 }
286 let Some(workspace) = self.workspace.upgrade() else {
287 return;
288 };
289 let thread_title = thread
290 .title
291 .clone()
292 .filter(|title| !title.is_empty())
293 .unwrap_or_else(|| SharedString::new_static("New Thread"));
294 let uri = MentionUri::Thread {
295 id: thread.session_id,
296 name: thread_title.to_string(),
297 };
298 let content = format!("{}\n", uri.as_link());
299
300 let content_len = content.len() - 1;
301
302 let start = self.editor.update(cx, |editor, cx| {
303 editor.set_text(content, window, cx);
304 editor
305 .buffer()
306 .read(cx)
307 .snapshot(cx)
308 .anchor_before(Point::zero())
309 .text_anchor
310 });
311
312 let supports_images = self.prompt_capabilities.borrow().image;
313
314 self.mention_set
315 .update(cx, |mention_set, cx| {
316 mention_set.confirm_mention_completion(
317 thread_title,
318 start,
319 content_len,
320 uri,
321 supports_images,
322 self.editor.clone(),
323 &workspace,
324 window,
325 cx,
326 )
327 })
328 .detach();
329 }
330
331 #[cfg(test)]
332 pub(crate) fn editor(&self) -> &Entity<Editor> {
333 &self.editor
334 }
335
336 pub fn is_empty(&self, cx: &App) -> bool {
337 self.editor.read(cx).is_empty(cx)
338 }
339
340 pub fn is_completions_menu_visible(&self, cx: &App) -> bool {
341 self.editor
342 .read(cx)
343 .context_menu()
344 .borrow()
345 .as_ref()
346 .is_some_and(|menu| matches!(menu, CodeContextMenu::Completions(_)) && menu.visible())
347 }
348
349 #[cfg(test)]
350 pub fn mention_set(&self) -> &Entity<MentionSet> {
351 &self.mention_set
352 }
353
354 fn validate_slash_commands(
355 text: &str,
356 available_commands: &[acp::AvailableCommand],
357 agent_name: &str,
358 ) -> Result<()> {
359 if let Some(parsed_command) = SlashCommandCompletion::try_parse(text, 0) {
360 if let Some(command_name) = parsed_command.command {
361 // Check if this command is in the list of available commands from the server
362 let is_supported = available_commands
363 .iter()
364 .any(|cmd| cmd.name == command_name);
365
366 if !is_supported {
367 return Err(anyhow!(
368 "The /{} command is not supported by {}.\n\nAvailable commands: {}",
369 command_name,
370 agent_name,
371 if available_commands.is_empty() {
372 "none".to_string()
373 } else {
374 available_commands
375 .iter()
376 .map(|cmd| format!("/{}", cmd.name))
377 .collect::<Vec<_>>()
378 .join(", ")
379 }
380 ));
381 }
382 }
383 }
384 Ok(())
385 }
386
387 pub fn contents(
388 &self,
389 full_mention_content: bool,
390 cx: &mut Context<Self>,
391 ) -> Task<Result<(Vec<acp::ContentBlock>, Vec<Entity<Buffer>>)>> {
392 // Check for unsupported slash commands before spawning async task
393 let text = self.editor.read(cx).text(cx);
394 let available_commands = self.available_commands.borrow().clone();
395 if let Err(err) =
396 Self::validate_slash_commands(&text, &available_commands, &self.agent_name)
397 {
398 return Task::ready(Err(err));
399 }
400
401 let contents = self
402 .mention_set
403 .update(cx, |store, cx| store.contents(full_mention_content, cx));
404 let editor = self.editor.clone();
405 let supports_embedded_context = self.prompt_capabilities.borrow().embedded_context;
406
407 cx.spawn(async move |_, cx| {
408 let contents = contents.await?;
409 let mut all_tracked_buffers = Vec::new();
410
411 let result = editor.update(cx, |editor, cx| {
412 let (mut ix, _) = text
413 .char_indices()
414 .find(|(_, c)| !c.is_whitespace())
415 .unwrap_or((0, '\0'));
416 let mut chunks: Vec<acp::ContentBlock> = Vec::new();
417 let text = editor.text(cx);
418 editor.display_map.update(cx, |map, cx| {
419 let snapshot = map.snapshot(cx);
420 for (crease_id, crease) in snapshot.crease_snapshot.creases() {
421 let Some((uri, mention)) = contents.get(&crease_id) else {
422 continue;
423 };
424
425 let crease_range = crease.range().to_offset(&snapshot.buffer_snapshot());
426 if crease_range.start.0 > ix {
427 let chunk = text[ix..crease_range.start.0].into();
428 chunks.push(chunk);
429 }
430 let chunk = match mention {
431 Mention::Text {
432 content,
433 tracked_buffers,
434 } => {
435 all_tracked_buffers.extend(tracked_buffers.iter().cloned());
436 if supports_embedded_context {
437 acp::ContentBlock::Resource(acp::EmbeddedResource::new(
438 acp::EmbeddedResourceResource::TextResourceContents(
439 acp::TextResourceContents::new(
440 content.clone(),
441 uri.to_uri().to_string(),
442 ),
443 ),
444 ))
445 } else {
446 acp::ContentBlock::ResourceLink(acp::ResourceLink::new(
447 uri.name(),
448 uri.to_uri().to_string(),
449 ))
450 }
451 }
452 Mention::Image(mention_image) => acp::ContentBlock::Image(
453 acp::ImageContent::new(
454 mention_image.data.clone(),
455 mention_image.format.mime_type(),
456 )
457 .uri(match uri {
458 MentionUri::File { .. } => Some(uri.to_uri().to_string()),
459 MentionUri::PastedImage => None,
460 other => {
461 debug_panic!(
462 "unexpected mention uri for image: {:?}",
463 other
464 );
465 None
466 }
467 }),
468 ),
469 Mention::Link => acp::ContentBlock::ResourceLink(
470 acp::ResourceLink::new(uri.name(), uri.to_uri().to_string()),
471 ),
472 };
473 chunks.push(chunk);
474 ix = crease_range.end.0;
475 }
476
477 if ix < text.len() {
478 let last_chunk = text[ix..].trim_end().to_owned();
479 if !last_chunk.is_empty() {
480 chunks.push(last_chunk.into());
481 }
482 }
483 });
484 anyhow::Ok((chunks, all_tracked_buffers))
485 })?;
486 Ok(result)
487 })
488 }
489
490 pub fn clear(&mut self, window: &mut Window, cx: &mut Context<Self>) {
491 self.editor.update(cx, |editor, cx| {
492 editor.clear(window, cx);
493 editor.remove_creases(
494 self.mention_set.update(cx, |mention_set, _cx| {
495 mention_set
496 .clear()
497 .map(|(crease_id, _)| crease_id)
498 .collect::<Vec<_>>()
499 }),
500 cx,
501 )
502 });
503 }
504
505 pub fn send(&mut self, cx: &mut Context<Self>) {
506 if !self.is_empty(cx) {
507 self.editor.update(cx, |editor, cx| {
508 editor.clear_inlay_hints(cx);
509 });
510 }
511 cx.emit(MessageEditorEvent::Send)
512 }
513
514 pub fn trigger_completion_menu(&mut self, window: &mut Window, cx: &mut Context<Self>) {
515 let editor = self.editor.clone();
516
517 cx.spawn_in(window, async move |_, cx| {
518 editor
519 .update_in(cx, |editor, window, cx| {
520 let menu_is_open =
521 editor.context_menu().borrow().as_ref().is_some_and(|menu| {
522 matches!(menu, CodeContextMenu::Completions(_)) && menu.visible()
523 });
524
525 let has_at_sign = {
526 let snapshot = editor.display_snapshot(cx);
527 let cursor = editor.selections.newest::<text::Point>(&snapshot).head();
528 let offset = cursor.to_offset(&snapshot);
529 if offset.0 > 0 {
530 snapshot
531 .buffer_snapshot()
532 .reversed_chars_at(offset)
533 .next()
534 .map(|sign| sign == '@')
535 .unwrap_or(false)
536 } else {
537 false
538 }
539 };
540
541 if menu_is_open && has_at_sign {
542 return;
543 }
544
545 editor.insert("@", window, cx);
546 editor.show_completions(&editor::actions::ShowCompletions, window, cx);
547 })
548 .log_err();
549 })
550 .detach();
551 }
552
553 fn chat(&mut self, _: &Chat, _: &mut Window, cx: &mut Context<Self>) {
554 self.send(cx);
555 }
556
557 fn send_immediately(&mut self, _: &SendImmediately, _: &mut Window, cx: &mut Context<Self>) {
558 if self.is_empty(cx) {
559 return;
560 }
561
562 self.editor.update(cx, |editor, cx| {
563 editor.clear_inlay_hints(cx);
564 });
565
566 cx.emit(MessageEditorEvent::SendImmediately)
567 }
568
569 fn chat_with_follow(
570 &mut self,
571 _: &ChatWithFollow,
572 window: &mut Window,
573 cx: &mut Context<Self>,
574 ) {
575 self.workspace
576 .update(cx, |this, cx| {
577 this.follow(CollaboratorId::Agent, window, cx)
578 })
579 .log_err();
580
581 self.send(cx);
582 }
583
584 fn cancel(&mut self, _: &editor::actions::Cancel, _: &mut Window, cx: &mut Context<Self>) {
585 cx.emit(MessageEditorEvent::Cancel)
586 }
587
588 fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context<Self>) {
589 let Some(workspace) = self.workspace.upgrade() else {
590 return;
591 };
592 let editor_clipboard_selections = cx
593 .read_from_clipboard()
594 .and_then(|item| item.entries().first().cloned())
595 .and_then(|entry| match entry {
596 ClipboardEntry::String(text) => {
597 text.metadata_json::<Vec<editor::ClipboardSelection>>()
598 }
599 _ => None,
600 });
601
602 // Insert creases for pasted clipboard selections that:
603 // 1. Contain exactly one selection
604 // 2. Have an associated file path
605 // 3. Span multiple lines (not single-line selections)
606 // 4. Belong to a file that exists in the current project
607 let should_insert_creases = util::maybe!({
608 let selections = editor_clipboard_selections.as_ref()?;
609 if selections.len() > 1 {
610 return Some(false);
611 }
612 let selection = selections.first()?;
613 let file_path = selection.file_path.as_ref()?;
614 let line_range = selection.line_range.as_ref()?;
615
616 if line_range.start() == line_range.end() {
617 return Some(false);
618 }
619
620 Some(
621 workspace
622 .read(cx)
623 .project()
624 .read(cx)
625 .project_path_for_absolute_path(file_path, cx)
626 .is_some(),
627 )
628 })
629 .unwrap_or(false);
630
631 if should_insert_creases && let Some(selections) = editor_clipboard_selections {
632 cx.stop_propagation();
633 let insertion_target = self
634 .editor
635 .read(cx)
636 .selections
637 .newest_anchor()
638 .start
639 .text_anchor;
640
641 let project = workspace.read(cx).project().clone();
642 for selection in selections {
643 if let (Some(file_path), Some(line_range)) =
644 (selection.file_path, selection.line_range)
645 {
646 let crease_text =
647 acp_thread::selection_name(Some(file_path.as_ref()), &line_range);
648
649 let mention_uri = MentionUri::Selection {
650 abs_path: Some(file_path.clone()),
651 line_range: line_range.clone(),
652 };
653
654 let mention_text = mention_uri.as_link().to_string();
655 let (excerpt_id, text_anchor, content_len) =
656 self.editor.update(cx, |editor, cx| {
657 let buffer = editor.buffer().read(cx);
658 let snapshot = buffer.snapshot(cx);
659 let (excerpt_id, _, buffer_snapshot) = snapshot.as_singleton().unwrap();
660 let text_anchor = insertion_target.bias_left(&buffer_snapshot);
661
662 editor.insert(&mention_text, window, cx);
663 editor.insert(" ", window, cx);
664
665 (*excerpt_id, text_anchor, mention_text.len())
666 });
667
668 let Some((crease_id, tx)) = insert_crease_for_mention(
669 excerpt_id,
670 text_anchor,
671 content_len,
672 crease_text.into(),
673 mention_uri.icon_path(cx),
674 None,
675 self.editor.clone(),
676 window,
677 cx,
678 ) else {
679 continue;
680 };
681 drop(tx);
682
683 let mention_task = cx
684 .spawn({
685 let project = project.clone();
686 async move |_, cx| {
687 let project_path = project
688 .update(cx, |project, cx| {
689 project.project_path_for_absolute_path(&file_path, cx)
690 })
691 .ok_or_else(|| "project path not found".to_string())?;
692
693 let buffer = project
694 .update(cx, |project, cx| project.open_buffer(project_path, cx))
695 .await
696 .map_err(|e| e.to_string())?;
697
698 Ok(buffer.update(cx, |buffer, cx| {
699 let start =
700 Point::new(*line_range.start(), 0).min(buffer.max_point());
701 let end = Point::new(*line_range.end() + 1, 0)
702 .min(buffer.max_point());
703 let content = buffer.text_for_range(start..end).collect();
704 Mention::Text {
705 content,
706 tracked_buffers: vec![cx.entity()],
707 }
708 }))
709 }
710 })
711 .shared();
712
713 self.mention_set.update(cx, |mention_set, _cx| {
714 mention_set.insert_mention(crease_id, mention_uri.clone(), mention_task)
715 });
716 }
717 }
718 return;
719 }
720
721 if self.prompt_capabilities.borrow().image
722 && let Some(task) =
723 paste_images_as_context(self.editor.clone(), self.mention_set.clone(), window, cx)
724 {
725 task.detach();
726 }
727 }
728
729 fn paste_raw(&mut self, _: &PasteRaw, window: &mut Window, cx: &mut Context<Self>) {
730 let editor = self.editor.clone();
731 window.defer(cx, move |window, cx| {
732 editor.update(cx, |editor, cx| editor.paste(&Paste, window, cx));
733 });
734 }
735
736 pub fn insert_dragged_files(
737 &mut self,
738 paths: Vec<project::ProjectPath>,
739 added_worktrees: Vec<Entity<Worktree>>,
740 window: &mut Window,
741 cx: &mut Context<Self>,
742 ) {
743 let Some(workspace) = self.workspace.upgrade() else {
744 return;
745 };
746 let project = workspace.read(cx).project().clone();
747 let path_style = project.read(cx).path_style(cx);
748 let buffer = self.editor.read(cx).buffer().clone();
749 let Some(buffer) = buffer.read(cx).as_singleton() else {
750 return;
751 };
752 let mut tasks = Vec::new();
753 for path in paths {
754 let Some(entry) = project.read(cx).entry_for_path(&path, cx) else {
755 continue;
756 };
757 let Some(worktree) = project.read(cx).worktree_for_id(path.worktree_id, cx) else {
758 continue;
759 };
760 let abs_path = worktree.read(cx).absolutize(&path.path);
761 let (file_name, _) = crate::completion_provider::extract_file_name_and_directory(
762 &path.path,
763 worktree.read(cx).root_name(),
764 path_style,
765 );
766
767 let uri = if entry.is_dir() {
768 MentionUri::Directory { abs_path }
769 } else {
770 MentionUri::File { abs_path }
771 };
772
773 let new_text = format!("{} ", uri.as_link());
774 let content_len = new_text.len() - 1;
775
776 let anchor = buffer.update(cx, |buffer, _cx| buffer.anchor_before(buffer.len()));
777
778 self.editor.update(cx, |message_editor, cx| {
779 message_editor.edit(
780 [(
781 multi_buffer::Anchor::max()..multi_buffer::Anchor::max(),
782 new_text,
783 )],
784 cx,
785 );
786 });
787 let supports_images = self.prompt_capabilities.borrow().image;
788 tasks.push(self.mention_set.update(cx, |mention_set, cx| {
789 mention_set.confirm_mention_completion(
790 file_name,
791 anchor,
792 content_len,
793 uri,
794 supports_images,
795 self.editor.clone(),
796 &workspace,
797 window,
798 cx,
799 )
800 }));
801 }
802 cx.spawn(async move |_, _| {
803 join_all(tasks).await;
804 drop(added_worktrees);
805 })
806 .detach();
807 }
808
809 /// Inserts code snippets as creases into the editor.
810 /// Each tuple contains (code_text, crease_title).
811 pub fn insert_code_creases(
812 &mut self,
813 creases: Vec<(String, String)>,
814 window: &mut Window,
815 cx: &mut Context<Self>,
816 ) {
817 use editor::display_map::{Crease, FoldPlaceholder};
818 use multi_buffer::MultiBufferRow;
819 use rope::Point;
820
821 self.editor.update(cx, |editor, cx| {
822 editor.insert("\n", window, cx);
823 for (text, crease_title) in creases {
824 let point = editor
825 .selections
826 .newest::<Point>(&editor.display_snapshot(cx))
827 .head();
828 let start_row = MultiBufferRow(point.row);
829
830 editor.insert(&text, window, cx);
831
832 let snapshot = editor.buffer().read(cx).snapshot(cx);
833 let anchor_before = snapshot.anchor_after(point);
834 let anchor_after = editor
835 .selections
836 .newest_anchor()
837 .head()
838 .bias_left(&snapshot);
839
840 editor.insert("\n", window, cx);
841
842 let fold_placeholder = FoldPlaceholder {
843 render: Arc::new({
844 let title = crease_title.clone();
845 move |_fold_id, _fold_range, _cx| {
846 ButtonLike::new("code-crease")
847 .style(ButtonStyle::Filled)
848 .layer(ElevationIndex::ElevatedSurface)
849 .child(Icon::new(IconName::TextSnippet))
850 .child(Label::new(title.clone()).single_line())
851 .into_any_element()
852 }
853 }),
854 merge_adjacent: false,
855 ..Default::default()
856 };
857
858 let crease = Crease::inline(
859 anchor_before..anchor_after,
860 fold_placeholder,
861 |row, is_folded, fold, _window, _cx| {
862 Disclosure::new(("code-crease-toggle", row.0 as u64), !is_folded)
863 .toggle_state(is_folded)
864 .on_click(move |_e, window, cx| fold(!is_folded, window, cx))
865 .into_any_element()
866 },
867 |_, _, _, _| gpui::Empty.into_any(),
868 );
869 editor.insert_creases(vec![crease], cx);
870 editor.fold_at(start_row, window, cx);
871 }
872 });
873 }
874
875 pub fn insert_selections(&mut self, window: &mut Window, cx: &mut Context<Self>) {
876 let editor = self.editor.read(cx);
877 let editor_buffer = editor.buffer().read(cx);
878 let Some(buffer) = editor_buffer.as_singleton() else {
879 return;
880 };
881 let cursor_anchor = editor.selections.newest_anchor().head();
882 let cursor_offset = cursor_anchor.to_offset(&editor_buffer.snapshot(cx));
883 let anchor = buffer.update(cx, |buffer, _cx| {
884 buffer.anchor_before(cursor_offset.0.min(buffer.len()))
885 });
886 let Some(workspace) = self.workspace.upgrade() else {
887 return;
888 };
889 let Some(completion) =
890 PromptCompletionProvider::<Entity<MessageEditor>>::completion_for_action(
891 PromptContextAction::AddSelections,
892 anchor..anchor,
893 self.editor.downgrade(),
894 self.mention_set.downgrade(),
895 &workspace,
896 cx,
897 )
898 else {
899 return;
900 };
901
902 self.editor.update(cx, |message_editor, cx| {
903 message_editor.edit([(cursor_anchor..cursor_anchor, completion.new_text)], cx);
904 message_editor.request_autoscroll(Autoscroll::fit(), cx);
905 });
906 if let Some(confirm) = completion.confirm {
907 confirm(CompletionIntent::Complete, window, cx);
908 }
909 }
910
911 pub fn set_read_only(&mut self, read_only: bool, cx: &mut Context<Self>) {
912 self.editor.update(cx, |message_editor, cx| {
913 message_editor.set_read_only(read_only);
914 cx.notify()
915 })
916 }
917
918 pub fn set_mode(&mut self, mode: EditorMode, cx: &mut Context<Self>) {
919 self.editor.update(cx, |editor, cx| {
920 editor.set_mode(mode);
921 cx.notify()
922 });
923 }
924
925 pub fn set_message(
926 &mut self,
927 message: Vec<acp::ContentBlock>,
928 window: &mut Window,
929 cx: &mut Context<Self>,
930 ) {
931 let Some(workspace) = self.workspace.upgrade() else {
932 return;
933 };
934
935 self.clear(window, cx);
936
937 let path_style = workspace.read(cx).project().read(cx).path_style(cx);
938 let mut text = String::new();
939 let mut mentions = Vec::new();
940
941 for chunk in message {
942 match chunk {
943 acp::ContentBlock::Text(text_content) => {
944 text.push_str(&text_content.text);
945 }
946 acp::ContentBlock::Resource(acp::EmbeddedResource {
947 resource: acp::EmbeddedResourceResource::TextResourceContents(resource),
948 ..
949 }) => {
950 let Some(mention_uri) = MentionUri::parse(&resource.uri, path_style).log_err()
951 else {
952 continue;
953 };
954 let start = text.len();
955 write!(&mut text, "{}", mention_uri.as_link()).ok();
956 let end = text.len();
957 mentions.push((
958 start..end,
959 mention_uri,
960 Mention::Text {
961 content: resource.text,
962 tracked_buffers: Vec::new(),
963 },
964 ));
965 }
966 acp::ContentBlock::ResourceLink(resource) => {
967 if let Some(mention_uri) =
968 MentionUri::parse(&resource.uri, path_style).log_err()
969 {
970 let start = text.len();
971 write!(&mut text, "{}", mention_uri.as_link()).ok();
972 let end = text.len();
973 mentions.push((start..end, mention_uri, Mention::Link));
974 }
975 }
976 acp::ContentBlock::Image(acp::ImageContent {
977 uri,
978 data,
979 mime_type,
980 ..
981 }) => {
982 let mention_uri = if let Some(uri) = uri {
983 MentionUri::parse(&uri, path_style)
984 } else {
985 Ok(MentionUri::PastedImage)
986 };
987 let Some(mention_uri) = mention_uri.log_err() else {
988 continue;
989 };
990 let Some(format) = ImageFormat::from_mime_type(&mime_type) else {
991 log::error!("failed to parse MIME type for image: {mime_type:?}");
992 continue;
993 };
994 let start = text.len();
995 write!(&mut text, "{}", mention_uri.as_link()).ok();
996 let end = text.len();
997 mentions.push((
998 start..end,
999 mention_uri,
1000 Mention::Image(MentionImage {
1001 data: data.into(),
1002 format,
1003 }),
1004 ));
1005 }
1006 _ => {}
1007 }
1008 }
1009
1010 let snapshot = self.editor.update(cx, |editor, cx| {
1011 editor.set_text(text, window, cx);
1012 editor.buffer().read(cx).snapshot(cx)
1013 });
1014
1015 for (range, mention_uri, mention) in mentions {
1016 let anchor = snapshot.anchor_before(MultiBufferOffset(range.start));
1017 let Some((crease_id, tx)) = insert_crease_for_mention(
1018 anchor.excerpt_id,
1019 anchor.text_anchor,
1020 range.end - range.start,
1021 mention_uri.name().into(),
1022 mention_uri.icon_path(cx),
1023 None,
1024 self.editor.clone(),
1025 window,
1026 cx,
1027 ) else {
1028 continue;
1029 };
1030 drop(tx);
1031
1032 self.mention_set.update(cx, |mention_set, _cx| {
1033 mention_set.insert_mention(
1034 crease_id,
1035 mention_uri.clone(),
1036 Task::ready(Ok(mention)).shared(),
1037 )
1038 });
1039 }
1040 cx.notify();
1041 }
1042
1043 pub fn text(&self, cx: &App) -> String {
1044 self.editor.read(cx).text(cx)
1045 }
1046
1047 pub fn set_placeholder_text(
1048 &mut self,
1049 placeholder: &str,
1050 window: &mut Window,
1051 cx: &mut Context<Self>,
1052 ) {
1053 self.editor.update(cx, |editor, cx| {
1054 editor.set_placeholder_text(placeholder, window, cx);
1055 });
1056 }
1057
1058 #[cfg(test)]
1059 pub fn set_text(&mut self, text: &str, window: &mut Window, cx: &mut Context<Self>) {
1060 self.editor.update(cx, |editor, cx| {
1061 editor.set_text(text, window, cx);
1062 });
1063 }
1064}
1065
1066impl Focusable for MessageEditor {
1067 fn focus_handle(&self, cx: &App) -> FocusHandle {
1068 self.editor.focus_handle(cx)
1069 }
1070}
1071
1072impl Render for MessageEditor {
1073 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1074 div()
1075 .key_context("MessageEditor")
1076 .on_action(cx.listener(Self::chat))
1077 .on_action(cx.listener(Self::send_immediately))
1078 .on_action(cx.listener(Self::chat_with_follow))
1079 .on_action(cx.listener(Self::cancel))
1080 .on_action(cx.listener(Self::paste_raw))
1081 .capture_action(cx.listener(Self::paste))
1082 .flex_1()
1083 .child({
1084 let settings = ThemeSettings::get_global(cx);
1085
1086 let text_style = TextStyle {
1087 color: cx.theme().colors().text,
1088 font_family: settings.buffer_font.family.clone(),
1089 font_fallbacks: settings.buffer_font.fallbacks.clone(),
1090 font_features: settings.buffer_font.features.clone(),
1091 font_size: settings.agent_buffer_font_size(cx).into(),
1092 line_height: relative(settings.buffer_line_height.value()),
1093 ..Default::default()
1094 };
1095
1096 EditorElement::new(
1097 &self.editor,
1098 EditorStyle {
1099 background: cx.theme().colors().editor_background,
1100 local_player: cx.theme().players().local(),
1101 text: text_style,
1102 syntax: cx.theme().syntax().clone(),
1103 inlay_hints_style: editor::make_inlay_hints_style(cx),
1104 ..Default::default()
1105 },
1106 )
1107 })
1108 }
1109}
1110
1111pub struct MessageEditorAddon {}
1112
1113impl MessageEditorAddon {
1114 pub fn new() -> Self {
1115 Self {}
1116 }
1117}
1118
1119impl Addon for MessageEditorAddon {
1120 fn to_any(&self) -> &dyn std::any::Any {
1121 self
1122 }
1123
1124 fn to_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
1125 Some(self)
1126 }
1127
1128 fn extend_key_context(&self, key_context: &mut KeyContext, cx: &App) {
1129 let settings = agent_settings::AgentSettings::get_global(cx);
1130 if settings.use_modifier_to_send {
1131 key_context.add("use_modifier_to_send");
1132 }
1133 }
1134}
1135
1136#[cfg(test)]
1137mod tests {
1138 use std::{cell::RefCell, ops::Range, path::Path, rc::Rc, sync::Arc};
1139
1140 use acp_thread::{AgentSessionInfo, MentionUri};
1141 use agent::{ThreadStore, outline};
1142 use agent_client_protocol as acp;
1143 use editor::{AnchorRangeExt as _, Editor, EditorMode, MultiBufferOffset};
1144 use fs::FakeFs;
1145 use futures::StreamExt as _;
1146 use gpui::{
1147 AppContext, Entity, EventEmitter, FocusHandle, Focusable, TestAppContext, VisualTestContext,
1148 };
1149 use language_model::LanguageModelRegistry;
1150 use lsp::{CompletionContext, CompletionTriggerKind};
1151 use project::{CompletionIntent, Project, ProjectPath};
1152 use serde_json::json;
1153 use text::Point;
1154 use ui::{App, Context, IntoElement, Render, SharedString, Window};
1155 use util::{path, paths::PathStyle, rel_path::rel_path};
1156 use workspace::{AppState, Item, Workspace};
1157
1158 use crate::acp::{
1159 message_editor::{Mention, MessageEditor},
1160 thread_view::tests::init_test,
1161 };
1162 use crate::completion_provider::{PromptCompletionProviderDelegate, PromptContextType};
1163
1164 #[gpui::test]
1165 async fn test_at_mention_removal(cx: &mut TestAppContext) {
1166 init_test(cx);
1167
1168 let fs = FakeFs::new(cx.executor());
1169 fs.insert_tree("/project", json!({"file": ""})).await;
1170 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
1171
1172 let (workspace, cx) =
1173 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
1174
1175 let thread_store = None;
1176 let history = cx
1177 .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
1178
1179 let message_editor = cx.update(|window, cx| {
1180 cx.new(|cx| {
1181 MessageEditor::new(
1182 workspace.downgrade(),
1183 project.downgrade(),
1184 thread_store.clone(),
1185 history.downgrade(),
1186 None,
1187 Default::default(),
1188 Default::default(),
1189 "Test Agent".into(),
1190 "Test",
1191 EditorMode::AutoHeight {
1192 min_lines: 1,
1193 max_lines: None,
1194 },
1195 window,
1196 cx,
1197 )
1198 })
1199 });
1200 let editor = message_editor.update(cx, |message_editor, _| message_editor.editor.clone());
1201
1202 cx.run_until_parked();
1203
1204 let excerpt_id = editor.update(cx, |editor, cx| {
1205 editor
1206 .buffer()
1207 .read(cx)
1208 .excerpt_ids()
1209 .into_iter()
1210 .next()
1211 .unwrap()
1212 });
1213 let completions = editor.update_in(cx, |editor, window, cx| {
1214 editor.set_text("Hello @file ", window, cx);
1215 let buffer = editor.buffer().read(cx).as_singleton().unwrap();
1216 let completion_provider = editor.completion_provider().unwrap();
1217 completion_provider.completions(
1218 excerpt_id,
1219 &buffer,
1220 text::Anchor::MAX,
1221 CompletionContext {
1222 trigger_kind: CompletionTriggerKind::TRIGGER_CHARACTER,
1223 trigger_character: Some("@".into()),
1224 },
1225 window,
1226 cx,
1227 )
1228 });
1229 let [_, completion]: [_; 2] = completions
1230 .await
1231 .unwrap()
1232 .into_iter()
1233 .flat_map(|response| response.completions)
1234 .collect::<Vec<_>>()
1235 .try_into()
1236 .unwrap();
1237
1238 editor.update_in(cx, |editor, window, cx| {
1239 let snapshot = editor.buffer().read(cx).snapshot(cx);
1240 let range = snapshot
1241 .anchor_range_in_excerpt(excerpt_id, completion.replace_range)
1242 .unwrap();
1243 editor.edit([(range, completion.new_text)], cx);
1244 (completion.confirm.unwrap())(CompletionIntent::Complete, window, cx);
1245 });
1246
1247 cx.run_until_parked();
1248
1249 // Backspace over the inserted crease (and the following space).
1250 editor.update_in(cx, |editor, window, cx| {
1251 editor.backspace(&Default::default(), window, cx);
1252 editor.backspace(&Default::default(), window, cx);
1253 });
1254
1255 let (content, _) = message_editor
1256 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
1257 .await
1258 .unwrap();
1259
1260 // We don't send a resource link for the deleted crease.
1261 pretty_assertions::assert_matches!(content.as_slice(), [acp::ContentBlock::Text { .. }]);
1262 }
1263
1264 #[gpui::test]
1265 async fn test_slash_command_validation(cx: &mut gpui::TestAppContext) {
1266 init_test(cx);
1267 let fs = FakeFs::new(cx.executor());
1268 fs.insert_tree(
1269 "/test",
1270 json!({
1271 ".zed": {
1272 "tasks.json": r#"[{"label": "test", "command": "echo"}]"#
1273 },
1274 "src": {
1275 "main.rs": "fn main() {}",
1276 },
1277 }),
1278 )
1279 .await;
1280
1281 let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
1282 let thread_store = None;
1283 let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default()));
1284 // Start with no available commands - simulating Claude which doesn't support slash commands
1285 let available_commands = Rc::new(RefCell::new(vec![]));
1286
1287 let (workspace, cx) =
1288 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
1289 let history = cx
1290 .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
1291 let workspace_handle = workspace.downgrade();
1292 let message_editor = workspace.update_in(cx, |_, window, cx| {
1293 cx.new(|cx| {
1294 MessageEditor::new(
1295 workspace_handle.clone(),
1296 project.downgrade(),
1297 thread_store.clone(),
1298 history.downgrade(),
1299 None,
1300 prompt_capabilities.clone(),
1301 available_commands.clone(),
1302 "Claude Code".into(),
1303 "Test",
1304 EditorMode::AutoHeight {
1305 min_lines: 1,
1306 max_lines: None,
1307 },
1308 window,
1309 cx,
1310 )
1311 })
1312 });
1313 let editor = message_editor.update(cx, |message_editor, _| message_editor.editor.clone());
1314
1315 // Test that slash commands fail when no available_commands are set (empty list means no commands supported)
1316 editor.update_in(cx, |editor, window, cx| {
1317 editor.set_text("/file test.txt", window, cx);
1318 });
1319
1320 let contents_result = message_editor
1321 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
1322 .await;
1323
1324 // Should fail because available_commands is empty (no commands supported)
1325 assert!(contents_result.is_err());
1326 let error_message = contents_result.unwrap_err().to_string();
1327 assert!(error_message.contains("not supported by Claude Code"));
1328 assert!(error_message.contains("Available commands: none"));
1329
1330 // Now simulate Claude providing its list of available commands (which doesn't include file)
1331 available_commands.replace(vec![acp::AvailableCommand::new("help", "Get help")]);
1332
1333 // Test that unsupported slash commands trigger an error when we have a list of available commands
1334 editor.update_in(cx, |editor, window, cx| {
1335 editor.set_text("/file test.txt", window, cx);
1336 });
1337
1338 let contents_result = message_editor
1339 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
1340 .await;
1341
1342 assert!(contents_result.is_err());
1343 let error_message = contents_result.unwrap_err().to_string();
1344 assert!(error_message.contains("not supported by Claude Code"));
1345 assert!(error_message.contains("/file"));
1346 assert!(error_message.contains("Available commands: /help"));
1347
1348 // Test that supported commands work fine
1349 editor.update_in(cx, |editor, window, cx| {
1350 editor.set_text("/help", window, cx);
1351 });
1352
1353 let contents_result = message_editor
1354 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
1355 .await;
1356
1357 // Should succeed because /help is in available_commands
1358 assert!(contents_result.is_ok());
1359
1360 // Test that regular text works fine
1361 editor.update_in(cx, |editor, window, cx| {
1362 editor.set_text("Hello Claude!", window, cx);
1363 });
1364
1365 let (content, _) = message_editor
1366 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
1367 .await
1368 .unwrap();
1369
1370 assert_eq!(content.len(), 1);
1371 if let acp::ContentBlock::Text(text) = &content[0] {
1372 assert_eq!(text.text, "Hello Claude!");
1373 } else {
1374 panic!("Expected ContentBlock::Text");
1375 }
1376
1377 // Test that @ mentions still work
1378 editor.update_in(cx, |editor, window, cx| {
1379 editor.set_text("Check this @", window, cx);
1380 });
1381
1382 // The @ mention functionality should not be affected
1383 let (content, _) = message_editor
1384 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
1385 .await
1386 .unwrap();
1387
1388 assert_eq!(content.len(), 1);
1389 if let acp::ContentBlock::Text(text) = &content[0] {
1390 assert_eq!(text.text, "Check this @");
1391 } else {
1392 panic!("Expected ContentBlock::Text");
1393 }
1394 }
1395
1396 struct MessageEditorItem(Entity<MessageEditor>);
1397
1398 impl Item for MessageEditorItem {
1399 type Event = ();
1400
1401 fn include_in_nav_history() -> bool {
1402 false
1403 }
1404
1405 fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
1406 "Test".into()
1407 }
1408 }
1409
1410 impl EventEmitter<()> for MessageEditorItem {}
1411
1412 impl Focusable for MessageEditorItem {
1413 fn focus_handle(&self, cx: &App) -> FocusHandle {
1414 self.0.read(cx).focus_handle(cx)
1415 }
1416 }
1417
1418 impl Render for MessageEditorItem {
1419 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
1420 self.0.clone().into_any_element()
1421 }
1422 }
1423
1424 #[gpui::test]
1425 async fn test_completion_provider_commands(cx: &mut TestAppContext) {
1426 init_test(cx);
1427
1428 let app_state = cx.update(AppState::test);
1429
1430 cx.update(|cx| {
1431 editor::init(cx);
1432 workspace::init(app_state.clone(), cx);
1433 });
1434
1435 let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
1436 let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
1437 let workspace = window.root(cx).unwrap();
1438
1439 let mut cx = VisualTestContext::from_window(*window, cx);
1440
1441 let thread_store = None;
1442 let history = cx
1443 .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
1444 let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default()));
1445 let available_commands = Rc::new(RefCell::new(vec![
1446 acp::AvailableCommand::new("quick-math", "2 + 2 = 4 - 1 = 3"),
1447 acp::AvailableCommand::new("say-hello", "Say hello to whoever you want").input(
1448 acp::AvailableCommandInput::Unstructured(acp::UnstructuredCommandInput::new(
1449 "<name>",
1450 )),
1451 ),
1452 ]));
1453
1454 let editor = workspace.update_in(&mut cx, |workspace, window, cx| {
1455 let workspace_handle = cx.weak_entity();
1456 let message_editor = cx.new(|cx| {
1457 MessageEditor::new(
1458 workspace_handle,
1459 project.downgrade(),
1460 thread_store.clone(),
1461 history.downgrade(),
1462 None,
1463 prompt_capabilities.clone(),
1464 available_commands.clone(),
1465 "Test Agent".into(),
1466 "Test",
1467 EditorMode::AutoHeight {
1468 max_lines: None,
1469 min_lines: 1,
1470 },
1471 window,
1472 cx,
1473 )
1474 });
1475 workspace.active_pane().update(cx, |pane, cx| {
1476 pane.add_item(
1477 Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
1478 true,
1479 true,
1480 None,
1481 window,
1482 cx,
1483 );
1484 });
1485 message_editor.read(cx).focus_handle(cx).focus(window, cx);
1486 message_editor.read(cx).editor().clone()
1487 });
1488
1489 cx.simulate_input("/");
1490
1491 editor.update_in(&mut cx, |editor, window, cx| {
1492 assert_eq!(editor.text(cx), "/");
1493 assert!(editor.has_visible_completions_menu());
1494
1495 assert_eq!(
1496 current_completion_labels_with_documentation(editor),
1497 &[
1498 ("quick-math".into(), "2 + 2 = 4 - 1 = 3".into()),
1499 ("say-hello".into(), "Say hello to whoever you want".into())
1500 ]
1501 );
1502 editor.set_text("", window, cx);
1503 });
1504
1505 cx.simulate_input("/qui");
1506
1507 editor.update_in(&mut cx, |editor, window, cx| {
1508 assert_eq!(editor.text(cx), "/qui");
1509 assert!(editor.has_visible_completions_menu());
1510
1511 assert_eq!(
1512 current_completion_labels_with_documentation(editor),
1513 &[("quick-math".into(), "2 + 2 = 4 - 1 = 3".into())]
1514 );
1515 editor.set_text("", window, cx);
1516 });
1517
1518 editor.update_in(&mut cx, |editor, window, cx| {
1519 assert!(editor.has_visible_completions_menu());
1520 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
1521 });
1522
1523 cx.run_until_parked();
1524
1525 editor.update_in(&mut cx, |editor, window, cx| {
1526 assert_eq!(editor.display_text(cx), "/quick-math ");
1527 assert!(!editor.has_visible_completions_menu());
1528 editor.set_text("", window, cx);
1529 });
1530
1531 cx.simulate_input("/say");
1532
1533 editor.update_in(&mut cx, |editor, _window, cx| {
1534 assert_eq!(editor.display_text(cx), "/say");
1535 assert!(editor.has_visible_completions_menu());
1536
1537 assert_eq!(
1538 current_completion_labels_with_documentation(editor),
1539 &[("say-hello".into(), "Say hello to whoever you want".into())]
1540 );
1541 });
1542
1543 editor.update_in(&mut cx, |editor, window, cx| {
1544 assert!(editor.has_visible_completions_menu());
1545 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
1546 });
1547
1548 cx.run_until_parked();
1549
1550 editor.update_in(&mut cx, |editor, _window, cx| {
1551 assert_eq!(editor.text(cx), "/say-hello ");
1552 assert_eq!(editor.display_text(cx), "/say-hello <name>");
1553 assert!(!editor.has_visible_completions_menu());
1554 });
1555
1556 cx.simulate_input("GPT5");
1557
1558 cx.run_until_parked();
1559
1560 editor.update_in(&mut cx, |editor, window, cx| {
1561 assert_eq!(editor.text(cx), "/say-hello GPT5");
1562 assert_eq!(editor.display_text(cx), "/say-hello GPT5");
1563 assert!(!editor.has_visible_completions_menu());
1564
1565 // Delete argument
1566 for _ in 0..5 {
1567 editor.backspace(&editor::actions::Backspace, window, cx);
1568 }
1569 });
1570
1571 cx.run_until_parked();
1572
1573 editor.update_in(&mut cx, |editor, window, cx| {
1574 assert_eq!(editor.text(cx), "/say-hello");
1575 // Hint is visible because argument was deleted
1576 assert_eq!(editor.display_text(cx), "/say-hello <name>");
1577
1578 // Delete last command letter
1579 editor.backspace(&editor::actions::Backspace, window, cx);
1580 });
1581
1582 cx.run_until_parked();
1583
1584 editor.update_in(&mut cx, |editor, _window, cx| {
1585 // Hint goes away once command no longer matches an available one
1586 assert_eq!(editor.text(cx), "/say-hell");
1587 assert_eq!(editor.display_text(cx), "/say-hell");
1588 assert!(!editor.has_visible_completions_menu());
1589 });
1590 }
1591
1592 #[gpui::test]
1593 async fn test_context_completion_provider_mentions(cx: &mut TestAppContext) {
1594 init_test(cx);
1595
1596 let app_state = cx.update(AppState::test);
1597
1598 cx.update(|cx| {
1599 editor::init(cx);
1600 workspace::init(app_state.clone(), cx);
1601 });
1602
1603 app_state
1604 .fs
1605 .as_fake()
1606 .insert_tree(
1607 path!("/dir"),
1608 json!({
1609 "editor": "",
1610 "a": {
1611 "one.txt": "1",
1612 "two.txt": "2",
1613 "three.txt": "3",
1614 "four.txt": "4"
1615 },
1616 "b": {
1617 "five.txt": "5",
1618 "six.txt": "6",
1619 "seven.txt": "7",
1620 "eight.txt": "8",
1621 },
1622 "x.png": "",
1623 }),
1624 )
1625 .await;
1626
1627 let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
1628 let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
1629 let workspace = window.root(cx).unwrap();
1630
1631 let worktree = project.update(cx, |project, cx| {
1632 let mut worktrees = project.worktrees(cx).collect::<Vec<_>>();
1633 assert_eq!(worktrees.len(), 1);
1634 worktrees.pop().unwrap()
1635 });
1636 let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id());
1637
1638 let mut cx = VisualTestContext::from_window(*window, cx);
1639
1640 let paths = vec![
1641 rel_path("a/one.txt"),
1642 rel_path("a/two.txt"),
1643 rel_path("a/three.txt"),
1644 rel_path("a/four.txt"),
1645 rel_path("b/five.txt"),
1646 rel_path("b/six.txt"),
1647 rel_path("b/seven.txt"),
1648 rel_path("b/eight.txt"),
1649 ];
1650
1651 let slash = PathStyle::local().primary_separator();
1652
1653 let mut opened_editors = Vec::new();
1654 for path in paths {
1655 let buffer = workspace
1656 .update_in(&mut cx, |workspace, window, cx| {
1657 workspace.open_path(
1658 ProjectPath {
1659 worktree_id,
1660 path: path.into(),
1661 },
1662 None,
1663 false,
1664 window,
1665 cx,
1666 )
1667 })
1668 .await
1669 .unwrap();
1670 opened_editors.push(buffer);
1671 }
1672
1673 let thread_store = cx.new(|cx| ThreadStore::new(cx));
1674 let history = cx
1675 .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
1676 let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default()));
1677
1678 let (message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| {
1679 let workspace_handle = cx.weak_entity();
1680 let message_editor = cx.new(|cx| {
1681 MessageEditor::new(
1682 workspace_handle,
1683 project.downgrade(),
1684 Some(thread_store),
1685 history.downgrade(),
1686 None,
1687 prompt_capabilities.clone(),
1688 Default::default(),
1689 "Test Agent".into(),
1690 "Test",
1691 EditorMode::AutoHeight {
1692 max_lines: None,
1693 min_lines: 1,
1694 },
1695 window,
1696 cx,
1697 )
1698 });
1699 workspace.active_pane().update(cx, |pane, cx| {
1700 pane.add_item(
1701 Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
1702 true,
1703 true,
1704 None,
1705 window,
1706 cx,
1707 );
1708 });
1709 message_editor.read(cx).focus_handle(cx).focus(window, cx);
1710 let editor = message_editor.read(cx).editor().clone();
1711 (message_editor, editor)
1712 });
1713
1714 cx.simulate_input("Lorem @");
1715
1716 editor.update_in(&mut cx, |editor, window, cx| {
1717 assert_eq!(editor.text(cx), "Lorem @");
1718 assert!(editor.has_visible_completions_menu());
1719
1720 assert_eq!(
1721 current_completion_labels(editor),
1722 &[
1723 format!("eight.txt b{slash}"),
1724 format!("seven.txt b{slash}"),
1725 format!("six.txt b{slash}"),
1726 format!("five.txt b{slash}"),
1727 "Files & Directories".into(),
1728 "Symbols".into()
1729 ]
1730 );
1731 editor.set_text("", window, cx);
1732 });
1733
1734 prompt_capabilities.replace(
1735 acp::PromptCapabilities::new()
1736 .image(true)
1737 .audio(true)
1738 .embedded_context(true),
1739 );
1740
1741 cx.simulate_input("Lorem ");
1742
1743 editor.update(&mut cx, |editor, cx| {
1744 assert_eq!(editor.text(cx), "Lorem ");
1745 assert!(!editor.has_visible_completions_menu());
1746 });
1747
1748 cx.simulate_input("@");
1749
1750 editor.update(&mut cx, |editor, cx| {
1751 assert_eq!(editor.text(cx), "Lorem @");
1752 assert!(editor.has_visible_completions_menu());
1753 assert_eq!(
1754 current_completion_labels(editor),
1755 &[
1756 format!("eight.txt b{slash}"),
1757 format!("seven.txt b{slash}"),
1758 format!("six.txt b{slash}"),
1759 format!("five.txt b{slash}"),
1760 "Files & Directories".into(),
1761 "Symbols".into(),
1762 "Threads".into(),
1763 "Fetch".into()
1764 ]
1765 );
1766 });
1767
1768 // Select and confirm "File"
1769 editor.update_in(&mut cx, |editor, window, cx| {
1770 assert!(editor.has_visible_completions_menu());
1771 editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
1772 editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
1773 editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
1774 editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
1775 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
1776 });
1777
1778 cx.run_until_parked();
1779
1780 editor.update(&mut cx, |editor, cx| {
1781 assert_eq!(editor.text(cx), "Lorem @file ");
1782 assert!(editor.has_visible_completions_menu());
1783 });
1784
1785 cx.simulate_input("one");
1786
1787 editor.update(&mut cx, |editor, cx| {
1788 assert_eq!(editor.text(cx), "Lorem @file one");
1789 assert!(editor.has_visible_completions_menu());
1790 assert_eq!(
1791 current_completion_labels(editor),
1792 vec![format!("one.txt a{slash}")]
1793 );
1794 });
1795
1796 editor.update_in(&mut cx, |editor, window, cx| {
1797 assert!(editor.has_visible_completions_menu());
1798 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
1799 });
1800
1801 let url_one = MentionUri::File {
1802 abs_path: path!("/dir/a/one.txt").into(),
1803 }
1804 .to_uri()
1805 .to_string();
1806 editor.update(&mut cx, |editor, cx| {
1807 let text = editor.text(cx);
1808 assert_eq!(text, format!("Lorem [@one.txt]({url_one}) "));
1809 assert!(!editor.has_visible_completions_menu());
1810 assert_eq!(fold_ranges(editor, cx).len(), 1);
1811 });
1812
1813 let contents = message_editor
1814 .update(&mut cx, |message_editor, cx| {
1815 message_editor
1816 .mention_set()
1817 .update(cx, |mention_set, cx| mention_set.contents(false, cx))
1818 })
1819 .await
1820 .unwrap()
1821 .into_values()
1822 .collect::<Vec<_>>();
1823
1824 {
1825 let [(uri, Mention::Text { content, .. })] = contents.as_slice() else {
1826 panic!("Unexpected mentions");
1827 };
1828 pretty_assertions::assert_eq!(content, "1");
1829 pretty_assertions::assert_eq!(
1830 uri,
1831 &MentionUri::parse(&url_one, PathStyle::local()).unwrap()
1832 );
1833 }
1834
1835 cx.simulate_input(" ");
1836
1837 editor.update(&mut cx, |editor, cx| {
1838 let text = editor.text(cx);
1839 assert_eq!(text, format!("Lorem [@one.txt]({url_one}) "));
1840 assert!(!editor.has_visible_completions_menu());
1841 assert_eq!(fold_ranges(editor, cx).len(), 1);
1842 });
1843
1844 cx.simulate_input("Ipsum ");
1845
1846 editor.update(&mut cx, |editor, cx| {
1847 let text = editor.text(cx);
1848 assert_eq!(text, format!("Lorem [@one.txt]({url_one}) Ipsum "),);
1849 assert!(!editor.has_visible_completions_menu());
1850 assert_eq!(fold_ranges(editor, cx).len(), 1);
1851 });
1852
1853 cx.simulate_input("@file ");
1854
1855 editor.update(&mut cx, |editor, cx| {
1856 let text = editor.text(cx);
1857 assert_eq!(text, format!("Lorem [@one.txt]({url_one}) Ipsum @file "),);
1858 assert!(editor.has_visible_completions_menu());
1859 assert_eq!(fold_ranges(editor, cx).len(), 1);
1860 });
1861
1862 editor.update_in(&mut cx, |editor, window, cx| {
1863 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
1864 });
1865
1866 cx.run_until_parked();
1867
1868 let contents = message_editor
1869 .update(&mut cx, |message_editor, cx| {
1870 message_editor
1871 .mention_set()
1872 .update(cx, |mention_set, cx| mention_set.contents(false, cx))
1873 })
1874 .await
1875 .unwrap()
1876 .into_values()
1877 .collect::<Vec<_>>();
1878
1879 let url_eight = MentionUri::File {
1880 abs_path: path!("/dir/b/eight.txt").into(),
1881 }
1882 .to_uri()
1883 .to_string();
1884
1885 {
1886 let [_, (uri, Mention::Text { content, .. })] = contents.as_slice() else {
1887 panic!("Unexpected mentions");
1888 };
1889 pretty_assertions::assert_eq!(content, "8");
1890 pretty_assertions::assert_eq!(
1891 uri,
1892 &MentionUri::parse(&url_eight, PathStyle::local()).unwrap()
1893 );
1894 }
1895
1896 editor.update(&mut cx, |editor, cx| {
1897 assert_eq!(
1898 editor.text(cx),
1899 format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) ")
1900 );
1901 assert!(!editor.has_visible_completions_menu());
1902 assert_eq!(fold_ranges(editor, cx).len(), 2);
1903 });
1904
1905 let plain_text_language = Arc::new(language::Language::new(
1906 language::LanguageConfig {
1907 name: "Plain Text".into(),
1908 matcher: language::LanguageMatcher {
1909 path_suffixes: vec!["txt".to_string()],
1910 ..Default::default()
1911 },
1912 ..Default::default()
1913 },
1914 None,
1915 ));
1916
1917 // Register the language and fake LSP
1918 let language_registry = project.read_with(&cx, |project, _| project.languages().clone());
1919 language_registry.add(plain_text_language);
1920
1921 let mut fake_language_servers = language_registry.register_fake_lsp(
1922 "Plain Text",
1923 language::FakeLspAdapter {
1924 capabilities: lsp::ServerCapabilities {
1925 workspace_symbol_provider: Some(lsp::OneOf::Left(true)),
1926 ..Default::default()
1927 },
1928 ..Default::default()
1929 },
1930 );
1931
1932 // Open the buffer to trigger LSP initialization
1933 let buffer = project
1934 .update(&mut cx, |project, cx| {
1935 project.open_local_buffer(path!("/dir/a/one.txt"), cx)
1936 })
1937 .await
1938 .unwrap();
1939
1940 // Register the buffer with language servers
1941 let _handle = project.update(&mut cx, |project, cx| {
1942 project.register_buffer_with_language_servers(&buffer, cx)
1943 });
1944
1945 cx.run_until_parked();
1946
1947 let fake_language_server = fake_language_servers.next().await.unwrap();
1948 fake_language_server.set_request_handler::<lsp::WorkspaceSymbolRequest, _, _>(
1949 move |_, _| async move {
1950 Ok(Some(lsp::WorkspaceSymbolResponse::Flat(vec![
1951 #[allow(deprecated)]
1952 lsp::SymbolInformation {
1953 name: "MySymbol".into(),
1954 location: lsp::Location {
1955 uri: lsp::Uri::from_file_path(path!("/dir/a/one.txt")).unwrap(),
1956 range: lsp::Range::new(
1957 lsp::Position::new(0, 0),
1958 lsp::Position::new(0, 1),
1959 ),
1960 },
1961 kind: lsp::SymbolKind::CONSTANT,
1962 tags: None,
1963 container_name: None,
1964 deprecated: None,
1965 },
1966 ])))
1967 },
1968 );
1969
1970 cx.simulate_input("@symbol ");
1971
1972 editor.update(&mut cx, |editor, cx| {
1973 assert_eq!(
1974 editor.text(cx),
1975 format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) @symbol ")
1976 );
1977 assert!(editor.has_visible_completions_menu());
1978 assert_eq!(current_completion_labels(editor), &["MySymbol one.txt L1"]);
1979 });
1980
1981 editor.update_in(&mut cx, |editor, window, cx| {
1982 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
1983 });
1984
1985 let symbol = MentionUri::Symbol {
1986 abs_path: path!("/dir/a/one.txt").into(),
1987 name: "MySymbol".into(),
1988 line_range: 0..=0,
1989 };
1990
1991 let contents = message_editor
1992 .update(&mut cx, |message_editor, cx| {
1993 message_editor
1994 .mention_set()
1995 .update(cx, |mention_set, cx| mention_set.contents(false, cx))
1996 })
1997 .await
1998 .unwrap()
1999 .into_values()
2000 .collect::<Vec<_>>();
2001
2002 {
2003 let [_, _, (uri, Mention::Text { content, .. })] = contents.as_slice() else {
2004 panic!("Unexpected mentions");
2005 };
2006 pretty_assertions::assert_eq!(content, "1");
2007 pretty_assertions::assert_eq!(uri, &symbol);
2008 }
2009
2010 cx.run_until_parked();
2011
2012 editor.read_with(&cx, |editor, cx| {
2013 assert_eq!(
2014 editor.text(cx),
2015 format!(
2016 "Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) ",
2017 symbol.to_uri(),
2018 )
2019 );
2020 });
2021
2022 // Try to mention an "image" file that will fail to load
2023 cx.simulate_input("@file x.png");
2024
2025 editor.update(&mut cx, |editor, cx| {
2026 assert_eq!(
2027 editor.text(cx),
2028 format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) @file x.png", symbol.to_uri())
2029 );
2030 assert!(editor.has_visible_completions_menu());
2031 assert_eq!(current_completion_labels(editor), &["x.png "]);
2032 });
2033
2034 editor.update_in(&mut cx, |editor, window, cx| {
2035 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2036 });
2037
2038 // Getting the message contents fails
2039 message_editor
2040 .update(&mut cx, |message_editor, cx| {
2041 message_editor
2042 .mention_set()
2043 .update(cx, |mention_set, cx| mention_set.contents(false, cx))
2044 })
2045 .await
2046 .expect_err("Should fail to load x.png");
2047
2048 cx.run_until_parked();
2049
2050 // Mention was removed
2051 editor.read_with(&cx, |editor, cx| {
2052 assert_eq!(
2053 editor.text(cx),
2054 format!(
2055 "Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) ",
2056 symbol.to_uri()
2057 )
2058 );
2059 });
2060
2061 // Once more
2062 cx.simulate_input("@file x.png");
2063
2064 editor.update(&mut cx, |editor, cx| {
2065 assert_eq!(
2066 editor.text(cx),
2067 format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) @file x.png", symbol.to_uri())
2068 );
2069 assert!(editor.has_visible_completions_menu());
2070 assert_eq!(current_completion_labels(editor), &["x.png "]);
2071 });
2072
2073 editor.update_in(&mut cx, |editor, window, cx| {
2074 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2075 });
2076
2077 // This time don't immediately get the contents, just let the confirmed completion settle
2078 cx.run_until_parked();
2079
2080 // Mention was removed
2081 editor.read_with(&cx, |editor, cx| {
2082 assert_eq!(
2083 editor.text(cx),
2084 format!(
2085 "Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) ",
2086 symbol.to_uri()
2087 )
2088 );
2089 });
2090
2091 // Now getting the contents succeeds, because the invalid mention was removed
2092 let contents = message_editor
2093 .update(&mut cx, |message_editor, cx| {
2094 message_editor
2095 .mention_set()
2096 .update(cx, |mention_set, cx| mention_set.contents(false, cx))
2097 })
2098 .await
2099 .unwrap();
2100 assert_eq!(contents.len(), 3);
2101 }
2102
2103 fn fold_ranges(editor: &Editor, cx: &mut App) -> Vec<Range<Point>> {
2104 let snapshot = editor.buffer().read(cx).snapshot(cx);
2105 editor.display_map.update(cx, |display_map, cx| {
2106 display_map
2107 .snapshot(cx)
2108 .folds_in_range(MultiBufferOffset(0)..snapshot.len())
2109 .map(|fold| fold.range.to_point(&snapshot))
2110 .collect()
2111 })
2112 }
2113
2114 fn current_completion_labels(editor: &Editor) -> Vec<String> {
2115 let completions = editor.current_completions().expect("Missing completions");
2116 completions
2117 .into_iter()
2118 .map(|completion| completion.label.text)
2119 .collect::<Vec<_>>()
2120 }
2121
2122 fn current_completion_labels_with_documentation(editor: &Editor) -> Vec<(String, String)> {
2123 let completions = editor.current_completions().expect("Missing completions");
2124 completions
2125 .into_iter()
2126 .map(|completion| {
2127 (
2128 completion.label.text,
2129 completion
2130 .documentation
2131 .map(|d| d.text().to_string())
2132 .unwrap_or_default(),
2133 )
2134 })
2135 .collect::<Vec<_>>()
2136 }
2137
2138 #[gpui::test]
2139 async fn test_large_file_mention_fallback(cx: &mut TestAppContext) {
2140 init_test(cx);
2141
2142 let fs = FakeFs::new(cx.executor());
2143
2144 // Create a large file that exceeds AUTO_OUTLINE_SIZE
2145 // Using plain text without a configured language, so no outline is available
2146 const LINE: &str = "This is a line of text in the file\n";
2147 let large_content = LINE.repeat(2 * (outline::AUTO_OUTLINE_SIZE / LINE.len()));
2148 assert!(large_content.len() > outline::AUTO_OUTLINE_SIZE);
2149
2150 // Create a small file that doesn't exceed AUTO_OUTLINE_SIZE
2151 let small_content = "fn small_function() { /* small */ }\n";
2152 assert!(small_content.len() < outline::AUTO_OUTLINE_SIZE);
2153
2154 fs.insert_tree(
2155 "/project",
2156 json!({
2157 "large_file.txt": large_content.clone(),
2158 "small_file.txt": small_content,
2159 }),
2160 )
2161 .await;
2162
2163 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
2164
2165 let (workspace, cx) =
2166 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
2167
2168 let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
2169 let history = cx
2170 .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
2171
2172 let message_editor = cx.update(|window, cx| {
2173 cx.new(|cx| {
2174 let editor = MessageEditor::new(
2175 workspace.downgrade(),
2176 project.downgrade(),
2177 thread_store.clone(),
2178 history.downgrade(),
2179 None,
2180 Default::default(),
2181 Default::default(),
2182 "Test Agent".into(),
2183 "Test",
2184 EditorMode::AutoHeight {
2185 min_lines: 1,
2186 max_lines: None,
2187 },
2188 window,
2189 cx,
2190 );
2191 // Enable embedded context so files are actually included
2192 editor
2193 .prompt_capabilities
2194 .replace(acp::PromptCapabilities::new().embedded_context(true));
2195 editor
2196 })
2197 });
2198
2199 // Test large file mention
2200 // Get the absolute path using the project's worktree
2201 let large_file_abs_path = project.read_with(cx, |project, cx| {
2202 let worktree = project.worktrees(cx).next().unwrap();
2203 let worktree_root = worktree.read(cx).abs_path();
2204 worktree_root.join("large_file.txt")
2205 });
2206 let large_file_task = message_editor.update(cx, |editor, cx| {
2207 editor.mention_set().update(cx, |set, cx| {
2208 set.confirm_mention_for_file(large_file_abs_path, true, cx)
2209 })
2210 });
2211
2212 let large_file_mention = large_file_task.await.unwrap();
2213 match large_file_mention {
2214 Mention::Text { content, .. } => {
2215 // Should contain some of the content but not all of it
2216 assert!(
2217 content.contains(LINE),
2218 "Should contain some of the file content"
2219 );
2220 assert!(
2221 !content.contains(&LINE.repeat(100)),
2222 "Should not contain the full file"
2223 );
2224 // Should be much smaller than original
2225 assert!(
2226 content.len() < large_content.len() / 10,
2227 "Should be significantly truncated"
2228 );
2229 }
2230 _ => panic!("Expected Text mention for large file"),
2231 }
2232
2233 // Test small file mention
2234 // Get the absolute path using the project's worktree
2235 let small_file_abs_path = project.read_with(cx, |project, cx| {
2236 let worktree = project.worktrees(cx).next().unwrap();
2237 let worktree_root = worktree.read(cx).abs_path();
2238 worktree_root.join("small_file.txt")
2239 });
2240 let small_file_task = message_editor.update(cx, |editor, cx| {
2241 editor.mention_set().update(cx, |set, cx| {
2242 set.confirm_mention_for_file(small_file_abs_path, true, cx)
2243 })
2244 });
2245
2246 let small_file_mention = small_file_task.await.unwrap();
2247 match small_file_mention {
2248 Mention::Text { content, .. } => {
2249 // Should contain the full actual content
2250 assert_eq!(content, small_content);
2251 }
2252 _ => panic!("Expected Text mention for small file"),
2253 }
2254 }
2255
2256 #[gpui::test]
2257 async fn test_insert_thread_summary(cx: &mut TestAppContext) {
2258 init_test(cx);
2259 cx.update(LanguageModelRegistry::test);
2260
2261 let fs = FakeFs::new(cx.executor());
2262 fs.insert_tree("/project", json!({"file": ""})).await;
2263 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
2264
2265 let (workspace, cx) =
2266 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
2267
2268 let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
2269 let history = cx
2270 .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
2271
2272 // Create a thread metadata to insert as summary
2273 let thread_metadata = AgentSessionInfo {
2274 session_id: acp::SessionId::new("thread-123"),
2275 cwd: None,
2276 title: Some("Previous Conversation".into()),
2277 updated_at: Some(chrono::Utc::now()),
2278 meta: None,
2279 };
2280
2281 let message_editor = cx.update(|window, cx| {
2282 cx.new(|cx| {
2283 let mut editor = MessageEditor::new(
2284 workspace.downgrade(),
2285 project.downgrade(),
2286 thread_store.clone(),
2287 history.downgrade(),
2288 None,
2289 Default::default(),
2290 Default::default(),
2291 "Test Agent".into(),
2292 "Test",
2293 EditorMode::AutoHeight {
2294 min_lines: 1,
2295 max_lines: None,
2296 },
2297 window,
2298 cx,
2299 );
2300 editor.insert_thread_summary(thread_metadata.clone(), window, cx);
2301 editor
2302 })
2303 });
2304
2305 // Construct expected values for verification
2306 let expected_uri = MentionUri::Thread {
2307 id: thread_metadata.session_id.clone(),
2308 name: thread_metadata.title.as_ref().unwrap().to_string(),
2309 };
2310 let expected_title = thread_metadata.title.as_ref().unwrap();
2311 let expected_link = format!("[@{}]({})", expected_title, expected_uri.to_uri());
2312
2313 message_editor.read_with(cx, |editor, cx| {
2314 let text = editor.text(cx);
2315
2316 assert!(
2317 text.contains(&expected_link),
2318 "Expected editor text to contain thread mention link.\nExpected substring: {}\nActual text: {}",
2319 expected_link,
2320 text
2321 );
2322
2323 let mentions = editor.mention_set().read(cx).mentions();
2324 assert_eq!(
2325 mentions.len(),
2326 1,
2327 "Expected exactly one mention after inserting thread summary"
2328 );
2329
2330 assert!(
2331 mentions.contains(&expected_uri),
2332 "Expected mentions to contain the thread URI"
2333 );
2334 });
2335 }
2336
2337 #[gpui::test]
2338 async fn test_insert_thread_summary_skipped_for_external_agents(cx: &mut TestAppContext) {
2339 init_test(cx);
2340 cx.update(LanguageModelRegistry::test);
2341
2342 let fs = FakeFs::new(cx.executor());
2343 fs.insert_tree("/project", json!({"file": ""})).await;
2344 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
2345
2346 let (workspace, cx) =
2347 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
2348
2349 let thread_store = None;
2350 let history = cx
2351 .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
2352
2353 let thread_metadata = AgentSessionInfo {
2354 session_id: acp::SessionId::new("thread-123"),
2355 cwd: None,
2356 title: Some("Previous Conversation".into()),
2357 updated_at: Some(chrono::Utc::now()),
2358 meta: None,
2359 };
2360
2361 let message_editor = cx.update(|window, cx| {
2362 cx.new(|cx| {
2363 let mut editor = MessageEditor::new(
2364 workspace.downgrade(),
2365 project.downgrade(),
2366 thread_store.clone(),
2367 history.downgrade(),
2368 None,
2369 Default::default(),
2370 Default::default(),
2371 "Test Agent".into(),
2372 "Test",
2373 EditorMode::AutoHeight {
2374 min_lines: 1,
2375 max_lines: None,
2376 },
2377 window,
2378 cx,
2379 );
2380 editor.insert_thread_summary(thread_metadata, window, cx);
2381 editor
2382 })
2383 });
2384
2385 message_editor.read_with(cx, |editor, cx| {
2386 assert!(
2387 editor.text(cx).is_empty(),
2388 "Expected thread summary to be skipped for external agents"
2389 );
2390 assert!(
2391 editor.mention_set().read(cx).mentions().is_empty(),
2392 "Expected no mentions when thread summary is skipped"
2393 );
2394 });
2395 }
2396
2397 #[gpui::test]
2398 async fn test_thread_mode_hidden_when_disabled(cx: &mut TestAppContext) {
2399 init_test(cx);
2400
2401 let fs = FakeFs::new(cx.executor());
2402 fs.insert_tree("/project", json!({"file": ""})).await;
2403 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
2404
2405 let (workspace, cx) =
2406 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
2407
2408 let thread_store = None;
2409 let history = cx
2410 .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
2411
2412 let message_editor = cx.update(|window, cx| {
2413 cx.new(|cx| {
2414 MessageEditor::new(
2415 workspace.downgrade(),
2416 project.downgrade(),
2417 thread_store.clone(),
2418 history.downgrade(),
2419 None,
2420 Default::default(),
2421 Default::default(),
2422 "Test Agent".into(),
2423 "Test",
2424 EditorMode::AutoHeight {
2425 min_lines: 1,
2426 max_lines: None,
2427 },
2428 window,
2429 cx,
2430 )
2431 })
2432 });
2433
2434 message_editor.update(cx, |editor, _cx| {
2435 editor
2436 .prompt_capabilities
2437 .replace(acp::PromptCapabilities::new().embedded_context(true));
2438 });
2439
2440 let supported_modes = {
2441 let app = cx.app.borrow();
2442 message_editor.supported_modes(&app)
2443 };
2444
2445 assert!(
2446 !supported_modes.contains(&PromptContextType::Thread),
2447 "Expected thread mode to be hidden when thread mentions are disabled"
2448 );
2449 }
2450
2451 #[gpui::test]
2452 async fn test_thread_mode_visible_when_enabled(cx: &mut TestAppContext) {
2453 init_test(cx);
2454
2455 let fs = FakeFs::new(cx.executor());
2456 fs.insert_tree("/project", json!({"file": ""})).await;
2457 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
2458
2459 let (workspace, cx) =
2460 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
2461
2462 let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
2463 let history = cx
2464 .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
2465
2466 let message_editor = cx.update(|window, cx| {
2467 cx.new(|cx| {
2468 MessageEditor::new(
2469 workspace.downgrade(),
2470 project.downgrade(),
2471 thread_store.clone(),
2472 history.downgrade(),
2473 None,
2474 Default::default(),
2475 Default::default(),
2476 "Test Agent".into(),
2477 "Test",
2478 EditorMode::AutoHeight {
2479 min_lines: 1,
2480 max_lines: None,
2481 },
2482 window,
2483 cx,
2484 )
2485 })
2486 });
2487
2488 message_editor.update(cx, |editor, _cx| {
2489 editor
2490 .prompt_capabilities
2491 .replace(acp::PromptCapabilities::new().embedded_context(true));
2492 });
2493
2494 let supported_modes = {
2495 let app = cx.app.borrow();
2496 message_editor.supported_modes(&app)
2497 };
2498
2499 assert!(
2500 supported_modes.contains(&PromptContextType::Thread),
2501 "Expected thread mode to be visible when enabled"
2502 );
2503 }
2504
2505 #[gpui::test]
2506 async fn test_whitespace_trimming(cx: &mut TestAppContext) {
2507 init_test(cx);
2508
2509 let fs = FakeFs::new(cx.executor());
2510 fs.insert_tree("/project", json!({"file.rs": "fn main() {}"}))
2511 .await;
2512 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
2513
2514 let (workspace, cx) =
2515 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
2516
2517 let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
2518 let history = cx
2519 .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
2520
2521 let message_editor = cx.update(|window, cx| {
2522 cx.new(|cx| {
2523 MessageEditor::new(
2524 workspace.downgrade(),
2525 project.downgrade(),
2526 thread_store.clone(),
2527 history.downgrade(),
2528 None,
2529 Default::default(),
2530 Default::default(),
2531 "Test Agent".into(),
2532 "Test",
2533 EditorMode::AutoHeight {
2534 min_lines: 1,
2535 max_lines: None,
2536 },
2537 window,
2538 cx,
2539 )
2540 })
2541 });
2542 let editor = message_editor.update(cx, |message_editor, _| message_editor.editor.clone());
2543
2544 cx.run_until_parked();
2545
2546 editor.update_in(cx, |editor, window, cx| {
2547 editor.set_text(" \u{A0}してhello world ", window, cx);
2548 });
2549
2550 let (content, _) = message_editor
2551 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
2552 .await
2553 .unwrap();
2554
2555 assert_eq!(content, vec!["してhello world".into()]);
2556 }
2557
2558 #[gpui::test]
2559 async fn test_editor_respects_embedded_context_capability(cx: &mut TestAppContext) {
2560 init_test(cx);
2561
2562 let fs = FakeFs::new(cx.executor());
2563
2564 let file_content = "fn main() { println!(\"Hello, world!\"); }\n";
2565
2566 fs.insert_tree(
2567 "/project",
2568 json!({
2569 "src": {
2570 "main.rs": file_content,
2571 }
2572 }),
2573 )
2574 .await;
2575
2576 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
2577
2578 let (workspace, cx) =
2579 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
2580
2581 let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
2582 let history = cx
2583 .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
2584
2585 let (message_editor, editor) = workspace.update_in(cx, |workspace, window, cx| {
2586 let workspace_handle = cx.weak_entity();
2587 let message_editor = cx.new(|cx| {
2588 MessageEditor::new(
2589 workspace_handle,
2590 project.downgrade(),
2591 thread_store.clone(),
2592 history.downgrade(),
2593 None,
2594 Default::default(),
2595 Default::default(),
2596 "Test Agent".into(),
2597 "Test",
2598 EditorMode::AutoHeight {
2599 max_lines: None,
2600 min_lines: 1,
2601 },
2602 window,
2603 cx,
2604 )
2605 });
2606 workspace.active_pane().update(cx, |pane, cx| {
2607 pane.add_item(
2608 Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
2609 true,
2610 true,
2611 None,
2612 window,
2613 cx,
2614 );
2615 });
2616 message_editor.read(cx).focus_handle(cx).focus(window, cx);
2617 let editor = message_editor.read(cx).editor().clone();
2618 (message_editor, editor)
2619 });
2620
2621 cx.simulate_input("What is in @file main");
2622
2623 editor.update_in(cx, |editor, window, cx| {
2624 assert!(editor.has_visible_completions_menu());
2625 assert_eq!(editor.text(cx), "What is in @file main");
2626 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2627 });
2628
2629 let content = message_editor
2630 .update(cx, |editor, cx| editor.contents(false, cx))
2631 .await
2632 .unwrap()
2633 .0;
2634
2635 let main_rs_uri = if cfg!(windows) {
2636 "file:///C:/project/src/main.rs"
2637 } else {
2638 "file:///project/src/main.rs"
2639 };
2640
2641 // When embedded context is `false` we should get a resource link
2642 pretty_assertions::assert_eq!(
2643 content,
2644 vec![
2645 "What is in ".into(),
2646 acp::ContentBlock::ResourceLink(acp::ResourceLink::new("main.rs", main_rs_uri))
2647 ]
2648 );
2649
2650 message_editor.update(cx, |editor, _cx| {
2651 editor
2652 .prompt_capabilities
2653 .replace(acp::PromptCapabilities::new().embedded_context(true))
2654 });
2655
2656 let content = message_editor
2657 .update(cx, |editor, cx| editor.contents(false, cx))
2658 .await
2659 .unwrap()
2660 .0;
2661
2662 // When embedded context is `true` we should get a resource
2663 pretty_assertions::assert_eq!(
2664 content,
2665 vec![
2666 "What is in ".into(),
2667 acp::ContentBlock::Resource(acp::EmbeddedResource::new(
2668 acp::EmbeddedResourceResource::TextResourceContents(
2669 acp::TextResourceContents::new(file_content, main_rs_uri)
2670 )
2671 ))
2672 ]
2673 );
2674 }
2675
2676 #[gpui::test]
2677 async fn test_autoscroll_after_insert_selections(cx: &mut TestAppContext) {
2678 init_test(cx);
2679
2680 let app_state = cx.update(AppState::test);
2681
2682 cx.update(|cx| {
2683 editor::init(cx);
2684 workspace::init(app_state.clone(), cx);
2685 });
2686
2687 app_state
2688 .fs
2689 .as_fake()
2690 .insert_tree(
2691 path!("/dir"),
2692 json!({
2693 "test.txt": "line1\nline2\nline3\nline4\nline5\n",
2694 }),
2695 )
2696 .await;
2697
2698 let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
2699 let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
2700 let workspace = window.root(cx).unwrap();
2701
2702 let worktree = project.update(cx, |project, cx| {
2703 let mut worktrees = project.worktrees(cx).collect::<Vec<_>>();
2704 assert_eq!(worktrees.len(), 1);
2705 worktrees.pop().unwrap()
2706 });
2707 let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id());
2708
2709 let mut cx = VisualTestContext::from_window(*window, cx);
2710
2711 // Open a regular editor with the created file, and select a portion of
2712 // the text that will be used for the selections that are meant to be
2713 // inserted in the agent panel.
2714 let editor = workspace
2715 .update_in(&mut cx, |workspace, window, cx| {
2716 workspace.open_path(
2717 ProjectPath {
2718 worktree_id,
2719 path: rel_path("test.txt").into(),
2720 },
2721 None,
2722 false,
2723 window,
2724 cx,
2725 )
2726 })
2727 .await
2728 .unwrap()
2729 .downcast::<Editor>()
2730 .unwrap();
2731
2732 editor.update_in(&mut cx, |editor, window, cx| {
2733 editor.change_selections(Default::default(), window, cx, |selections| {
2734 selections.select_ranges([Point::new(0, 0)..Point::new(0, 5)]);
2735 });
2736 });
2737
2738 let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
2739 let history = cx
2740 .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
2741
2742 // Create a new `MessageEditor`. The `EditorMode::full()` has to be used
2743 // to ensure we have a fixed viewport, so we can eventually actually
2744 // place the cursor outside of the visible area.
2745 let message_editor = workspace.update_in(&mut cx, |workspace, window, cx| {
2746 let workspace_handle = cx.weak_entity();
2747 let message_editor = cx.new(|cx| {
2748 MessageEditor::new(
2749 workspace_handle,
2750 project.downgrade(),
2751 thread_store.clone(),
2752 history.downgrade(),
2753 None,
2754 Default::default(),
2755 Default::default(),
2756 "Test Agent".into(),
2757 "Test",
2758 EditorMode::full(),
2759 window,
2760 cx,
2761 )
2762 });
2763 workspace.active_pane().update(cx, |pane, cx| {
2764 pane.add_item(
2765 Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
2766 true,
2767 true,
2768 None,
2769 window,
2770 cx,
2771 );
2772 });
2773
2774 message_editor
2775 });
2776
2777 message_editor.update_in(&mut cx, |message_editor, window, cx| {
2778 message_editor.editor.update(cx, |editor, cx| {
2779 // Update the Agent Panel's Message Editor text to have 100
2780 // lines, ensuring that the cursor is set at line 90 and that we
2781 // then scroll all the way to the top, so the cursor's position
2782 // remains off screen.
2783 let mut lines = String::new();
2784 for _ in 1..=100 {
2785 lines.push_str(&"Another line in the agent panel's message editor\n");
2786 }
2787 editor.set_text(lines.as_str(), window, cx);
2788 editor.change_selections(Default::default(), window, cx, |selections| {
2789 selections.select_ranges([Point::new(90, 0)..Point::new(90, 0)]);
2790 });
2791 editor.set_scroll_position(gpui::Point::new(0., 0.), window, cx);
2792 });
2793 });
2794
2795 cx.run_until_parked();
2796
2797 // Before proceeding, let's assert that the cursor is indeed off screen,
2798 // otherwise the rest of the test doesn't make sense.
2799 message_editor.update_in(&mut cx, |message_editor, window, cx| {
2800 message_editor.editor.update(cx, |editor, cx| {
2801 let snapshot = editor.snapshot(window, cx);
2802 let cursor_row = editor.selections.newest::<Point>(&snapshot).head().row;
2803 let scroll_top = snapshot.scroll_position().y as u32;
2804 let visible_lines = editor.visible_line_count().unwrap() as u32;
2805 let visible_range = scroll_top..(scroll_top + visible_lines);
2806
2807 assert!(!visible_range.contains(&cursor_row));
2808 })
2809 });
2810
2811 // Now let's insert the selection in the Agent Panel's editor and
2812 // confirm that, after the insertion, the cursor is now in the visible
2813 // range.
2814 message_editor.update_in(&mut cx, |message_editor, window, cx| {
2815 message_editor.insert_selections(window, cx);
2816 });
2817
2818 cx.run_until_parked();
2819
2820 message_editor.update_in(&mut cx, |message_editor, window, cx| {
2821 message_editor.editor.update(cx, |editor, cx| {
2822 let snapshot = editor.snapshot(window, cx);
2823 let cursor_row = editor.selections.newest::<Point>(&snapshot).head().row;
2824 let scroll_top = snapshot.scroll_position().y as u32;
2825 let visible_lines = editor.visible_line_count().unwrap() as u32;
2826 let visible_range = scroll_top..(scroll_top + visible_lines);
2827
2828 assert!(visible_range.contains(&cursor_row));
2829 })
2830 });
2831 }
2832}