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