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