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::HistoryStore;
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<HistoryStore>,
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 Ok((chunks, all_tracked_buffers))
470 })?;
471 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 .map_err(|e| e.to_string())?
682 .ok_or_else(|| "project path not found".to_string())?;
683
684 let buffer = project
685 .update(cx, |project, cx| project.open_buffer(project_path, cx))
686 .map_err(|e| e.to_string())?
687 .await
688 .map_err(|e| e.to_string())?;
689
690 buffer
691 .update(cx, |buffer, cx| {
692 let start = Point::new(*line_range.start(), 0)
693 .min(buffer.max_point());
694 let end = Point::new(*line_range.end() + 1, 0)
695 .min(buffer.max_point());
696 let content = buffer.text_for_range(start..end).collect();
697 Mention::Text {
698 content,
699 tracked_buffers: vec![cx.entity()],
700 }
701 })
702 .map_err(|e| e.to_string())
703 }
704 })
705 .shared();
706
707 self.mention_set.update(cx, |mention_set, _cx| {
708 mention_set.insert_mention(crease_id, mention_uri.clone(), mention_task)
709 });
710 }
711 }
712 return;
713 }
714
715 if self.prompt_capabilities.borrow().image
716 && let Some(task) =
717 paste_images_as_context(self.editor.clone(), self.mention_set.clone(), window, cx)
718 {
719 task.detach();
720 }
721 }
722
723 fn paste_raw(&mut self, _: &PasteRaw, window: &mut Window, cx: &mut Context<Self>) {
724 let editor = self.editor.clone();
725 window.defer(cx, move |window, cx| {
726 editor.update(cx, |editor, cx| editor.paste(&Paste, window, cx));
727 });
728 }
729
730 pub fn insert_dragged_files(
731 &mut self,
732 paths: Vec<project::ProjectPath>,
733 added_worktrees: Vec<Entity<Worktree>>,
734 window: &mut Window,
735 cx: &mut Context<Self>,
736 ) {
737 let Some(workspace) = self.workspace.upgrade() else {
738 return;
739 };
740 let project = workspace.read(cx).project().clone();
741 let path_style = project.read(cx).path_style(cx);
742 let buffer = self.editor.read(cx).buffer().clone();
743 let Some(buffer) = buffer.read(cx).as_singleton() else {
744 return;
745 };
746 let mut tasks = Vec::new();
747 for path in paths {
748 let Some(entry) = project.read(cx).entry_for_path(&path, cx) else {
749 continue;
750 };
751 let Some(worktree) = project.read(cx).worktree_for_id(path.worktree_id, cx) else {
752 continue;
753 };
754 let abs_path = worktree.read(cx).absolutize(&path.path);
755 let (file_name, _) = crate::completion_provider::extract_file_name_and_directory(
756 &path.path,
757 worktree.read(cx).root_name(),
758 path_style,
759 );
760
761 let uri = if entry.is_dir() {
762 MentionUri::Directory { abs_path }
763 } else {
764 MentionUri::File { abs_path }
765 };
766
767 let new_text = format!("{} ", uri.as_link());
768 let content_len = new_text.len() - 1;
769
770 let anchor = buffer.update(cx, |buffer, _cx| buffer.anchor_before(buffer.len()));
771
772 self.editor.update(cx, |message_editor, cx| {
773 message_editor.edit(
774 [(
775 multi_buffer::Anchor::max()..multi_buffer::Anchor::max(),
776 new_text,
777 )],
778 cx,
779 );
780 });
781 let supports_images = self.prompt_capabilities.borrow().image;
782 tasks.push(self.mention_set.update(cx, |mention_set, cx| {
783 mention_set.confirm_mention_completion(
784 file_name,
785 anchor,
786 content_len,
787 uri,
788 supports_images,
789 self.editor.clone(),
790 &workspace,
791 window,
792 cx,
793 )
794 }));
795 }
796 cx.spawn(async move |_, _| {
797 join_all(tasks).await;
798 drop(added_worktrees);
799 })
800 .detach();
801 }
802
803 pub fn insert_selections(&mut self, window: &mut Window, cx: &mut Context<Self>) {
804 let editor = self.editor.read(cx);
805 let editor_buffer = editor.buffer().read(cx);
806 let Some(buffer) = editor_buffer.as_singleton() else {
807 return;
808 };
809 let cursor_anchor = editor.selections.newest_anchor().head();
810 let cursor_offset = cursor_anchor.to_offset(&editor_buffer.snapshot(cx));
811 let anchor = buffer.update(cx, |buffer, _cx| {
812 buffer.anchor_before(cursor_offset.0.min(buffer.len()))
813 });
814 let Some(workspace) = self.workspace.upgrade() else {
815 return;
816 };
817 let Some(completion) =
818 PromptCompletionProvider::<Entity<MessageEditor>>::completion_for_action(
819 PromptContextAction::AddSelections,
820 anchor..anchor,
821 self.editor.downgrade(),
822 self.mention_set.downgrade(),
823 &workspace,
824 cx,
825 )
826 else {
827 return;
828 };
829
830 self.editor.update(cx, |message_editor, cx| {
831 message_editor.edit([(cursor_anchor..cursor_anchor, completion.new_text)], cx);
832 message_editor.request_autoscroll(Autoscroll::fit(), cx);
833 });
834 if let Some(confirm) = completion.confirm {
835 confirm(CompletionIntent::Complete, window, cx);
836 }
837 }
838
839 pub fn set_read_only(&mut self, read_only: bool, cx: &mut Context<Self>) {
840 self.editor.update(cx, |message_editor, cx| {
841 message_editor.set_read_only(read_only);
842 cx.notify()
843 })
844 }
845
846 pub fn set_mode(&mut self, mode: EditorMode, cx: &mut Context<Self>) {
847 self.editor.update(cx, |editor, cx| {
848 editor.set_mode(mode);
849 cx.notify()
850 });
851 }
852
853 pub fn set_message(
854 &mut self,
855 message: Vec<acp::ContentBlock>,
856 window: &mut Window,
857 cx: &mut Context<Self>,
858 ) {
859 let Some(workspace) = self.workspace.upgrade() else {
860 return;
861 };
862
863 self.clear(window, cx);
864
865 let path_style = workspace.read(cx).project().read(cx).path_style(cx);
866 let mut text = String::new();
867 let mut mentions = Vec::new();
868
869 for chunk in message {
870 match chunk {
871 acp::ContentBlock::Text(text_content) => {
872 text.push_str(&text_content.text);
873 }
874 acp::ContentBlock::Resource(acp::EmbeddedResource {
875 resource: acp::EmbeddedResourceResource::TextResourceContents(resource),
876 ..
877 }) => {
878 let Some(mention_uri) = MentionUri::parse(&resource.uri, path_style).log_err()
879 else {
880 continue;
881 };
882 let start = text.len();
883 write!(&mut text, "{}", mention_uri.as_link()).ok();
884 let end = text.len();
885 mentions.push((
886 start..end,
887 mention_uri,
888 Mention::Text {
889 content: resource.text,
890 tracked_buffers: Vec::new(),
891 },
892 ));
893 }
894 acp::ContentBlock::ResourceLink(resource) => {
895 if let Some(mention_uri) =
896 MentionUri::parse(&resource.uri, path_style).log_err()
897 {
898 let start = text.len();
899 write!(&mut text, "{}", mention_uri.as_link()).ok();
900 let end = text.len();
901 mentions.push((start..end, mention_uri, Mention::Link));
902 }
903 }
904 acp::ContentBlock::Image(acp::ImageContent {
905 uri,
906 data,
907 mime_type,
908 ..
909 }) => {
910 let mention_uri = if let Some(uri) = uri {
911 MentionUri::parse(&uri, path_style)
912 } else {
913 Ok(MentionUri::PastedImage)
914 };
915 let Some(mention_uri) = mention_uri.log_err() else {
916 continue;
917 };
918 let Some(format) = ImageFormat::from_mime_type(&mime_type) else {
919 log::error!("failed to parse MIME type for image: {mime_type:?}");
920 continue;
921 };
922 let start = text.len();
923 write!(&mut text, "{}", mention_uri.as_link()).ok();
924 let end = text.len();
925 mentions.push((
926 start..end,
927 mention_uri,
928 Mention::Image(MentionImage {
929 data: data.into(),
930 format,
931 }),
932 ));
933 }
934 _ => {}
935 }
936 }
937
938 let snapshot = self.editor.update(cx, |editor, cx| {
939 editor.set_text(text, window, cx);
940 editor.buffer().read(cx).snapshot(cx)
941 });
942
943 for (range, mention_uri, mention) in mentions {
944 let anchor = snapshot.anchor_before(MultiBufferOffset(range.start));
945 let Some((crease_id, tx)) = insert_crease_for_mention(
946 anchor.excerpt_id,
947 anchor.text_anchor,
948 range.end - range.start,
949 mention_uri.name().into(),
950 mention_uri.icon_path(cx),
951 None,
952 self.editor.clone(),
953 window,
954 cx,
955 ) else {
956 continue;
957 };
958 drop(tx);
959
960 self.mention_set.update(cx, |mention_set, _cx| {
961 mention_set.insert_mention(
962 crease_id,
963 mention_uri.clone(),
964 Task::ready(Ok(mention)).shared(),
965 )
966 });
967 }
968 cx.notify();
969 }
970
971 pub fn text(&self, cx: &App) -> String {
972 self.editor.read(cx).text(cx)
973 }
974
975 pub fn set_placeholder_text(
976 &mut self,
977 placeholder: &str,
978 window: &mut Window,
979 cx: &mut Context<Self>,
980 ) {
981 self.editor.update(cx, |editor, cx| {
982 editor.set_placeholder_text(placeholder, window, cx);
983 });
984 }
985
986 #[cfg(test)]
987 pub fn set_text(&mut self, text: &str, window: &mut Window, cx: &mut Context<Self>) {
988 self.editor.update(cx, |editor, cx| {
989 editor.set_text(text, window, cx);
990 });
991 }
992}
993
994impl Focusable for MessageEditor {
995 fn focus_handle(&self, cx: &App) -> FocusHandle {
996 self.editor.focus_handle(cx)
997 }
998}
999
1000impl Render for MessageEditor {
1001 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1002 div()
1003 .key_context("MessageEditor")
1004 .on_action(cx.listener(Self::chat))
1005 .on_action(cx.listener(Self::queue_message))
1006 .on_action(cx.listener(Self::chat_with_follow))
1007 .on_action(cx.listener(Self::cancel))
1008 .on_action(cx.listener(Self::paste_raw))
1009 .capture_action(cx.listener(Self::paste))
1010 .flex_1()
1011 .child({
1012 let settings = ThemeSettings::get_global(cx);
1013
1014 let text_style = TextStyle {
1015 color: cx.theme().colors().text,
1016 font_family: settings.buffer_font.family.clone(),
1017 font_fallbacks: settings.buffer_font.fallbacks.clone(),
1018 font_features: settings.buffer_font.features.clone(),
1019 font_size: settings.agent_buffer_font_size(cx).into(),
1020 line_height: relative(settings.buffer_line_height.value()),
1021 ..Default::default()
1022 };
1023
1024 EditorElement::new(
1025 &self.editor,
1026 EditorStyle {
1027 background: cx.theme().colors().editor_background,
1028 local_player: cx.theme().players().local(),
1029 text: text_style,
1030 syntax: cx.theme().syntax().clone(),
1031 inlay_hints_style: editor::make_inlay_hints_style(cx),
1032 ..Default::default()
1033 },
1034 )
1035 })
1036 }
1037}
1038
1039pub struct MessageEditorAddon {}
1040
1041impl MessageEditorAddon {
1042 pub fn new() -> Self {
1043 Self {}
1044 }
1045}
1046
1047impl Addon for MessageEditorAddon {
1048 fn to_any(&self) -> &dyn std::any::Any {
1049 self
1050 }
1051
1052 fn to_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
1053 Some(self)
1054 }
1055
1056 fn extend_key_context(&self, key_context: &mut KeyContext, cx: &App) {
1057 let settings = agent_settings::AgentSettings::get_global(cx);
1058 if settings.use_modifier_to_send {
1059 key_context.add("use_modifier_to_send");
1060 }
1061 }
1062}
1063
1064#[cfg(test)]
1065mod tests {
1066 use std::{cell::RefCell, ops::Range, path::Path, rc::Rc, sync::Arc};
1067
1068 use acp_thread::MentionUri;
1069 use agent::{HistoryStore, outline};
1070 use agent_client_protocol as acp;
1071 use assistant_text_thread::TextThreadStore;
1072 use editor::{AnchorRangeExt as _, Editor, EditorMode, MultiBufferOffset};
1073 use fs::FakeFs;
1074 use futures::StreamExt as _;
1075 use gpui::{
1076 AppContext, Entity, EventEmitter, FocusHandle, Focusable, TestAppContext, VisualTestContext,
1077 };
1078 use language_model::LanguageModelRegistry;
1079 use lsp::{CompletionContext, CompletionTriggerKind};
1080 use project::{CompletionIntent, Project, ProjectPath};
1081 use serde_json::json;
1082 use text::Point;
1083 use ui::{App, Context, IntoElement, Render, SharedString, Window};
1084 use util::{path, paths::PathStyle, rel_path::rel_path};
1085 use workspace::{AppState, Item, Workspace};
1086
1087 use crate::acp::{
1088 message_editor::{Mention, MessageEditor},
1089 thread_view::tests::init_test,
1090 };
1091
1092 #[gpui::test]
1093 async fn test_at_mention_removal(cx: &mut TestAppContext) {
1094 init_test(cx);
1095
1096 let fs = FakeFs::new(cx.executor());
1097 fs.insert_tree("/project", json!({"file": ""})).await;
1098 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
1099
1100 let (workspace, cx) =
1101 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
1102
1103 let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx));
1104 let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx));
1105
1106 let message_editor = cx.update(|window, cx| {
1107 cx.new(|cx| {
1108 MessageEditor::new(
1109 workspace.downgrade(),
1110 project.downgrade(),
1111 history_store.clone(),
1112 None,
1113 Default::default(),
1114 Default::default(),
1115 "Test Agent".into(),
1116 "Test",
1117 EditorMode::AutoHeight {
1118 min_lines: 1,
1119 max_lines: None,
1120 },
1121 window,
1122 cx,
1123 )
1124 })
1125 });
1126 let editor = message_editor.update(cx, |message_editor, _| message_editor.editor.clone());
1127
1128 cx.run_until_parked();
1129
1130 let excerpt_id = editor.update(cx, |editor, cx| {
1131 editor
1132 .buffer()
1133 .read(cx)
1134 .excerpt_ids()
1135 .into_iter()
1136 .next()
1137 .unwrap()
1138 });
1139 let completions = editor.update_in(cx, |editor, window, cx| {
1140 editor.set_text("Hello @file ", window, cx);
1141 let buffer = editor.buffer().read(cx).as_singleton().unwrap();
1142 let completion_provider = editor.completion_provider().unwrap();
1143 completion_provider.completions(
1144 excerpt_id,
1145 &buffer,
1146 text::Anchor::MAX,
1147 CompletionContext {
1148 trigger_kind: CompletionTriggerKind::TRIGGER_CHARACTER,
1149 trigger_character: Some("@".into()),
1150 },
1151 window,
1152 cx,
1153 )
1154 });
1155 let [_, completion]: [_; 2] = completions
1156 .await
1157 .unwrap()
1158 .into_iter()
1159 .flat_map(|response| response.completions)
1160 .collect::<Vec<_>>()
1161 .try_into()
1162 .unwrap();
1163
1164 editor.update_in(cx, |editor, window, cx| {
1165 let snapshot = editor.buffer().read(cx).snapshot(cx);
1166 let range = snapshot
1167 .anchor_range_in_excerpt(excerpt_id, completion.replace_range)
1168 .unwrap();
1169 editor.edit([(range, completion.new_text)], cx);
1170 (completion.confirm.unwrap())(CompletionIntent::Complete, window, cx);
1171 });
1172
1173 cx.run_until_parked();
1174
1175 // Backspace over the inserted crease (and the following space).
1176 editor.update_in(cx, |editor, window, cx| {
1177 editor.backspace(&Default::default(), window, cx);
1178 editor.backspace(&Default::default(), window, cx);
1179 });
1180
1181 let (content, _) = message_editor
1182 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
1183 .await
1184 .unwrap();
1185
1186 // We don't send a resource link for the deleted crease.
1187 pretty_assertions::assert_matches!(content.as_slice(), [acp::ContentBlock::Text { .. }]);
1188 }
1189
1190 #[gpui::test]
1191 async fn test_slash_command_validation(cx: &mut gpui::TestAppContext) {
1192 init_test(cx);
1193 let fs = FakeFs::new(cx.executor());
1194 fs.insert_tree(
1195 "/test",
1196 json!({
1197 ".zed": {
1198 "tasks.json": r#"[{"label": "test", "command": "echo"}]"#
1199 },
1200 "src": {
1201 "main.rs": "fn main() {}",
1202 },
1203 }),
1204 )
1205 .await;
1206
1207 let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
1208 let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx));
1209 let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx));
1210 let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default()));
1211 // Start with no available commands - simulating Claude which doesn't support slash commands
1212 let available_commands = Rc::new(RefCell::new(vec![]));
1213
1214 let (workspace, cx) =
1215 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
1216 let workspace_handle = workspace.downgrade();
1217 let message_editor = workspace.update_in(cx, |_, window, cx| {
1218 cx.new(|cx| {
1219 MessageEditor::new(
1220 workspace_handle.clone(),
1221 project.downgrade(),
1222 history_store.clone(),
1223 None,
1224 prompt_capabilities.clone(),
1225 available_commands.clone(),
1226 "Claude Code".into(),
1227 "Test",
1228 EditorMode::AutoHeight {
1229 min_lines: 1,
1230 max_lines: None,
1231 },
1232 window,
1233 cx,
1234 )
1235 })
1236 });
1237 let editor = message_editor.update(cx, |message_editor, _| message_editor.editor.clone());
1238
1239 // Test that slash commands fail when no available_commands are set (empty list means no commands supported)
1240 editor.update_in(cx, |editor, window, cx| {
1241 editor.set_text("/file test.txt", window, cx);
1242 });
1243
1244 let contents_result = message_editor
1245 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
1246 .await;
1247
1248 // Should fail because available_commands is empty (no commands supported)
1249 assert!(contents_result.is_err());
1250 let error_message = contents_result.unwrap_err().to_string();
1251 assert!(error_message.contains("not supported by Claude Code"));
1252 assert!(error_message.contains("Available commands: none"));
1253
1254 // Now simulate Claude providing its list of available commands (which doesn't include file)
1255 available_commands.replace(vec![acp::AvailableCommand::new("help", "Get help")]);
1256
1257 // Test that unsupported slash commands trigger an error when we have a list of available commands
1258 editor.update_in(cx, |editor, window, cx| {
1259 editor.set_text("/file test.txt", window, cx);
1260 });
1261
1262 let contents_result = message_editor
1263 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
1264 .await;
1265
1266 assert!(contents_result.is_err());
1267 let error_message = contents_result.unwrap_err().to_string();
1268 assert!(error_message.contains("not supported by Claude Code"));
1269 assert!(error_message.contains("/file"));
1270 assert!(error_message.contains("Available commands: /help"));
1271
1272 // Test that supported commands work fine
1273 editor.update_in(cx, |editor, window, cx| {
1274 editor.set_text("/help", window, cx);
1275 });
1276
1277 let contents_result = message_editor
1278 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
1279 .await;
1280
1281 // Should succeed because /help is in available_commands
1282 assert!(contents_result.is_ok());
1283
1284 // Test that regular text works fine
1285 editor.update_in(cx, |editor, window, cx| {
1286 editor.set_text("Hello Claude!", window, cx);
1287 });
1288
1289 let (content, _) = message_editor
1290 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
1291 .await
1292 .unwrap();
1293
1294 assert_eq!(content.len(), 1);
1295 if let acp::ContentBlock::Text(text) = &content[0] {
1296 assert_eq!(text.text, "Hello Claude!");
1297 } else {
1298 panic!("Expected ContentBlock::Text");
1299 }
1300
1301 // Test that @ mentions still work
1302 editor.update_in(cx, |editor, window, cx| {
1303 editor.set_text("Check this @", window, cx);
1304 });
1305
1306 // The @ mention functionality should not be affected
1307 let (content, _) = message_editor
1308 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
1309 .await
1310 .unwrap();
1311
1312 assert_eq!(content.len(), 1);
1313 if let acp::ContentBlock::Text(text) = &content[0] {
1314 assert_eq!(text.text, "Check this @");
1315 } else {
1316 panic!("Expected ContentBlock::Text");
1317 }
1318 }
1319
1320 struct MessageEditorItem(Entity<MessageEditor>);
1321
1322 impl Item for MessageEditorItem {
1323 type Event = ();
1324
1325 fn include_in_nav_history() -> bool {
1326 false
1327 }
1328
1329 fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
1330 "Test".into()
1331 }
1332 }
1333
1334 impl EventEmitter<()> for MessageEditorItem {}
1335
1336 impl Focusable for MessageEditorItem {
1337 fn focus_handle(&self, cx: &App) -> FocusHandle {
1338 self.0.read(cx).focus_handle(cx)
1339 }
1340 }
1341
1342 impl Render for MessageEditorItem {
1343 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
1344 self.0.clone().into_any_element()
1345 }
1346 }
1347
1348 #[gpui::test]
1349 async fn test_completion_provider_commands(cx: &mut TestAppContext) {
1350 init_test(cx);
1351
1352 let app_state = cx.update(AppState::test);
1353
1354 cx.update(|cx| {
1355 editor::init(cx);
1356 workspace::init(app_state.clone(), cx);
1357 });
1358
1359 let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
1360 let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
1361 let workspace = window.root(cx).unwrap();
1362
1363 let mut cx = VisualTestContext::from_window(*window, cx);
1364
1365 let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx));
1366 let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx));
1367 let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default()));
1368 let available_commands = Rc::new(RefCell::new(vec![
1369 acp::AvailableCommand::new("quick-math", "2 + 2 = 4 - 1 = 3"),
1370 acp::AvailableCommand::new("say-hello", "Say hello to whoever you want").input(
1371 acp::AvailableCommandInput::Unstructured(acp::UnstructuredCommandInput::new(
1372 "<name>",
1373 )),
1374 ),
1375 ]));
1376
1377 let editor = workspace.update_in(&mut cx, |workspace, window, cx| {
1378 let workspace_handle = cx.weak_entity();
1379 let message_editor = cx.new(|cx| {
1380 MessageEditor::new(
1381 workspace_handle,
1382 project.downgrade(),
1383 history_store.clone(),
1384 None,
1385 prompt_capabilities.clone(),
1386 available_commands.clone(),
1387 "Test Agent".into(),
1388 "Test",
1389 EditorMode::AutoHeight {
1390 max_lines: None,
1391 min_lines: 1,
1392 },
1393 window,
1394 cx,
1395 )
1396 });
1397 workspace.active_pane().update(cx, |pane, cx| {
1398 pane.add_item(
1399 Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
1400 true,
1401 true,
1402 None,
1403 window,
1404 cx,
1405 );
1406 });
1407 message_editor.read(cx).focus_handle(cx).focus(window, cx);
1408 message_editor.read(cx).editor().clone()
1409 });
1410
1411 cx.simulate_input("/");
1412
1413 editor.update_in(&mut cx, |editor, window, cx| {
1414 assert_eq!(editor.text(cx), "/");
1415 assert!(editor.has_visible_completions_menu());
1416
1417 assert_eq!(
1418 current_completion_labels_with_documentation(editor),
1419 &[
1420 ("quick-math".into(), "2 + 2 = 4 - 1 = 3".into()),
1421 ("say-hello".into(), "Say hello to whoever you want".into())
1422 ]
1423 );
1424 editor.set_text("", window, cx);
1425 });
1426
1427 cx.simulate_input("/qui");
1428
1429 editor.update_in(&mut cx, |editor, window, cx| {
1430 assert_eq!(editor.text(cx), "/qui");
1431 assert!(editor.has_visible_completions_menu());
1432
1433 assert_eq!(
1434 current_completion_labels_with_documentation(editor),
1435 &[("quick-math".into(), "2 + 2 = 4 - 1 = 3".into())]
1436 );
1437 editor.set_text("", window, cx);
1438 });
1439
1440 editor.update_in(&mut cx, |editor, window, cx| {
1441 assert!(editor.has_visible_completions_menu());
1442 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
1443 });
1444
1445 cx.run_until_parked();
1446
1447 editor.update_in(&mut cx, |editor, window, cx| {
1448 assert_eq!(editor.display_text(cx), "/quick-math ");
1449 assert!(!editor.has_visible_completions_menu());
1450 editor.set_text("", window, cx);
1451 });
1452
1453 cx.simulate_input("/say");
1454
1455 editor.update_in(&mut cx, |editor, _window, cx| {
1456 assert_eq!(editor.display_text(cx), "/say");
1457 assert!(editor.has_visible_completions_menu());
1458
1459 assert_eq!(
1460 current_completion_labels_with_documentation(editor),
1461 &[("say-hello".into(), "Say hello to whoever you want".into())]
1462 );
1463 });
1464
1465 editor.update_in(&mut cx, |editor, window, cx| {
1466 assert!(editor.has_visible_completions_menu());
1467 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
1468 });
1469
1470 cx.run_until_parked();
1471
1472 editor.update_in(&mut cx, |editor, _window, cx| {
1473 assert_eq!(editor.text(cx), "/say-hello ");
1474 assert_eq!(editor.display_text(cx), "/say-hello <name>");
1475 assert!(!editor.has_visible_completions_menu());
1476 });
1477
1478 cx.simulate_input("GPT5");
1479
1480 cx.run_until_parked();
1481
1482 editor.update_in(&mut cx, |editor, window, cx| {
1483 assert_eq!(editor.text(cx), "/say-hello GPT5");
1484 assert_eq!(editor.display_text(cx), "/say-hello GPT5");
1485 assert!(!editor.has_visible_completions_menu());
1486
1487 // Delete argument
1488 for _ in 0..5 {
1489 editor.backspace(&editor::actions::Backspace, window, cx);
1490 }
1491 });
1492
1493 cx.run_until_parked();
1494
1495 editor.update_in(&mut cx, |editor, window, cx| {
1496 assert_eq!(editor.text(cx), "/say-hello");
1497 // Hint is visible because argument was deleted
1498 assert_eq!(editor.display_text(cx), "/say-hello <name>");
1499
1500 // Delete last command letter
1501 editor.backspace(&editor::actions::Backspace, window, cx);
1502 });
1503
1504 cx.run_until_parked();
1505
1506 editor.update_in(&mut cx, |editor, _window, cx| {
1507 // Hint goes away once command no longer matches an available one
1508 assert_eq!(editor.text(cx), "/say-hell");
1509 assert_eq!(editor.display_text(cx), "/say-hell");
1510 assert!(!editor.has_visible_completions_menu());
1511 });
1512 }
1513
1514 #[gpui::test]
1515 async fn test_context_completion_provider_mentions(cx: &mut TestAppContext) {
1516 init_test(cx);
1517
1518 let app_state = cx.update(AppState::test);
1519
1520 cx.update(|cx| {
1521 editor::init(cx);
1522 workspace::init(app_state.clone(), cx);
1523 });
1524
1525 app_state
1526 .fs
1527 .as_fake()
1528 .insert_tree(
1529 path!("/dir"),
1530 json!({
1531 "editor": "",
1532 "a": {
1533 "one.txt": "1",
1534 "two.txt": "2",
1535 "three.txt": "3",
1536 "four.txt": "4"
1537 },
1538 "b": {
1539 "five.txt": "5",
1540 "six.txt": "6",
1541 "seven.txt": "7",
1542 "eight.txt": "8",
1543 },
1544 "x.png": "",
1545 }),
1546 )
1547 .await;
1548
1549 let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
1550 let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
1551 let workspace = window.root(cx).unwrap();
1552
1553 let worktree = project.update(cx, |project, cx| {
1554 let mut worktrees = project.worktrees(cx).collect::<Vec<_>>();
1555 assert_eq!(worktrees.len(), 1);
1556 worktrees.pop().unwrap()
1557 });
1558 let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id());
1559
1560 let mut cx = VisualTestContext::from_window(*window, cx);
1561
1562 let paths = vec![
1563 rel_path("a/one.txt"),
1564 rel_path("a/two.txt"),
1565 rel_path("a/three.txt"),
1566 rel_path("a/four.txt"),
1567 rel_path("b/five.txt"),
1568 rel_path("b/six.txt"),
1569 rel_path("b/seven.txt"),
1570 rel_path("b/eight.txt"),
1571 ];
1572
1573 let slash = PathStyle::local().primary_separator();
1574
1575 let mut opened_editors = Vec::new();
1576 for path in paths {
1577 let buffer = workspace
1578 .update_in(&mut cx, |workspace, window, cx| {
1579 workspace.open_path(
1580 ProjectPath {
1581 worktree_id,
1582 path: path.into(),
1583 },
1584 None,
1585 false,
1586 window,
1587 cx,
1588 )
1589 })
1590 .await
1591 .unwrap();
1592 opened_editors.push(buffer);
1593 }
1594
1595 let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx));
1596 let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx));
1597 let prompt_capabilities = Rc::new(RefCell::new(acp::PromptCapabilities::default()));
1598
1599 let (message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| {
1600 let workspace_handle = cx.weak_entity();
1601 let message_editor = cx.new(|cx| {
1602 MessageEditor::new(
1603 workspace_handle,
1604 project.downgrade(),
1605 history_store.clone(),
1606 None,
1607 prompt_capabilities.clone(),
1608 Default::default(),
1609 "Test Agent".into(),
1610 "Test",
1611 EditorMode::AutoHeight {
1612 max_lines: None,
1613 min_lines: 1,
1614 },
1615 window,
1616 cx,
1617 )
1618 });
1619 workspace.active_pane().update(cx, |pane, cx| {
1620 pane.add_item(
1621 Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
1622 true,
1623 true,
1624 None,
1625 window,
1626 cx,
1627 );
1628 });
1629 message_editor.read(cx).focus_handle(cx).focus(window, cx);
1630 let editor = message_editor.read(cx).editor().clone();
1631 (message_editor, editor)
1632 });
1633
1634 cx.simulate_input("Lorem @");
1635
1636 editor.update_in(&mut cx, |editor, window, cx| {
1637 assert_eq!(editor.text(cx), "Lorem @");
1638 assert!(editor.has_visible_completions_menu());
1639
1640 assert_eq!(
1641 current_completion_labels(editor),
1642 &[
1643 format!("eight.txt b{slash}"),
1644 format!("seven.txt b{slash}"),
1645 format!("six.txt b{slash}"),
1646 format!("five.txt b{slash}"),
1647 "Files & Directories".into(),
1648 "Symbols".into()
1649 ]
1650 );
1651 editor.set_text("", window, cx);
1652 });
1653
1654 prompt_capabilities.replace(
1655 acp::PromptCapabilities::new()
1656 .image(true)
1657 .audio(true)
1658 .embedded_context(true),
1659 );
1660
1661 cx.simulate_input("Lorem ");
1662
1663 editor.update(&mut cx, |editor, cx| {
1664 assert_eq!(editor.text(cx), "Lorem ");
1665 assert!(!editor.has_visible_completions_menu());
1666 });
1667
1668 cx.simulate_input("@");
1669
1670 editor.update(&mut cx, |editor, cx| {
1671 assert_eq!(editor.text(cx), "Lorem @");
1672 assert!(editor.has_visible_completions_menu());
1673 assert_eq!(
1674 current_completion_labels(editor),
1675 &[
1676 format!("eight.txt b{slash}"),
1677 format!("seven.txt b{slash}"),
1678 format!("six.txt b{slash}"),
1679 format!("five.txt b{slash}"),
1680 "Files & Directories".into(),
1681 "Symbols".into(),
1682 "Threads".into(),
1683 "Fetch".into()
1684 ]
1685 );
1686 });
1687
1688 // Select and confirm "File"
1689 editor.update_in(&mut cx, |editor, window, cx| {
1690 assert!(editor.has_visible_completions_menu());
1691 editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
1692 editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
1693 editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
1694 editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
1695 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
1696 });
1697
1698 cx.run_until_parked();
1699
1700 editor.update(&mut cx, |editor, cx| {
1701 assert_eq!(editor.text(cx), "Lorem @file ");
1702 assert!(editor.has_visible_completions_menu());
1703 });
1704
1705 cx.simulate_input("one");
1706
1707 editor.update(&mut cx, |editor, cx| {
1708 assert_eq!(editor.text(cx), "Lorem @file one");
1709 assert!(editor.has_visible_completions_menu());
1710 assert_eq!(
1711 current_completion_labels(editor),
1712 vec![format!("one.txt a{slash}")]
1713 );
1714 });
1715
1716 editor.update_in(&mut cx, |editor, window, cx| {
1717 assert!(editor.has_visible_completions_menu());
1718 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
1719 });
1720
1721 let url_one = MentionUri::File {
1722 abs_path: path!("/dir/a/one.txt").into(),
1723 }
1724 .to_uri()
1725 .to_string();
1726 editor.update(&mut cx, |editor, cx| {
1727 let text = editor.text(cx);
1728 assert_eq!(text, format!("Lorem [@one.txt]({url_one}) "));
1729 assert!(!editor.has_visible_completions_menu());
1730 assert_eq!(fold_ranges(editor, cx).len(), 1);
1731 });
1732
1733 let contents = message_editor
1734 .update(&mut cx, |message_editor, cx| {
1735 message_editor
1736 .mention_set()
1737 .update(cx, |mention_set, cx| mention_set.contents(false, cx))
1738 })
1739 .await
1740 .unwrap()
1741 .into_values()
1742 .collect::<Vec<_>>();
1743
1744 {
1745 let [(uri, Mention::Text { content, .. })] = contents.as_slice() else {
1746 panic!("Unexpected mentions");
1747 };
1748 pretty_assertions::assert_eq!(content, "1");
1749 pretty_assertions::assert_eq!(
1750 uri,
1751 &MentionUri::parse(&url_one, PathStyle::local()).unwrap()
1752 );
1753 }
1754
1755 cx.simulate_input(" ");
1756
1757 editor.update(&mut cx, |editor, cx| {
1758 let text = editor.text(cx);
1759 assert_eq!(text, format!("Lorem [@one.txt]({url_one}) "));
1760 assert!(!editor.has_visible_completions_menu());
1761 assert_eq!(fold_ranges(editor, cx).len(), 1);
1762 });
1763
1764 cx.simulate_input("Ipsum ");
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 "),);
1769 assert!(!editor.has_visible_completions_menu());
1770 assert_eq!(fold_ranges(editor, cx).len(), 1);
1771 });
1772
1773 cx.simulate_input("@file ");
1774
1775 editor.update(&mut cx, |editor, cx| {
1776 let text = editor.text(cx);
1777 assert_eq!(text, format!("Lorem [@one.txt]({url_one}) Ipsum @file "),);
1778 assert!(editor.has_visible_completions_menu());
1779 assert_eq!(fold_ranges(editor, cx).len(), 1);
1780 });
1781
1782 editor.update_in(&mut cx, |editor, window, cx| {
1783 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
1784 });
1785
1786 cx.run_until_parked();
1787
1788 let contents = message_editor
1789 .update(&mut cx, |message_editor, cx| {
1790 message_editor
1791 .mention_set()
1792 .update(cx, |mention_set, cx| mention_set.contents(false, cx))
1793 })
1794 .await
1795 .unwrap()
1796 .into_values()
1797 .collect::<Vec<_>>();
1798
1799 let url_eight = MentionUri::File {
1800 abs_path: path!("/dir/b/eight.txt").into(),
1801 }
1802 .to_uri()
1803 .to_string();
1804
1805 {
1806 let [_, (uri, Mention::Text { content, .. })] = contents.as_slice() else {
1807 panic!("Unexpected mentions");
1808 };
1809 pretty_assertions::assert_eq!(content, "8");
1810 pretty_assertions::assert_eq!(
1811 uri,
1812 &MentionUri::parse(&url_eight, PathStyle::local()).unwrap()
1813 );
1814 }
1815
1816 editor.update(&mut cx, |editor, cx| {
1817 assert_eq!(
1818 editor.text(cx),
1819 format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) ")
1820 );
1821 assert!(!editor.has_visible_completions_menu());
1822 assert_eq!(fold_ranges(editor, cx).len(), 2);
1823 });
1824
1825 let plain_text_language = Arc::new(language::Language::new(
1826 language::LanguageConfig {
1827 name: "Plain Text".into(),
1828 matcher: language::LanguageMatcher {
1829 path_suffixes: vec!["txt".to_string()],
1830 ..Default::default()
1831 },
1832 ..Default::default()
1833 },
1834 None,
1835 ));
1836
1837 // Register the language and fake LSP
1838 let language_registry = project.read_with(&cx, |project, _| project.languages().clone());
1839 language_registry.add(plain_text_language);
1840
1841 let mut fake_language_servers = language_registry.register_fake_lsp(
1842 "Plain Text",
1843 language::FakeLspAdapter {
1844 capabilities: lsp::ServerCapabilities {
1845 workspace_symbol_provider: Some(lsp::OneOf::Left(true)),
1846 ..Default::default()
1847 },
1848 ..Default::default()
1849 },
1850 );
1851
1852 // Open the buffer to trigger LSP initialization
1853 let buffer = project
1854 .update(&mut cx, |project, cx| {
1855 project.open_local_buffer(path!("/dir/a/one.txt"), cx)
1856 })
1857 .await
1858 .unwrap();
1859
1860 // Register the buffer with language servers
1861 let _handle = project.update(&mut cx, |project, cx| {
1862 project.register_buffer_with_language_servers(&buffer, cx)
1863 });
1864
1865 cx.run_until_parked();
1866
1867 let fake_language_server = fake_language_servers.next().await.unwrap();
1868 fake_language_server.set_request_handler::<lsp::WorkspaceSymbolRequest, _, _>(
1869 move |_, _| async move {
1870 Ok(Some(lsp::WorkspaceSymbolResponse::Flat(vec![
1871 #[allow(deprecated)]
1872 lsp::SymbolInformation {
1873 name: "MySymbol".into(),
1874 location: lsp::Location {
1875 uri: lsp::Uri::from_file_path(path!("/dir/a/one.txt")).unwrap(),
1876 range: lsp::Range::new(
1877 lsp::Position::new(0, 0),
1878 lsp::Position::new(0, 1),
1879 ),
1880 },
1881 kind: lsp::SymbolKind::CONSTANT,
1882 tags: None,
1883 container_name: None,
1884 deprecated: None,
1885 },
1886 ])))
1887 },
1888 );
1889
1890 cx.simulate_input("@symbol ");
1891
1892 editor.update(&mut cx, |editor, cx| {
1893 assert_eq!(
1894 editor.text(cx),
1895 format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) @symbol ")
1896 );
1897 assert!(editor.has_visible_completions_menu());
1898 assert_eq!(current_completion_labels(editor), &["MySymbol one.txt L1"]);
1899 });
1900
1901 editor.update_in(&mut cx, |editor, window, cx| {
1902 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
1903 });
1904
1905 let symbol = MentionUri::Symbol {
1906 abs_path: path!("/dir/a/one.txt").into(),
1907 name: "MySymbol".into(),
1908 line_range: 0..=0,
1909 };
1910
1911 let contents = message_editor
1912 .update(&mut cx, |message_editor, cx| {
1913 message_editor
1914 .mention_set()
1915 .update(cx, |mention_set, cx| mention_set.contents(false, cx))
1916 })
1917 .await
1918 .unwrap()
1919 .into_values()
1920 .collect::<Vec<_>>();
1921
1922 {
1923 let [_, _, (uri, Mention::Text { content, .. })] = contents.as_slice() else {
1924 panic!("Unexpected mentions");
1925 };
1926 pretty_assertions::assert_eq!(content, "1");
1927 pretty_assertions::assert_eq!(uri, &symbol);
1928 }
1929
1930 cx.run_until_parked();
1931
1932 editor.read_with(&cx, |editor, cx| {
1933 assert_eq!(
1934 editor.text(cx),
1935 format!(
1936 "Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) ",
1937 symbol.to_uri(),
1938 )
1939 );
1940 });
1941
1942 // Try to mention an "image" file that will fail to load
1943 cx.simulate_input("@file x.png");
1944
1945 editor.update(&mut cx, |editor, cx| {
1946 assert_eq!(
1947 editor.text(cx),
1948 format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) @file x.png", symbol.to_uri())
1949 );
1950 assert!(editor.has_visible_completions_menu());
1951 assert_eq!(current_completion_labels(editor), &["x.png "]);
1952 });
1953
1954 editor.update_in(&mut cx, |editor, window, cx| {
1955 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
1956 });
1957
1958 // Getting the message contents fails
1959 message_editor
1960 .update(&mut cx, |message_editor, cx| {
1961 message_editor
1962 .mention_set()
1963 .update(cx, |mention_set, cx| mention_set.contents(false, cx))
1964 })
1965 .await
1966 .expect_err("Should fail to load x.png");
1967
1968 cx.run_until_parked();
1969
1970 // Mention was removed
1971 editor.read_with(&cx, |editor, cx| {
1972 assert_eq!(
1973 editor.text(cx),
1974 format!(
1975 "Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) ",
1976 symbol.to_uri()
1977 )
1978 );
1979 });
1980
1981 // Once more
1982 cx.simulate_input("@file x.png");
1983
1984 editor.update(&mut cx, |editor, cx| {
1985 assert_eq!(
1986 editor.text(cx),
1987 format!("Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) @file x.png", symbol.to_uri())
1988 );
1989 assert!(editor.has_visible_completions_menu());
1990 assert_eq!(current_completion_labels(editor), &["x.png "]);
1991 });
1992
1993 editor.update_in(&mut cx, |editor, window, cx| {
1994 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
1995 });
1996
1997 // This time don't immediately get the contents, just let the confirmed completion settle
1998 cx.run_until_parked();
1999
2000 // Mention was removed
2001 editor.read_with(&cx, |editor, cx| {
2002 assert_eq!(
2003 editor.text(cx),
2004 format!(
2005 "Lorem [@one.txt]({url_one}) Ipsum [@eight.txt]({url_eight}) [@MySymbol]({}) ",
2006 symbol.to_uri()
2007 )
2008 );
2009 });
2010
2011 // Now getting the contents succeeds, because the invalid mention was removed
2012 let contents = message_editor
2013 .update(&mut cx, |message_editor, cx| {
2014 message_editor
2015 .mention_set()
2016 .update(cx, |mention_set, cx| mention_set.contents(false, cx))
2017 })
2018 .await
2019 .unwrap();
2020 assert_eq!(contents.len(), 3);
2021 }
2022
2023 fn fold_ranges(editor: &Editor, cx: &mut App) -> Vec<Range<Point>> {
2024 let snapshot = editor.buffer().read(cx).snapshot(cx);
2025 editor.display_map.update(cx, |display_map, cx| {
2026 display_map
2027 .snapshot(cx)
2028 .folds_in_range(MultiBufferOffset(0)..snapshot.len())
2029 .map(|fold| fold.range.to_point(&snapshot))
2030 .collect()
2031 })
2032 }
2033
2034 fn current_completion_labels(editor: &Editor) -> Vec<String> {
2035 let completions = editor.current_completions().expect("Missing completions");
2036 completions
2037 .into_iter()
2038 .map(|completion| completion.label.text)
2039 .collect::<Vec<_>>()
2040 }
2041
2042 fn current_completion_labels_with_documentation(editor: &Editor) -> Vec<(String, String)> {
2043 let completions = editor.current_completions().expect("Missing completions");
2044 completions
2045 .into_iter()
2046 .map(|completion| {
2047 (
2048 completion.label.text,
2049 completion
2050 .documentation
2051 .map(|d| d.text().to_string())
2052 .unwrap_or_default(),
2053 )
2054 })
2055 .collect::<Vec<_>>()
2056 }
2057
2058 #[gpui::test]
2059 async fn test_large_file_mention_fallback(cx: &mut TestAppContext) {
2060 init_test(cx);
2061
2062 let fs = FakeFs::new(cx.executor());
2063
2064 // Create a large file that exceeds AUTO_OUTLINE_SIZE
2065 // Using plain text without a configured language, so no outline is available
2066 const LINE: &str = "This is a line of text in the file\n";
2067 let large_content = LINE.repeat(2 * (outline::AUTO_OUTLINE_SIZE / LINE.len()));
2068 assert!(large_content.len() > outline::AUTO_OUTLINE_SIZE);
2069
2070 // Create a small file that doesn't exceed AUTO_OUTLINE_SIZE
2071 let small_content = "fn small_function() { /* small */ }\n";
2072 assert!(small_content.len() < outline::AUTO_OUTLINE_SIZE);
2073
2074 fs.insert_tree(
2075 "/project",
2076 json!({
2077 "large_file.txt": large_content.clone(),
2078 "small_file.txt": small_content,
2079 }),
2080 )
2081 .await;
2082
2083 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
2084
2085 let (workspace, cx) =
2086 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
2087
2088 let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx));
2089 let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx));
2090
2091 let message_editor = cx.update(|window, cx| {
2092 cx.new(|cx| {
2093 let editor = MessageEditor::new(
2094 workspace.downgrade(),
2095 project.downgrade(),
2096 history_store.clone(),
2097 None,
2098 Default::default(),
2099 Default::default(),
2100 "Test Agent".into(),
2101 "Test",
2102 EditorMode::AutoHeight {
2103 min_lines: 1,
2104 max_lines: None,
2105 },
2106 window,
2107 cx,
2108 );
2109 // Enable embedded context so files are actually included
2110 editor
2111 .prompt_capabilities
2112 .replace(acp::PromptCapabilities::new().embedded_context(true));
2113 editor
2114 })
2115 });
2116
2117 // Test large file mention
2118 // Get the absolute path using the project's worktree
2119 let large_file_abs_path = project.read_with(cx, |project, cx| {
2120 let worktree = project.worktrees(cx).next().unwrap();
2121 let worktree_root = worktree.read(cx).abs_path();
2122 worktree_root.join("large_file.txt")
2123 });
2124 let large_file_task = message_editor.update(cx, |editor, cx| {
2125 editor.mention_set().update(cx, |set, cx| {
2126 set.confirm_mention_for_file(large_file_abs_path, true, cx)
2127 })
2128 });
2129
2130 let large_file_mention = large_file_task.await.unwrap();
2131 match large_file_mention {
2132 Mention::Text { content, .. } => {
2133 // Should contain some of the content but not all of it
2134 assert!(
2135 content.contains(LINE),
2136 "Should contain some of the file content"
2137 );
2138 assert!(
2139 !content.contains(&LINE.repeat(100)),
2140 "Should not contain the full file"
2141 );
2142 // Should be much smaller than original
2143 assert!(
2144 content.len() < large_content.len() / 10,
2145 "Should be significantly truncated"
2146 );
2147 }
2148 _ => panic!("Expected Text mention for large file"),
2149 }
2150
2151 // Test small file mention
2152 // Get the absolute path using the project's worktree
2153 let small_file_abs_path = project.read_with(cx, |project, cx| {
2154 let worktree = project.worktrees(cx).next().unwrap();
2155 let worktree_root = worktree.read(cx).abs_path();
2156 worktree_root.join("small_file.txt")
2157 });
2158 let small_file_task = message_editor.update(cx, |editor, cx| {
2159 editor.mention_set().update(cx, |set, cx| {
2160 set.confirm_mention_for_file(small_file_abs_path, true, cx)
2161 })
2162 });
2163
2164 let small_file_mention = small_file_task.await.unwrap();
2165 match small_file_mention {
2166 Mention::Text { content, .. } => {
2167 // Should contain the full actual content
2168 assert_eq!(content, small_content);
2169 }
2170 _ => panic!("Expected Text mention for small file"),
2171 }
2172 }
2173
2174 #[gpui::test]
2175 async fn test_insert_thread_summary(cx: &mut TestAppContext) {
2176 init_test(cx);
2177 cx.update(LanguageModelRegistry::test);
2178
2179 let fs = FakeFs::new(cx.executor());
2180 fs.insert_tree("/project", json!({"file": ""})).await;
2181 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
2182
2183 let (workspace, cx) =
2184 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
2185
2186 let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx));
2187 let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx));
2188
2189 // Create a thread metadata to insert as summary
2190 let thread_metadata = agent::DbThreadMetadata {
2191 id: acp::SessionId::new("thread-123"),
2192 title: "Previous Conversation".into(),
2193 updated_at: chrono::Utc::now(),
2194 };
2195
2196 let message_editor = cx.update(|window, cx| {
2197 cx.new(|cx| {
2198 let mut editor = MessageEditor::new(
2199 workspace.downgrade(),
2200 project.downgrade(),
2201 history_store.clone(),
2202 None,
2203 Default::default(),
2204 Default::default(),
2205 "Test Agent".into(),
2206 "Test",
2207 EditorMode::AutoHeight {
2208 min_lines: 1,
2209 max_lines: None,
2210 },
2211 window,
2212 cx,
2213 );
2214 editor.insert_thread_summary(thread_metadata.clone(), window, cx);
2215 editor
2216 })
2217 });
2218
2219 // Construct expected values for verification
2220 let expected_uri = MentionUri::Thread {
2221 id: thread_metadata.id.clone(),
2222 name: thread_metadata.title.to_string(),
2223 };
2224 let expected_link = format!("[@{}]({})", thread_metadata.title, expected_uri.to_uri());
2225
2226 message_editor.read_with(cx, |editor, cx| {
2227 let text = editor.text(cx);
2228
2229 assert!(
2230 text.contains(&expected_link),
2231 "Expected editor text to contain thread mention link.\nExpected substring: {}\nActual text: {}",
2232 expected_link,
2233 text
2234 );
2235
2236 let mentions = editor.mention_set().read(cx).mentions();
2237 assert_eq!(
2238 mentions.len(),
2239 1,
2240 "Expected exactly one mention after inserting thread summary"
2241 );
2242
2243 assert!(
2244 mentions.contains(&expected_uri),
2245 "Expected mentions to contain the thread URI"
2246 );
2247 });
2248 }
2249
2250 #[gpui::test]
2251 async fn test_whitespace_trimming(cx: &mut TestAppContext) {
2252 init_test(cx);
2253
2254 let fs = FakeFs::new(cx.executor());
2255 fs.insert_tree("/project", json!({"file.rs": "fn main() {}"}))
2256 .await;
2257 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
2258
2259 let (workspace, cx) =
2260 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
2261
2262 let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx));
2263 let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx));
2264
2265 let message_editor = cx.update(|window, cx| {
2266 cx.new(|cx| {
2267 MessageEditor::new(
2268 workspace.downgrade(),
2269 project.downgrade(),
2270 history_store.clone(),
2271 None,
2272 Default::default(),
2273 Default::default(),
2274 "Test Agent".into(),
2275 "Test",
2276 EditorMode::AutoHeight {
2277 min_lines: 1,
2278 max_lines: None,
2279 },
2280 window,
2281 cx,
2282 )
2283 })
2284 });
2285 let editor = message_editor.update(cx, |message_editor, _| message_editor.editor.clone());
2286
2287 cx.run_until_parked();
2288
2289 editor.update_in(cx, |editor, window, cx| {
2290 editor.set_text(" \u{A0}してhello world ", window, cx);
2291 });
2292
2293 let (content, _) = message_editor
2294 .update(cx, |message_editor, cx| message_editor.contents(false, cx))
2295 .await
2296 .unwrap();
2297
2298 assert_eq!(content, vec!["してhello world".into()]);
2299 }
2300
2301 #[gpui::test]
2302 async fn test_editor_respects_embedded_context_capability(cx: &mut TestAppContext) {
2303 init_test(cx);
2304
2305 let fs = FakeFs::new(cx.executor());
2306
2307 let file_content = "fn main() { println!(\"Hello, world!\"); }\n";
2308
2309 fs.insert_tree(
2310 "/project",
2311 json!({
2312 "src": {
2313 "main.rs": file_content,
2314 }
2315 }),
2316 )
2317 .await;
2318
2319 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
2320
2321 let (workspace, cx) =
2322 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
2323
2324 let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx));
2325 let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx));
2326
2327 let (message_editor, editor) = workspace.update_in(cx, |workspace, window, cx| {
2328 let workspace_handle = cx.weak_entity();
2329 let message_editor = cx.new(|cx| {
2330 MessageEditor::new(
2331 workspace_handle,
2332 project.downgrade(),
2333 history_store.clone(),
2334 None,
2335 Default::default(),
2336 Default::default(),
2337 "Test Agent".into(),
2338 "Test",
2339 EditorMode::AutoHeight {
2340 max_lines: None,
2341 min_lines: 1,
2342 },
2343 window,
2344 cx,
2345 )
2346 });
2347 workspace.active_pane().update(cx, |pane, cx| {
2348 pane.add_item(
2349 Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
2350 true,
2351 true,
2352 None,
2353 window,
2354 cx,
2355 );
2356 });
2357 message_editor.read(cx).focus_handle(cx).focus(window, cx);
2358 let editor = message_editor.read(cx).editor().clone();
2359 (message_editor, editor)
2360 });
2361
2362 cx.simulate_input("What is in @file main");
2363
2364 editor.update_in(cx, |editor, window, cx| {
2365 assert!(editor.has_visible_completions_menu());
2366 assert_eq!(editor.text(cx), "What is in @file main");
2367 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
2368 });
2369
2370 let content = message_editor
2371 .update(cx, |editor, cx| editor.contents(false, cx))
2372 .await
2373 .unwrap()
2374 .0;
2375
2376 let main_rs_uri = if cfg!(windows) {
2377 "file:///C:/project/src/main.rs"
2378 } else {
2379 "file:///project/src/main.rs"
2380 };
2381
2382 // When embedded context is `false` we should get a resource link
2383 pretty_assertions::assert_eq!(
2384 content,
2385 vec![
2386 "What is in ".into(),
2387 acp::ContentBlock::ResourceLink(acp::ResourceLink::new("main.rs", main_rs_uri))
2388 ]
2389 );
2390
2391 message_editor.update(cx, |editor, _cx| {
2392 editor
2393 .prompt_capabilities
2394 .replace(acp::PromptCapabilities::new().embedded_context(true))
2395 });
2396
2397 let content = message_editor
2398 .update(cx, |editor, cx| editor.contents(false, cx))
2399 .await
2400 .unwrap()
2401 .0;
2402
2403 // When embedded context is `true` we should get a resource
2404 pretty_assertions::assert_eq!(
2405 content,
2406 vec![
2407 "What is in ".into(),
2408 acp::ContentBlock::Resource(acp::EmbeddedResource::new(
2409 acp::EmbeddedResourceResource::TextResourceContents(
2410 acp::TextResourceContents::new(file_content, main_rs_uri)
2411 )
2412 ))
2413 ]
2414 );
2415 }
2416
2417 #[gpui::test]
2418 async fn test_autoscroll_after_insert_selections(cx: &mut TestAppContext) {
2419 init_test(cx);
2420
2421 let app_state = cx.update(AppState::test);
2422
2423 cx.update(|cx| {
2424 editor::init(cx);
2425 workspace::init(app_state.clone(), cx);
2426 });
2427
2428 app_state
2429 .fs
2430 .as_fake()
2431 .insert_tree(
2432 path!("/dir"),
2433 json!({
2434 "test.txt": "line1\nline2\nline3\nline4\nline5\n",
2435 }),
2436 )
2437 .await;
2438
2439 let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
2440 let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
2441 let workspace = window.root(cx).unwrap();
2442
2443 let worktree = project.update(cx, |project, cx| {
2444 let mut worktrees = project.worktrees(cx).collect::<Vec<_>>();
2445 assert_eq!(worktrees.len(), 1);
2446 worktrees.pop().unwrap()
2447 });
2448 let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id());
2449
2450 let mut cx = VisualTestContext::from_window(*window, cx);
2451
2452 // Open a regular editor with the created file, and select a portion of
2453 // the text that will be used for the selections that are meant to be
2454 // inserted in the agent panel.
2455 let editor = workspace
2456 .update_in(&mut cx, |workspace, window, cx| {
2457 workspace.open_path(
2458 ProjectPath {
2459 worktree_id,
2460 path: rel_path("test.txt").into(),
2461 },
2462 None,
2463 false,
2464 window,
2465 cx,
2466 )
2467 })
2468 .await
2469 .unwrap()
2470 .downcast::<Editor>()
2471 .unwrap();
2472
2473 editor.update_in(&mut cx, |editor, window, cx| {
2474 editor.change_selections(Default::default(), window, cx, |selections| {
2475 selections.select_ranges([Point::new(0, 0)..Point::new(0, 5)]);
2476 });
2477 });
2478
2479 let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx));
2480 let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx));
2481
2482 // Create a new `MessageEditor`. The `EditorMode::full()` has to be used
2483 // to ensure we have a fixed viewport, so we can eventually actually
2484 // place the cursor outside of the visible area.
2485 let message_editor = workspace.update_in(&mut cx, |workspace, window, cx| {
2486 let workspace_handle = cx.weak_entity();
2487 let message_editor = cx.new(|cx| {
2488 MessageEditor::new(
2489 workspace_handle,
2490 project.downgrade(),
2491 history_store.clone(),
2492 None,
2493 Default::default(),
2494 Default::default(),
2495 "Test Agent".into(),
2496 "Test",
2497 EditorMode::full(),
2498 window,
2499 cx,
2500 )
2501 });
2502 workspace.active_pane().update(cx, |pane, cx| {
2503 pane.add_item(
2504 Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
2505 true,
2506 true,
2507 None,
2508 window,
2509 cx,
2510 );
2511 });
2512
2513 message_editor
2514 });
2515
2516 message_editor.update_in(&mut cx, |message_editor, window, cx| {
2517 message_editor.editor.update(cx, |editor, cx| {
2518 // Update the Agent Panel's Message Editor text to have 100
2519 // lines, ensuring that the cursor is set at line 90 and that we
2520 // then scroll all the way to the top, so the cursor's position
2521 // remains off screen.
2522 let mut lines = String::new();
2523 for _ in 1..=100 {
2524 lines.push_str(&"Another line in the agent panel's message editor\n");
2525 }
2526 editor.set_text(lines.as_str(), window, cx);
2527 editor.change_selections(Default::default(), window, cx, |selections| {
2528 selections.select_ranges([Point::new(90, 0)..Point::new(90, 0)]);
2529 });
2530 editor.set_scroll_position(gpui::Point::new(0., 0.), window, cx);
2531 });
2532 });
2533
2534 cx.run_until_parked();
2535
2536 // Before proceeding, let's assert that the cursor is indeed off screen,
2537 // otherwise the rest of the test doesn't make sense.
2538 message_editor.update_in(&mut cx, |message_editor, window, cx| {
2539 message_editor.editor.update(cx, |editor, cx| {
2540 let snapshot = editor.snapshot(window, cx);
2541 let cursor_row = editor.selections.newest::<Point>(&snapshot).head().row;
2542 let scroll_top = snapshot.scroll_position().y as u32;
2543 let visible_lines = editor.visible_line_count().unwrap() as u32;
2544 let visible_range = scroll_top..(scroll_top + visible_lines);
2545
2546 assert!(!visible_range.contains(&cursor_row));
2547 })
2548 });
2549
2550 // Now let's insert the selection in the Agent Panel's editor and
2551 // confirm that, after the insertion, the cursor is now in the visible
2552 // range.
2553 message_editor.update_in(&mut cx, |message_editor, window, cx| {
2554 message_editor.insert_selections(window, cx);
2555 });
2556
2557 cx.run_until_parked();
2558
2559 message_editor.update_in(&mut cx, |message_editor, window, cx| {
2560 message_editor.editor.update(cx, |editor, cx| {
2561 let snapshot = editor.snapshot(window, cx);
2562 let cursor_row = editor.selections.newest::<Point>(&snapshot).head().row;
2563 let scroll_top = snapshot.scroll_position().y as u32;
2564 let visible_lines = editor.visible_line_count().unwrap() as u32;
2565 let visible_range = scroll_top..(scroll_top + visible_lines);
2566
2567 assert!(visible_range.contains(&cursor_row));
2568 })
2569 });
2570 }
2571}