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