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