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 let buffer_snapshot = snapshot.buffer_snapshot();
705 let prefix_char_count = prefix.chars().count();
706 buffer_snapshot
707 .reversed_chars_at(offset)
708 .take(prefix_char_count)
709 .eq(prefix.chars().rev())
710 };
711
712 if menu_is_open && has_prefix {
713 return;
714 }
715
716 editor.insert(&prefix, window, cx);
717 editor.show_completions(&editor::actions::ShowCompletions, window, cx);
718 })
719 .log_err();
720 })
721 .detach();
722 }
723
724 fn chat(&mut self, _: &Chat, _: &mut Window, cx: &mut Context<Self>) {
725 self.send(cx);
726 }
727
728 fn send_immediately(&mut self, _: &SendImmediately, _: &mut Window, cx: &mut Context<Self>) {
729 if self.is_empty(cx) {
730 return;
731 }
732
733 self.editor.update(cx, |editor, cx| {
734 editor.clear_inlay_hints(cx);
735 });
736
737 cx.emit(MessageEditorEvent::SendImmediately)
738 }
739
740 fn chat_with_follow(
741 &mut self,
742 _: &ChatWithFollow,
743 window: &mut Window,
744 cx: &mut Context<Self>,
745 ) {
746 self.workspace
747 .update(cx, |this, cx| {
748 this.follow(CollaboratorId::Agent, window, cx)
749 })
750 .log_err();
751
752 self.send(cx);
753 }
754
755 fn cancel(&mut self, _: &editor::actions::Cancel, _: &mut Window, cx: &mut Context<Self>) {
756 cx.emit(MessageEditorEvent::Cancel)
757 }
758
759 fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context<Self>) {
760 let Some(workspace) = self.workspace.upgrade() else {
761 return;
762 };
763 let editor_clipboard_selections = cx
764 .read_from_clipboard()
765 .and_then(|item| item.entries().first().cloned())
766 .and_then(|entry| match entry {
767 ClipboardEntry::String(text) => {
768 text.metadata_json::<Vec<editor::ClipboardSelection>>()
769 }
770 _ => None,
771 });
772
773 // Insert creases for pasted clipboard selections that:
774 // 1. Contain exactly one selection
775 // 2. Have an associated file path
776 // 3. Span multiple lines (not single-line selections)
777 // 4. Belong to a file that exists in the current project
778 let should_insert_creases = util::maybe!({
779 let selections = editor_clipboard_selections.as_ref()?;
780 if selections.len() > 1 {
781 return Some(false);
782 }
783 let selection = selections.first()?;
784 let file_path = selection.file_path.as_ref()?;
785 let line_range = selection.line_range.as_ref()?;
786
787 if line_range.start() == line_range.end() {
788 return Some(false);
789 }
790
791 Some(
792 workspace
793 .read(cx)
794 .project()
795 .read(cx)
796 .project_path_for_absolute_path(file_path, cx)
797 .is_some(),
798 )
799 })
800 .unwrap_or(false);
801
802 if should_insert_creases && let Some(selections) = editor_clipboard_selections {
803 cx.stop_propagation();
804 let insertion_target = self
805 .editor
806 .read(cx)
807 .selections
808 .newest_anchor()
809 .start
810 .text_anchor;
811
812 let project = workspace.read(cx).project().clone();
813 for selection in selections {
814 if let (Some(file_path), Some(line_range)) =
815 (selection.file_path, selection.line_range)
816 {
817 let crease_text =
818 acp_thread::selection_name(Some(file_path.as_ref()), &line_range);
819
820 let mention_uri = MentionUri::Selection {
821 abs_path: Some(file_path.clone()),
822 line_range: line_range.clone(),
823 };
824
825 let mention_text = mention_uri.as_link().to_string();
826 let (excerpt_id, text_anchor, content_len) =
827 self.editor.update(cx, |editor, cx| {
828 let buffer = editor.buffer().read(cx);
829 let snapshot = buffer.snapshot(cx);
830 let (excerpt_id, _, buffer_snapshot) = snapshot.as_singleton().unwrap();
831 let text_anchor = insertion_target.bias_left(&buffer_snapshot);
832
833 editor.insert(&mention_text, window, cx);
834 editor.insert(" ", window, cx);
835
836 (*excerpt_id, text_anchor, mention_text.len())
837 });
838
839 let Some((crease_id, tx)) = insert_crease_for_mention(
840 excerpt_id,
841 text_anchor,
842 content_len,
843 crease_text.into(),
844 mention_uri.icon_path(cx),
845 None,
846 self.editor.clone(),
847 window,
848 cx,
849 ) else {
850 continue;
851 };
852 drop(tx);
853
854 let mention_task = cx
855 .spawn({
856 let project = project.clone();
857 async move |_, cx| {
858 let project_path = project
859 .update(cx, |project, cx| {
860 project.project_path_for_absolute_path(&file_path, cx)
861 })
862 .ok_or_else(|| "project path not found".to_string())?;
863
864 let buffer = project
865 .update(cx, |project, cx| project.open_buffer(project_path, cx))
866 .await
867 .map_err(|e| e.to_string())?;
868
869 Ok(buffer.update(cx, |buffer, cx| {
870 let start =
871 Point::new(*line_range.start(), 0).min(buffer.max_point());
872 let end = Point::new(*line_range.end() + 1, 0)
873 .min(buffer.max_point());
874 let content = buffer.text_for_range(start..end).collect();
875 Mention::Text {
876 content,
877 tracked_buffers: vec![cx.entity()],
878 }
879 }))
880 }
881 })
882 .shared();
883
884 self.mention_set.update(cx, |mention_set, _cx| {
885 mention_set.insert_mention(crease_id, mention_uri.clone(), mention_task)
886 });
887 }
888 }
889 return;
890 }
891
892 if self.prompt_capabilities.borrow().image
893 && let Some(task) =
894 paste_images_as_context(self.editor.clone(), self.mention_set.clone(), window, cx)
895 {
896 task.detach();
897 }
898 }
899
900 fn paste_raw(&mut self, _: &PasteRaw, window: &mut Window, cx: &mut Context<Self>) {
901 let editor = self.editor.clone();
902 window.defer(cx, move |window, cx| {
903 editor.update(cx, |editor, cx| editor.paste(&Paste, window, cx));
904 });
905 }
906
907 pub fn insert_dragged_files(
908 &mut self,
909 paths: Vec<project::ProjectPath>,
910 added_worktrees: Vec<Entity<Worktree>>,
911 window: &mut Window,
912 cx: &mut Context<Self>,
913 ) {
914 let Some(workspace) = self.workspace.upgrade() else {
915 return;
916 };
917 let project = workspace.read(cx).project().clone();
918 let path_style = project.read(cx).path_style(cx);
919 let buffer = self.editor.read(cx).buffer().clone();
920 let Some(buffer) = buffer.read(cx).as_singleton() else {
921 return;
922 };
923 let mut tasks = Vec::new();
924 for path in paths {
925 let Some(entry) = project.read(cx).entry_for_path(&path, cx) else {
926 continue;
927 };
928 let Some(worktree) = project.read(cx).worktree_for_id(path.worktree_id, cx) else {
929 continue;
930 };
931 let abs_path = worktree.read(cx).absolutize(&path.path);
932 let (file_name, _) = crate::completion_provider::extract_file_name_and_directory(
933 &path.path,
934 worktree.read(cx).root_name(),
935 path_style,
936 );
937
938 let uri = if entry.is_dir() {
939 MentionUri::Directory { abs_path }
940 } else {
941 MentionUri::File { abs_path }
942 };
943
944 let new_text = format!("{} ", uri.as_link());
945 let content_len = new_text.len() - 1;
946
947 let anchor = buffer.update(cx, |buffer, _cx| buffer.anchor_before(buffer.len()));
948
949 self.editor.update(cx, |message_editor, cx| {
950 message_editor.edit(
951 [(
952 multi_buffer::Anchor::max()..multi_buffer::Anchor::max(),
953 new_text,
954 )],
955 cx,
956 );
957 });
958 let supports_images = self.prompt_capabilities.borrow().image;
959 tasks.push(self.mention_set.update(cx, |mention_set, cx| {
960 mention_set.confirm_mention_completion(
961 file_name,
962 anchor,
963 content_len,
964 uri,
965 supports_images,
966 self.editor.clone(),
967 &workspace,
968 window,
969 cx,
970 )
971 }));
972 }
973 cx.spawn(async move |_, _| {
974 join_all(tasks).await;
975 drop(added_worktrees);
976 })
977 .detach();
978 }
979
980 /// Inserts code snippets as creases into the editor.
981 /// Each tuple contains (code_text, crease_title).
982 pub fn insert_code_creases(
983 &mut self,
984 creases: Vec<(String, String)>,
985 window: &mut Window,
986 cx: &mut Context<Self>,
987 ) {
988 use editor::display_map::{Crease, FoldPlaceholder};
989 use multi_buffer::MultiBufferRow;
990 use rope::Point;
991
992 self.editor.update(cx, |editor, cx| {
993 editor.insert("\n", window, cx);
994 for (text, crease_title) in creases {
995 let point = editor
996 .selections
997 .newest::<Point>(&editor.display_snapshot(cx))
998 .head();
999 let start_row = MultiBufferRow(point.row);
1000
1001 editor.insert(&text, window, cx);
1002
1003 let snapshot = editor.buffer().read(cx).snapshot(cx);
1004 let anchor_before = snapshot.anchor_after(point);
1005 let anchor_after = editor
1006 .selections
1007 .newest_anchor()
1008 .head()
1009 .bias_left(&snapshot);
1010
1011 editor.insert("\n", window, cx);
1012
1013 let fold_placeholder = FoldPlaceholder {
1014 render: Arc::new({
1015 let title = crease_title.clone();
1016 move |_fold_id, _fold_range, _cx| {
1017 ButtonLike::new("code-crease")
1018 .style(ButtonStyle::Filled)
1019 .layer(ElevationIndex::ElevatedSurface)
1020 .child(Icon::new(IconName::TextSnippet))
1021 .child(Label::new(title.clone()).single_line())
1022 .into_any_element()
1023 }
1024 }),
1025 merge_adjacent: false,
1026 ..Default::default()
1027 };
1028
1029 let crease = Crease::inline(
1030 anchor_before..anchor_after,
1031 fold_placeholder,
1032 |row, is_folded, fold, _window, _cx| {
1033 Disclosure::new(("code-crease-toggle", row.0 as u64), !is_folded)
1034 .toggle_state(is_folded)
1035 .on_click(move |_e, window, cx| fold(!is_folded, window, cx))
1036 .into_any_element()
1037 },
1038 |_, _, _, _| gpui::Empty.into_any(),
1039 );
1040 editor.insert_creases(vec![crease], cx);
1041 editor.fold_at(start_row, window, cx);
1042 }
1043 });
1044 }
1045
1046 pub fn insert_selections(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1047 let editor = self.editor.read(cx);
1048 let editor_buffer = editor.buffer().read(cx);
1049 let Some(buffer) = editor_buffer.as_singleton() else {
1050 return;
1051 };
1052 let cursor_anchor = editor.selections.newest_anchor().head();
1053 let cursor_offset = cursor_anchor.to_offset(&editor_buffer.snapshot(cx));
1054 let anchor = buffer.update(cx, |buffer, _cx| {
1055 buffer.anchor_before(cursor_offset.0.min(buffer.len()))
1056 });
1057 let Some(workspace) = self.workspace.upgrade() else {
1058 return;
1059 };
1060 let Some(completion) =
1061 PromptCompletionProvider::<Entity<MessageEditor>>::completion_for_action(
1062 PromptContextAction::AddSelections,
1063 anchor..anchor,
1064 self.editor.downgrade(),
1065 self.mention_set.downgrade(),
1066 &workspace,
1067 cx,
1068 )
1069 else {
1070 return;
1071 };
1072
1073 self.editor.update(cx, |message_editor, cx| {
1074 message_editor.edit([(cursor_anchor..cursor_anchor, completion.new_text)], cx);
1075 message_editor.request_autoscroll(Autoscroll::fit(), cx);
1076 });
1077 if let Some(confirm) = completion.confirm {
1078 confirm(CompletionIntent::Complete, window, cx);
1079 }
1080 }
1081
1082 pub fn add_images_from_picker(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1083 if !self.prompt_capabilities.borrow().image {
1084 return;
1085 }
1086
1087 let editor = self.editor.clone();
1088 let mention_set = self.mention_set.clone();
1089
1090 let paths_receiver = cx.prompt_for_paths(gpui::PathPromptOptions {
1091 files: true,
1092 directories: false,
1093 multiple: true,
1094 prompt: Some("Select Images".into()),
1095 });
1096
1097 window
1098 .spawn(cx, async move |cx| {
1099 let paths = match paths_receiver.await {
1100 Ok(Ok(Some(paths))) => paths,
1101 _ => return Ok::<(), anyhow::Error>(()),
1102 };
1103
1104 let supported_formats = [
1105 ("png", gpui::ImageFormat::Png),
1106 ("jpg", gpui::ImageFormat::Jpeg),
1107 ("jpeg", gpui::ImageFormat::Jpeg),
1108 ("webp", gpui::ImageFormat::Webp),
1109 ("gif", gpui::ImageFormat::Gif),
1110 ("bmp", gpui::ImageFormat::Bmp),
1111 ("tiff", gpui::ImageFormat::Tiff),
1112 ("tif", gpui::ImageFormat::Tiff),
1113 ("ico", gpui::ImageFormat::Ico),
1114 ];
1115
1116 let mut images = Vec::new();
1117 for path in paths {
1118 let extension = path
1119 .extension()
1120 .and_then(|ext| ext.to_str())
1121 .map(|s| s.to_lowercase());
1122
1123 let Some(format) = extension.and_then(|ext| {
1124 supported_formats
1125 .iter()
1126 .find(|(e, _)| *e == ext)
1127 .map(|(_, f)| *f)
1128 }) else {
1129 continue;
1130 };
1131
1132 let Ok(content) = async_fs::read(&path).await else {
1133 continue;
1134 };
1135
1136 images.push(gpui::Image::from_bytes(format, content));
1137 }
1138
1139 crate::mention_set::insert_images_as_context(images, editor, mention_set, cx).await;
1140 Ok(())
1141 })
1142 .detach_and_log_err(cx);
1143 }
1144
1145 pub fn set_read_only(&mut self, read_only: bool, cx: &mut Context<Self>) {
1146 self.editor.update(cx, |message_editor, cx| {
1147 message_editor.set_read_only(read_only);
1148 cx.notify()
1149 })
1150 }
1151
1152 pub fn set_mode(&mut self, mode: EditorMode, cx: &mut Context<Self>) {
1153 self.editor.update(cx, |editor, cx| {
1154 editor.set_mode(mode);
1155 cx.notify()
1156 });
1157 }
1158
1159 pub fn set_message(
1160 &mut self,
1161 message: Vec<acp::ContentBlock>,
1162 window: &mut Window,
1163 cx: &mut Context<Self>,
1164 ) {
1165 let Some(workspace) = self.workspace.upgrade() else {
1166 return;
1167 };
1168
1169 self.clear(window, cx);
1170
1171 let path_style = workspace.read(cx).project().read(cx).path_style(cx);
1172 let mut text = String::new();
1173 let mut mentions = Vec::new();
1174
1175 for chunk in message {
1176 match chunk {
1177 acp::ContentBlock::Text(text_content) => {
1178 text.push_str(&text_content.text);
1179 }
1180 acp::ContentBlock::Resource(acp::EmbeddedResource {
1181 resource: acp::EmbeddedResourceResource::TextResourceContents(resource),
1182 ..
1183 }) => {
1184 let Some(mention_uri) = MentionUri::parse(&resource.uri, path_style).log_err()
1185 else {
1186 continue;
1187 };
1188 let start = text.len();
1189 write!(&mut text, "{}", mention_uri.as_link()).ok();
1190 let end = text.len();
1191 mentions.push((
1192 start..end,
1193 mention_uri,
1194 Mention::Text {
1195 content: resource.text,
1196 tracked_buffers: Vec::new(),
1197 },
1198 ));
1199 }
1200 acp::ContentBlock::ResourceLink(resource) => {
1201 if let Some(mention_uri) =
1202 MentionUri::parse(&resource.uri, path_style).log_err()
1203 {
1204 let start = text.len();
1205 write!(&mut text, "{}", mention_uri.as_link()).ok();
1206 let end = text.len();
1207 mentions.push((start..end, mention_uri, Mention::Link));
1208 }
1209 }
1210 acp::ContentBlock::Image(acp::ImageContent {
1211 uri,
1212 data,
1213 mime_type,
1214 ..
1215 }) => {
1216 let mention_uri = if let Some(uri) = uri {
1217 MentionUri::parse(&uri, path_style)
1218 } else {
1219 Ok(MentionUri::PastedImage)
1220 };
1221 let Some(mention_uri) = mention_uri.log_err() else {
1222 continue;
1223 };
1224 let Some(format) = ImageFormat::from_mime_type(&mime_type) else {
1225 log::error!("failed to parse MIME type for image: {mime_type:?}");
1226 continue;
1227 };
1228 let start = text.len();
1229 write!(&mut text, "{}", mention_uri.as_link()).ok();
1230 let end = text.len();
1231 mentions.push((
1232 start..end,
1233 mention_uri,
1234 Mention::Image(MentionImage {
1235 data: data.into(),
1236 format,
1237 }),
1238 ));
1239 }
1240 _ => {}
1241 }
1242 }
1243
1244 let snapshot = self.editor.update(cx, |editor, cx| {
1245 editor.set_text(text, window, cx);
1246 editor.buffer().read(cx).snapshot(cx)
1247 });
1248
1249 for (range, mention_uri, mention) in mentions {
1250 let anchor = snapshot.anchor_before(MultiBufferOffset(range.start));
1251 let Some((crease_id, tx)) = insert_crease_for_mention(
1252 anchor.excerpt_id,
1253 anchor.text_anchor,
1254 range.end - range.start,
1255 mention_uri.name().into(),
1256 mention_uri.icon_path(cx),
1257 None,
1258 self.editor.clone(),
1259 window,
1260 cx,
1261 ) else {
1262 continue;
1263 };
1264 drop(tx);
1265
1266 self.mention_set.update(cx, |mention_set, _cx| {
1267 mention_set.insert_mention(
1268 crease_id,
1269 mention_uri.clone(),
1270 Task::ready(Ok(mention)).shared(),
1271 )
1272 });
1273 }
1274 cx.notify();
1275 }
1276
1277 pub fn text(&self, cx: &App) -> String {
1278 self.editor.read(cx).text(cx)
1279 }
1280
1281 pub fn set_placeholder_text(
1282 &mut self,
1283 placeholder: &str,
1284 window: &mut Window,
1285 cx: &mut Context<Self>,
1286 ) {
1287 self.editor.update(cx, |editor, cx| {
1288 editor.set_placeholder_text(placeholder, window, cx);
1289 });
1290 }
1291
1292 #[cfg(test)]
1293 pub fn set_text(&mut self, text: &str, window: &mut Window, cx: &mut Context<Self>) {
1294 self.editor.update(cx, |editor, cx| {
1295 editor.set_text(text, window, cx);
1296 });
1297 }
1298}
1299
1300impl Focusable for MessageEditor {
1301 fn focus_handle(&self, cx: &App) -> FocusHandle {
1302 self.editor.focus_handle(cx)
1303 }
1304}
1305
1306impl Render for MessageEditor {
1307 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1308 div()
1309 .key_context("MessageEditor")
1310 .on_action(cx.listener(Self::chat))
1311 .on_action(cx.listener(Self::send_immediately))
1312 .on_action(cx.listener(Self::chat_with_follow))
1313 .on_action(cx.listener(Self::cancel))
1314 .on_action(cx.listener(Self::paste_raw))
1315 .capture_action(cx.listener(Self::paste))
1316 .flex_1()
1317 .child({
1318 let settings = ThemeSettings::get_global(cx);
1319
1320 let text_style = TextStyle {
1321 color: cx.theme().colors().text,
1322 font_family: settings.buffer_font.family.clone(),
1323 font_fallbacks: settings.buffer_font.fallbacks.clone(),
1324 font_features: settings.buffer_font.features.clone(),
1325 font_size: settings.agent_buffer_font_size(cx).into(),
1326 line_height: relative(settings.buffer_line_height.value()),
1327 ..Default::default()
1328 };
1329
1330 EditorElement::new(
1331 &self.editor,
1332 EditorStyle {
1333 background: cx.theme().colors().editor_background,
1334 local_player: cx.theme().players().local(),
1335 text: text_style,
1336 syntax: cx.theme().syntax().clone(),
1337 inlay_hints_style: editor::make_inlay_hints_style(cx),
1338 ..Default::default()
1339 },
1340 )
1341 })
1342 }
1343}
1344
1345pub struct MessageEditorAddon {}
1346
1347impl MessageEditorAddon {
1348 pub fn new() -> Self {
1349 Self {}
1350 }
1351}
1352
1353impl Addon for MessageEditorAddon {
1354 fn to_any(&self) -> &dyn std::any::Any {
1355 self
1356 }
1357
1358 fn to_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
1359 Some(self)
1360 }
1361
1362 fn extend_key_context(&self, key_context: &mut KeyContext, cx: &App) {
1363 let settings = agent_settings::AgentSettings::get_global(cx);
1364 if settings.use_modifier_to_send {
1365 key_context.add("use_modifier_to_send");
1366 }
1367 }
1368}
1369
1370#[cfg(test)]
1371mod tests {
1372 use std::{cell::RefCell, ops::Range, path::Path, rc::Rc, sync::Arc};
1373
1374 use acp_thread::{AgentSessionInfo, MentionUri};
1375 use agent::{ThreadStore, outline};
1376 use agent_client_protocol as acp;
1377 use editor::{AnchorRangeExt as _, Editor, EditorMode, MultiBufferOffset};
1378
1379 use fs::FakeFs;
1380 use futures::StreamExt as _;
1381 use gpui::{
1382 AppContext, Entity, EventEmitter, FocusHandle, Focusable, TestAppContext, VisualTestContext,
1383 };
1384 use language_model::LanguageModelRegistry;
1385 use lsp::{CompletionContext, CompletionTriggerKind};
1386 use project::{CompletionIntent, Project, ProjectPath};
1387 use serde_json::json;
1388
1389 use text::Point;
1390 use ui::{App, Context, IntoElement, Render, SharedString, Window};
1391 use util::{path, paths::PathStyle, rel_path::rel_path};
1392 use workspace::{AppState, Item, Workspace};
1393
1394 use crate::acp::{
1395 message_editor::{Mention, MessageEditor},
1396 thread_view::tests::init_test,
1397 };
1398 use crate::completion_provider::{PromptCompletionProviderDelegate, PromptContextType};
1399
1400 #[gpui::test]
1401 async fn test_at_mention_removal(cx: &mut TestAppContext) {
1402 init_test(cx);
1403
1404 let fs = FakeFs::new(cx.executor());
1405 fs.insert_tree("/project", json!({"file": ""})).await;
1406 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
1407
1408 let (workspace, cx) =
1409 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
1410
1411 let thread_store = None;
1412 let history = cx
1413 .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
1414
1415 let message_editor = cx.update(|window, cx| {
1416 cx.new(|cx| {
1417 MessageEditor::new_with_cache(
1418 workspace.downgrade(),
1419 project.downgrade(),
1420 thread_store.clone(),
1421 history.downgrade(),
1422 None,
1423 Default::default(),
1424 Default::default(),
1425 Default::default(),
1426 Default::default(),
1427 "Test Agent".into(),
1428 "Test",
1429 EditorMode::AutoHeight {
1430 min_lines: 1,
1431 max_lines: None,
1432 },
1433 window,
1434 cx,
1435 )
1436 })
1437 });
1438 let editor = message_editor.update(cx, |message_editor, _| message_editor.editor.clone());
1439
1440 cx.run_until_parked();
1441
1442 let excerpt_id = editor.update(cx, |editor, cx| {
1443 editor
1444 .buffer()
1445 .read(cx)
1446 .excerpt_ids()
1447 .into_iter()
1448 .next()
1449 .unwrap()
1450 });
1451 let completions = editor.update_in(cx, |editor, window, cx| {
1452 editor.set_text("Hello @file ", window, cx);
1453 let buffer = editor.buffer().read(cx).as_singleton().unwrap();
1454 let completion_provider = editor.completion_provider().unwrap();
1455 completion_provider.completions(
1456 excerpt_id,
1457 &buffer,
1458 text::Anchor::MAX,
1459 CompletionContext {
1460 trigger_kind: CompletionTriggerKind::TRIGGER_CHARACTER,
1461 trigger_character: Some("@".into()),
1462 },
1463 window,
1464 cx,
1465 )
1466 });
1467 let [_, completion]: [_; 2] = completions
1468 .await
1469 .unwrap()
1470 .into_iter()
1471 .flat_map(|response| response.completions)
1472 .collect::<Vec<_>>()
1473 .try_into()
1474 .unwrap();
1475
1476 editor.update_in(cx, |editor, window, cx| {
1477 let snapshot = editor.buffer().read(cx).snapshot(cx);
1478 let range = snapshot
1479 .anchor_range_in_excerpt(excerpt_id, completion.replace_range)
1480 .unwrap();
1481 editor.edit([(range, completion.new_text)], cx);
1482 (completion.confirm.unwrap())(CompletionIntent::Complete, window, cx);
1483 });
1484
1485 cx.run_until_parked();
1486
1487 // Backspace over the inserted crease (and the following space).
1488 editor.update_in(cx, |editor, window, cx| {
1489 editor.backspace(&Default::default(), window, cx);
1490 editor.backspace(&Default::default(), window, cx);
1491 });
1492
1493 let (content, _) = message_editor
1494 .update(cx, |message_editor, cx| {
1495 message_editor.contents_with_cache(false, None, None, cx)
1496 })
1497 .await
1498 .unwrap();
1499
1500 // We don't send a resource link for the deleted crease.
1501 pretty_assertions::assert_matches!(content.as_slice(), [acp::ContentBlock::Text { .. }]);
1502 }
1503
1504 #[gpui::test]
1505 async fn test_slash_command_validation(cx: &mut gpui::TestAppContext) {
1506 init_test(cx);
1507 let fs = FakeFs::new(cx.executor());
1508 fs.insert_tree(
1509 "/test",
1510 json!({
1511 ".zed": {
1512 "tasks.json": r#"[{"label": "test", "command": "echo"}]"#
1513 },
1514 "src": {
1515 "main.rs": "fn main() {}",
1516 },
1517 }),
1518 )
1519 .await;
1520
1521 let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
1522 let thread_store = None;
1523 let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default()));
1524 // Start with no available commands - simulating Claude which doesn't support slash commands
1525 let available_commands = Rc::new(RefCell::new(vec![]));
1526
1527 let (workspace, cx) =
1528 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
1529 let history = cx
1530 .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
1531 let workspace_handle = workspace.downgrade();
1532 let message_editor = workspace.update_in(cx, |_, window, cx| {
1533 cx.new(|cx| {
1534 MessageEditor::new_with_cache(
1535 workspace_handle.clone(),
1536 project.downgrade(),
1537 thread_store.clone(),
1538 history.downgrade(),
1539 None,
1540 prompt_capabilities.clone(),
1541 available_commands.clone(),
1542 Default::default(),
1543 Default::default(),
1544 "Claude Code".into(),
1545 "Test",
1546 EditorMode::AutoHeight {
1547 min_lines: 1,
1548 max_lines: None,
1549 },
1550 window,
1551 cx,
1552 )
1553 })
1554 });
1555 let editor = message_editor.update(cx, |message_editor, _| message_editor.editor.clone());
1556
1557 // Test that slash commands fail when no available_commands are set (empty list means no commands supported)
1558 editor.update_in(cx, |editor, window, cx| {
1559 editor.set_text("/file test.txt", window, cx);
1560 });
1561
1562 let contents_result = message_editor
1563 .update(cx, |message_editor, cx| {
1564 message_editor.contents_with_cache(false, None, None, cx)
1565 })
1566 .await;
1567
1568 // Should fail because available_commands is empty (no commands supported)
1569 assert!(contents_result.is_err());
1570 let error_message = contents_result.unwrap_err().to_string();
1571 assert!(error_message.contains("not supported by Claude Code"));
1572 assert!(error_message.contains("Available commands: none"));
1573
1574 // Now simulate Claude providing its list of available commands (which doesn't include file)
1575 available_commands.replace(vec![acp::AvailableCommand::new("help", "Get help")]);
1576
1577 // Test that unsupported slash commands trigger an error when we have a list of available commands
1578 editor.update_in(cx, |editor, window, cx| {
1579 editor.set_text("/file test.txt", window, cx);
1580 });
1581
1582 let contents_result = message_editor
1583 .update(cx, |message_editor, cx| {
1584 message_editor.contents_with_cache(false, None, None, cx)
1585 })
1586 .await;
1587
1588 assert!(contents_result.is_err());
1589 let error_message = contents_result.unwrap_err().to_string();
1590 assert!(error_message.contains("not supported by Claude Code"));
1591 assert!(error_message.contains("/file"));
1592 assert!(error_message.contains("Available commands: /help"));
1593
1594 // Test that supported commands work fine
1595 editor.update_in(cx, |editor, window, cx| {
1596 editor.set_text("/help", window, cx);
1597 });
1598
1599 let contents_result = message_editor
1600 .update(cx, |message_editor, cx| {
1601 message_editor.contents_with_cache(false, None, None, cx)
1602 })
1603 .await;
1604
1605 // Should succeed because /help is in available_commands
1606 assert!(contents_result.is_ok());
1607
1608 // Test that regular text works fine
1609 editor.update_in(cx, |editor, window, cx| {
1610 editor.set_text("Hello Claude!", window, cx);
1611 });
1612
1613 let (content, _) = message_editor
1614 .update(cx, |message_editor, cx| {
1615 message_editor.contents_with_cache(false, None, None, cx)
1616 })
1617 .await
1618 .unwrap();
1619
1620 assert_eq!(content.len(), 1);
1621 if let acp::ContentBlock::Text(text) = &content[0] {
1622 assert_eq!(text.text, "Hello Claude!");
1623 } else {
1624 panic!("Expected ContentBlock::Text");
1625 }
1626
1627 // Test that @ mentions still work
1628 editor.update_in(cx, |editor, window, cx| {
1629 editor.set_text("Check this @", window, cx);
1630 });
1631
1632 // The @ mention functionality should not be affected
1633 let (content, _) = message_editor
1634 .update(cx, |message_editor, cx| {
1635 message_editor.contents_with_cache(false, None, None, cx)
1636 })
1637 .await
1638 .unwrap();
1639
1640 assert_eq!(content.len(), 1);
1641 if let acp::ContentBlock::Text(text) = &content[0] {
1642 assert_eq!(text.text, "Check this @");
1643 } else {
1644 panic!("Expected ContentBlock::Text");
1645 }
1646 }
1647
1648 struct MessageEditorItem(Entity<MessageEditor>);
1649
1650 impl Item for MessageEditorItem {
1651 type Event = ();
1652
1653 fn include_in_nav_history() -> bool {
1654 false
1655 }
1656
1657 fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
1658 "Test".into()
1659 }
1660 }
1661
1662 impl EventEmitter<()> for MessageEditorItem {}
1663
1664 impl Focusable for MessageEditorItem {
1665 fn focus_handle(&self, cx: &App) -> FocusHandle {
1666 self.0.read(cx).focus_handle(cx)
1667 }
1668 }
1669
1670 impl Render for MessageEditorItem {
1671 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
1672 self.0.clone().into_any_element()
1673 }
1674 }
1675
1676 #[gpui::test]
1677 async fn test_completion_provider_commands(cx: &mut TestAppContext) {
1678 init_test(cx);
1679
1680 let app_state = cx.update(AppState::test);
1681
1682 cx.update(|cx| {
1683 editor::init(cx);
1684 workspace::init(app_state.clone(), cx);
1685 });
1686
1687 let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
1688 let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
1689 let workspace = window.root(cx).unwrap();
1690
1691 let mut cx = VisualTestContext::from_window(*window, cx);
1692
1693 let thread_store = None;
1694 let history = cx
1695 .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
1696 let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default()));
1697 let available_commands = Rc::new(RefCell::new(vec![
1698 acp::AvailableCommand::new("quick-math", "2 + 2 = 4 - 1 = 3"),
1699 acp::AvailableCommand::new("say-hello", "Say hello to whoever you want").input(
1700 acp::AvailableCommandInput::Unstructured(acp::UnstructuredCommandInput::new(
1701 "<name>",
1702 )),
1703 ),
1704 ]));
1705
1706 let editor = workspace.update_in(&mut cx, |workspace, window, cx| {
1707 let workspace_handle = cx.weak_entity();
1708 let message_editor = cx.new(|cx| {
1709 MessageEditor::new_with_cache(
1710 workspace_handle,
1711 project.downgrade(),
1712 thread_store.clone(),
1713 history.downgrade(),
1714 None,
1715 prompt_capabilities.clone(),
1716 available_commands.clone(),
1717 Default::default(),
1718 Default::default(),
1719 "Test Agent".into(),
1720 "Test",
1721 EditorMode::AutoHeight {
1722 max_lines: None,
1723 min_lines: 1,
1724 },
1725 window,
1726 cx,
1727 )
1728 });
1729 workspace.active_pane().update(cx, |pane, cx| {
1730 pane.add_item(
1731 Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
1732 true,
1733 true,
1734 None,
1735 window,
1736 cx,
1737 );
1738 });
1739 message_editor.read(cx).focus_handle(cx).focus(window, cx);
1740 message_editor.read(cx).editor().clone()
1741 });
1742
1743 cx.simulate_input("/");
1744
1745 editor.update_in(&mut cx, |editor, window, cx| {
1746 assert_eq!(editor.text(cx), "/");
1747 assert!(editor.has_visible_completions_menu());
1748
1749 assert_eq!(
1750 current_completion_labels_with_documentation(editor),
1751 &[
1752 ("quick-math".into(), "2 + 2 = 4 - 1 = 3".into()),
1753 ("say-hello".into(), "Say hello to whoever you want".into())
1754 ]
1755 );
1756 editor.set_text("", window, cx);
1757 });
1758
1759 cx.simulate_input("/qui");
1760
1761 editor.update_in(&mut cx, |editor, window, cx| {
1762 assert_eq!(editor.text(cx), "/qui");
1763 assert!(editor.has_visible_completions_menu());
1764
1765 assert_eq!(
1766 current_completion_labels_with_documentation(editor),
1767 &[("quick-math".into(), "2 + 2 = 4 - 1 = 3".into())]
1768 );
1769 editor.set_text("", window, cx);
1770 });
1771
1772 editor.update_in(&mut cx, |editor, window, cx| {
1773 assert!(editor.has_visible_completions_menu());
1774 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
1775 });
1776
1777 cx.run_until_parked();
1778
1779 editor.update_in(&mut cx, |editor, window, cx| {
1780 assert_eq!(editor.display_text(cx), "/quick-math ");
1781 assert!(!editor.has_visible_completions_menu());
1782 editor.set_text("", window, cx);
1783 });
1784
1785 cx.simulate_input("/say");
1786
1787 editor.update_in(&mut cx, |editor, _window, cx| {
1788 assert_eq!(editor.display_text(cx), "/say");
1789 assert!(editor.has_visible_completions_menu());
1790
1791 assert_eq!(
1792 current_completion_labels_with_documentation(editor),
1793 &[("say-hello".into(), "Say hello to whoever you want".into())]
1794 );
1795 });
1796
1797 editor.update_in(&mut cx, |editor, window, cx| {
1798 assert!(editor.has_visible_completions_menu());
1799 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
1800 });
1801
1802 cx.run_until_parked();
1803
1804 editor.update_in(&mut cx, |editor, _window, cx| {
1805 assert_eq!(editor.text(cx), "/say-hello ");
1806 assert_eq!(editor.display_text(cx), "/say-hello <name>");
1807 assert!(!editor.has_visible_completions_menu());
1808 });
1809
1810 cx.simulate_input("GPT5");
1811
1812 cx.run_until_parked();
1813
1814 editor.update_in(&mut cx, |editor, window, cx| {
1815 assert_eq!(editor.text(cx), "/say-hello GPT5");
1816 assert_eq!(editor.display_text(cx), "/say-hello GPT5");
1817 assert!(!editor.has_visible_completions_menu());
1818
1819 // Delete argument
1820 for _ in 0..5 {
1821 editor.backspace(&editor::actions::Backspace, window, cx);
1822 }
1823 });
1824
1825 cx.run_until_parked();
1826
1827 editor.update_in(&mut cx, |editor, window, cx| {
1828 assert_eq!(editor.text(cx), "/say-hello");
1829 // Hint is visible because argument was deleted
1830 assert_eq!(editor.display_text(cx), "/say-hello <name>");
1831
1832 // Delete last command letter
1833 editor.backspace(&editor::actions::Backspace, window, cx);
1834 });
1835
1836 cx.run_until_parked();
1837
1838 editor.update_in(&mut cx, |editor, _window, cx| {
1839 // Hint goes away once command no longer matches an available one
1840 assert_eq!(editor.text(cx), "/say-hell");
1841 assert_eq!(editor.display_text(cx), "/say-hell");
1842 assert!(!editor.has_visible_completions_menu());
1843 });
1844 }
1845
1846 #[gpui::test]
1847 async fn test_context_completion_provider_mentions(cx: &mut TestAppContext) {
1848 init_test(cx);
1849
1850 let app_state = cx.update(AppState::test);
1851
1852 cx.update(|cx| {
1853 editor::init(cx);
1854 workspace::init(app_state.clone(), cx);
1855 });
1856
1857 app_state
1858 .fs
1859 .as_fake()
1860 .insert_tree(
1861 path!("/dir"),
1862 json!({
1863 "editor": "",
1864 "a": {
1865 "one.txt": "1",
1866 "two.txt": "2",
1867 "three.txt": "3",
1868 "four.txt": "4"
1869 },
1870 "b": {
1871 "five.txt": "5",
1872 "six.txt": "6",
1873 "seven.txt": "7",
1874 "eight.txt": "8",
1875 },
1876 "x.png": "",
1877 }),
1878 )
1879 .await;
1880
1881 let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
1882 let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
1883 let workspace = window.root(cx).unwrap();
1884
1885 let worktree = project.update(cx, |project, cx| {
1886 let mut worktrees = project.worktrees(cx).collect::<Vec<_>>();
1887 assert_eq!(worktrees.len(), 1);
1888 worktrees.pop().unwrap()
1889 });
1890 let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id());
1891
1892 let mut cx = VisualTestContext::from_window(*window, cx);
1893
1894 let paths = vec![
1895 rel_path("a/one.txt"),
1896 rel_path("a/two.txt"),
1897 rel_path("a/three.txt"),
1898 rel_path("a/four.txt"),
1899 rel_path("b/five.txt"),
1900 rel_path("b/six.txt"),
1901 rel_path("b/seven.txt"),
1902 rel_path("b/eight.txt"),
1903 ];
1904
1905 let slash = PathStyle::local().primary_separator();
1906
1907 let mut opened_editors = Vec::new();
1908 for path in paths {
1909 let buffer = workspace
1910 .update_in(&mut cx, |workspace, window, cx| {
1911 workspace.open_path(
1912 ProjectPath {
1913 worktree_id,
1914 path: path.into(),
1915 },
1916 None,
1917 false,
1918 window,
1919 cx,
1920 )
1921 })
1922 .await
1923 .unwrap();
1924 opened_editors.push(buffer);
1925 }
1926
1927 let thread_store = cx.new(|cx| ThreadStore::new(cx));
1928 let history = cx
1929 .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
1930 let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default()));
1931
1932 let (message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| {
1933 let workspace_handle = cx.weak_entity();
1934 let message_editor = cx.new(|cx| {
1935 MessageEditor::new_with_cache(
1936 workspace_handle,
1937 project.downgrade(),
1938 Some(thread_store),
1939 history.downgrade(),
1940 None,
1941 prompt_capabilities.clone(),
1942 Default::default(),
1943 Default::default(),
1944 Default::default(),
1945 "Test Agent".into(),
1946 "Test",
1947 EditorMode::AutoHeight {
1948 max_lines: None,
1949 min_lines: 1,
1950 },
1951 window,
1952 cx,
1953 )
1954 });
1955 workspace.active_pane().update(cx, |pane, cx| {
1956 pane.add_item(
1957 Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
1958 true,
1959 true,
1960 None,
1961 window,
1962 cx,
1963 );
1964 });
1965 message_editor.read(cx).focus_handle(cx).focus(window, cx);
1966 let editor = message_editor.read(cx).editor().clone();
1967 (message_editor, editor)
1968 });
1969
1970 cx.simulate_input("Lorem @");
1971
1972 editor.update_in(&mut cx, |editor, window, cx| {
1973 assert_eq!(editor.text(cx), "Lorem @");
1974 assert!(editor.has_visible_completions_menu());
1975
1976 assert_eq!(
1977 current_completion_labels(editor),
1978 &[
1979 format!("eight.txt b{slash}"),
1980 format!("seven.txt b{slash}"),
1981 format!("six.txt b{slash}"),
1982 format!("five.txt b{slash}"),
1983 "Files & Directories".into(),
1984 "Symbols".into()
1985 ]
1986 );
1987 editor.set_text("", window, cx);
1988 });
1989
1990 prompt_capabilities.replace(
1991 acp::PromptCapabilities::new()
1992 .image(true)
1993 .audio(true)
1994 .embedded_context(true),
1995 );
1996
1997 cx.simulate_input("Lorem ");
1998
1999 editor.update(&mut cx, |editor, cx| {
2000 assert_eq!(editor.text(cx), "Lorem ");
2001 assert!(!editor.has_visible_completions_menu());
2002 });
2003
2004 cx.simulate_input("@");
2005
2006 editor.update(&mut cx, |editor, cx| {
2007 assert_eq!(editor.text(cx), "Lorem @");
2008 assert!(editor.has_visible_completions_menu());
2009 assert_eq!(
2010 current_completion_labels(editor),
2011 &[
2012 format!("eight.txt b{slash}"),
2013 format!("seven.txt b{slash}"),
2014 format!("six.txt b{slash}"),
2015 format!("five.txt b{slash}"),
2016 "Files & Directories".into(),
2017 "Symbols".into(),
2018 "Threads".into(),
2019 "Fetch".into()
2020 ]
2021 );
2022 });
2023
2024 // Select and confirm "File"
2025 editor.update_in(&mut cx, |editor, window, cx| {
2026 assert!(editor.has_visible_completions_menu());
2027 editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
2028 editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
2029 editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
2030 editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
2031 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2032 });
2033
2034 cx.run_until_parked();
2035
2036 editor.update(&mut cx, |editor, cx| {
2037 assert_eq!(editor.text(cx), "Lorem @file ");
2038 assert!(editor.has_visible_completions_menu());
2039 });
2040
2041 cx.simulate_input("one");
2042
2043 editor.update(&mut cx, |editor, cx| {
2044 assert_eq!(editor.text(cx), "Lorem @file one");
2045 assert!(editor.has_visible_completions_menu());
2046 assert_eq!(
2047 current_completion_labels(editor),
2048 vec![format!("one.txt a{slash}")]
2049 );
2050 });
2051
2052 editor.update_in(&mut cx, |editor, window, cx| {
2053 assert!(editor.has_visible_completions_menu());
2054 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2055 });
2056
2057 let url_one = MentionUri::File {
2058 abs_path: path!("/dir/a/one.txt").into(),
2059 }
2060 .to_uri()
2061 .to_string();
2062 editor.update(&mut cx, |editor, cx| {
2063 let text = editor.text(cx);
2064 assert_eq!(text, format!("Lorem [@one.txt]({url_one}) "));
2065 assert!(!editor.has_visible_completions_menu());
2066 assert_eq!(fold_ranges(editor, cx).len(), 1);
2067 });
2068
2069 let contents = message_editor
2070 .update(&mut cx, |message_editor, cx| {
2071 message_editor
2072 .mention_set()
2073 .update(cx, |mention_set, cx| mention_set.contents(false, cx))
2074 })
2075 .await
2076 .unwrap()
2077 .into_values()
2078 .collect::<Vec<_>>();
2079
2080 {
2081 let [(uri, Mention::Text { content, .. })] = contents.as_slice() else {
2082 panic!("Unexpected mentions");
2083 };
2084 pretty_assertions::assert_eq!(content, "1");
2085 pretty_assertions::assert_eq!(
2086 uri,
2087 &MentionUri::parse(&url_one, PathStyle::local()).unwrap()
2088 );
2089 }
2090
2091 cx.simulate_input(" ");
2092
2093 editor.update(&mut cx, |editor, cx| {
2094 let text = editor.text(cx);
2095 assert_eq!(text, format!("Lorem [@one.txt]({url_one}) "));
2096 assert!(!editor.has_visible_completions_menu());
2097 assert_eq!(fold_ranges(editor, cx).len(), 1);
2098 });
2099
2100 cx.simulate_input("Ipsum ");
2101
2102 editor.update(&mut cx, |editor, cx| {
2103 let text = editor.text(cx);
2104 assert_eq!(text, format!("Lorem [@one.txt]({url_one}) Ipsum "),);
2105 assert!(!editor.has_visible_completions_menu());
2106 assert_eq!(fold_ranges(editor, cx).len(), 1);
2107 });
2108
2109 cx.simulate_input("@file ");
2110
2111 editor.update(&mut cx, |editor, cx| {
2112 let text = editor.text(cx);
2113 assert_eq!(text, format!("Lorem [@one.txt]({url_one}) Ipsum @file "),);
2114 assert!(editor.has_visible_completions_menu());
2115 assert_eq!(fold_ranges(editor, cx).len(), 1);
2116 });
2117
2118 editor.update_in(&mut cx, |editor, window, cx| {
2119 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2120 });
2121
2122 cx.run_until_parked();
2123
2124 let contents = message_editor
2125 .update(&mut cx, |message_editor, cx| {
2126 message_editor
2127 .mention_set()
2128 .update(cx, |mention_set, cx| mention_set.contents(false, cx))
2129 })
2130 .await
2131 .unwrap()
2132 .into_values()
2133 .collect::<Vec<_>>();
2134
2135 let url_eight = MentionUri::File {
2136 abs_path: path!("/dir/b/eight.txt").into(),
2137 }
2138 .to_uri()
2139 .to_string();
2140
2141 {
2142 let [_, (uri, Mention::Text { content, .. })] = contents.as_slice() else {
2143 panic!("Unexpected mentions");
2144 };
2145 pretty_assertions::assert_eq!(content, "8");
2146 pretty_assertions::assert_eq!(
2147 uri,
2148 &MentionUri::parse(&url_eight, PathStyle::local()).unwrap()
2149 );
2150 }
2151
2152 editor.update(&mut cx, |editor, cx| {
2153 assert_eq!(
2154 editor.text(cx),
2155 format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) ")
2156 );
2157 assert!(!editor.has_visible_completions_menu());
2158 assert_eq!(fold_ranges(editor, cx).len(), 2);
2159 });
2160
2161 let plain_text_language = Arc::new(language::Language::new(
2162 language::LanguageConfig {
2163 name: "Plain Text".into(),
2164 matcher: language::LanguageMatcher {
2165 path_suffixes: vec!["txt".to_string()],
2166 ..Default::default()
2167 },
2168 ..Default::default()
2169 },
2170 None,
2171 ));
2172
2173 // Register the language and fake LSP
2174 let language_registry = project.read_with(&cx, |project, _| project.languages().clone());
2175 language_registry.add(plain_text_language);
2176
2177 let mut fake_language_servers = language_registry.register_fake_lsp(
2178 "Plain Text",
2179 language::FakeLspAdapter {
2180 capabilities: lsp::ServerCapabilities {
2181 workspace_symbol_provider: Some(lsp::OneOf::Left(true)),
2182 ..Default::default()
2183 },
2184 ..Default::default()
2185 },
2186 );
2187
2188 // Open the buffer to trigger LSP initialization
2189 let buffer = project
2190 .update(&mut cx, |project, cx| {
2191 project.open_local_buffer(path!("/dir/a/one.txt"), cx)
2192 })
2193 .await
2194 .unwrap();
2195
2196 // Register the buffer with language servers
2197 let _handle = project.update(&mut cx, |project, cx| {
2198 project.register_buffer_with_language_servers(&buffer, cx)
2199 });
2200
2201 cx.run_until_parked();
2202
2203 let fake_language_server = fake_language_servers.next().await.unwrap();
2204 fake_language_server.set_request_handler::<lsp::WorkspaceSymbolRequest, _, _>(
2205 move |_, _| async move {
2206 Ok(Some(lsp::WorkspaceSymbolResponse::Flat(vec![
2207 #[allow(deprecated)]
2208 lsp::SymbolInformation {
2209 name: "MySymbol".into(),
2210 location: lsp::Location {
2211 uri: lsp::Uri::from_file_path(path!("/dir/a/one.txt")).unwrap(),
2212 range: lsp::Range::new(
2213 lsp::Position::new(0, 0),
2214 lsp::Position::new(0, 1),
2215 ),
2216 },
2217 kind: lsp::SymbolKind::CONSTANT,
2218 tags: None,
2219 container_name: None,
2220 deprecated: None,
2221 },
2222 ])))
2223 },
2224 );
2225
2226 cx.simulate_input("@symbol ");
2227
2228 editor.update(&mut cx, |editor, cx| {
2229 assert_eq!(
2230 editor.text(cx),
2231 format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) @symbol ")
2232 );
2233 assert!(editor.has_visible_completions_menu());
2234 assert_eq!(current_completion_labels(editor), &["MySymbol one.txt L1"]);
2235 });
2236
2237 editor.update_in(&mut cx, |editor, window, cx| {
2238 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2239 });
2240
2241 let symbol = MentionUri::Symbol {
2242 abs_path: path!("/dir/a/one.txt").into(),
2243 name: "MySymbol".into(),
2244 line_range: 0..=0,
2245 };
2246
2247 let contents = message_editor
2248 .update(&mut cx, |message_editor, cx| {
2249 message_editor
2250 .mention_set()
2251 .update(cx, |mention_set, cx| mention_set.contents(false, cx))
2252 })
2253 .await
2254 .unwrap()
2255 .into_values()
2256 .collect::<Vec<_>>();
2257
2258 {
2259 let [_, _, (uri, Mention::Text { content, .. })] = contents.as_slice() else {
2260 panic!("Unexpected mentions");
2261 };
2262 pretty_assertions::assert_eq!(content, "1");
2263 pretty_assertions::assert_eq!(uri, &symbol);
2264 }
2265
2266 cx.run_until_parked();
2267
2268 editor.read_with(&cx, |editor, cx| {
2269 assert_eq!(
2270 editor.text(cx),
2271 format!(
2272 "Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) ",
2273 symbol.to_uri(),
2274 )
2275 );
2276 });
2277
2278 // Try to mention an "image" file that will fail to load
2279 cx.simulate_input("@file x.png");
2280
2281 editor.update(&mut cx, |editor, cx| {
2282 assert_eq!(
2283 editor.text(cx),
2284 format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) @file x.png", symbol.to_uri())
2285 );
2286 assert!(editor.has_visible_completions_menu());
2287 assert_eq!(current_completion_labels(editor), &["x.png "]);
2288 });
2289
2290 editor.update_in(&mut cx, |editor, window, cx| {
2291 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2292 });
2293
2294 // Getting the message contents fails
2295 message_editor
2296 .update(&mut cx, |message_editor, cx| {
2297 message_editor
2298 .mention_set()
2299 .update(cx, |mention_set, cx| mention_set.contents(false, cx))
2300 })
2301 .await
2302 .expect_err("Should fail to load x.png");
2303
2304 cx.run_until_parked();
2305
2306 // Mention was removed
2307 editor.read_with(&cx, |editor, cx| {
2308 assert_eq!(
2309 editor.text(cx),
2310 format!(
2311 "Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) ",
2312 symbol.to_uri()
2313 )
2314 );
2315 });
2316
2317 // Once more
2318 cx.simulate_input("@file x.png");
2319
2320 editor.update(&mut cx, |editor, cx| {
2321 assert_eq!(
2322 editor.text(cx),
2323 format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) @file x.png", symbol.to_uri())
2324 );
2325 assert!(editor.has_visible_completions_menu());
2326 assert_eq!(current_completion_labels(editor), &["x.png "]);
2327 });
2328
2329 editor.update_in(&mut cx, |editor, window, cx| {
2330 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2331 });
2332
2333 // This time don't immediately get the contents, just let the confirmed completion settle
2334 cx.run_until_parked();
2335
2336 // Mention was removed
2337 editor.read_with(&cx, |editor, cx| {
2338 assert_eq!(
2339 editor.text(cx),
2340 format!(
2341 "Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) ",
2342 symbol.to_uri()
2343 )
2344 );
2345 });
2346
2347 // Now getting the contents succeeds, because the invalid mention was removed
2348 let contents = message_editor
2349 .update(&mut cx, |message_editor, cx| {
2350 message_editor
2351 .mention_set()
2352 .update(cx, |mention_set, cx| mention_set.contents(false, cx))
2353 })
2354 .await
2355 .unwrap();
2356 assert_eq!(contents.len(), 3);
2357 }
2358
2359 fn fold_ranges(editor: &Editor, cx: &mut App) -> Vec<Range<Point>> {
2360 let snapshot = editor.buffer().read(cx).snapshot(cx);
2361 editor.display_map.update(cx, |display_map, cx| {
2362 display_map
2363 .snapshot(cx)
2364 .folds_in_range(MultiBufferOffset(0)..snapshot.len())
2365 .map(|fold| fold.range.to_point(&snapshot))
2366 .collect()
2367 })
2368 }
2369
2370 fn current_completion_labels(editor: &Editor) -> Vec<String> {
2371 let completions = editor.current_completions().expect("Missing completions");
2372 completions
2373 .into_iter()
2374 .map(|completion| completion.label.text)
2375 .collect::<Vec<_>>()
2376 }
2377
2378 fn current_completion_labels_with_documentation(editor: &Editor) -> Vec<(String, String)> {
2379 let completions = editor.current_completions().expect("Missing completions");
2380 completions
2381 .into_iter()
2382 .map(|completion| {
2383 (
2384 completion.label.text,
2385 completion
2386 .documentation
2387 .map(|d| d.text().to_string())
2388 .unwrap_or_default(),
2389 )
2390 })
2391 .collect::<Vec<_>>()
2392 }
2393
2394 #[gpui::test]
2395 async fn test_large_file_mention_fallback(cx: &mut TestAppContext) {
2396 init_test(cx);
2397
2398 let fs = FakeFs::new(cx.executor());
2399
2400 // Create a large file that exceeds AUTO_OUTLINE_SIZE
2401 // Using plain text without a configured language, so no outline is available
2402 const LINE: &str = "This is a line of text in the file\n";
2403 let large_content = LINE.repeat(2 * (outline::AUTO_OUTLINE_SIZE / LINE.len()));
2404 assert!(large_content.len() > outline::AUTO_OUTLINE_SIZE);
2405
2406 // Create a small file that doesn't exceed AUTO_OUTLINE_SIZE
2407 let small_content = "fn small_function() { /* small */ }\n";
2408 assert!(small_content.len() < outline::AUTO_OUTLINE_SIZE);
2409
2410 fs.insert_tree(
2411 "/project",
2412 json!({
2413 "large_file.txt": large_content.clone(),
2414 "small_file.txt": small_content,
2415 }),
2416 )
2417 .await;
2418
2419 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
2420
2421 let (workspace, cx) =
2422 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
2423
2424 let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
2425 let history = cx
2426 .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
2427
2428 let message_editor = cx.update(|window, cx| {
2429 cx.new(|cx| {
2430 let editor = MessageEditor::new_with_cache(
2431 workspace.downgrade(),
2432 project.downgrade(),
2433 thread_store.clone(),
2434 history.downgrade(),
2435 None,
2436 Default::default(),
2437 Default::default(),
2438 Default::default(),
2439 Default::default(),
2440 "Test Agent".into(),
2441 "Test",
2442 EditorMode::AutoHeight {
2443 min_lines: 1,
2444 max_lines: None,
2445 },
2446 window,
2447 cx,
2448 );
2449 // Enable embedded context so files are actually included
2450 editor
2451 .prompt_capabilities
2452 .replace(acp::PromptCapabilities::new().embedded_context(true));
2453 editor
2454 })
2455 });
2456
2457 // Test large file mention
2458 // Get the absolute path using the project's worktree
2459 let large_file_abs_path = project.read_with(cx, |project, cx| {
2460 let worktree = project.worktrees(cx).next().unwrap();
2461 let worktree_root = worktree.read(cx).abs_path();
2462 worktree_root.join("large_file.txt")
2463 });
2464 let large_file_task = message_editor.update(cx, |editor, cx| {
2465 editor.mention_set().update(cx, |set, cx| {
2466 set.confirm_mention_for_file(large_file_abs_path, true, cx)
2467 })
2468 });
2469
2470 let large_file_mention = large_file_task.await.unwrap();
2471 match large_file_mention {
2472 Mention::Text { content, .. } => {
2473 // Should contain some of the content but not all of it
2474 assert!(
2475 content.contains(LINE),
2476 "Should contain some of the file content"
2477 );
2478 assert!(
2479 !content.contains(&LINE.repeat(100)),
2480 "Should not contain the full file"
2481 );
2482 // Should be much smaller than original
2483 assert!(
2484 content.len() < large_content.len() / 10,
2485 "Should be significantly truncated"
2486 );
2487 }
2488 _ => panic!("Expected Text mention for large file"),
2489 }
2490
2491 // Test small file mention
2492 // Get the absolute path using the project's worktree
2493 let small_file_abs_path = project.read_with(cx, |project, cx| {
2494 let worktree = project.worktrees(cx).next().unwrap();
2495 let worktree_root = worktree.read(cx).abs_path();
2496 worktree_root.join("small_file.txt")
2497 });
2498 let small_file_task = message_editor.update(cx, |editor, cx| {
2499 editor.mention_set().update(cx, |set, cx| {
2500 set.confirm_mention_for_file(small_file_abs_path, true, cx)
2501 })
2502 });
2503
2504 let small_file_mention = small_file_task.await.unwrap();
2505 match small_file_mention {
2506 Mention::Text { content, .. } => {
2507 // Should contain the full actual content
2508 assert_eq!(content, small_content);
2509 }
2510 _ => panic!("Expected Text mention for small file"),
2511 }
2512 }
2513
2514 #[gpui::test]
2515 async fn test_insert_thread_summary(cx: &mut TestAppContext) {
2516 init_test(cx);
2517 cx.update(LanguageModelRegistry::test);
2518
2519 let fs = FakeFs::new(cx.executor());
2520 fs.insert_tree("/project", json!({"file": ""})).await;
2521 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
2522
2523 let (workspace, cx) =
2524 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
2525
2526 let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
2527 let history = cx
2528 .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
2529
2530 // Create a thread metadata to insert as summary
2531 let thread_metadata = AgentSessionInfo {
2532 session_id: acp::SessionId::new("thread-123"),
2533 cwd: None,
2534 title: Some("Previous Conversation".into()),
2535 updated_at: Some(chrono::Utc::now()),
2536 meta: None,
2537 };
2538
2539 let message_editor = cx.update(|window, cx| {
2540 cx.new(|cx| {
2541 let mut editor = MessageEditor::new_with_cache(
2542 workspace.downgrade(),
2543 project.downgrade(),
2544 thread_store.clone(),
2545 history.downgrade(),
2546 None,
2547 Default::default(),
2548 Default::default(),
2549 Default::default(),
2550 Default::default(),
2551 "Test Agent".into(),
2552 "Test",
2553 EditorMode::AutoHeight {
2554 min_lines: 1,
2555 max_lines: None,
2556 },
2557 window,
2558 cx,
2559 );
2560 editor.insert_thread_summary(thread_metadata.clone(), window, cx);
2561 editor
2562 })
2563 });
2564
2565 // Construct expected values for verification
2566 let expected_uri = MentionUri::Thread {
2567 id: thread_metadata.session_id.clone(),
2568 name: thread_metadata.title.as_ref().unwrap().to_string(),
2569 };
2570 let expected_title = thread_metadata.title.as_ref().unwrap();
2571 let expected_link = format!("[@{}]({})", expected_title, expected_uri.to_uri());
2572
2573 message_editor.read_with(cx, |editor, cx| {
2574 let text = editor.text(cx);
2575
2576 assert!(
2577 text.contains(&expected_link),
2578 "Expected editor text to contain thread mention link.\nExpected substring: {}\nActual text: {}",
2579 expected_link,
2580 text
2581 );
2582
2583 let mentions = editor.mention_set().read(cx).mentions();
2584 assert_eq!(
2585 mentions.len(),
2586 1,
2587 "Expected exactly one mention after inserting thread summary"
2588 );
2589
2590 assert!(
2591 mentions.contains(&expected_uri),
2592 "Expected mentions to contain the thread URI"
2593 );
2594 });
2595 }
2596
2597 #[gpui::test]
2598 async fn test_insert_thread_summary_skipped_for_external_agents(cx: &mut TestAppContext) {
2599 init_test(cx);
2600 cx.update(LanguageModelRegistry::test);
2601
2602 let fs = FakeFs::new(cx.executor());
2603 fs.insert_tree("/project", json!({"file": ""})).await;
2604 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
2605
2606 let (workspace, cx) =
2607 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
2608
2609 let thread_store = None;
2610 let history = cx
2611 .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
2612
2613 let thread_metadata = AgentSessionInfo {
2614 session_id: acp::SessionId::new("thread-123"),
2615 cwd: None,
2616 title: Some("Previous Conversation".into()),
2617 updated_at: Some(chrono::Utc::now()),
2618 meta: None,
2619 };
2620
2621 let message_editor = cx.update(|window, cx| {
2622 cx.new(|cx| {
2623 let mut editor = MessageEditor::new_with_cache(
2624 workspace.downgrade(),
2625 project.downgrade(),
2626 thread_store.clone(),
2627 history.downgrade(),
2628 None,
2629 Default::default(),
2630 Default::default(),
2631 Default::default(),
2632 Default::default(),
2633 "Test Agent".into(),
2634 "Test",
2635 EditorMode::AutoHeight {
2636 min_lines: 1,
2637 max_lines: None,
2638 },
2639 window,
2640 cx,
2641 );
2642 editor.insert_thread_summary(thread_metadata, window, cx);
2643 editor
2644 })
2645 });
2646
2647 message_editor.read_with(cx, |editor, cx| {
2648 assert!(
2649 editor.text(cx).is_empty(),
2650 "Expected thread summary to be skipped for external agents"
2651 );
2652 assert!(
2653 editor.mention_set().read(cx).mentions().is_empty(),
2654 "Expected no mentions when thread summary is skipped"
2655 );
2656 });
2657 }
2658
2659 #[gpui::test]
2660 async fn test_thread_mode_hidden_when_disabled(cx: &mut TestAppContext) {
2661 init_test(cx);
2662
2663 let fs = FakeFs::new(cx.executor());
2664 fs.insert_tree("/project", json!({"file": ""})).await;
2665 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
2666
2667 let (workspace, cx) =
2668 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
2669
2670 let thread_store = None;
2671 let history = cx
2672 .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
2673
2674 let message_editor = cx.update(|window, cx| {
2675 cx.new(|cx| {
2676 MessageEditor::new_with_cache(
2677 workspace.downgrade(),
2678 project.downgrade(),
2679 thread_store.clone(),
2680 history.downgrade(),
2681 None,
2682 Default::default(),
2683 Default::default(),
2684 Default::default(),
2685 Default::default(),
2686 "Test Agent".into(),
2687 "Test",
2688 EditorMode::AutoHeight {
2689 min_lines: 1,
2690 max_lines: None,
2691 },
2692 window,
2693 cx,
2694 )
2695 })
2696 });
2697
2698 message_editor.update(cx, |editor, _cx| {
2699 editor
2700 .prompt_capabilities
2701 .replace(acp::PromptCapabilities::new().embedded_context(true));
2702 });
2703
2704 let supported_modes = {
2705 let app = cx.app.borrow();
2706 message_editor.supported_modes(&app)
2707 };
2708
2709 assert!(
2710 !supported_modes.contains(&PromptContextType::Thread),
2711 "Expected thread mode to be hidden when thread mentions are disabled"
2712 );
2713 }
2714
2715 #[gpui::test]
2716 async fn test_thread_mode_visible_when_enabled(cx: &mut TestAppContext) {
2717 init_test(cx);
2718
2719 let fs = FakeFs::new(cx.executor());
2720 fs.insert_tree("/project", json!({"file": ""})).await;
2721 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
2722
2723 let (workspace, cx) =
2724 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
2725
2726 let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
2727 let history = cx
2728 .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
2729
2730 let message_editor = cx.update(|window, cx| {
2731 cx.new(|cx| {
2732 MessageEditor::new_with_cache(
2733 workspace.downgrade(),
2734 project.downgrade(),
2735 thread_store.clone(),
2736 history.downgrade(),
2737 None,
2738 Default::default(),
2739 Default::default(),
2740 Default::default(),
2741 Default::default(),
2742 "Test Agent".into(),
2743 "Test",
2744 EditorMode::AutoHeight {
2745 min_lines: 1,
2746 max_lines: None,
2747 },
2748 window,
2749 cx,
2750 )
2751 })
2752 });
2753
2754 message_editor.update(cx, |editor, _cx| {
2755 editor
2756 .prompt_capabilities
2757 .replace(acp::PromptCapabilities::new().embedded_context(true));
2758 });
2759
2760 let supported_modes = {
2761 let app = cx.app.borrow();
2762 message_editor.supported_modes(&app)
2763 };
2764
2765 assert!(
2766 supported_modes.contains(&PromptContextType::Thread),
2767 "Expected thread mode to be visible when enabled"
2768 );
2769 }
2770
2771 #[gpui::test]
2772 async fn test_whitespace_trimming(cx: &mut TestAppContext) {
2773 init_test(cx);
2774
2775 let fs = FakeFs::new(cx.executor());
2776 fs.insert_tree("/project", json!({"file.rs": "fn main() {}"}))
2777 .await;
2778 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
2779
2780 let (workspace, cx) =
2781 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
2782
2783 let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
2784 let history = cx
2785 .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
2786
2787 let message_editor = cx.update(|window, cx| {
2788 cx.new(|cx| {
2789 MessageEditor::new_with_cache(
2790 workspace.downgrade(),
2791 project.downgrade(),
2792 thread_store.clone(),
2793 history.downgrade(),
2794 None,
2795 Default::default(),
2796 Default::default(),
2797 Default::default(),
2798 Default::default(),
2799 "Test Agent".into(),
2800 "Test",
2801 EditorMode::AutoHeight {
2802 min_lines: 1,
2803 max_lines: None,
2804 },
2805 window,
2806 cx,
2807 )
2808 })
2809 });
2810 let editor = message_editor.update(cx, |message_editor, _| message_editor.editor.clone());
2811
2812 cx.run_until_parked();
2813
2814 editor.update_in(cx, |editor, window, cx| {
2815 editor.set_text(" \u{A0}してhello world ", window, cx);
2816 });
2817
2818 let (content, _) = message_editor
2819 .update(cx, |message_editor, cx| {
2820 message_editor.contents_with_cache(false, None, None, cx)
2821 })
2822 .await
2823 .unwrap();
2824
2825 assert_eq!(content, vec!["してhello world".into()]);
2826 }
2827
2828 #[gpui::test]
2829 async fn test_editor_respects_embedded_context_capability(cx: &mut TestAppContext) {
2830 init_test(cx);
2831
2832 let fs = FakeFs::new(cx.executor());
2833
2834 let file_content = "fn main() { println!(\"Hello, world!\"); }\n";
2835
2836 fs.insert_tree(
2837 "/project",
2838 json!({
2839 "src": {
2840 "main.rs": file_content,
2841 }
2842 }),
2843 )
2844 .await;
2845
2846 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
2847
2848 let (workspace, cx) =
2849 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
2850
2851 let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
2852 let history = cx
2853 .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
2854
2855 let (message_editor, editor) = workspace.update_in(cx, |workspace, window, cx| {
2856 let workspace_handle = cx.weak_entity();
2857 let message_editor = cx.new(|cx| {
2858 MessageEditor::new_with_cache(
2859 workspace_handle,
2860 project.downgrade(),
2861 thread_store.clone(),
2862 history.downgrade(),
2863 None,
2864 Default::default(),
2865 Default::default(),
2866 Default::default(),
2867 Default::default(),
2868 "Test Agent".into(),
2869 "Test",
2870 EditorMode::AutoHeight {
2871 max_lines: None,
2872 min_lines: 1,
2873 },
2874 window,
2875 cx,
2876 )
2877 });
2878 workspace.active_pane().update(cx, |pane, cx| {
2879 pane.add_item(
2880 Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
2881 true,
2882 true,
2883 None,
2884 window,
2885 cx,
2886 );
2887 });
2888 message_editor.read(cx).focus_handle(cx).focus(window, cx);
2889 let editor = message_editor.read(cx).editor().clone();
2890 (message_editor, editor)
2891 });
2892
2893 cx.simulate_input("What is in @file main");
2894
2895 editor.update_in(cx, |editor, window, cx| {
2896 assert!(editor.has_visible_completions_menu());
2897 assert_eq!(editor.text(cx), "What is in @file main");
2898 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2899 });
2900
2901 let content = message_editor
2902 .update(cx, |editor, cx| {
2903 editor.contents_with_cache(false, None, None, cx)
2904 })
2905 .await
2906 .unwrap()
2907 .0;
2908
2909 let main_rs_uri = if cfg!(windows) {
2910 "file:///C:/project/src/main.rs"
2911 } else {
2912 "file:///project/src/main.rs"
2913 };
2914
2915 // When embedded context is `false` we should get a resource link
2916 pretty_assertions::assert_eq!(
2917 content,
2918 vec![
2919 "What is in ".into(),
2920 acp::ContentBlock::ResourceLink(acp::ResourceLink::new("main.rs", main_rs_uri))
2921 ]
2922 );
2923
2924 message_editor.update(cx, |editor, _cx| {
2925 editor
2926 .prompt_capabilities
2927 .replace(acp::PromptCapabilities::new().embedded_context(true))
2928 });
2929
2930 let content = message_editor
2931 .update(cx, |editor, cx| {
2932 editor.contents_with_cache(false, None, None, cx)
2933 })
2934 .await
2935 .unwrap()
2936 .0;
2937
2938 // When embedded context is `true` we should get a resource
2939 pretty_assertions::assert_eq!(
2940 content,
2941 vec![
2942 "What is in ".into(),
2943 acp::ContentBlock::Resource(acp::EmbeddedResource::new(
2944 acp::EmbeddedResourceResource::TextResourceContents(
2945 acp::TextResourceContents::new(file_content, main_rs_uri)
2946 )
2947 ))
2948 ]
2949 );
2950 }
2951
2952 #[gpui::test]
2953 async fn test_autoscroll_after_insert_selections(cx: &mut TestAppContext) {
2954 init_test(cx);
2955
2956 let app_state = cx.update(AppState::test);
2957
2958 cx.update(|cx| {
2959 editor::init(cx);
2960 workspace::init(app_state.clone(), cx);
2961 });
2962
2963 app_state
2964 .fs
2965 .as_fake()
2966 .insert_tree(
2967 path!("/dir"),
2968 json!({
2969 "test.txt": "line1\nline2\nline3\nline4\nline5\n",
2970 }),
2971 )
2972 .await;
2973
2974 let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
2975 let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
2976 let workspace = window.root(cx).unwrap();
2977
2978 let worktree = project.update(cx, |project, cx| {
2979 let mut worktrees = project.worktrees(cx).collect::<Vec<_>>();
2980 assert_eq!(worktrees.len(), 1);
2981 worktrees.pop().unwrap()
2982 });
2983 let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id());
2984
2985 let mut cx = VisualTestContext::from_window(*window, cx);
2986
2987 // Open a regular editor with the created file, and select a portion of
2988 // the text that will be used for the selections that are meant to be
2989 // inserted in the agent panel.
2990 let editor = workspace
2991 .update_in(&mut cx, |workspace, window, cx| {
2992 workspace.open_path(
2993 ProjectPath {
2994 worktree_id,
2995 path: rel_path("test.txt").into(),
2996 },
2997 None,
2998 false,
2999 window,
3000 cx,
3001 )
3002 })
3003 .await
3004 .unwrap()
3005 .downcast::<Editor>()
3006 .unwrap();
3007
3008 editor.update_in(&mut cx, |editor, window, cx| {
3009 editor.change_selections(Default::default(), window, cx, |selections| {
3010 selections.select_ranges([Point::new(0, 0)..Point::new(0, 5)]);
3011 });
3012 });
3013
3014 let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
3015 let history = cx
3016 .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
3017
3018 // Create a new `MessageEditor`. The `EditorMode::full()` has to be used
3019 // to ensure we have a fixed viewport, so we can eventually actually
3020 // place the cursor outside of the visible area.
3021 let message_editor = workspace.update_in(&mut cx, |workspace, window, cx| {
3022 let workspace_handle = cx.weak_entity();
3023 let message_editor = cx.new(|cx| {
3024 MessageEditor::new_with_cache(
3025 workspace_handle,
3026 project.downgrade(),
3027 thread_store.clone(),
3028 history.downgrade(),
3029 None,
3030 Default::default(),
3031 Default::default(),
3032 Default::default(),
3033 Default::default(),
3034 "Test Agent".into(),
3035 "Test",
3036 EditorMode::full(),
3037 window,
3038 cx,
3039 )
3040 });
3041 workspace.active_pane().update(cx, |pane, cx| {
3042 pane.add_item(
3043 Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
3044 true,
3045 true,
3046 None,
3047 window,
3048 cx,
3049 );
3050 });
3051
3052 message_editor
3053 });
3054
3055 message_editor.update_in(&mut cx, |message_editor, window, cx| {
3056 message_editor.editor.update(cx, |editor, cx| {
3057 // Update the Agent Panel's Message Editor text to have 100
3058 // lines, ensuring that the cursor is set at line 90 and that we
3059 // then scroll all the way to the top, so the cursor's position
3060 // remains off screen.
3061 let mut lines = String::new();
3062 for _ in 1..=100 {
3063 lines.push_str(&"Another line in the agent panel's message editor\n");
3064 }
3065 editor.set_text(lines.as_str(), window, cx);
3066 editor.change_selections(Default::default(), window, cx, |selections| {
3067 selections.select_ranges([Point::new(90, 0)..Point::new(90, 0)]);
3068 });
3069 editor.set_scroll_position(gpui::Point::new(0., 0.), window, cx);
3070 });
3071 });
3072
3073 cx.run_until_parked();
3074
3075 // Before proceeding, let's assert that the cursor is indeed off screen,
3076 // otherwise the rest of the test doesn't make sense.
3077 message_editor.update_in(&mut cx, |message_editor, window, cx| {
3078 message_editor.editor.update(cx, |editor, cx| {
3079 let snapshot = editor.snapshot(window, cx);
3080 let cursor_row = editor.selections.newest::<Point>(&snapshot).head().row;
3081 let scroll_top = snapshot.scroll_position().y as u32;
3082 let visible_lines = editor.visible_line_count().unwrap() as u32;
3083 let visible_range = scroll_top..(scroll_top + visible_lines);
3084
3085 assert!(!visible_range.contains(&cursor_row));
3086 })
3087 });
3088
3089 // Now let's insert the selection in the Agent Panel's editor and
3090 // confirm that, after the insertion, the cursor is now in the visible
3091 // range.
3092 message_editor.update_in(&mut cx, |message_editor, window, cx| {
3093 message_editor.insert_selections(window, cx);
3094 });
3095
3096 cx.run_until_parked();
3097
3098 message_editor.update_in(&mut cx, |message_editor, window, cx| {
3099 message_editor.editor.update(cx, |editor, cx| {
3100 let snapshot = editor.snapshot(window, cx);
3101 let cursor_row = editor.selections.newest::<Point>(&snapshot).head().row;
3102 let scroll_top = snapshot.scroll_position().y as u32;
3103 let visible_lines = editor.visible_line_count().unwrap() as u32;
3104 let visible_range = scroll_top..(scroll_top + visible_lines);
3105
3106 assert!(visible_range.contains(&cursor_row));
3107 })
3108 });
3109 }
3110
3111 #[gpui::test]
3112 async fn test_insert_context_with_multibyte_characters(cx: &mut TestAppContext) {
3113 init_test(cx);
3114
3115 let app_state = cx.update(AppState::test);
3116
3117 cx.update(|cx| {
3118 editor::init(cx);
3119 workspace::init(app_state.clone(), cx);
3120 });
3121
3122 app_state
3123 .fs
3124 .as_fake()
3125 .insert_tree(path!("/dir"), json!({}))
3126 .await;
3127
3128 let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
3129 let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
3130 let workspace = window.root(cx).unwrap();
3131
3132 let mut cx = VisualTestContext::from_window(*window, cx);
3133
3134 let thread_store = cx.new(|cx| ThreadStore::new(cx));
3135 let history = cx
3136 .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
3137
3138 let (message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| {
3139 let workspace_handle = cx.weak_entity();
3140 let message_editor = cx.new(|cx| {
3141 MessageEditor::new_with_cache(
3142 workspace_handle,
3143 project.downgrade(),
3144 Some(thread_store),
3145 history.downgrade(),
3146 None,
3147 Default::default(),
3148 Default::default(),
3149 Default::default(),
3150 Default::default(),
3151 "Test Agent".into(),
3152 "Test",
3153 EditorMode::AutoHeight {
3154 max_lines: None,
3155 min_lines: 1,
3156 },
3157 window,
3158 cx,
3159 )
3160 });
3161 workspace.active_pane().update(cx, |pane, cx| {
3162 pane.add_item(
3163 Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
3164 true,
3165 true,
3166 None,
3167 window,
3168 cx,
3169 );
3170 });
3171 message_editor.read(cx).focus_handle(cx).focus(window, cx);
3172 let editor = message_editor.read(cx).editor().clone();
3173 (message_editor, editor)
3174 });
3175
3176 editor.update_in(&mut cx, |editor, window, cx| {
3177 editor.set_text("😄😄", window, cx);
3178 });
3179
3180 cx.run_until_parked();
3181
3182 message_editor.update_in(&mut cx, |message_editor, window, cx| {
3183 message_editor.insert_context_type("file", window, cx);
3184 });
3185
3186 cx.run_until_parked();
3187
3188 editor.update(&mut cx, |editor, cx| {
3189 assert_eq!(editor.text(cx), "😄😄@file");
3190 });
3191 }
3192}