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