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