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