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